From aa2ccc43f8323b20db6002c0697039252678cb28 Mon Sep 17 00:00:00 2001 From: snss10 Date: Sat, 8 Aug 2026 12:34:51 +0530 Subject: [PATCH 1/6] build: add the Windows setup foundation --- .gitattributes | 1 + .github/workflows/build.yml | 84 +++- .gitignore | 1 + Models/README.md | 4 +- NSIS-LICENSE.txt | 26 ++ PRIVACY.md | 40 ++ README.md | 24 +- THIRD-PARTY-NOTICES.md | 7 + TextRecast.slnx | 4 + installer/README.md | 36 ++ installer/TextRecast.Package/TextRecast.nsi | 313 +++++++++++++ src/TextRecast.App/App.xaml.cs | 87 +++- src/TextRecast.App/TextRecast.App.csproj | 7 + src/TextRecast.Deployment/AssemblyInfo.cs | 3 + .../Hardware/HardwareInspector.cs | 0 .../Hardware/HardwareProfile.cs | 0 .../Hardware/WindowsHardwareProbe.cs | 0 .../Installer/WindowsInstallerIdentity.cs | 26 ++ .../SLM/ModelSelectionSettingsStore.cs | 50 +- .../SLM/SlmModelCatalog.cs | 24 +- .../SLM/SlmModelInstallationSet.cs | 23 +- .../SLM/SlmModelInstaller.cs | 35 +- .../SLM/SlmModelProfile.cs | 0 .../SLM/SlmModelRecommender.cs | 0 .../SLM/SlmModelRequirements.cs | 0 .../SLM/SlmModelSetupPlanner.cs | 0 .../SLM/SlmRuntimeProfileIds.cs | 14 + .../Setup/ModelSetupState.cs | 151 +++++++ .../Setup/ModelSetupStateStore.cs | 187 ++++++++ .../Setup/V02ModelSetupStateMigrator.cs | 96 ++++ .../Storage/AtomicFileWriter.cs | 50 ++ .../TextRecast.Deployment.csproj | 6 + .../SLM/Granite41ModelAdapter.cs | 8 +- .../SLM/Qwen25ModelAdapter.cs | 2 +- .../SLM/Qwen35ModelAdapter.cs | 11 +- .../TextRecast.Infrastructure.csproj | 1 + src/TextRecast.Setup/App.xaml | 3 + src/TextRecast.Setup/App.xaml.cs | 94 ++++ src/TextRecast.Setup/AssemblyInfo.cs | 3 + src/TextRecast.Setup/EmbeddedPackageEngine.cs | 427 ++++++++++++++++++ src/TextRecast.Setup/MainWindow.xaml | 92 ++++ src/TextRecast.Setup/MainWindow.xaml.cs | 107 +++++ src/TextRecast.Setup/SetupCommandLine.cs | 94 ++++ src/TextRecast.Setup/TextRecast.Setup.csproj | 44 ++ .../HardwareInspectorTests.cs | 2 +- .../ModelSelectionSettingsStoreTests.cs | 2 +- .../ModelSetupStateStoreTests.cs | 271 +++++++++++ .../SlmModelCatalogTests.cs | 28 +- .../SlmModelInstallationSetTests.cs | 12 +- .../SlmModelInstallerTests.cs | 39 +- .../SlmModelRecommenderTests.cs | 4 +- .../SlmModelSetupPlannerTests.cs | 2 +- .../TextRecast.Deployment.Tests.csproj | 17 + .../V02ModelSetupStateMigratorTests.cs | 248 ++++++++++ .../WindowsInstallerIdentityTests.cs | 91 ++++ .../TextRecast.Infrastructure.Tests.csproj | 1 + .../SetupArchitectureTests.cs | 176 ++++++++ .../SetupCommandLineTests.cs | 52 +++ .../TextRecast.Setup.Tests.csproj | 19 + tools/Build-WindowsInstaller.ps1 | 110 +++++ tools/Get-Nsis.ps1 | 63 +++ tools/Test-WindowsInstaller.ps1 | 279 ++++++++++++ .../TextRecast.ModelBenchmarks.csproj | 1 + 63 files changed, 3477 insertions(+), 125 deletions(-) create mode 100644 NSIS-LICENSE.txt create mode 100644 PRIVACY.md create mode 100644 installer/README.md create mode 100644 installer/TextRecast.Package/TextRecast.nsi create mode 100644 src/TextRecast.Deployment/AssemblyInfo.cs rename src/{TextRecast.Infrastructure => TextRecast.Deployment}/Hardware/HardwareInspector.cs (100%) rename src/{TextRecast.Infrastructure => TextRecast.Deployment}/Hardware/HardwareProfile.cs (100%) rename src/{TextRecast.Infrastructure => TextRecast.Deployment}/Hardware/WindowsHardwareProbe.cs (100%) create mode 100644 src/TextRecast.Deployment/Installer/WindowsInstallerIdentity.cs rename src/{TextRecast.Infrastructure => TextRecast.Deployment}/SLM/ModelSelectionSettingsStore.cs (73%) rename src/{TextRecast.Infrastructure => TextRecast.Deployment}/SLM/SlmModelCatalog.cs (87%) rename src/{TextRecast.Infrastructure => TextRecast.Deployment}/SLM/SlmModelInstallationSet.cs (63%) rename src/{TextRecast.Infrastructure => TextRecast.Deployment}/SLM/SlmModelInstaller.cs (95%) rename src/{TextRecast.Infrastructure => TextRecast.Deployment}/SLM/SlmModelProfile.cs (100%) rename src/{TextRecast.Infrastructure => TextRecast.Deployment}/SLM/SlmModelRecommender.cs (100%) rename src/{TextRecast.Infrastructure => TextRecast.Deployment}/SLM/SlmModelRequirements.cs (100%) rename src/{TextRecast.Infrastructure => TextRecast.Deployment}/SLM/SlmModelSetupPlanner.cs (100%) create mode 100644 src/TextRecast.Deployment/SLM/SlmRuntimeProfileIds.cs create mode 100644 src/TextRecast.Deployment/Setup/ModelSetupState.cs create mode 100644 src/TextRecast.Deployment/Setup/ModelSetupStateStore.cs create mode 100644 src/TextRecast.Deployment/Setup/V02ModelSetupStateMigrator.cs create mode 100644 src/TextRecast.Deployment/Storage/AtomicFileWriter.cs create mode 100644 src/TextRecast.Deployment/TextRecast.Deployment.csproj create mode 100644 src/TextRecast.Setup/App.xaml create mode 100644 src/TextRecast.Setup/App.xaml.cs create mode 100644 src/TextRecast.Setup/AssemblyInfo.cs create mode 100644 src/TextRecast.Setup/EmbeddedPackageEngine.cs create mode 100644 src/TextRecast.Setup/MainWindow.xaml create mode 100644 src/TextRecast.Setup/MainWindow.xaml.cs create mode 100644 src/TextRecast.Setup/SetupCommandLine.cs create mode 100644 src/TextRecast.Setup/TextRecast.Setup.csproj rename tests/{TextRecast.Infrastructure.Tests => TextRecast.Deployment.Tests}/HardwareInspectorTests.cs (98%) rename tests/{TextRecast.Infrastructure.Tests => TextRecast.Deployment.Tests}/ModelSelectionSettingsStoreTests.cs (99%) create mode 100644 tests/TextRecast.Deployment.Tests/ModelSetupStateStoreTests.cs rename tests/{TextRecast.Infrastructure.Tests => TextRecast.Deployment.Tests}/SlmModelCatalogTests.cs (84%) rename tests/{TextRecast.Infrastructure.Tests => TextRecast.Deployment.Tests}/SlmModelInstallationSetTests.cs (94%) rename tests/{TextRecast.Infrastructure.Tests => TextRecast.Deployment.Tests}/SlmModelInstallerTests.cs (95%) rename tests/{TextRecast.Infrastructure.Tests => TextRecast.Deployment.Tests}/SlmModelRecommenderTests.cs (98%) rename tests/{TextRecast.Infrastructure.Tests => TextRecast.Deployment.Tests}/SlmModelSetupPlannerTests.cs (98%) create mode 100644 tests/TextRecast.Deployment.Tests/TextRecast.Deployment.Tests.csproj create mode 100644 tests/TextRecast.Deployment.Tests/V02ModelSetupStateMigratorTests.cs create mode 100644 tests/TextRecast.Deployment.Tests/WindowsInstallerIdentityTests.cs create mode 100644 tests/TextRecast.Setup.Tests/SetupArchitectureTests.cs create mode 100644 tests/TextRecast.Setup.Tests/SetupCommandLineTests.cs create mode 100644 tests/TextRecast.Setup.Tests/TextRecast.Setup.Tests.csproj create mode 100644 tools/Build-WindowsInstaller.ps1 create mode 100644 tools/Get-Nsis.ps1 create mode 100644 tools/Test-WindowsInstaller.ps1 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/workflows/build.yml b/.github/workflows/build.yml index 892cb41..aea4eb5 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 @@ -52,6 +52,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 +66,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 @@ -88,7 +109,9 @@ jobs: "TextRecast.exe", "LICENSE", "NOTICE", + "PRIVACY.md", "THIRD-PARTY-NOTICES.md", + "NSIS-LICENSE.txt", "DOTNET-LICENSE.txt", "DOTNET-THIRD-PARTY-NOTICES.txt", "WPF-LICENSE.txt" @@ -99,9 +122,16 @@ jobs: } } - $modelFiles = @(Get-ChildItem -Path $publishDirectory -Filter "*.gguf" -File -Recurse) - if ($modelFiles.Count -ne 0) { - throw "Release packages must not contain GGUF model files." + $excludedPayloads = @( + Get-ChildItem -Path $publishDirectory -File -Recurse | + Where-Object { + $_.Name -like "*.gguf" -or + $_.Name -like "*.partial" -or + $_.Name -like "*.partial.metadata.json" + } + ) + if ($excludedPayloads.Count -ne 0) { + throw "Release packages must not contain model or partial-download files." } $archiveFileName = "$env:PACKAGE_NAME.zip" @@ -111,6 +141,43 @@ jobs: $checksum = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() Set-Content -LiteralPath "$archivePath.sha256" -Value "$checksum $archiveFileName" -NoNewline + - name: Prepare controlled upgrade fixture + shell: pwsh + run: | + $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 + + - 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 + + - 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: 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 release package uses: actions/upload-artifact@v7 with: @@ -119,3 +186,12 @@ jobs: artifacts/release/${{ steps.version.outputs.package_name }}.zip artifacts/release/${{ steps.version.outputs.package_name }}.zip.sha256 if-no-files-found: error + + - name: Upload Windows setup + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.version.outputs.setup_name }} + path: | + 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..0cbbb92 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ Desktop.ini # Large local model binaries are downloaded separately. Models/*.gguf Models/*.partial +Models/*.partial.metadata.json # Local documentation drafts and planning notes docs/ diff --git a/Models/README.md b/Models/README.md index c42f251..d52281e 100644 --- a/Models/README.md +++ b/Models/README.md @@ -41,8 +41,8 @@ TextRecast uses HTTP Range and If-Range to resume compatible partial downloads. ## Offline or portable 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 build; 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. -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/NSIS-LICENSE.txt b/NSIS-LICENSE.txt new file mode 100644 index 0000000..299c59a --- /dev/null +++ b/NSIS-LICENSE.txt @@ -0,0 +1,26 @@ +NSIS (Nullsoft Scriptable Install System) +Copyright (C) 1999-2026 Contributors + +TextRecast's Windows installer is built with NSIS 3.12 and uses the NSIS +installer code and zlib compression module under the zlib/libpng license. + +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: https://nsis.sourceforge.io/ +Upstream license file: https://github.com/kichik/nsis/blob/v3.12/COPYING diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..7bea1fc --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,40 @@ +# TextRecast Privacy Notice + +Effective: August 2, 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 shortcuts. Uninstalling the application 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..e9ab763 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ Drag the floating icon to reposition it. Right-click it to view the active model ## 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. @@ -202,7 +204,7 @@ dotnet run --project src/TextRecast.App/TextRecast.App.csproj dotnet publish src/TextRecast.App/TextRecast.App.csproj -c Release -r win-x64 --self-contained true -o artifacts/TextRecast-win-x64 ``` -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. +Distribute the complete output folder, not only the executable. Normal publishes intentionally contain no model; the user chooses and confirms 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`. @@ -210,30 +212,32 @@ For tagged releases, GitHub Actions creates a model-free `TextRecast-vX.Y.Z-win- - [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 recommendations, 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, and Setup 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/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index b71c1fc..c222d13 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -47,6 +47,13 @@ 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 applicable copyright and license notice is +distributed as `NSIS-LICENSE.txt`. + ## 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. diff --git a/TextRecast.slnx b/TextRecast.slnx index 887dc9a..9e08e56 100644 --- a/TextRecast.slnx +++ b/TextRecast.slnx @@ -2,11 +2,15 @@ + + + + diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 0000000..c0295ae --- /dev/null +++ b/installer/README.md @@ -0,0 +1,36 @@ +# 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, 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. + +## 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 -o artifacts/publish/TextRecast-win-x64 +./tools/Build-WindowsInstaller.ps1 -Version 0.2.0 -NoRestore +``` + +The result is written to `artifacts/release/TextRecast-v0.2.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 shortcut and uninstall metadata are written only after payload activation. Uninstall removes the product-owned installation tree, shortcut, 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. + +## Licensing + +NSIS 3.12 and its zlib compression module are used under the zlib/libpng license. The applicable notice is kept in `NSIS-LICENSE.txt` and installed beside the application. The package also contains TextRecast's Apache license, privacy notice, project notice, third-party notices, 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..5cae44f --- /dev/null +++ b/installer/TextRecast.Package/TextRecast.nsi @@ -0,0 +1,313 @@ +!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 "$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 "$SMPROGRAMS\TextRecast\TextRecast.lnk" + RMDir "$SMPROGRAMS\TextRecast" + RMDir /r "$INSTDIR" +SectionEnd diff --git a/src/TextRecast.App/App.xaml.cs b/src/TextRecast.App/App.xaml.cs index 8570089..1b9b531 100644 --- a/src/TextRecast.App/App.xaml.cs +++ b/src/TextRecast.App/App.xaml.cs @@ -5,6 +5,7 @@ using System.Windows; using TextRecast.App.Presentation; using TextRecast.Core.Application; +using TextRecast.Deployment.Setup; using TextRecast.Infrastructure.Hardware; using TextRecast.Infrastructure.SLM; using TextRecast.Infrastructure.Windows.Replacement; @@ -18,6 +19,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; @@ -29,6 +31,12 @@ protected override void OnStartup(StartupEventArgs e) base.OnStartup(e); ShutdownMode = ShutdownMode.OnExplicitShutdown; + if (e.Args.Contains(VerifyInstallationArgument, StringComparer.OrdinalIgnoreCase)) + { + Shutdown(VerifyInstallation()); + return; + } + if (!TryAcquireSingleInstance()) { Shutdown(); @@ -38,6 +46,42 @@ protected override void OnStartup(StartupEventArgs e) _ = StartAsync(); } + private static int VerifyInstallation() + { + var requiredFiles = new[] + { + "LICENSE", + "NOTICE", + "PRIVACY.md", + "THIRD-PARTY-NOTICES.md", + "NSIS-LICENSE.txt", + "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,12 +99,27 @@ 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 + var setupStateStore = new ModelSetupStateStore(profiles); + var migrationResult = await new V02ModelSetupStateMigrator( + setupStateStore, + settingsStore, + installations, + profiles) + .LoadOrMigrateWithResultAsync(); + var setupState = migrationResult.State; + var candidatePaths = installations.FindModelCandidatesByExpectedSize(); + var activeModelId = setupState.ActiveModel?.ModelId; + var activeModelPath = migrationResult.VerifiedLegacyModelPath; + if (activeModelId is not null && activeModelPath is null) + { + activeModelPath = await installations + .GetInstaller(activeModelId) + .FindVerifiedInstalledModelAsync(); + } + + var choices = CreateModelChoices(profiles, candidatePaths.Keys, userModelDirectory); + var selectedChoice = activeModelId is not null && activeModelPath is not null ? choices.FirstOrDefault(choice => choice.IsCompatible && choice.IsInstalled && @@ -72,14 +131,16 @@ private async Task StartAsync() if (selectedChoice is not null) { modelProfile = selectedChoice.Profile; - modelPath = installedPaths[modelProfile.Id]; + modelPath = activeModelPath!; } else { var initialModelId = choices.Any(choice => choice.IsCompatible && - choice.Profile.Id.Equals(settings.ActiveModelId, StringComparison.Ordinal)) - ? settings.ActiveModelId! + choice.Profile.Id.Equals( + setupState.ActiveModel?.ModelId, + StringComparison.Ordinal)) + ? setupState.ActiveModel!.ModelId : SlmModelCatalog.Default.Id; var selectionWindow = new ModelSelectionWindow(choices, initialModelId); if (selectionWindow.ShowDialog() != true || selectionWindow.SelectedProfile is null) @@ -90,7 +151,9 @@ private async Task StartAsync() modelProfile = selectionWindow.SelectedProfile; var modelInstaller = installations.GetInstaller(modelProfile.Id); - modelPath = modelInstaller.FindInstalledModel() ?? string.Empty; + modelPath = modelProfile.Id.Equals(activeModelId, StringComparison.Ordinal) + ? activeModelPath ?? string.Empty + : await modelInstaller.FindVerifiedInstalledModelAsync() ?? string.Empty; if (string.IsNullOrWhiteSpace(modelPath)) { var downloadWindow = new ModelDownloadWindow(modelInstaller, modelProfile); @@ -104,6 +167,11 @@ private async Task StartAsync() modelPath = downloadWindow.InstalledModelPath; } + setupState = setupState.ActivateVerifiedModel( + modelProfile, + DateTimeOffset.UtcNow); + await setupStateStore.SaveAsync(setupState); + await settingsStore.SaveAsync(new ModelSelectionSettings { Mode = ModelSelectionMode.Manual, @@ -116,6 +184,7 @@ await settingsStore.SaveAsync(new ModelSelectionSettings catch (Exception exception) when (exception is HardwareInspectionException or IOException or + InvalidDataException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) diff --git a/src/TextRecast.App/TextRecast.App.csproj b/src/TextRecast.App/TextRecast.App.csproj index 500369d..d85c6d8 100644 --- a/src/TextRecast.App/TextRecast.App.csproj +++ b/src/TextRecast.App/TextRecast.App.csproj @@ -13,12 +13,14 @@ true x64 true + false none false + @@ -33,6 +35,7 @@ @@ -41,9 +44,13 @@ + + FindInstalledModels() + public IReadOnlyDictionary FindModelCandidatesByExpectedSize() { return installers - .Select(pair => (pair.Key, Path: pair.Value.FindInstalledModel())) + .Select(pair => (pair.Key, Path: pair.Value.FindCandidateModelByExpectedSize())) .Where(item => item.Path is not null) .ToDictionary(item => item.Key, item => item.Path!, StringComparer.Ordinal); } + + public async Task> FindVerifiedInstalledModelsAsync( + CancellationToken cancellationToken = default) + { + var verifiedModels = new Dictionary(StringComparer.Ordinal); + foreach (var pair in installers) + { + cancellationToken.ThrowIfCancellationRequested(); + var path = await pair.Value + .FindVerifiedInstalledModelAsync(cancellationToken) + .ConfigureAwait(false); + if (path is not null) + { + verifiedModels.Add(pair.Key, path); + } + } + + return verifiedModels; + } } diff --git a/src/TextRecast.Infrastructure/SLM/SlmModelInstaller.cs b/src/TextRecast.Deployment/SLM/SlmModelInstaller.cs similarity index 95% rename from src/TextRecast.Infrastructure/SLM/SlmModelInstaller.cs rename to src/TextRecast.Deployment/SLM/SlmModelInstaller.cs index 7e4b7b7..8b1d889 100644 --- a/src/TextRecast.Infrastructure/SLM/SlmModelInstaller.cs +++ b/src/TextRecast.Deployment/SLM/SlmModelInstaller.cs @@ -49,7 +49,7 @@ public SlmModelInstaller( public string PartialMetadataPath => $"{PartialModelPath}.metadata.json"; - public string? FindInstalledModel() + internal string? FindCandidateModelByExpectedSize() { if (HasExpectedSize(PackagedModelPath)) { @@ -59,6 +59,21 @@ public SlmModelInstaller( return HasExpectedSize(UserModelPath) ? UserModelPath : null; } + public async Task FindVerifiedInstalledModelAsync( + CancellationToken cancellationToken = default) + { + if (HasExpectedSize(PackagedModelPath) && + await HasExpectedHashAsync(PackagedModelPath, cancellationToken).ConfigureAwait(false)) + { + return PackagedModelPath; + } + + return HasExpectedSize(UserModelPath) && + await HasExpectedHashAsync(UserModelPath, cancellationToken).ConfigureAwait(false) + ? UserModelPath + : null; + } + public async Task DownloadAsync( IProgress? progress, CancellationToken cancellationToken) @@ -489,6 +504,17 @@ private bool HasExpectedSize(string path) } private async Task VerifyHashAsync(string path, CancellationToken cancellationToken) + { + if (!await HasExpectedHashAsync(path, cancellationToken).ConfigureAwait(false)) + { + throw new InvalidDataException( + "The downloaded model failed its integrity check. Please try again."); + } + } + + private async Task HasExpectedHashAsync( + string path, + CancellationToken cancellationToken) { await using var stream = new FileStream( path, @@ -500,11 +526,8 @@ private async Task VerifyHashAsync(string path, CancellationToken cancellationTo var hash = await SHA256 .HashDataAsync(stream, cancellationToken) .ConfigureAwait(false); - if (!Convert.ToHexStringLower(hash).Equals(_profile.ExpectedSha256, StringComparison.Ordinal)) - { - throw new InvalidDataException( - "The downloaded model failed its integrity check. Please try again."); - } + return Convert.ToHexStringLower(hash) + .Equals(_profile.ExpectedSha256, StringComparison.Ordinal); } private void ResetPartialDownload() diff --git a/src/TextRecast.Infrastructure/SLM/SlmModelProfile.cs b/src/TextRecast.Deployment/SLM/SlmModelProfile.cs similarity index 100% rename from src/TextRecast.Infrastructure/SLM/SlmModelProfile.cs rename to src/TextRecast.Deployment/SLM/SlmModelProfile.cs diff --git a/src/TextRecast.Infrastructure/SLM/SlmModelRecommender.cs b/src/TextRecast.Deployment/SLM/SlmModelRecommender.cs similarity index 100% rename from src/TextRecast.Infrastructure/SLM/SlmModelRecommender.cs rename to src/TextRecast.Deployment/SLM/SlmModelRecommender.cs diff --git a/src/TextRecast.Infrastructure/SLM/SlmModelRequirements.cs b/src/TextRecast.Deployment/SLM/SlmModelRequirements.cs similarity index 100% rename from src/TextRecast.Infrastructure/SLM/SlmModelRequirements.cs rename to src/TextRecast.Deployment/SLM/SlmModelRequirements.cs diff --git a/src/TextRecast.Infrastructure/SLM/SlmModelSetupPlanner.cs b/src/TextRecast.Deployment/SLM/SlmModelSetupPlanner.cs similarity index 100% rename from src/TextRecast.Infrastructure/SLM/SlmModelSetupPlanner.cs rename to src/TextRecast.Deployment/SLM/SlmModelSetupPlanner.cs diff --git a/src/TextRecast.Deployment/SLM/SlmRuntimeProfileIds.cs b/src/TextRecast.Deployment/SLM/SlmRuntimeProfileIds.cs new file mode 100644 index 0000000..917a4f1 --- /dev/null +++ b/src/TextRecast.Deployment/SLM/SlmRuntimeProfileIds.cs @@ -0,0 +1,14 @@ +namespace TextRecast.Infrastructure.SLM; + +public static class SlmRuntimeProfileIds +{ + public const string Qwen25AdapterId = "qwen2.5-chatml"; + public const string Qwen25PromptProfileId = "qwen2.5-production-v1"; + public const string Qwen35AdapterId = "qwen3.5-chatml"; + public const string Qwen35BalancedPromptProfileId = "qwen35-2b-balanced-v3"; + public const string Qwen35QualityPromptProfileId = "qwen35-4b-balanced-v3"; + public const string Qwen35SamplingProfileId = "qwen3.5-default-v1"; + public const string Granite41AdapterId = "granite4.1-chat"; + public const string Granite41BalancedPromptProfileId = "granite41-3b-balanced-v3"; + public const string GreedySamplingProfileId = "greedy-v1"; +} diff --git a/src/TextRecast.Deployment/Setup/ModelSetupState.cs b/src/TextRecast.Deployment/Setup/ModelSetupState.cs new file mode 100644 index 0000000..41fca78 --- /dev/null +++ b/src/TextRecast.Deployment/Setup/ModelSetupState.cs @@ -0,0 +1,151 @@ +using System.Text.Json.Serialization; +using TextRecast.Infrastructure.SLM; + +namespace TextRecast.Deployment.Setup; + +public enum PendingModelSetupStage +{ + ApplicationInstalled, + DownloadingModel, + VerifyingModel +} + +public sealed record ActiveModelState +{ + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + public required string ModelId { get; init; } + public required string ExpectedSha256 { get; init; } + public required long ExpectedFileSize { get; init; } + public required string SourceRevision { get; init; } + public required DateTimeOffset VerifiedAtUtc { get; init; } +} + +public sealed record PendingModelSetupState +{ + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + public required Guid OperationId { get; init; } + public required string ModelId { get; init; } + public required string ApplicationVersion { get; init; } + public required PendingModelSetupStage Stage { get; init; } + public required DateTimeOffset StartedAtUtc { get; init; } + public required DateTimeOffset UpdatedAtUtc { get; init; } +} + +public sealed record ModelSetupState +{ + public const int CurrentSchemaVersion = 1; + + public static ModelSetupState Default { get; } = new(); + + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + public ActiveModelState? ActiveModel { get; init; } + public PendingModelSetupState? PendingSetup { get; init; } + + [JsonIgnore] + public bool IsComplete => ActiveModel is not null && PendingSetup is null; + + public ModelSetupState BeginPendingSetup( + string modelId, + string applicationVersion, + DateTimeOffset startedAtUtc, + Guid? operationId = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modelId); + ArgumentException.ThrowIfNullOrWhiteSpace(applicationVersion); + EnsureUtc(startedAtUtc, nameof(startedAtUtc)); + + var id = operationId ?? Guid.NewGuid(); + if (id == Guid.Empty) + { + throw new ArgumentException( + "The setup operation identifier cannot be empty.", + nameof(operationId)); + } + + return this with + { + PendingSetup = new PendingModelSetupState + { + OperationId = id, + ModelId = modelId, + ApplicationVersion = applicationVersion, + Stage = PendingModelSetupStage.ApplicationInstalled, + StartedAtUtc = startedAtUtc, + UpdatedAtUtc = startedAtUtc + } + }; + } + + public ModelSetupState AdvancePendingSetup( + PendingModelSetupStage stage, + DateTimeOffset updatedAtUtc) + { + if (PendingSetup is null) + { + throw new InvalidOperationException("There is no pending model setup to update."); + } + + if (!Enum.IsDefined(stage)) + { + throw new ArgumentOutOfRangeException(nameof(stage)); + } + + EnsureUtc(updatedAtUtc, nameof(updatedAtUtc)); + if (updatedAtUtc < PendingSetup.UpdatedAtUtc) + { + throw new ArgumentOutOfRangeException( + nameof(updatedAtUtc), + "The setup update time cannot move backwards."); + } + + return this with + { + PendingSetup = PendingSetup with + { + Stage = stage, + UpdatedAtUtc = updatedAtUtc + } + }; + } + + public ModelSetupState ActivateVerifiedModel( + SlmModelProfile profile, + DateTimeOffset verifiedAtUtc) + { + ArgumentNullException.ThrowIfNull(profile); + EnsureUtc(verifiedAtUtc, nameof(verifiedAtUtc)); + if (PendingSetup is not null && + !PendingSetup.ModelId.Equals(profile.Id, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The verified model does not match the pending model setup."); + } + + return this with + { + ActiveModel = new ActiveModelState + { + ModelId = profile.Id, + ExpectedSha256 = profile.ExpectedSha256, + ExpectedFileSize = profile.ExpectedFileSize, + SourceRevision = profile.SourceRevision, + VerifiedAtUtc = verifiedAtUtc + }, + PendingSetup = null + }; + } + + private static void EnsureUtc(DateTimeOffset value, string parameterName) + { + if (value == default || value.Offset != TimeSpan.Zero) + { + throw new ArgumentException( + "Setup-state timestamps must be non-default UTC values.", + parameterName); + } + } +} diff --git a/src/TextRecast.Deployment/Setup/ModelSetupStateStore.cs b/src/TextRecast.Deployment/Setup/ModelSetupStateStore.cs new file mode 100644 index 0000000..3f3dc13 --- /dev/null +++ b/src/TextRecast.Deployment/Setup/ModelSetupStateStore.cs @@ -0,0 +1,187 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using TextRecast.Deployment.Storage; +using TextRecast.Infrastructure.SLM; + +namespace TextRecast.Deployment.Setup; + +public sealed class ModelSetupStateStore +{ + private const string ApplicationDirectoryName = "TextRecast"; + private const string StateFileName = "setup-state.json"; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + Converters = { new JsonStringEnumConverter(allowIntegerValues: false) } + }; + + private readonly Dictionary profiles; + + public ModelSetupStateStore(IEnumerable profiles) + : this(GetDefaultStatePath(), profiles) + { + } + + internal ModelSetupStateStore( + string statePath, + IEnumerable profiles) + { + ArgumentException.ThrowIfNullOrWhiteSpace(statePath); + ArgumentNullException.ThrowIfNull(profiles); + + StatePath = Path.GetFullPath(statePath); + this.profiles = profiles.ToDictionary( + profile => profile.Id, + StringComparer.Ordinal); + if (this.profiles.Count == 0) + { + throw new ArgumentException( + "At least one known model profile is required.", + nameof(profiles)); + } + } + + public string StatePath { get; } + + public async Task LoadAsync( + CancellationToken cancellationToken = default) + { + string json; + try + { + json = await File.ReadAllTextAsync(StatePath, cancellationToken) + .ConfigureAwait(false); + } + catch (FileNotFoundException) + { + return ModelSetupState.Default; + } + catch (DirectoryNotFoundException) + { + return ModelSetupState.Default; + } + + ModelSetupState? state; + try + { + state = JsonSerializer.Deserialize( + json, + SerializerOptions); + } + catch (JsonException) + { + return ModelSetupState.Default; + } + catch (NotSupportedException) + { + return ModelSetupState.Default; + } + + if (state is not null) + { + ThrowIfUnsupportedSchema(state); + } + + return IsValid(state) + ? state! + : ModelSetupState.Default; + } + + public async Task SaveAsync( + ModelSetupState state, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(state); + if (!IsValid(state)) + { + throw new ArgumentException( + "The setup state is invalid or references an unknown model.", + nameof(state)); + } + + var json = JsonSerializer.Serialize(state, SerializerOptions); + await AtomicFileWriter.WriteAsync(StatePath, json, cancellationToken) + .ConfigureAwait(false); + } + + private static string GetDefaultStatePath() + { + var localApplicationData = Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(localApplicationData)) + { + throw new InvalidOperationException( + "The local application-data directory is unavailable."); + } + + return Path.Combine( + localApplicationData, + ApplicationDirectoryName, + StateFileName); + } + + private bool IsValid(ModelSetupState? state) + { + return state is not null && + state.SchemaVersion == ModelSetupState.CurrentSchemaVersion && + IsValid(state.ActiveModel) && + IsValid(state.PendingSetup); + } + + private bool IsValid(ActiveModelState? activeModel) + { + if (activeModel is null) + { + return true; + } + + return !string.IsNullOrWhiteSpace(activeModel.ModelId) && + !string.IsNullOrWhiteSpace(activeModel.ExpectedSha256) && + !string.IsNullOrWhiteSpace(activeModel.SourceRevision) && + profiles.TryGetValue(activeModel.ModelId, out var profile) && + activeModel.ExpectedSha256.Equals( + profile.ExpectedSha256, + StringComparison.OrdinalIgnoreCase) && + activeModel.ExpectedFileSize == profile.ExpectedFileSize && + activeModel.SourceRevision.Equals( + profile.SourceRevision, + StringComparison.Ordinal) && + IsUtc(activeModel.VerifiedAtUtc); + } + + private bool IsValid(PendingModelSetupState? pendingSetup) + { + if (pendingSetup is null) + { + return true; + } + + return pendingSetup.OperationId != Guid.Empty && + !string.IsNullOrWhiteSpace(pendingSetup.ModelId) && + profiles.ContainsKey(pendingSetup.ModelId) && + !string.IsNullOrWhiteSpace(pendingSetup.ApplicationVersion) && + Enum.IsDefined(pendingSetup.Stage) && + IsUtc(pendingSetup.StartedAtUtc) && + IsUtc(pendingSetup.UpdatedAtUtc) && + pendingSetup.UpdatedAtUtc >= pendingSetup.StartedAtUtc; + } + + private static bool IsUtc(DateTimeOffset value) + { + return value != default && value.Offset == TimeSpan.Zero; + } + + private static void ThrowIfUnsupportedSchema(ModelSetupState state) + { + if (state.SchemaVersion != ModelSetupState.CurrentSchemaVersion || + state.ActiveModel is { SchemaVersion: not ActiveModelState.CurrentSchemaVersion } || + state.PendingSetup is { SchemaVersion: not PendingModelSetupState.CurrentSchemaVersion }) + { + throw new InvalidDataException( + "The setup state was created by an unsupported TextRecast version and was not changed."); + } + } +} diff --git a/src/TextRecast.Deployment/Setup/V02ModelSetupStateMigrator.cs b/src/TextRecast.Deployment/Setup/V02ModelSetupStateMigrator.cs new file mode 100644 index 0000000..5d69391 --- /dev/null +++ b/src/TextRecast.Deployment/Setup/V02ModelSetupStateMigrator.cs @@ -0,0 +1,96 @@ +using System.IO; +using TextRecast.Infrastructure.SLM; + +namespace TextRecast.Deployment.Setup; + +public sealed record ModelSetupStateMigrationResult( + ModelSetupState State, + string? VerifiedLegacyModelPath); + +public sealed class V02ModelSetupStateMigrator +{ + private readonly SlmModelInstallationSet installations; + private readonly ModelSelectionSettingsStore legacySettingsStore; + private readonly Dictionary profiles; + private readonly ModelSetupStateStore stateStore; + private readonly TimeProvider timeProvider; + + public V02ModelSetupStateMigrator( + ModelSetupStateStore stateStore, + ModelSelectionSettingsStore legacySettingsStore, + SlmModelInstallationSet installations, + IEnumerable profiles) + : this( + stateStore, + legacySettingsStore, + installations, + profiles, + TimeProvider.System) + { + } + + internal V02ModelSetupStateMigrator( + ModelSetupStateStore stateStore, + ModelSelectionSettingsStore legacySettingsStore, + SlmModelInstallationSet installations, + IEnumerable profiles, + TimeProvider timeProvider) + { + this.stateStore = stateStore ?? + throw new ArgumentNullException(nameof(stateStore)); + this.legacySettingsStore = legacySettingsStore ?? + throw new ArgumentNullException(nameof(legacySettingsStore)); + this.installations = installations ?? + throw new ArgumentNullException(nameof(installations)); + ArgumentNullException.ThrowIfNull(profiles); + this.profiles = profiles.ToDictionary( + profile => profile.Id, + StringComparer.Ordinal); + this.timeProvider = timeProvider ?? + throw new ArgumentNullException(nameof(timeProvider)); + } + + public async Task LoadOrMigrateAsync( + CancellationToken cancellationToken = default) + { + var result = await LoadOrMigrateWithResultAsync(cancellationToken) + .ConfigureAwait(false); + return result.State; + } + + public async Task LoadOrMigrateWithResultAsync( + CancellationToken cancellationToken = default) + { + if (File.Exists(stateStore.StatePath)) + { + var existingState = await stateStore + .LoadAsync(cancellationToken) + .ConfigureAwait(false); + return new ModelSetupStateMigrationResult(existingState, null); + } + + var state = ModelSetupState.Default; + string? verifiedLegacyModelPath = null; + var legacySettings = await legacySettingsStore + .LoadAsync(cancellationToken) + .ConfigureAwait(false); + if (legacySettings.ActiveModelId is string activeModelId && + profiles.TryGetValue(activeModelId, out var profile)) + { + var verifiedPath = await installations + .GetInstaller(activeModelId) + .FindVerifiedInstalledModelAsync(cancellationToken) + .ConfigureAwait(false); + if (verifiedPath is not null) + { + verifiedLegacyModelPath = verifiedPath; + state = state.ActivateVerifiedModel( + profile, + timeProvider.GetUtcNow()); + } + } + + await stateStore.SaveAsync(state, cancellationToken).ConfigureAwait(false); + return new ModelSetupStateMigrationResult(state, verifiedLegacyModelPath); + } +} diff --git a/src/TextRecast.Deployment/Storage/AtomicFileWriter.cs b/src/TextRecast.Deployment/Storage/AtomicFileWriter.cs new file mode 100644 index 0000000..fae0159 --- /dev/null +++ b/src/TextRecast.Deployment/Storage/AtomicFileWriter.cs @@ -0,0 +1,50 @@ +using System.IO; +using System.Text; + +namespace TextRecast.Deployment.Storage; + +internal static class AtomicFileWriter +{ + private static readonly Encoding Utf8WithoutByteOrderMark = new UTF8Encoding(false); + + public static async Task WriteAsync( + string destinationPath, + string content, + CancellationToken cancellationToken) + { + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (string.IsNullOrEmpty(destinationDirectory)) + { + throw new ArgumentException( + "The destination path must include a directory.", + nameof(destinationPath)); + } + + Directory.CreateDirectory(destinationDirectory); + var temporaryPath = Path.Combine( + destinationDirectory, + $".{Path.GetFileName(destinationPath)}.{Guid.NewGuid():N}.tmp"); + + try + { + var bytes = Utf8WithoutByteOrderMark.GetBytes(content); + await using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + File.Move(temporaryPath, destinationPath, overwrite: true); + } + finally + { + File.Delete(temporaryPath); + } + } +} diff --git a/src/TextRecast.Deployment/TextRecast.Deployment.csproj b/src/TextRecast.Deployment/TextRecast.Deployment.csproj new file mode 100644 index 0000000..dc80edd --- /dev/null +++ b/src/TextRecast.Deployment/TextRecast.Deployment.csproj @@ -0,0 +1,6 @@ + + + net10.0-windows + TextRecast.Deployment + + diff --git a/src/TextRecast.Infrastructure/SLM/Granite41ModelAdapter.cs b/src/TextRecast.Infrastructure/SLM/Granite41ModelAdapter.cs index 2425cc5..b4d5a26 100644 --- a/src/TextRecast.Infrastructure/SLM/Granite41ModelAdapter.cs +++ b/src/TextRecast.Infrastructure/SLM/Granite41ModelAdapter.cs @@ -5,9 +5,11 @@ namespace TextRecast.Infrastructure.SLM; public sealed class Granite41ModelAdapter : ISlmModelAdapter { - public const string AdapterId = "granite4.1-chat"; - public const string BalancedPromptProfileId = "granite41-3b-balanced-v3"; - public const string GreedySamplingProfileId = "greedy-v1"; + public const string AdapterId = SlmRuntimeProfileIds.Granite41AdapterId; + public const string BalancedPromptProfileId = + SlmRuntimeProfileIds.Granite41BalancedPromptProfileId; + public const string GreedySamplingProfileId = + SlmRuntimeProfileIds.GreedySamplingProfileId; private const string SystemInstruction = "Perform the task on the source rather than describing the task. Preserve factual status, actors, exact role labels, recurrence, conditions, causes, negation, names, numbers, relative-time phrases, and deadline wording. Wording may change as requested, but facts may not. Keep unknown referents general, add no rationale, and return only the revised text."; diff --git a/src/TextRecast.Infrastructure/SLM/Qwen25ModelAdapter.cs b/src/TextRecast.Infrastructure/SLM/Qwen25ModelAdapter.cs index b55cef6..32ee270 100644 --- a/src/TextRecast.Infrastructure/SLM/Qwen25ModelAdapter.cs +++ b/src/TextRecast.Infrastructure/SLM/Qwen25ModelAdapter.cs @@ -5,7 +5,7 @@ namespace TextRecast.Infrastructure.SLM; public sealed class Qwen25ModelAdapter : ISlmModelAdapter { - public const string AdapterId = "qwen2.5-chatml"; + public const string AdapterId = SlmRuntimeProfileIds.Qwen25AdapterId; private static readonly IReadOnlyList ChatMlStopSequences = Array.AsReadOnly(["<|im_end|>", "<|im_start|>"]); diff --git a/src/TextRecast.Infrastructure/SLM/Qwen35ModelAdapter.cs b/src/TextRecast.Infrastructure/SLM/Qwen35ModelAdapter.cs index 1ee1172..c35c30a 100644 --- a/src/TextRecast.Infrastructure/SLM/Qwen35ModelAdapter.cs +++ b/src/TextRecast.Infrastructure/SLM/Qwen35ModelAdapter.cs @@ -5,10 +5,13 @@ namespace TextRecast.Infrastructure.SLM; public sealed class Qwen35ModelAdapter : ISlmModelAdapter { - public const string AdapterId = "qwen3.5-chatml"; - public const string BalancedPromptProfileId = "qwen35-2b-balanced-v3"; - public const string QualityPromptProfileId = "qwen35-4b-balanced-v3"; - public const string DefaultSamplingProfileId = "qwen3.5-default-v1"; + public const string AdapterId = SlmRuntimeProfileIds.Qwen35AdapterId; + public const string BalancedPromptProfileId = + SlmRuntimeProfileIds.Qwen35BalancedPromptProfileId; + public const string QualityPromptProfileId = + SlmRuntimeProfileIds.Qwen35QualityPromptProfileId; + public const string DefaultSamplingProfileId = + SlmRuntimeProfileIds.Qwen35SamplingProfileId; private const string BalancedSystemInstruction = "Carry out the task on the source. Change wording and detail only as the task calls for, while preserving real-world meaning. Do not turn facts into advice, success into failure, or suggestions into requirements. Retain actors, responsibility, causes, conditions, recurrence, negation, names, numbers, and deadline wording. Invent nothing and return only the edit. Do not explain or show reasoning."; diff --git a/src/TextRecast.Infrastructure/TextRecast.Infrastructure.csproj b/src/TextRecast.Infrastructure/TextRecast.Infrastructure.csproj index 5e466dd..e51fb4e 100644 --- a/src/TextRecast.Infrastructure/TextRecast.Infrastructure.csproj +++ b/src/TextRecast.Infrastructure/TextRecast.Infrastructure.csproj @@ -9,6 +9,7 @@ + diff --git a/src/TextRecast.Setup/App.xaml b/src/TextRecast.Setup/App.xaml new file mode 100644 index 0000000..dc070d2 --- /dev/null +++ b/src/TextRecast.Setup/App.xaml @@ -0,0 +1,3 @@ + diff --git a/src/TextRecast.Setup/App.xaml.cs b/src/TextRecast.Setup/App.xaml.cs new file mode 100644 index 0000000..3c237b0 --- /dev/null +++ b/src/TextRecast.Setup/App.xaml.cs @@ -0,0 +1,94 @@ +using System.ComponentModel; +using System.IO; +using System.Windows; + +namespace TextRecast.Setup; + +public partial class App : global::System.Windows.Application +{ + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + ShutdownMode = ShutdownMode.OnExplicitShutdown; + + SetupOptions options; + var quietRequested = SetupCommandLine.IsQuietRequested(e.Args); + try + { + options = SetupCommandLine.Parse(e.Args); + } + catch (ArgumentException exception) + { + if (!quietRequested) + { + MessageBox.Show( + exception.Message, + "TextRecast Setup", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + Shutdown(2); + return; + } + + var engine = new EmbeddedPackageEngine(); + if (options.Operation is SetupOperation.Interactive) + { + MainWindow = new MainWindow(engine); + MainWindow.Show(); + ShutdownMode = ShutdownMode.OnMainWindowClose; + return; + } + + _ = RunCommandAsync(engine, options); + } + + private async Task RunCommandAsync( + EmbeddedPackageEngine engine, + SetupOptions options) + { + try + { + var installDirectory = options.InstallDirectory ?? + engine.ResolveInstalledDirectory(); + var result = options.Operation switch + { + SetupOperation.Install => await engine.InstallAsync(installDirectory), + SetupOperation.Uninstall => await engine.UninstallAsync(installDirectory), + _ => throw new InvalidOperationException("A setup operation was not selected.") + }; + + Environment.ExitCode = result.ExitCode; + if (!options.Quiet && !result.Succeeded) + { + MessageBox.Show( + result.Message, + "TextRecast Setup", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + } + catch (Exception exception) when (exception is + Win32Exception or + IOException or + InvalidDataException or + InvalidOperationException or + UnauthorizedAccessException or + ArgumentException) + { + Environment.ExitCode = 1; + if (!options.Quiet) + { + MessageBox.Show( + exception.Message, + "TextRecast Setup", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + } + finally + { + Shutdown(Environment.ExitCode); + } + } +} diff --git a/src/TextRecast.Setup/AssemblyInfo.cs b/src/TextRecast.Setup/AssemblyInfo.cs new file mode 100644 index 0000000..2acfdf6 --- /dev/null +++ b/src/TextRecast.Setup/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("TextRecast.Setup.Tests")] diff --git a/src/TextRecast.Setup/EmbeddedPackageEngine.cs b/src/TextRecast.Setup/EmbeddedPackageEngine.cs new file mode 100644 index 0000000..06499e4 --- /dev/null +++ b/src/TextRecast.Setup/EmbeddedPackageEngine.cs @@ -0,0 +1,427 @@ +using System.Diagnostics; +using System.IO; +using System.Reflection; +using Microsoft.Win32; +using TextRecast.Deployment.Installer; + +namespace TextRecast.Setup; + +internal sealed record PackageOperationResult(int ExitCode, string Message) +{ + public bool Succeeded => ExitCode == 0; +} + +internal interface IInstallerPackageEngine +{ + string DefaultInstallDirectory { get; } + + string ResolveInstalledDirectory(); + + bool IsInstalled(string installDirectory); + + Task InstallAsync(string installDirectory); + + Task UninstallAsync(string installDirectory); +} + +internal sealed class EmbeddedPackageEngine : IInstallerPackageEngine +{ + private const string ApplicationDirectoryName = "Application"; + private const string PayloadMarkerFileName = ".textrecast-payload-complete"; + private const string PackageResourceName = "TextRecast.Package.exe"; + private const string InstallLocationValueName = "InstallLocation"; + + public EmbeddedPackageEngine() + { + var localApplicationData = Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(localApplicationData)) + { + throw new InvalidOperationException( + "The local application-data directory is unavailable."); + } + + LocalApplicationDataDirectory = Path.GetFullPath(localApplicationData); + DefaultInstallDirectory = WindowsInstallerIdentity.GetDefaultInstallDirectory( + LocalApplicationDataDirectory); + } + + internal string LocalApplicationDataDirectory { get; } + + public string DefaultInstallDirectory { get; } + + public string ResolveInstalledDirectory() + { + using var applicationKey = Registry.CurrentUser.OpenSubKey( + WindowsInstallerIdentity.ApplicationRegistryKey, + writable: false); + var registeredPath = applicationKey?.GetValue( + InstallLocationValueName, + defaultValue: null, + RegistryValueOptions.DoNotExpandEnvironmentNames) as string; + + if (!string.IsNullOrWhiteSpace(registeredPath)) + { + try + { + return ValidateInstallDirectory(registeredPath); + } + catch (ArgumentException) + { + } + } + + return DefaultInstallDirectory; + } + + public bool IsInstalled(string installDirectory) + { + var validatedDirectory = ValidateInstallDirectory(installDirectory); + var applicationDirectory = GetApplicationDirectory(validatedDirectory); + return HasValidInstallationMarker(validatedDirectory) && + File.Exists(Path.Combine( + applicationDirectory, + WindowsInstallerIdentity.ExecutableName)) && + HasValidPayloadMarker(applicationDirectory) && + File.Exists(Path.Combine( + validatedDirectory, + WindowsInstallerIdentity.UninstallerName)); + } + + public async Task InstallAsync(string installDirectory) + { + var validatedDirectory = ValidateInstallDirectory(installDirectory); + EnsureInstallTargetIsOwnedOrEmpty(validatedDirectory); + var extractionDirectory = CreateExtractionDirectory(); + try + { + var packagePath = await ExtractPackageAsync(extractionDirectory) + .ConfigureAwait(false); + var exitCode = await RunProcessAsync( + packagePath, + ["/S", $"/D={validatedDirectory}"]) + .ConfigureAwait(false); + if (exitCode != 0) + { + return new PackageOperationResult( + exitCode, + $"The Windows package engine returned exit code {exitCode}."); + } + + VerifyInstalledPayload(validatedDirectory); + return new PackageOperationResult(0, "TextRecast was installed successfully."); + } + finally + { + TryDeleteDirectory(extractionDirectory); + } + } + + public async Task UninstallAsync(string installDirectory) + { + var validatedDirectory = ValidateInstallDirectory(installDirectory); + if (!Directory.Exists(validatedDirectory)) + { + return new PackageOperationResult(0, "TextRecast is not installed."); + } + + if (!HasValidInstallationMarker(validatedDirectory)) + { + throw new InvalidDataException( + "The installation marker is missing or invalid. Application files were not removed."); + } + + var uninstallerPath = Path.Combine( + validatedDirectory, + WindowsInstallerIdentity.UninstallerName); + if (!File.Exists(uninstallerPath)) + { + return new PackageOperationResult( + 1, + "The installation is incomplete and has no uninstaller. Run Setup again to repair it."); + } + + var exitCode = await RunProcessAsync(uninstallerPath, ["/S"]) + .ConfigureAwait(false); + if (exitCode != 0) + { + return new PackageOperationResult( + exitCode, + $"The Windows uninstaller returned exit code {exitCode}."); + } + + if (!await WaitForDirectoryRemovalAsync(validatedDirectory) + .ConfigureAwait(false)) + { + return new PackageOperationResult( + 1, + "The uninstaller completed, but the installation directory remains."); + } + + return new PackageOperationResult(0, "TextRecast was removed successfully."); + } + + internal string ValidateInstallDirectory(string installDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(installDirectory); + var fullPath = Path.GetFullPath(installDirectory) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var localPath = LocalApplicationDataDirectory + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var relativePath = Path.GetRelativePath(localPath, fullPath); + if (relativePath.Equals(".", StringComparison.Ordinal) || + relativePath.Equals("..", StringComparison.Ordinal) || + relativePath.StartsWith( + $"..{Path.DirectorySeparatorChar}", + StringComparison.Ordinal) || + Path.IsPathRooted(relativePath)) + { + throw new ArgumentException( + "TextRecast must be installed inside the current user's local application-data directory.", + nameof(installDirectory)); + } + + var userDataDirectory = Path.Combine(localPath, "TextRecast"); + if (IsSameOrDescendant(fullPath, userDataDirectory)) + { + throw new ArgumentException( + "The application cannot be installed inside TextRecast's model and settings directory.", + nameof(installDirectory)); + } + + if (!Path.GetFileName(fullPath).Equals( + WindowsInstallerIdentity.InstallFolderName, + StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"The installation directory must end with '{WindowsInstallerIdentity.InstallFolderName}'.", + nameof(installDirectory)); + } + + return fullPath; + } + + internal void EnsureInstallTargetIsOwnedOrEmpty(string installDirectory) + { + var validatedDirectory = ValidateInstallDirectory(installDirectory); + if (!Directory.Exists(validatedDirectory) || + !Directory.EnumerateFileSystemEntries(validatedDirectory).Any() || + HasValidInstallationMarker(validatedDirectory)) + { + return; + } + + throw new InvalidDataException( + "The selected installation directory contains files that are not owned by TextRecast."); + } + + private static bool HasValidInstallationMarker(string installDirectory) + { + var markerPath = Path.Combine(installDirectory, ".textrecast-install"); + try + { + return File.Exists(markerPath) && + File.ReadAllText(markerPath).Equals( + WindowsInstallerIdentity.ProductId, + StringComparison.Ordinal); + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + private static bool HasValidPayloadMarker(string applicationDirectory) + { + var markerPath = Path.Combine(applicationDirectory, PayloadMarkerFileName); + try + { + var value = File.ReadAllText(markerPath); + return !string.IsNullOrWhiteSpace(value) && + value.Equals(value.Trim(), StringComparison.Ordinal) && + Version.TryParse(value, out var version) && + version.Build >= 0; + } + catch (FileNotFoundException) + { + return false; + } + catch (DirectoryNotFoundException) + { + return false; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + private static bool IsSameOrDescendant(string path, string parent) + { + var normalizedPath = path.TrimEnd( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar); + var normalizedParent = parent.TrimEnd( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar); + return normalizedPath.Equals(normalizedParent, StringComparison.OrdinalIgnoreCase) || + normalizedPath.StartsWith( + $"{normalizedParent}{Path.DirectorySeparatorChar}", + StringComparison.OrdinalIgnoreCase); + } + + private static string CreateExtractionDirectory() + { + var path = Path.Combine( + Path.GetTempPath(), + "TextRecast.Setup", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static async Task ExtractPackageAsync(string extractionDirectory) + { + await using var packageStream = Assembly.GetExecutingAssembly() + .GetManifestResourceStream(PackageResourceName) ?? + throw new InvalidDataException( + "The setup executable does not contain the internal application package."); + var packagePath = Path.Combine(extractionDirectory, "TextRecast.Package.exe"); + await using var output = new FileStream( + packagePath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 1024 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough); + await packageStream.CopyToAsync(output).ConfigureAwait(false); + await output.FlushAsync().ConfigureAwait(false); + return packagePath; + } + + private static async Task RunProcessAsync( + string executablePath, + IReadOnlyList arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = executablePath, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(executablePath) ?? + AppContext.BaseDirectory + }; + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo) ?? + throw new InvalidOperationException( + "The Windows package engine could not be started."); + await process.WaitForExitAsync().ConfigureAwait(false); + return process.ExitCode; + } + + private static async Task WaitForDirectoryRemovalAsync(string path) + { + const int attemptCount = 40; + for (var attempt = 0; attempt < attemptCount; attempt++) + { + if (!Directory.Exists(path)) + { + return true; + } + + await Task.Delay(TimeSpan.FromMilliseconds(50)).ConfigureAwait(false); + } + + return !Directory.Exists(path); + } + + private static void VerifyInstalledPayload(string installDirectory) + { + if (!HasValidInstallationMarker(installDirectory)) + { + throw new InvalidDataException( + "The installed application has a missing or invalid ownership marker."); + } + + var applicationDirectory = GetApplicationDirectory(installDirectory); + var requiredApplicationFiles = new[] + { + WindowsInstallerIdentity.ExecutableName, + "LICENSE", + "NOTICE", + "PRIVACY.md", + "THIRD-PARTY-NOTICES.md", + "NSIS-LICENSE.txt", + "DOTNET-LICENSE.txt", + "DOTNET-THIRD-PARTY-NOTICES.txt", + "WPF-LICENSE.txt", + PayloadMarkerFileName + }; + var missingFile = requiredApplicationFiles.FirstOrDefault(file => + !File.Exists(Path.Combine(applicationDirectory, file))); + if (missingFile is not null) + { + throw new InvalidDataException( + $"The installed application is missing required file: {missingFile}"); + } + + if (!HasValidPayloadMarker(applicationDirectory)) + { + throw new InvalidDataException( + "The installed application has a missing or invalid payload marker."); + } + + if (!File.Exists(Path.Combine( + installDirectory, + WindowsInstallerIdentity.UninstallerName))) + { + throw new InvalidDataException( + $"The installed application is missing required file: {WindowsInstallerIdentity.UninstallerName}"); + } + + var excludedPayload = Directory.EnumerateFiles( + installDirectory, + "*", + SearchOption.AllDirectories) + .FirstOrDefault(path => + path.EndsWith(".gguf", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".partial", StringComparison.OrdinalIgnoreCase) || + path.EndsWith( + ".partial.metadata.json", + StringComparison.OrdinalIgnoreCase)); + if (excludedPayload is not null) + { + throw new InvalidDataException( + "The model-free installation unexpectedly contains model data."); + } + } + + private static string GetApplicationDirectory(string installDirectory) => + Path.Combine(installDirectory, ApplicationDirectoryName); + + private static void TryDeleteDirectory(string path) + { + try + { + Directory.Delete(path, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/src/TextRecast.Setup/MainWindow.xaml b/src/TextRecast.Setup/MainWindow.xaml new file mode 100644 index 0000000..bc6aa48 --- /dev/null +++ b/src/TextRecast.Setup/MainWindow.xaml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 12 + 4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + + + + + + + - - + ShowInTaskbar="False" + Topmost="True"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +