diff --git a/.gitattributes b/.gitattributes index ca18ae5..4c6a980 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,6 +8,7 @@ *.xaml text eol=crlf *.xml text eol=crlf *.ps1 text eol=crlf +*.nsi text eol=crlf *.md text eol=lf *.json text eol=lf *.yml text eol=lf diff --git a/.github/releases/v0.3.0.md b/.github/releases/v0.3.0.md new file mode 100644 index 0000000..776d875 --- /dev/null +++ b/.github/releases/v0.3.0.md @@ -0,0 +1,36 @@ +# TextRecast v0.3.0 + +TextRecast 0.3.0 introduces a guided Windows installer and an explicit review-before-replacement workflow. + +## Highlights + +- Installs per user through one self-contained Windows setup executable; .NET 10 does not need to be installed separately. +- Presents the Apache-2.0 license, privacy notice, destination, and four compatible local-model choices before installation. +- Downloads only the model selected by the user, with resumable transfer and exact size and SHA-256 verification. +- Keeps source text unchanged while the user generates, reviews, edits, copies, or regenerates a revision. +- Revalidates the captured source and replaces it only after **Replace selection** is chosen. +- Closes the editor after a clean replacement; failures and clipboard-restoration warnings remain visible with the revision preserved. +- Uses a compact Windows-controlled light/dark interface for setup, the floating launcher, rewrite editor, and information dialogs. +- Lets the floating launcher toggle the editor open and closed without recapturing an already open selection. +- Offers default-checked Launch, Desktop shortcut, and Start at sign-in choices on the completion page. +- Removes application files and owned shortcuts during uninstall while preserving downloaded models and setup state for reinstall. + +## Included model choices + +| Profile | Model | Download | +| --- | --- | ---: | +| Fast/default | Qwen 2.5 1.5B Q4_K_M | 1.04 GiB | +| Balanced | Qwen 3.5 2B Q5_K_M | 1.34 GiB | +| Best quality | Qwen 3.5 4B Q5_K_M | 2.93 GiB | +| Alternative | Granite 4.1 3B Q5_K_M | 2.27 GiB | + +All choices currently target English rewriting. Local models can change meaning or details, so review every revision before replacement. + +## Install + +1. Download `TextRecast-v0.3.0-win-x64-setup.exe` and its `.exe.sha256` file. +2. Optionally verify the SHA-256 checksum. +3. Close any running TextRecast instance, run Setup, and choose one compatible model. +4. Keep or clear the completion-page Desktop and Start at sign-in options, then choose **Finish**. + +The installer contains no GGUF model. An internet connection is required when the selected model is not already installed. The current Microsoft Visual C++ x64 Redistributable may also be required. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 892cb41..bafc352 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ permissions: jobs: build: runs-on: windows-latest - timeout-minutes: 20 + timeout-minutes: 40 steps: - name: Check out repository @@ -51,7 +51,7 @@ jobs: } "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "package_name=TextRecast-v$version-win-x64" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "setup_name=TextRecast-v$version-win-x64-setup" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Restore dependencies run: dotnet restore TextRecast.slnx @@ -65,6 +65,26 @@ jobs: - name: Build release run: dotnet build TextRecast.slnx -c Release --no-restore + - name: Verify setup dependency boundary + shell: pwsh + run: | + [xml] $setupProject = Get-Content -Raw src/TextRecast.Setup/TextRecast.Setup.csproj + [xml] $deploymentProject = Get-Content -Raw src/TextRecast.Deployment/TextRecast.Deployment.csproj + + $setupReferences = @($setupProject.SelectNodes("//ProjectReference") | ForEach-Object { $_.Include }) + if ($setupReferences.Count -ne 1 -or + $setupReferences[0] -ne "..\TextRecast.Deployment\TextRecast.Deployment.csproj") { + throw "The setup host must reference only TextRecast.Deployment." + } + + if (@($deploymentProject.SelectNodes("//PackageReference")).Count -ne 0) { + throw "TextRecast.Deployment must remain package-free." + } + + if (@($setupProject.SelectNodes("//PackageReference")).Count -ne 0) { + throw "The setup host must remain package-free." + } + - name: Publish self-contained application run: >- dotnet publish src/TextRecast.App/TextRecast.App.csproj @@ -75,47 +95,48 @@ jobs: -p:Version=${{ steps.version.outputs.version }} -o artifacts/publish/TextRecast-win-x64 - - name: Create versioned release package + - name: Prepare controlled upgrade fixture shell: pwsh - env: - PACKAGE_NAME: ${{ steps.version.outputs.package_name }} run: | - $publishDirectory = (Resolve-Path "artifacts/publish/TextRecast-win-x64").Path - $releaseDirectory = Join-Path $PWD "artifacts/release" - New-Item -ItemType Directory -Path $releaseDirectory -Force | Out-Null - - $requiredFiles = @( - "TextRecast.exe", - "LICENSE", - "NOTICE", - "THIRD-PARTY-NOTICES.md", - "DOTNET-LICENSE.txt", - "DOTNET-THIRD-PARTY-NOTICES.txt", - "WPF-LICENSE.txt" - ) - foreach ($file in $requiredFiles) { - if (-not (Test-Path -LiteralPath (Join-Path $publishDirectory $file) -PathType Leaf)) { - throw "Published application is missing required file: $file" - } - } + $source = (Resolve-Path "artifacts/publish/TextRecast-win-x64").Path + $fixture = Join-Path $PWD "artifacts/smoke/previous-publish" + New-Item -ItemType Directory -Path $fixture -Force | Out-Null + Copy-Item -Path (Join-Path $source "*") -Destination $fixture -Recurse -Force + Set-Content -LiteralPath (Join-Path $fixture "obsolete-v0.1-fixture.txt") -Value "upgrade fixture" -NoNewline - $modelFiles = @(Get-ChildItem -Path $publishDirectory -Filter "*.gguf" -File -Recurse) - if ($modelFiles.Count -ne 0) { - throw "Release packages must not contain GGUF model files." - } - - $archiveFileName = "$env:PACKAGE_NAME.zip" - $archivePath = Join-Path $releaseDirectory $archiveFileName - Compress-Archive -Path (Join-Path $publishDirectory "*") -DestinationPath $archivePath -Force + - name: Build controlled upgrade installer fixture + shell: pwsh + run: >- + ./tools/Build-WindowsInstaller.ps1 + -Version 0.1.0 + -PublishDirectory artifacts/smoke/previous-publish + -OutputDirectory artifacts/smoke/previous + -NoRestore - $checksum = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() - Set-Content -LiteralPath "$archivePath.sha256" -Value "$checksum $archiveFileName" -NoNewline + - name: Build Windows setup + shell: pwsh + run: >- + ./tools/Build-WindowsInstaller.ps1 + -Version ${{ steps.version.outputs.version }} + -PublishDirectory artifacts/publish/TextRecast-win-x64 + -OutputDirectory artifacts/release + -NoRestore - - name: Upload Windows release package + - name: Smoke test install, launch, payload replacement, uninstall, and reinstall + shell: pwsh + run: >- + ./tools/Test-WindowsInstaller.ps1 + -SetupPath artifacts/release/${{ steps.version.outputs.setup_name }}.exe + -ExpectedVersion ${{ steps.version.outputs.version }} + -PreviousSetupPath artifacts/smoke/previous/TextRecast-v0.1.0-win-x64-setup.exe + -PreviousVersion 0.1.0 + -UpgradeFixtureFileName obsolete-v0.1-fixture.txt + + - name: Upload Windows setup uses: actions/upload-artifact@v7 with: - name: ${{ steps.version.outputs.package_name }} + name: ${{ steps.version.outputs.setup_name }} path: | - artifacts/release/${{ steps.version.outputs.package_name }}.zip - artifacts/release/${{ steps.version.outputs.package_name }}.zip.sha256 + artifacts/release/${{ steps.version.outputs.setup_name }}.exe + artifacts/release/${{ steps.version.outputs.setup_name }}.exe.sha256 if-no-files-found: error diff --git a/.gitignore b/.gitignore index 1d229ac..95aad76 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,8 @@ Desktop.ini # Large local model binaries are downloaded separately. Models/*.gguf Models/*.partial +Models/*.partial.metadata.json # Local documentation drafts and planning notes docs/ +/design-qa.md diff --git a/Directory.Build.props b/Directory.Build.props index 18efe00..3e9e9ee 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.2.0 + 0.3.0 Copyright 2026 snss10 Apache-2.0 false diff --git a/Models/README.md b/Models/README.md index c42f251..39acdca 100644 --- a/Models/README.md +++ b/Models/README.md @@ -1,6 +1,6 @@ # Supported local models -TextRecast runs one cataloged GGUF model at a time. During setup, the user reviews the available choices and confirms the exact model before any download starts. Release archives do not contain model binaries. +TextRecast runs one cataloged GGUF model at a time. During setup, the user reviews the available choices and confirms the exact model before any download starts. Normal release payloads do not contain model binaries. All current profiles are for English rewriting. The three newer choices are experimental, and every replacement should be reviewed in the source application. @@ -39,10 +39,10 @@ An interrupted download uses two temporary files beside its final destination: TextRecast uses HTTP Range and If-Range to resume compatible partial downloads. It restarts when the server ignores the range, returns inconsistent metadata, or exposes a changed validator. The final file is installed only after its exact size and SHA-256 pass verification. -## Offline or portable builds +## Offline builds -Place only exact cataloged GGUF files in this directory before publishing. The App project links `Models/*.gguf` into its output, and setup marks matching files as installed. Do not rename a model file or substitute another quantization without adding and testing a separate catalog profile. +Place only exact cataloged GGUF files in this directory before an intentional offline publish. Model inclusion is disabled by default so normal builds and installers cannot accidentally carry a local model. Pass `-p:IncludeBundledModels=true` when publishing an offline installer input; setup then recognizes an exact catalog match as installed. Do not rename a model file or substitute another quantization without adding and testing a separate catalog profile. -The normal GitHub Actions release intentionally fails if a GGUF file enters the self-contained ZIP. This keeps the public application download small and leaves the model choice with the user. +The normal GitHub Actions build intentionally fails if a GGUF or partial-download file enters either the self-contained application payload or the Windows setup input. This keeps the public installer model-free and leaves the model choice with the user. -Catalog metadata is defined in [`SlmModelCatalog.cs`](../src/TextRecast.Infrastructure/SLM/SlmModelCatalog.cs). +Catalog metadata is defined in [`SlmModelCatalog.cs`](../src/TextRecast.Deployment/SLM/SlmModelCatalog.cs). diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..a410b56 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,40 @@ +# TextRecast Privacy Notice + +Effective: August 8, 2026 + +TextRecast is a local Windows writing assistant. This notice describes the data handled by the application and its installer. + +## Text processing + +Selected text, generated text, prompts, and model output are processed on the current computer by the local language model. TextRecast does not send that content to a hosted text-processing service, and it does not save a writing history. + +TextRecast does not currently include an account system, advertising, analytics, telemetry, or automatic crash reporting. + +## Clipboard and Windows access + +TextRecast uses Windows UI Automation to read and replace a selection when the source application supports it. It can temporarily use the Windows clipboard as a fallback. The application attempts to restore the previous clipboard contents and reports when restoration cannot be verified. + +Windows clipboard history, clipboard synchronization, accessibility software, or third-party clipboard managers operate outside TextRecast and may retain content placed on the clipboard. Disable those features before handling sensitive text if that behavior is not acceptable. + +## Network access + +TextRecast connects to the pinned model source only when a selected model is not already installed and the user confirms its download. The request identifies the model file and necessarily exposes ordinary connection data, such as the IP address and HTTP request metadata, to the model host. Selected text and generated text are not included in that request. + +After a verified model is installed, normal text formatting is local and does not require a text-processing service. + +## Data stored on the computer + +TextRecast can store the following data for the current Windows user under `%LOCALAPPDATA%\TextRecast`: + +- the selected local model and its verified model metadata; +- model-selection and setup state; +- a partial model download so an interrupted download can resume; and +- the downloaded model file. + +The installer stores application files under the selected per-user installation directory and adds normal Windows uninstall information and a Start Menu shortcut. On the completion page, it offers a desktop shortcut and a shortcut in the current user's Windows Startup folder; both choices are enabled by default and can be cleared before choosing Finish. The Startup shortcut launches the floating TextRecast button when that Windows user signs in. Uninstall removes these application shortcuts but preserves the user model and setup data so reinstalling does not require another model download. That data can be removed manually by deleting `%LOCALAPPDATA%\TextRecast` after TextRecast is closed. + +## Questions and changes + +Privacy-related questions can be raised through the repository's issue tracker. Do not include private text, clipboard contents, credentials, or other sensitive data in an issue. + +Material changes to TextRecast's data handling will be reflected in this notice and its effective date. diff --git a/README.md b/README.md index eaccda1..9ccfe7f 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ ## About -TextRecast is a lightweight Windows desktop utility that rewrites text inside the applications you already use. Select editable text, click the floating TextRecast button, choose an operation, and apply the result back to the original selection. +TextRecast is a lightweight Windows desktop utility that rewrites text inside the applications you already use. Select editable text, click the floating TextRecast button, choose an operation, review or edit the local result, and replace the original selection only when it is ready. -Inference runs locally through LLamaSharp's CPU backend. On first setup, TextRecast presents four cataloged models and downloads only the option the user explicitly confirms. Qwen 2.5 1.5B remains the fast default; the other choices are clearly labeled experimental. +Inference runs locally through LLamaSharp's CPU backend. Guided setup presents four cataloged models and downloads only the compatible option the user explicitly selects. No model is bundled with the installer. ## Features @@ -34,10 +34,13 @@ Inference runs locally through LLamaSharp's CPU backend. On first setup, TextRec - Change tone to Professional, Casual, Friendly, Formal, or Direct - Work in browsers, editors, messaging apps, and other Windows applications - Run formatting locally without an account or API key +- Review and edit generated text before replacing the source selection +- Copy or regenerate a revision without changing the source text - Choose a compatible local model before any download begins - Resume an interrupted model download instead of starting over -- Cancel an active generation and retry a failed replacement +- Cancel an active generation while preserving the captured source - Revalidate the source window and selected text before replacing anything +- Launch the floating button at Windows sign-in when that setup option is enabled ## Install and run @@ -48,20 +51,21 @@ Inference runs locally through LLamaSharp's CPU backend. On first setup, TextRec - Approximately 2-4 GB of available disk space, depending on the selected model - An internet connection when no valid packaged or previously installed model is available -The published Windows x64 application is self-contained. Users running that build do **not** need to install .NET 10. +The Windows installer and installed application are self-contained. Users do **not** need to install .NET 10. -### Run a published build +### Install a published build -1. Download the latest `TextRecast-vX.Y.Z-win-x64.zip` from the [Releases page](https://github.com/snss10/TextRecast/releases). -2. Optionally verify it with the accompanying `.zip.sha256` file. -3. Extract the entire archive to a folder. -4. Run `TextRecast.exe`. +1. Download `TextRecast-vX.Y.Z-win-x64-setup.exe` and its matching `.exe.sha256` file from the [Releases page](https://github.com/snss10/TextRecast/releases). +2. Optionally verify the checksum with `Get-FileHash .\TextRecast-vX.Y.Z-win-x64-setup.exe -Algorithm SHA256`. +3. Run Setup, review the Apache-2.0 license and privacy notice, choose the per-user destination, and select one compatible model. +4. Choose **Install**. Setup installs the application, downloads only the selected model, and verifies its exact size and SHA-256. +5. On the completion page, keep or clear the default-checked Launch, Desktop shortcut, and Start at sign-in choices, then choose **Finish**. -Keep every file from the archive together; the application depends on the included .NET runtime and native inference libraries. If a packaged release is not available yet, follow [Build from source](#build-from-source). +Installation does not request administrator privileges. Application files are installed for the current Windows user; downloaded models and setup state remain under that user's local application data. -## First launch and model download +## Setup and model download -TextRecast shows the available models before making a network request. Each choice includes its role, download size, hardware compatibility, language support, and known limitations. The exact model and size are shown again for confirmation; only that model is downloaded. +TextRecast shows the available models before making a network request. Setup presents the model name, profile, and download size, disables choices that do not meet measured hardware requirements, and shows the selected model again before installation. Only that model is downloaded. | Role | Model | Download | Status | | --- | --- | ---: | --- | @@ -70,7 +74,7 @@ TextRecast shows the available models before making a network request. Each choi | Best quality | Qwen 3.5 4B Q5_K_M | 2.93 GiB | Experimental; verify roles, titles, and deadlines | | Alternative | Granite 4.1 3B Q5_K_M | 2.27 GiB | Experimental; higher semantic-drift risk | -These profiles are currently supported for English rewriting only. None is perfect, so review the replaced text in the source application after every operation. Hardware recommendations are guidance; Qwen 2.5 remains selectable when hardware inspection is unavailable. +These profiles are currently supported for English rewriting only. None is perfect, so review every generated revision before replacement. Hardware checks can disable an unsafe choice, but they never preselect a model; Qwen 2.5 remains selectable when hardware inspection is unavailable. Cancelling or losing the connection keeps a validated partial download. Select **Retry** on the same computer to continue from the saved point. TextRecast restarts safely if the server no longer accepts the saved range or the remote file has changed. A model becomes active only after its exact size and SHA-256 checksum pass verification. A valid installed selection is reused on later launches. @@ -108,14 +112,17 @@ There are no author-specific absolute paths in this README. 2. Click the floating TextRecast icon. 3. Choose **Improve**, **Shorter**, **Longer**, **Summarize**, or **Tone**. 4. Choose a style when using **Tone**. -5. Click **Apply** to generate the formatted text and replace the selection. +5. Review or edit the generated revision. +6. Choose **Copy**, **Regenerate**, **Cancel**, or **Replace selection**. -TextRecast returns focus to the source application, confirms that the original selection is still valid, and then replaces it. If replacement cannot be completed safely, the original text remains unchanged and the generated result remains available for **Retry replace**. +**Replace selection** returns focus to the source application, confirms that the original selection is still valid, and replaces it with the exact revised text. A clean success closes the editor. A failure, cancellation, or clipboard-restoration warning keeps the editor and revision visible with a message. -Drag the floating icon to reposition it. Right-click it to view the active model and its limitations, or choose **Quit TextRecast** to close the application. +Clicking the floating icon again closes an open editor without capturing text again. Drag the icon to reposition it. Right-click it to format a new selection, view model/legal/about information, or exit TextRecast. ## Privacy and safeguards +The complete data-handling statement is available in the [TextRecast Privacy Notice](PRIVACY.md). + - TextRecast itself does not write selected or generated text to logs or files. - Formatting runs on the local CPU and does not use a text-processing API. - TextRecast does not maintain a formatting history. @@ -141,6 +148,9 @@ Capture the selection Format with the local SLM | v +Review or edit the revision + | + v Revalidate the original selection | v @@ -152,7 +162,7 @@ Replace the text and restore the clipboard when possible
The cloned project says that .NET 10 is missing -A source checkout requires the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) for development. A published self-contained Windows x64 build does not require a separate .NET installation. +A source checkout requires the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) for development. The published self-contained installer does not require a separate .NET installation.
@@ -176,7 +186,7 @@ Keep the source application open and the text selected before clicking TextRecas
The selection could not be replaced -The window, process, selection, or capture age may have changed. Select the original text again and retry. Windows can also block simulated input when the source application is running as administrator and TextRecast is not. +The window, process, selection, or capture age may have changed. The editor keeps the generated revision visible; select the source text again and start a new capture. Windows can also block simulated input when the source application is running as administrator and TextRecast is not.
@@ -196,44 +206,47 @@ dotnet test TextRecast.slnx -c Release --no-build dotnet run --project src/TextRecast.App/TextRecast.App.csproj ``` -### Publish a self-contained Windows build +### Build the Windows installer ```powershell -dotnet publish src/TextRecast.App/TextRecast.App.csproj -c Release -r win-x64 --self-contained true -o artifacts/TextRecast-win-x64 +dotnet publish src/TextRecast.App/TextRecast.App.csproj -c Release -r win-x64 --self-contained true --no-restore -o artifacts/publish/TextRecast-win-x64 +./tools/Build-WindowsInstaller.ps1 -Version 0.3.0 -NoRestore ``` -Distribute the complete output folder, not only the executable. Any cataloged GGUF files documented in [Models/README.md](Models/README.md) are packaged when placed in the repository's `Models` directory before publishing. Normal release packages intentionally contain no model; the user chooses and confirms one during setup. +The self-contained application publish directory is an internal installer input, not a separately supported portable release. Normal publishes intentionally contain no model; the user chooses one during setup. An offline build can explicitly include an exact cataloged GGUF file with `-p:IncludeBundledModels=true`; see [Models/README.md](Models/README.md). -For tagged releases, GitHub Actions creates a model-free `TextRecast-vX.Y.Z-win-x64.zip` containing the complete runtime, TextRecast legal files, and the legal files supplied with the bundled .NET runtime packs, plus a matching SHA-256 file. A release tag must use `vMAJOR.MINOR.PATCH` and match the version in `Directory.Build.props`. +For tagged releases, GitHub Actions creates the model-free setup executable and matching SHA-256 file. The installer contains the complete runtime and required TextRecast, .NET, WPF, and third-party legal notices. A release tag must use `vMAJOR.MINOR.PATCH` and match the version in `Directory.Build.props`. ## Project layout - [TextRecast.App](src/TextRecast.App/) contains the WPF presentation layer and application composition. - [TextRecast.Core](src/TextRecast.Core/) contains platform-independent workflows, contracts, results, and models. -- [TextRecast.Infrastructure](src/TextRecast.Infrastructure/) contains local SLM inference and Windows integrations. -- [tests](tests/) contains Core and Infrastructure automated tests. +- [TextRecast.Deployment](src/TextRecast.Deployment/) contains the model catalog, verified downloads, hardware compatibility checks, setup state, and installer identity without inference dependencies. +- [TextRecast.Infrastructure](src/TextRecast.Infrastructure/) contains local SLM inference and Windows capture/replacement integrations. +- [TextRecast.Setup](src/TextRecast.Setup/) contains the self-contained managed Windows setup host. +- [installer](installer/) contains the internal per-user application package definition. +- [tests](tests/) contains Core, Deployment, Infrastructure, Setup, and application UI automated tests. - [Models](Models/) documents supported models, integrity metadata, and offline packaging. ```text -TextRecast.App ---------> TextRecast.Core - | - +---------------> TextRecast.Infrastructure - | - +---------------> TextRecast.Core +TextRecast.Setup -------> TextRecast.Deployment +TextRecast.App ---------> TextRecast.Core + TextRecast.Deployment + TextRecast.Infrastructure +TextRecast.Infrastructure -> TextRecast.Core + TextRecast.Deployment ``` -Model profiles are defined in [SlmModelCatalog.cs](src/TextRecast.Infrastructure/SLM/SlmModelCatalog.cs). A new model can reuse installation, verification, storage, inference lifecycle, chunking, and workflow components; models with a different chat format should provide a matching prompt builder. +Model profiles are defined in [SlmModelCatalog.cs](src/TextRecast.Deployment/SLM/SlmModelCatalog.cs). A new model can reuse installation, verification, storage, inference lifecycle, chunking, and workflow components; models with a different chat format should provide a matching prompt builder. The model-free setup build and its transaction boundaries are documented in [installer/README.md](installer/README.md). ## Technology - WPF on .NET 10 +- NSIS 3.12 for the internal per-user application package - [LLamaSharp](https://github.com/SciSharp/LLamaSharp) with CPU inference - [Qwen 2.5 GGUF](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF), [Qwen 3.5 GGUF](https://huggingface.co/unsloth/Qwen3.5-2B-GGUF), and [Granite 4.1 GGUF](https://huggingface.co/ibm-granite/granite-4.1-3b-GGUF) - Windows UI Automation, clipboard, and input APIs ## Security and license -Report security issues using the process in [SECURITY.md](SECURITY.md). +Report security issues using the process in [SECURITY.md](SECURITY.md). Data handling is described in [PRIVACY.md](PRIVACY.md). TextRecast source code and documentation are licensed under the [Apache License 2.0](LICENSE). Copyright 2026 snss10. diff --git a/SECURITY.md b/SECURITY.md index dbca828..22741b1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,4 +28,8 @@ The application must continue to: - Verify the source window, process, capture age, and selection before replacement - Reject empty, oversized, or unsupported replacement content - Verify downloaded model size and SHA-256 before installation +- Start model network requests only after explicit user selection and Install +- Keep retries and resumed downloads bound to the selected catalog model +- Restrict installer writes and cleanup to validated per-user, product-owned paths +- Preserve the previous complete application payload when an upgrade activation fails - Run with the current user's privileges rather than requesting elevation diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index b71c1fc..5f7038d 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -6,6 +6,10 @@ TextRecast contains or uses the third-party components listed below. Each compon The following production dependencies declare the MIT License in their NuGet package metadata: +- Direct production package references: `LLamaSharp`, `LLamaSharp.Backend.Cpu`, and `MahApps.Metro.IconPacks.Lucide`. +- The remaining packages in the table are production transitive dependencies resolved by those packages. +- Test-only dependencies are not included because they are not distributed with the application or installer. + | Package | Version | Authors or project | License | | --- | --- | --- | --- | | CommunityToolkit.HighPerformance | 8.4.2 | Microsoft / .NET Community Toolkit | MIT | @@ -47,10 +51,41 @@ The self-contained Windows release includes Microsoft .NET runtime and Windows D - `DOTNET-THIRD-PARTY-NOTICES.txt` - `WPF-LICENSE.txt` +## Windows installer engine + +The model-free Windows setup executable is built with NSIS 3.12. The NSIS +installer code and zlib compression module used by TextRecast are available +under the zlib/libpng license. The generated installer is build output; the +NSIS compiler and source distribution are not redistributed with TextRecast. + +Copyright (C) 1999-2026 Contributors + +This software is provided 'as-is', without any express or implied warranty. +In no event will the authors be held liable for any damages arising from the +use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not claim + that you wrote the original software. If you use this software in a + product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + +Upstream project: + +Upstream license: + ## Lucide icon artwork `MahApps.Metro.IconPacks.Lucide` incorporates icon data from the [Lucide project](https://github.com/lucide-icons/lucide). Lucide is licensed under the ISC License, and the Lucide icons derived from Feather retain the Feather MIT notice below. +TextRecast uses only the glyphs needed for launcher commands, rewrite operations, status, copy, and regeneration. The complete Lucide and Feather notices remain here because the distributed MahApps icon-pack assembly contains the broader icon data, not only the glyphs referenced by TextRecast. + ### Lucide ISC License Copyright (c) 2026 Lucide Icons and Contributors diff --git a/TextRecast.slnx b/TextRecast.slnx index 887dc9a..4c78de5 100644 --- a/TextRecast.slnx +++ b/TextRecast.slnx @@ -2,11 +2,16 @@ + + + + + diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 0000000..a108079 --- /dev/null +++ b/installer/README.md @@ -0,0 +1,66 @@ +# Windows installer architecture + +TextRecast uses a two-layer per-user Windows setup: + +1. `TextRecast.Setup` is a self-contained WPF host. It owns setup orchestration and has no dependency on LLamaSharp or the inference project. +2. An internal NSIS package owns application files, the Start Menu shortcut, optional TextRecast Desktop/Startup shortcuts, and Windows uninstall registration. It is embedded in the setup host and is not published separately. + +The application publish directory is the only package input. Normal publishes disable bundled models, and the build script rejects `*.gguf`, `*.partial`, and `*.partial.metadata.json` before invoking NSIS. + +## Guided setup contract + +The managed setup host owns this seven-step journey: + +1. Welcome +2. License and Privacy +3. Destination +4. Model Choice +5. Ready to Install +6. Installing, downloading, and verifying +7. Complete + +Legal and privacy content is packaged locally. Setup requires acceptance and one explicit compatible model choice before Install is enabled. No model request begins before Install, and retries or resumes remain bound to that selected catalog entry. Application installation completes before model setup begins; a model becomes active only after its exact size and SHA-256 pass verification. + +The completion page keeps Launch TextRecast, Add a desktop shortcut, and Start TextRecast when I sign in as explicit checkboxes. All three default to enabled. Finish copies the package-owned Start Menu shortcut to the current user's Desktop and Startup folders according to those choices. Noninteractive installation applies the same desktop and sign-in defaults. + +The Install command changes the coordinator to the Installing page and yields one WPF render before starting the embedded package process. This keeps the UI responsive and makes the transition visible immediately. During installation, the shared compact footer shows only Cancel. Closing the window is guarded while package work is active. + +The visual baseline and screen-specific decisions are maintained in the ignored local planning file `docs/design/v0.3.0/README.md`; executable behavior is enforced by setup and deployment tests in the repository. + +## Build + +First create the self-contained application publish, then build the setup executable: + +```powershell +dotnet publish src/TextRecast.App/TextRecast.App.csproj -c Release -r win-x64 --self-contained true --no-restore -o artifacts/publish/TextRecast-win-x64 +./tools/Build-WindowsInstaller.ps1 -Version 0.3.0 -NoRestore +``` + +The result is written to `artifacts/release/TextRecast-v0.3.0-win-x64-setup.exe` with a matching SHA-256 file. + +The tool bootstrap script downloads the official NSIS 3.12 archive and accepts it only when its SHA-256 is `56581F90DB321581C5381193D796FFFCF2D24B2F8FED2160A6C6A3BAA67F2C4F`. It expands that verified archive into a fresh hash-keyed directory and also checks the compiler SHA-256 (`25D1AA7081DB1A9DE9690B59983F9652B7409A189C3444328CC1841EFF693E8D`) before every package build. The archive and extracted compiler remain under ignored `artifacts/tools` paths. + +## Ownership and recovery + +- Installation is per-user and defaults to `%LOCALAPPDATA%\Programs\TextRecast`. +- The stable product identity is declared in `WindowsInstallerIdentity` and mirrored into the package build definition. +- Application files are staged under `Application.pending`, checked for required files, and then activated as one directory. The previous complete payload is retained during activation, restored if activation fails, and recovered on the next setup run after an interrupted swap. +- The Start Menu shortcut and uninstall metadata are written only after payload activation. Completion can copy that owned shortcut to the current user's Desktop and Startup folders. Uninstall removes the product-owned installation tree, all three known TextRecast shortcuts, and registry entries. +- Models, partial downloads, settings, and setup state live under `%LOCALAPPDATA%\TextRecast` and survive application uninstall. +- The setup host extracts its internal package to a unique temporary directory and removes that directory after the package process exits. + +The model-free setup can be exercised without UI by passing `--install --quiet` or `--uninstall --quiet`. `--install-directory ` is available for isolated smoke tests and is restricted to the current user's local application-data tree. + +Source verification uses: + +```powershell +dotnet format TextRecast.slnx --verify-no-changes --no-restore +dotnet build TextRecast.slnx -c Release --no-restore +dotnet test TextRecast.slnx -c Release --no-build +``` + +The packaging smoke test installs into an isolated per-user location, verifies payload replacement and uninstall ownership, and preserves model/settings data outside the package-owned application directory. + +## Licensing + +NSIS 3.12 and its zlib compression module are used under the zlib/libpng license. Their attribution and complete license terms are consolidated in `THIRD-PARTY-NOTICES.md`; a duplicate standalone NSIS notice is not installed. The package also contains TextRecast's Apache license, privacy notice, project notice, and the exact legal files from the bundled .NET and WPF runtime packs. diff --git a/installer/TextRecast.Package/TextRecast.nsi b/installer/TextRecast.Package/TextRecast.nsi new file mode 100644 index 0000000..ce810d7 --- /dev/null +++ b/installer/TextRecast.Package/TextRecast.nsi @@ -0,0 +1,317 @@ +!ifndef APP_VERSION + !error "APP_VERSION must be defined." +!endif +!ifndef FILE_VERSION + !error "FILE_VERSION must be defined." +!endif +!ifndef PUBLISH_DIRECTORY + !error "PUBLISH_DIRECTORY must be defined." +!endif +!ifndef OUTPUT_FILE + !error "OUTPUT_FILE must be defined." +!endif +!ifndef PRODUCT_ID + !error "PRODUCT_ID must be defined." +!endif + +Unicode true +RequestExecutionLevel user +SetCompressor zlib +SetOverwrite on + +Name "TextRecast" +Caption "TextRecast Application Package" +OutFile "${OUTPUT_FILE}" +InstallDir "$LOCALAPPDATA\Programs\TextRecast" +InstallDirRegKey HKCU "Software\TextRecast" "InstallLocation" +BrandingText "TextRecast" +ShowInstDetails nevershow +ShowUninstDetails nevershow + +VIProductVersion "${FILE_VERSION}" +VIAddVersionKey /LANG=1033 "ProductName" "TextRecast" +VIAddVersionKey /LANG=1033 "ProductVersion" "${APP_VERSION}" +VIAddVersionKey /LANG=1033 "FileVersion" "${APP_VERSION}" +VIAddVersionKey /LANG=1033 "CompanyName" "snss10" +VIAddVersionKey /LANG=1033 "FileDescription" "TextRecast per-user application package" +VIAddVersionKey /LANG=1033 "LegalCopyright" "Copyright 2026 snss10" + +!macro Fail CODE MESSAGE + SetErrorLevel ${CODE} + Abort "${MESSAGE}" +!macroend + +Page instfiles +UninstPage uninstConfirm +UninstPage instfiles + +Section "TextRecast" MainSection + SetShellVarContext current + SetOutPath "$INSTDIR" + + IfFileExists "$INSTDIR\.textrecast-install" validate_ownership_marker create_ownership_marker + + validate_ownership_marker: + ClearErrors + FileOpen $0 "$INSTDIR\.textrecast-install" r + IfErrors ownership_marker_failed + FileRead $0 $1 + FileClose $0 + IfErrors ownership_marker_failed + StrCmp $1 "${PRODUCT_ID}" ownership_marker_valid ownership_marker_invalid + + create_ownership_marker: + ClearErrors + FileOpen $0 "$INSTDIR\.textrecast-install" w + IfErrors ownership_marker_failed + FileWrite $0 "${PRODUCT_ID}" + IfErrors ownership_marker_write_failed + FileClose $0 + IfErrors ownership_marker_failed + Goto ownership_marker_valid + + ownership_marker_write_failed: + FileClose $0 + + ownership_marker_failed: + !insertmacro Fail 10 "The TextRecast installation ownership marker could not be created or read." + + ownership_marker_invalid: + !insertmacro Fail 11 "The selected directory is not owned by this TextRecast installation." + + ownership_marker_valid: + IfFileExists "$INSTDIR\Application\.textrecast-payload-complete" 0 check_previous_payload + IfFileExists "$INSTDIR\Application\TextRecast.exe" 0 check_previous_payload + IfFileExists "$INSTDIR\Application\LICENSE" 0 check_previous_payload + ClearErrors + FileOpen $0 "$INSTDIR\Application\.textrecast-payload-complete" r + IfErrors check_previous_payload + FileRead $0 $2 + FileClose $0 + IfErrors check_previous_payload + StrCmp $2 "" check_previous_payload current_payload_valid + + check_previous_payload: + IfFileExists "$INSTDIR\Application.previous\.textrecast-payload-complete" 0 no_previous_payload + IfFileExists "$INSTDIR\Application.previous\TextRecast.exe" 0 no_previous_payload + ClearErrors + FileOpen $0 "$INSTDIR\Application.previous\.textrecast-payload-complete" r + IfErrors no_previous_payload + FileRead $0 $2 + FileClose $0 + IfErrors no_previous_payload + StrCmp $2 "" no_previous_payload restore_previous_payload + + restore_previous_payload: + RMDir /r "$INSTDIR\Application" + IfFileExists "$INSTDIR\Application\*.*" recovery_failed + RMDir "$INSTDIR\Application" + ClearErrors + Rename "$INSTDIR\Application.previous" "$INSTDIR\Application" + IfErrors recovery_failed + Goto current_payload_valid + + no_previous_payload: + RMDir /r "$INSTDIR\Application" + IfFileExists "$INSTDIR\Application\*.*" recovery_failed + RMDir "$INSTDIR\Application" + Goto current_payload_valid + + recovery_failed: + !insertmacro Fail 20 "The previous TextRecast application payload could not be recovered. Close TextRecast and retry Setup." + + current_payload_valid: + RMDir "$INSTDIR\Application.pending" + IfFileExists "$INSTDIR\Application.pending\*.*" clean_pending_payload pending_payload_ready + + clean_pending_payload: + ClearErrors + RMDir /r "$INSTDIR\Application.pending" + IfErrors pending_cleanup_failed + + pending_payload_ready: + ClearErrors + CreateDirectory "$INSTDIR\Application.pending" + IfErrors stage_failed + SetOutPath "$INSTDIR\Application.pending" + ClearErrors + File /r "${PUBLISH_DIRECTORY}\*" + IfErrors stage_failed + IfFileExists "$INSTDIR\Application.pending\TextRecast.exe" 0 stage_failed + IfFileExists "$INSTDIR\Application.pending\LICENSE" 0 stage_failed + IfFileExists "$INSTDIR\Application.pending\PRIVACY.md" 0 stage_failed + + ClearErrors + FileOpen $0 "$INSTDIR\Application.pending\.textrecast-payload-complete" w + IfErrors stage_failed + FileWrite $0 "${APP_VERSION}" + IfErrors payload_marker_write_failed + FileClose $0 + IfErrors stage_failed + + ClearErrors + SetOutPath "$INSTDIR" + IfErrors stage_failed + + RMDir "$INSTDIR\Application.previous" + IfFileExists "$INSTDIR\Application.previous\*.*" clean_previous_payload previous_payload_ready + + clean_previous_payload: + ClearErrors + RMDir /r "$INSTDIR\Application.previous" + IfErrors previous_cleanup_failed + + previous_payload_ready: + StrCpy $2 "" + IfFileExists "$INSTDIR\Application\.textrecast-payload-complete" 0 activate_staged_payload + ClearErrors + FileOpen $0 "$INSTDIR\Application\.textrecast-payload-complete" r + IfErrors current_move_failed + FileRead $0 $2 + FileClose $0 + IfErrors current_move_failed + StrCmp $2 "" current_move_failed + ClearErrors + Rename "$INSTDIR\Application" "$INSTDIR\Application.previous" + IfErrors current_move_failed + + activate_staged_payload: + ClearErrors + Rename "$INSTDIR\Application.pending" "$INSTDIR\Application" + IfErrors staged_move_failed + Goto payload_activated + + current_move_failed: + RMDir /r "$INSTDIR\Application.pending" + !insertmacro Fail 30 "The existing application files are in use. Close TextRecast and retry Setup." + + staged_move_failed: + ClearErrors + RMDir /r "$INSTDIR\Application" + IfFileExists "$INSTDIR\Application.previous\.textrecast-payload-complete" 0 staged_move_unrecoverable + ClearErrors + Rename "$INSTDIR\Application.previous" "$INSTDIR\Application" + IfErrors staged_move_unrecoverable + + staged_move_unrecoverable: + !insertmacro Fail 31 "The new application payload could not be activated. The previous payload was preserved when available." + + payload_marker_write_failed: + FileClose $0 + + stage_failed: + RMDir /r "$INSTDIR\Application.pending" + !insertmacro Fail 32 "The application payload could not be staged. Existing application files were not changed." + + pending_cleanup_failed: + !insertmacro Fail 33 "A previous staged payload could not be removed. Close TextRecast Setup and retry." + + previous_cleanup_failed: + RMDir /r "$INSTDIR\Application.pending" + !insertmacro Fail 34 "The previous rollback payload could not be cleaned up. Close TextRecast and retry Setup." + + payload_activated: + SetOutPath "$INSTDIR" + ClearErrors + WriteUninstaller "$INSTDIR\Uninstall.exe" + IfErrors metadata_failed + + ClearErrors + CreateDirectory "$SMPROGRAMS\TextRecast" + CreateShortcut "$SMPROGRAMS\TextRecast\TextRecast.lnk" "$INSTDIR\Application\TextRecast.exe" + IfErrors metadata_failed + + ClearErrors + WriteRegStr HKCU "Software\TextRecast" "ProductId" "${PRODUCT_ID}" + WriteRegStr HKCU "Software\TextRecast" "InstallLocation" "$INSTDIR" + WriteRegStr HKCU "Software\TextRecast" "Version" "${APP_VERSION}" + + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "DisplayName" "TextRecast" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "DisplayVersion" "${APP_VERSION}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "Publisher" "snss10" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "DisplayIcon" "$INSTDIR\Application\TextRecast.exe" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "InstallLocation" "$INSTDIR" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "UninstallString" '$\"$INSTDIR\Uninstall.exe$\"' + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "QuietUninstallString" '$\"$INSTDIR\Uninstall.exe$\" /S' + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "URLInfoAbout" "https://github.com/snss10/TextRecast" + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "NoModify" 1 + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "NoRepair" 1 + IfErrors metadata_failed + + RMDir /r "$INSTDIR\Application.previous" + IfFileExists "$INSTDIR\Application.previous\*.*" previous_cleanup_after_install_failed + RMDir "$INSTDIR\Application.previous" + Goto install_complete + + metadata_failed: + IfFileExists "$INSTDIR\Application.previous\.textrecast-payload-complete" restore_after_metadata_failure clean_after_metadata_failure + + restore_after_metadata_failure: + ClearErrors + RMDir /r "$INSTDIR\Application" + IfErrors metadata_rollback_failed + ClearErrors + Rename "$INSTDIR\Application.previous" "$INSTDIR\Application" + IfErrors metadata_rollback_failed + + ClearErrors + CreateDirectory "$SMPROGRAMS\TextRecast" + CreateShortcut "$SMPROGRAMS\TextRecast\TextRecast.lnk" "$INSTDIR\Application\TextRecast.exe" + WriteRegStr HKCU "Software\TextRecast" "ProductId" "${PRODUCT_ID}" + WriteRegStr HKCU "Software\TextRecast" "InstallLocation" "$INSTDIR" + WriteRegStr HKCU "Software\TextRecast" "Version" "$2" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "DisplayName" "TextRecast" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "DisplayVersion" "$2" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "Publisher" "snss10" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "DisplayIcon" "$INSTDIR\Application\TextRecast.exe" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "InstallLocation" "$INSTDIR" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "UninstallString" '$\"$INSTDIR\Uninstall.exe$\"' + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "QuietUninstallString" '$\"$INSTDIR\Uninstall.exe$\" /S' + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "URLInfoAbout" "https://github.com/snss10/TextRecast" + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "NoModify" 1 + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" "NoRepair" 1 + !insertmacro Fail 40 "Setup could not update the Windows installation metadata. The previous application payload was restored." + + clean_after_metadata_failure: + Delete "$DESKTOP\TextRecast.lnk" + Delete "$SMSTARTUP\TextRecast.lnk" + Delete "$SMPROGRAMS\TextRecast\TextRecast.lnk" + RMDir "$SMPROGRAMS\TextRecast" + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" + DeleteRegKey HKCU "Software\TextRecast" + Delete "$INSTDIR\Uninstall.exe" + RMDir /r "$INSTDIR\Application" + Delete "$INSTDIR\.textrecast-install" + RMDir "$INSTDIR" + !insertmacro Fail 41 "Setup could not create the Windows installation metadata. The incomplete installation was removed." + + metadata_rollback_failed: + !insertmacro Fail 42 "Setup could not finish or restore the previous application payload. Run Setup again to recover it." + + previous_cleanup_after_install_failed: + !insertmacro Fail 43 "TextRecast was updated, but its previous rollback payload could not be removed. Run Setup again after closing TextRecast." + + install_complete: +SectionEnd + +Section "Uninstall" + SetShellVarContext current + ClearErrors + FileOpen $0 "$INSTDIR\.textrecast-install" r + IfErrors uninstall_marker_invalid + FileRead $0 $1 + FileClose $0 + StrCmp $1 "${PRODUCT_ID}" uninstall_allowed + + uninstall_marker_invalid: + !insertmacro Fail 50 "The TextRecast installation marker is missing or invalid. Application files were not removed." + + uninstall_allowed: + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\TextRecast" + DeleteRegKey HKCU "Software\TextRecast" + Delete "$DESKTOP\TextRecast.lnk" + Delete "$SMSTARTUP\TextRecast.lnk" + Delete "$SMPROGRAMS\TextRecast\TextRecast.lnk" + RMDir "$SMPROGRAMS\TextRecast" + RMDir /r "$INSTDIR" +SectionEnd diff --git a/src/TextRecast.App/App.xaml b/src/TextRecast.App/App.xaml index d681db7..71c94cc 100644 --- a/src/TextRecast.App/App.xaml +++ b/src/TextRecast.App/App.xaml @@ -1,3 +1,11 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + + + + + + + + diff --git a/src/TextRecast.App/App.xaml.cs b/src/TextRecast.App/App.xaml.cs index 8570089..2e1e9a7 100644 --- a/src/TextRecast.App/App.xaml.cs +++ b/src/TextRecast.App/App.xaml.cs @@ -5,7 +5,7 @@ using System.Windows; using TextRecast.App.Presentation; using TextRecast.Core.Application; -using TextRecast.Infrastructure.Hardware; +using TextRecast.Deployment.Setup; using TextRecast.Infrastructure.SLM; using TextRecast.Infrastructure.Windows.Replacement; using TextRecast.Infrastructure.Windows.Selection; @@ -18,6 +18,7 @@ namespace TextRecast.App; Justification = "WPF owns the application lifecycle; OnExit releases process-lifetime resources.")] public partial class App : global::System.Windows.Application { + private const string VerifyInstallationArgument = "--verify-installation"; private const string SingleInstanceNamePrefix = @"Local\TextRecast-"; private LocalSlmTextFormatter? _formatter; private HttpClient? _modelDownloadClient; @@ -27,8 +28,15 @@ public partial class App : global::System.Windows.Application protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); + ApplicationTheme.Initialize(); ShutdownMode = ShutdownMode.OnExplicitShutdown; + if (e.Args.Contains(VerifyInstallationArgument, StringComparer.OrdinalIgnoreCase)) + { + Shutdown(VerifyInstallation()); + return; + } + if (!TryAcquireSingleInstance()) { Shutdown(); @@ -38,6 +46,41 @@ protected override void OnStartup(StartupEventArgs e) _ = StartAsync(); } + private static int VerifyInstallation() + { + var requiredFiles = new[] + { + "LICENSE", + "NOTICE", + "PRIVACY.md", + "THIRD-PARTY-NOTICES.md", + "DOTNET-LICENSE.txt", + "DOTNET-THIRD-PARTY-NOTICES.txt", + "WPF-LICENSE.txt" + }; + + if (requiredFiles.Any(file => + !File.Exists(Path.Combine(AppContext.BaseDirectory, file)))) + { + return 1; + } + + var excludedPayloadPatterns = new[] + { + "*.gguf", + "*.partial", + "*.partial.metadata.json" + }; + + return excludedPayloadPatterns.Any(pattern => + Directory.EnumerateFiles( + AppContext.BaseDirectory, + pattern, + SearchOption.AllDirectories).Any()) + ? 1 + : 0; + } + private async Task StartAsync() { try @@ -55,67 +98,49 @@ private async Task StartAsync() _modelDownloadClient, packagedModelDirectory, userModelDirectory); - var installedPaths = installations.FindInstalledModels(); - var settingsStore = new ModelSelectionSettingsStore(profiles.Select(profile => profile.Id)); - var settings = await settingsStore.LoadAsync(); - var choices = CreateModelChoices(profiles, installedPaths.Keys, userModelDirectory); - var selectedChoice = settings.ActiveModelId is string activeModelId - ? choices.FirstOrDefault(choice => - choice.IsCompatible && - choice.IsInstalled && - choice.Profile.Id.Equals(activeModelId, StringComparison.Ordinal)) - : null; - - SlmModelProfile modelProfile; - string modelPath; - if (selectedChoice is not null) + var setupStateStore = new ModelSetupStateStore(profiles); + var migrationResult = await new V02ModelSetupStateMigrator( + setupStateStore, + settingsStore, + installations, + profiles) + .LoadOrMigrateWithResultAsync(); + var setupState = migrationResult.State; + var activeModelId = setupState.ActiveModel?.ModelId; + var activeModelPath = migrationResult.VerifiedLegacyModelPath; + if (activeModelId is not null && activeModelPath is null) { - modelProfile = selectedChoice.Profile; - modelPath = installedPaths[modelProfile.Id]; + activeModelPath = await installations + .GetInstaller(activeModelId) + .FindVerifiedInstalledModelAsync(); } - else - { - var initialModelId = choices.Any(choice => - choice.IsCompatible && - choice.Profile.Id.Equals(settings.ActiveModelId, StringComparison.Ordinal)) - ? settings.ActiveModelId! - : SlmModelCatalog.Default.Id; - var selectionWindow = new ModelSelectionWindow(choices, initialModelId); - if (selectionWindow.ShowDialog() != true || selectionWindow.SelectedProfile is null) - { - Shutdown(); - return; - } - modelProfile = selectionWindow.SelectedProfile; - var modelInstaller = installations.GetInstaller(modelProfile.Id); - modelPath = modelInstaller.FindInstalledModel() ?? string.Empty; - if (string.IsNullOrWhiteSpace(modelPath)) + if (SetupRecoveryLauncher.IsRecoveryRequired( + setupState, + activeModelId, + activeModelPath)) + { + if (!SetupRecoveryLauncher.TryLaunch(out var recoveryError)) { - var downloadWindow = new ModelDownloadWindow(modelInstaller, modelProfile); - if (downloadWindow.ShowDialog() != true || - string.IsNullOrWhiteSpace(downloadWindow.InstalledModelPath)) - { - Shutdown(); - return; - } - - modelPath = downloadWindow.InstalledModelPath; + MessageBox.Show( + recoveryError, + "Complete TextRecast setup", + MessageBoxButton.OK, + MessageBoxImage.Warning); } - await settingsStore.SaveAsync(new ModelSelectionSettings - { - Mode = ModelSelectionMode.Manual, - ActiveModelId = modelProfile.Id - }); + Shutdown(); + return; } - StartMainWindow(modelProfile, modelPath); + StartMainWindow( + SlmModelCatalog.GetById(activeModelId!), + activeModelPath!); } catch (Exception exception) when (exception is - HardwareInspectionException or IOException or + InvalidDataException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) @@ -146,35 +171,6 @@ private void StartMainWindow(SlmModelProfile modelProfile, string modelPath) ShutdownMode = ShutdownMode.OnMainWindowClose; } - private static ModelSelectionChoice[] CreateModelChoices( - IReadOnlyList profiles, - IEnumerable installedModelIds, - string userModelDirectory) - { - var installedIds = new HashSet(installedModelIds, StringComparer.Ordinal); - HardwareProfile? hardware = null; - try - { - hardware = new HardwareInspector().Inspect(userModelDirectory); - } - catch (HardwareInspectionException) - { - } - - return SlmModelSetupPlanner.CreateChoices( - profiles, - hardware, - installedIds, - SlmModelCatalog.Default.Id) - .Select(choice => new ModelSelectionChoice( - choice.Profile, - choice.IsCompatible, - choice.IsInstalled, - choice.IsRecommended, - choice.CompatibilityText)) - .ToArray(); - } - protected override void OnExit(ExitEventArgs e) { _formatter?.Dispose(); diff --git a/src/TextRecast.App/AssemblyInfo.cs b/src/TextRecast.App/AssemblyInfo.cs index 061bf86..dad171a 100644 --- a/src/TextRecast.App/AssemblyInfo.cs +++ b/src/TextRecast.App/AssemblyInfo.cs @@ -1,5 +1,8 @@ +using System.Runtime.CompilerServices; using System.Windows; +[assembly: InternalsVisibleTo("TextRecast.App.Tests")] + [assembly: ThemeInfo( ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly diff --git a/src/TextRecast.App/Presentation/ApplicationInformation.cs b/src/TextRecast.App/Presentation/ApplicationInformation.cs new file mode 100644 index 0000000..6153870 --- /dev/null +++ b/src/TextRecast.App/Presentation/ApplicationInformation.cs @@ -0,0 +1,129 @@ +using System.Globalization; +using System.IO; +using System.Reflection; +using System.Text.RegularExpressions; +using TextRecast.Infrastructure.SLM; + +namespace TextRecast.App.Presentation; + +internal sealed record InformationPage(string Header, string Content); + +internal sealed record InformationDialogContent( + string Title, + string Heading, + string Description, + IReadOnlyList Pages); + +internal static class ApplicationInformation +{ + private static readonly (string Header, string FileName)[] LegalFiles = + [ + ("Privacy", "PRIVACY.md"), + ("License", "LICENSE"), + ("Notice", "NOTICE"), + ("Third-party notices", "THIRD-PARTY-NOTICES.md") + ]; + + public static InformationDialogContent CreateModel(SlmModelProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + var profileName = profile.Role switch + { + SlmModelRole.Fast => "Fast/default", + SlmModelRole.Balanced => "Balanced", + SlmModelRole.Quality => "Best quality", + SlmModelRole.Alternative => "Alternative", + _ => profile.Role.ToString() + }; + var content = string.Join( + Environment.NewLine + Environment.NewLine, + $"Profile{Environment.NewLine}{profileName}", + $"Download size{Environment.NewLine}{FormatGiB(profile.ExpectedFileSize)}", + $"Language{Environment.NewLine}{profile.LanguageSupport}", + $"Source{Environment.NewLine}{profile.SourceRepository}", + $"License{Environment.NewLine}{profile.LicenseExpression}", + $"Review guidance{Environment.NewLine}{profile.LimitationNotice}", + "TextRecast runs this model locally. Always review generated text before using it."); + + return new InformationDialogContent( + "Model information", + profile.DisplayName, + "The active local language model used by TextRecast.", + [new InformationPage("Details", content)]); + } + + public static InformationDialogContent CreateLegalAndPrivacy() + { + var pages = LegalFiles + .Select(item => new InformationPage( + item.Header, + ReadPackagedDocument(item.FileName))) + .ToArray(); + return new InformationDialogContent( + "Legal & Privacy", + "Legal & Privacy", + "These documents are packaged with TextRecast and remain available offline.", + pages); + } + + public static InformationDialogContent CreateAbout() + { + var assembly = Assembly.GetExecutingAssembly(); + var version = assembly.GetName().Version?.ToString(3) ?? "Unknown"; + var content = string.Join( + Environment.NewLine + Environment.NewLine, + $"Version{Environment.NewLine}{version}", + "TextRecast is a Windows writing assistant that formats selected text with a local language model.", + "Text processing and model inference stay on this computer. Model downloads occur only during setup or recovery.", + "Licensed under the Apache License 2.0.", + "Copyright 2026 snss10"); + return new InformationDialogContent( + "About TextRecast", + "TextRecast", + "Local writing assistance for selected text.", + [new InformationPage("About", content)]); + } + + private static string ReadPackagedDocument(string fileName) + { + var path = Path.Combine(AppContext.BaseDirectory, fileName); + try + { + var content = File.ReadAllText(path); + return fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + ? FormatMarkdownForDisplay(content) + : content; + } + catch (Exception exception) when (exception is + FileNotFoundException or + DirectoryNotFoundException or + IOException or + UnauthorizedAccessException) + { + return $"The packaged {fileName} document could not be opened. " + + "Repair TextRecast with the official setup and try again."; + } + } + + private static string FormatMarkdownForDisplay(string markdown) + { + var lines = markdown + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n') + .Select(line => Regex.Replace(line, @"^#{1,6}\s+", string.Empty)) + .Select(line => line.StartsWith("- ", StringComparison.Ordinal) + ? $"• {line[2..]}" + : line) + .Select(line => Regex.Replace(line, @"\[([^\]]+)\]\(([^)]+)\)", "$1 ($2)")) + .Select(line => line.Replace("`", string.Empty, StringComparison.Ordinal)); + return string.Join(Environment.NewLine, lines); + } + + private static string FormatGiB(long bytes) + { + const double gibibyte = 1024d * 1024 * 1024; + return string.Create( + CultureInfo.InvariantCulture, + $"{bytes / gibibyte:F2} GiB"); + } +} diff --git a/src/TextRecast.App/Presentation/ApplicationTheme.cs b/src/TextRecast.App/Presentation/ApplicationTheme.cs new file mode 100644 index 0000000..9eca495 --- /dev/null +++ b/src/TextRecast.App/Presentation/ApplicationTheme.cs @@ -0,0 +1,131 @@ +using System.Runtime.InteropServices; +using System.Security; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; +using Microsoft.Win32; + +namespace TextRecast.App.Presentation; + +internal static class ApplicationTheme +{ + private const int DarkTitleBarAttribute = 20; + private static readonly DependencyProperty IsDarkProperty = + DependencyProperty.RegisterAttached( + "IsDark", + typeof(bool), + typeof(ApplicationTheme)); + + public static bool IsDarkMode() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); + return key?.GetValue("AppsUseLightTheme") is int value && value == 0; + } + catch (Exception exception) when ( + exception is SecurityException or UnauthorizedAccessException) + { + return false; + } + } + + public static void Initialize() + { + SetApplicationPalette(IsDarkMode()); + } + + public static void Apply(Window window) + { + ArgumentNullException.ThrowIfNull(window); + Apply(window, IsDarkMode()); + } + + internal static void Apply(Window window, bool dark) + { + ArgumentNullException.ThrowIfNull(window); + SetApplicationPalette(dark); + window.SetValue(IsDarkProperty, dark); + window.SourceInitialized -= Window_SourceInitialized; + window.SourceInitialized += Window_SourceInitialized; + window.Activated -= Window_Activated; + window.Activated += Window_Activated; + ApplyTitleBar(window, dark); + } + + private static void Window_SourceInitialized(object? sender, EventArgs e) + { + if (sender is Window window) + { + ApplyTitleBar(window, (bool)window.GetValue(IsDarkProperty)); + } + } + + private static void Window_Activated(object? sender, EventArgs e) + { + if (sender is Window window) + { + var dark = IsDarkMode(); + if (dark != (bool)window.GetValue(IsDarkProperty)) + { + Apply(window, dark); + } + } + } + + private static void SetApplicationPalette(bool dark) + { + var resources = global::System.Windows.Application.Current.Resources; + Set(resources, "SurfaceBrush", dark ? "#202225" : "#FFFFFF"); + Set(resources, "CanvasBrush", dark ? "#17191C" : "#F6F6F6"); + Set(resources, "InkBrush", dark ? "#F5F5F5" : "#171717"); + Set(resources, "HeaderBrush", dark ? "#17191C" : "#202225"); + Set(resources, "HeaderHoverBrush", dark ? "#363A40" : "#35383D"); + Set(resources, "FooterBrush", dark ? "#1C1F22" : "#F3F3F3"); + Set(resources, "PrimaryHoverBrush", dark ? "#60CDFF" : "#005A9E"); + Set(resources, "SecondaryTextBrush", dark ? "#C7C7C7" : "#4A4A4A"); + Set(resources, "DividerBrush", dark ? "#4A4D51" : "#C9C9C9"); + Set(resources, "ActionBorderBrush", dark ? "#6B6F75" : "#8B8B8B"); + Set(resources, "DisabledSurfaceBrush", dark ? "#24282C" : "#F4F4F4"); + Set(resources, "DisabledTextBrush", dark ? "#85898E" : "#8A8A8A"); + Set(resources, "AccentBrush", dark ? "#60CDFF" : "#0067C0"); + Set(resources, "AccentOutlineBrush", dark ? "#4BA7D1" : "#60A5D8"); + Set(resources, "AccentSoftBrush", dark ? "#173B4D" : "#E5F1FB"); + Set(resources, "SuccessBrush", dark ? "#4CCB70" : "#15803D"); + Set(resources, "WarningBrush", dark ? "#F0A04B" : "#B45309"); + Set(resources, "ErrorBrush", dark ? "#FF6B6B" : "#B42318"); + Set(resources, "MenuHoverBrush", dark ? "#34373C" : "#E8E8E8"); + Set(resources, "MenuIconBrush", dark ? "#E3E3E3" : "#34373B"); + Set(resources, "LauncherBorderBrush", dark ? "#72777F" : "#5E636B"); + } + + private static void Set(ResourceDictionary resources, string key, string color) + { + resources[key] = new SolidColorBrush( + (Color)ColorConverter.ConvertFromString(color)); + } + + private static void ApplyTitleBar(Window window, bool dark) + { + var handle = new WindowInteropHelper(window).Handle; + if (handle == nint.Zero) + { + return; + } + + var enabled = dark ? 1 : 0; + _ = DwmSetWindowAttribute( + handle, + DarkTitleBarAttribute, + ref enabled, + sizeof(int)); + } + + [DllImport("dwmapi.dll")] + private static extern int DwmSetWindowAttribute( + nint windowHandle, + int attribute, + ref int attributeValue, + int attributeSize); +} diff --git a/src/TextRecast.App/Presentation/InformationWindow.xaml b/src/TextRecast.App/Presentation/InformationWindow.xaml new file mode 100644 index 0000000..77c9974 --- /dev/null +++ b/src/TextRecast.App/Presentation/InformationWindow.xaml @@ -0,0 +1,107 @@ + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + 12 + 4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +