diff --git a/.github/workflows/release-winget.yml b/.github/workflows/release-winget.yml new file mode 100644 index 000000000..60a35c2e2 --- /dev/null +++ b/.github/workflows/release-winget.yml @@ -0,0 +1,72 @@ +name: Windows Release for WinGet + +on: + push: + tags: + - "ver.*" + workflow_dispatch: + inputs: + version: + description: "Release version or tag, for example 1.2.0, 1.2.1-rc1, 1.2.1-alpha1, or ver.1.2.0" + required: false + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-release: + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: ilammy/msvc-dev-cmd@v1 + with: + arch: amd64 + + - name: Resolve version + id: version + shell: pwsh + run: | + $value = "${{ github.event.inputs.version }}" + if (-not $value -and "${{ github.ref_type }}" -eq "tag") { + $value = "${{ github.ref_name }}" + } + if ($value.StartsWith("ver.")) { + $clean = $value.Substring(4) + } elseif ($value.StartsWith("v")) { + $clean = $value.Substring(1) + } else { + $clean = $value + } + "value=$clean" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Build portable release and WinGet manifests + shell: pwsh + run: | + ./scripts/release-windows-winget.ps1 ` + -Version "${{ steps.version.outputs.value }}" ` + -OutputDir release ` + -GitHubRepository "${{ github.repository }}" ` + -PackageIdentifier "BYVoid.OpenCC" ` + -Publisher "BYVoid" + + - name: Upload release artifacts + uses: actions/upload-artifact@v4 + with: + name: opencc-winget-release-${{ steps.version.outputs.value }} + path: | + release/*.zip + release/*.sha256 + release/winget-manifests/** + + - name: Publish GitHub release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: | + release/*.zip + release/*.sha256 + generate_release_notes: true diff --git a/README.md b/README.md index 9c47c9b89..3eb1ea081 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Discussion (Telegram): https://t.me/open_chinese_convert * [Fedora](https://packages.fedoraproject.org/pkgs/opencc/opencc/) * [Arch Linux](https://archlinux.org/packages/extra/x86_64/opencc/) * [macOS](https://formulae.brew.sh/formula/opencc) +* WinGet (`winget install BYVoid.OpenCC`) * [Bazel](https://registry.bazel.build/modules/opencc) * [Node.js](https://npmjs.org/package/opencc) * [Python](https://pypi.org/project/OpenCC/) @@ -43,6 +44,8 @@ Discussion (Telegram): https://t.me/open_chinese_convert * Windows (x86_64): [Latest build](https://ci.appveyor.com/api/projects/Carbo/opencc/artifacts/OpenCC.zip?branch=master&job=Environment:%20nodejs_version=none;%20Platform:%20x64) * Windows (x86): [Latest build](https://ci.appveyor.com/api/projects/Carbo/opencc/artifacts/OpenCC.zip?branch=master&job=Environment:%20nodejs_version=none;%20Platform:%20x86) +For a versioned Windows release intended for WinGet distribution, see [doc/windows-winget-release.md](doc/windows-winget-release.md). + ## Usage 使用 ### Online demo 線上轉換展示 diff --git a/doc/windows-winget-release.md b/doc/windows-winget-release.md new file mode 100644 index 000000000..69ccd9d8f --- /dev/null +++ b/doc/windows-winget-release.md @@ -0,0 +1,146 @@ +# Windows Release for WinGet + +This repository now includes a Windows release path intended for WinGet distribution. + +## Output + +Running `scripts/release-windows-winget.ps1` produces: + +- `OpenCC--windows-x64-portable.zip` +- `OpenCC--windows-x64-portable.zip.sha256` +- `winget-manifests/b/BYVoid/OpenCC//` + +By default the WinGet identity remains `BYVoid.OpenCC`, but the GitHub release source can be pointed at another repository for testing. + +The zip is structured as a portable package: + +- `OpenCC--windows-x64/bin/*.exe` +- `OpenCC--windows-x64/bin/opencc.dll` +- `OpenCC--windows-x64/share/opencc/*.json` +- `OpenCC--windows-x64/share/opencc/*.ocd2` + +OpenCC now also searches `../share/opencc` relative to the running executable or library on Windows, so the portable package works after WinGet extracts it. + +## Directory layout + +With the default arguments: + +- `BuildDir = build\winget-x64` +- `OutputDir = dist\winget-x64` + +the Windows release flow writes files into these locations: + +```text +build/ + winget-x64/ + src/ + data/ + test/ + Release/ + Testing/ + +dist/ + winget-x64/ + OpenCC--windows-x64-portable.zip + OpenCC--windows-x64-portable.zip.sha256 + staging/ + OpenCC--windows-x64/ + bin/ + opencc.exe + opencc_dict.exe + opencc_phrase_extract.exe + opencc.dll + share/ + opencc/ + *.json + *.ocd2 + LICENSE.txt + README.md + winget-manifests/ + b/ + BYVoid/ + OpenCC/ + / + BYVoid.OpenCC.yaml + BYVoid.OpenCC.installer.yaml + BYVoid.OpenCC.locale.en-US.yaml +``` + +The important distinction is: + +- `build\winget-x64` is the CMake and test working directory +- `dist\winget-x64\staging\...` is the installed portable tree before zipping +- `dist\winget-x64\OpenCC--windows-x64-portable.zip` is the final release asset +- `dist\winget-x64\winget-manifests\...` is the manifest payload to copy into `winget-pkgs` + +## Local build + +Run this from a Visual Studio developer shell or an environment where MSVC is configured: + +```powershell +./scripts/release-windows-winget.ps1 +``` + +To build a specific tag or skip tests: + +```powershell +./scripts/release-windows-winget.ps1 -Version 1.2.0 +./scripts/release-windows-winget.ps1 -Version 1.2.1-rc1 +./scripts/release-windows-winget.ps1 -Version 1.2.1-alpha1 +./scripts/release-windows-winget.ps1 -Version ver.1.2.0 -SkipTests +``` + +To test from a fork while keeping the upstream WinGet package identity: + +```powershell +./scripts/release-windows-winget.ps1 -Version 1.2.1-rc1 -GitHubRepository frankslin/OpenCC +``` + +This keeps: + +- `PackageIdentifier: BYVoid.OpenCC` +- `manifests/b/BYVoid/OpenCC//` + +but points release URLs at: + +- `https://github.com/frankslin/OpenCC` + +Accepted version formats are: + +- `x.y.z` +- `x.y.z-rcN` +- `x.y.z-alphaN` + +Values such as `1.2.0.exp` are rejected. + +## GitHub Actions + +The workflow `.github/workflows/release-winget.yml` runs on: + +- pushed tags matching `ver.*` +- manual `workflow_dispatch` + +On a tag build it will: + +1. build the x64 portable Windows release +2. upload the zip and checksum to the GitHub release +3. upload the generated WinGet manifests as a workflow artifact + +The workflow passes `${{ github.repository }}` into the release script, so fork builds point to the fork's GitHub release URLs automatically while still using the upstream WinGet package identifier by default. + +## WinGet submission + +The generated manifests are ready to copy into a `winget-pkgs` checkout under: + +```text +manifests/b/BYVoid/OpenCC// +``` + +Recommended validation flow in a `winget-pkgs` clone: + +```powershell +winget validate manifests\b\BYVoid\OpenCC\ +winget install --manifest manifests\b\BYVoid\OpenCC\ +``` + +If you later automate WinGet submission, the generated manifest directory can be used directly as the source payload for a PR bot or `wingetcreate update`. diff --git a/scripts/release-windows-winget.ps1 b/scripts/release-windows-winget.ps1 new file mode 100644 index 000000000..0007c07ee --- /dev/null +++ b/scripts/release-windows-winget.ps1 @@ -0,0 +1,227 @@ +param( + [string]$Version = "", + [string]$Arch = "x64", + [string]$BuildDir = "build\winget-$Arch", + [string]$OutputDir = "dist\winget-$Arch", + [string]$GitHubRepository = "BYVoid/OpenCC", + [string]$PackageIdentifier = "BYVoid.OpenCC", + [string]$Publisher = "BYVoid", + [switch]$SkipTests +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Get-ProjectVersion { + $cmakeFile = Join-Path $PSScriptRoot "..\CMakeLists.txt" + $content = Get-Content -Path $cmakeFile -Raw + + $major = [regex]::Match($content, 'OPENCC_VERSION_MAJOR\s+(\d+)').Groups[1].Value + $minor = [regex]::Match($content, 'OPENCC_VERSION_MINOR\s+(\d+)').Groups[1].Value + $revision = [regex]::Match($content, 'OPENCC_VERSION_REVISION\s+(\d+)').Groups[1].Value + + if (-not $major -or -not $minor -or -not $revision) { + throw "Failed to parse version from CMakeLists.txt." + } + + return "$major.$minor.$revision" +} + +function Write-Utf8File { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Content + ) + + $directory = Split-Path -Parent $Path + if ($directory) { + New-Item -ItemType Directory -Force -Path $directory | Out-Null + } + + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) +} + +function Normalize-ReleaseVersion { + param( + [Parameter(Mandatory = $true)][string]$RawVersion + ) + + $value = $RawVersion.Trim() + if ($value.StartsWith("ver.")) { + $value = $value.Substring(4) + } elseif ($value.StartsWith("v")) { + $value = $value.Substring(1) + } + + if ($value -notmatch '^\d+\.\d+\.\d+(-(alpha|rc)\d+)?$') { + throw "Invalid version '$RawVersion'. Expected x.y.z, x.y.z-alphaN, or x.y.z-rcN." + } + + return $value +} + +function Normalize-GitHubRepository { + param( + [Parameter(Mandatory = $true)][string]$RawRepository + ) + + $value = $RawRepository.Trim() + if ($value -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') { + throw "Invalid GitHub repository '$RawRepository'. Expected owner/name." + } + return $value +} + +function New-WinGetManifests { + param( + [Parameter(Mandatory = $true)][string]$PackageVersion, + [Parameter(Mandatory = $true)][string]$PackageUrl, + [Parameter(Mandatory = $true)][string]$InstallerSha256, + [Parameter(Mandatory = $true)][string]$ManifestRoot, + [Parameter(Mandatory = $true)][string]$PortableRoot, + [Parameter(Mandatory = $true)][string]$PackageIdentifier, + [Parameter(Mandatory = $true)][string]$Publisher, + [Parameter(Mandatory = $true)][string]$PublisherUrl, + [Parameter(Mandatory = $true)][string]$PublisherSupportUrl, + [Parameter(Mandatory = $true)][string]$PackagePageUrl, + [Parameter(Mandatory = $true)][string]$LicenseUrl + ) + $releaseDate = Get-Date -Format "yyyy-MM-dd" + + $versionManifest = @" +PackageIdentifier: $packageIdentifier +PackageVersion: $PackageVersion +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.9.0 +"@ + + $installerManifest = @" +PackageIdentifier: $packageIdentifier +PackageVersion: $PackageVersion +InstallerType: zip +NestedInstallerType: portable +ReleaseDate: $releaseDate +Installers: +- Architecture: x64 + InstallerUrl: $PackageUrl + InstallerSha256: $InstallerSha256 + NestedInstallerFiles: + - RelativeFilePath: $PortableRoot/bin/opencc.exe + PortableCommandAlias: opencc + - RelativeFilePath: $PortableRoot/bin/opencc_dict.exe + PortableCommandAlias: opencc_dict + - RelativeFilePath: $PortableRoot/bin/opencc_phrase_extract.exe + PortableCommandAlias: opencc_phrase_extract +ManifestType: installer +ManifestVersion: 1.9.0 +"@ + + $localeManifest = @" +PackageIdentifier: $packageIdentifier +PackageVersion: $PackageVersion +PackageLocale: en-US +Publisher: $Publisher +PublisherUrl: $PublisherUrl +PublisherSupportUrl: $PublisherSupportUrl +Author: Carbo Kuo and OpenCC contributors +PackageName: OpenCC +PackageUrl: $PackagePageUrl +License: Apache-2.0 +LicenseUrl: $LicenseUrl +ShortDescription: Open Chinese Convert +Description: Command-line tools and libraries for Simplified Chinese, Traditional Chinese, regional variant, and Japanese Kanji conversion. +Moniker: opencc +Tags: +- chinese +- conversion +- opencc +- simplified-chinese +- traditional-chinese +ManifestType: defaultLocale +ManifestVersion: 1.9.0 +"@ + + Write-Utf8File -Path (Join-Path $ManifestRoot "BYVoid.OpenCC.yaml") -Content $versionManifest + Write-Utf8File -Path (Join-Path $ManifestRoot "BYVoid.OpenCC.installer.yaml") -Content $installerManifest + Write-Utf8File -Path (Join-Path $ManifestRoot "BYVoid.OpenCC.locale.en-US.yaml") -Content $localeManifest +} + +$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) +Push-Location $repoRoot + +try { + if (-not $Version) { + $Version = Get-ProjectVersion + } + + $Version = Normalize-ReleaseVersion -RawVersion $Version + $GitHubRepository = Normalize-GitHubRepository -RawRepository $GitHubRepository + + if ($Arch -ne "x64") { + throw "This script currently supports only -Arch x64 for WinGet releases." + } + + $packageRoot = "https://github.com/$GitHubRepository" + $releaseUrlBase = "$packageRoot/releases/download/ver.$Version" + $licenseUrl = "$packageRoot/blob/master/LICENSE" + $publisherSupportUrl = "$packageRoot/issues" + + $resolvedBuildDir = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $BuildDir)) + $resolvedOutputDir = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $OutputDir)) + $portableRoot = "OpenCC-$Version-windows-$Arch" + $stagingRoot = Join-Path $resolvedOutputDir "staging" + $installRoot = Join-Path $stagingRoot $portableRoot + $assetName = "OpenCC-$Version-windows-$Arch-portable.zip" + $assetPath = Join-Path $resolvedOutputDir $assetName + $checksumPath = "$assetPath.sha256" + $wingetPathIdentifier = $PackageIdentifier.Replace('.', '\') + $wingetRoot = Join-Path $resolvedOutputDir "winget-manifests\$wingetPathIdentifier\$Version" + $releaseUrl = "$releaseUrlBase/$assetName" + + Remove-Item -Recurse -Force $resolvedBuildDir -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $resolvedOutputDir -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $resolvedBuildDir | Out-Null + New-Item -ItemType Directory -Force -Path $installRoot | Out-Null + + cmake -S . -B $resolvedBuildDir -A x64 ` + -DCMAKE_BUILD_TYPE=Release ` + -DCMAKE_INSTALL_PREFIX:PATH=$installRoot ` + -DENABLE_GTEST:BOOL=ON ` + -DENABLE_BENCHMARK:BOOL=OFF + + cmake --build $resolvedBuildDir --config Release --target install + + if (-not $SkipTests) { + ctest --test-dir $resolvedBuildDir --build-config Release --output-on-failure + } + + Copy-Item -Path LICENSE -Destination (Join-Path $installRoot "LICENSE.txt") + Copy-Item -Path README.md -Destination (Join-Path $installRoot "README.md") + + Compress-Archive -Path $installRoot -DestinationPath $assetPath -CompressionLevel Optimal + + $hash = (Get-FileHash -Path $assetPath -Algorithm SHA256).Hash.ToUpperInvariant() + Write-Utf8File -Path $checksumPath -Content "$hash *$assetName`n" + + New-WinGetManifests ` + -PackageVersion $Version ` + -PackageUrl $releaseUrl ` + -InstallerSha256 $hash ` + -ManifestRoot $wingetRoot ` + -PortableRoot $portableRoot ` + -PackageIdentifier $PackageIdentifier ` + -Publisher $Publisher ` + -PublisherUrl $packageRoot ` + -PublisherSupportUrl $publisherSupportUrl ` + -PackagePageUrl $packageRoot ` + -LicenseUrl $licenseUrl + + Write-Host "Release archive: $assetPath" + Write-Host "SHA256: $hash" + Write-Host "WinGet manifests: $wingetRoot" +} +finally { + Pop-Location +} diff --git a/src/Config.cpp b/src/Config.cpp index 839a99535..55dfc0ba6 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -21,6 +21,13 @@ #include #include +#if defined(_WIN32) || defined(_WIN64) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + #include #include "Config.hpp" @@ -42,6 +49,67 @@ namespace opencc { namespace { +std::string GetParentDirectory(const std::string& path); + +#if defined(_WIN32) || defined(_WIN64) +std::string Utf8FromWide(const std::wstring& wide) { + if (wide.empty()) { + return ""; + } + int requiredSize = WideCharToMultiByte(CP_UTF8, 0, wide.c_str(), -1, nullptr, + 0, nullptr, nullptr); + if (requiredSize <= 1) { + return ""; + } + std::string utf8(static_cast(requiredSize), '\0'); + WideCharToMultiByte(CP_UTF8, 0, wide.c_str(), -1, utf8.data(), requiredSize, + nullptr, nullptr); + utf8.resize(static_cast(requiredSize - 1)); + return utf8; +} + +std::string GetModulePath(HMODULE module) { + std::wstring buffer(MAX_PATH, L'\0'); + for (;;) { + DWORD copied = + GetModuleFileNameW(module, buffer.data(), static_cast(buffer.size())); + if (copied == 0) { + return ""; + } + if (copied < buffer.size() - 1) { + buffer.resize(copied); + return Utf8FromWide(buffer); + } + buffer.resize(buffer.size() * 2); + } +} + +std::string GetCurrentProcessModulePath() { + return GetModulePath(nullptr); +} + +std::string GetCurrentLibraryModulePath() { + HMODULE module = nullptr; + if (!GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&GetCurrentLibraryModulePath), &module)) { + return ""; + } + return GetModulePath(module); +} + +void AppendWindowsPortableSearchPaths(std::vector& paths, + const std::string& modulePath) { + const std::string parent = GetParentDirectory(modulePath); + if (parent.empty()) { + return; + } + paths.push_back(parent); + paths.push_back(parent + "../share/opencc"); +} +#endif + class ConfigInternal { public: std::vector paths; @@ -257,6 +325,13 @@ ConverterPtr Config::NewFromFile(const std::string& fileName, impl->paths.push_back(parent); } } +#if defined(_WIN32) || defined(_WIN64) + if (argv0 != nullptr) { + AppendWindowsPortableSearchPaths(impl->paths, argv0); + } + AppendWindowsPortableSearchPaths(impl->paths, GetCurrentProcessModulePath()); + AppendWindowsPortableSearchPaths(impl->paths, GetCurrentLibraryModulePath()); +#endif if (PACKAGE_DATA_DIRECTORY != "") { impl->paths.push_back(PACKAGE_DATA_DIRECTORY); }