diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b50e4b2..bf66c92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,18 @@ jobs: Compress-Archive -Path "publish-selfcontained/*" -DestinationPath $zipNameSelfContained echo "ZIP_NAME_SELFCONTAINED=$zipNameSelfContained" >> $env:GITHUB_ENV + - name: Install Inno Setup + run: winget install --id JRSoftware.InnoSetup.7 -e --source winget --accept-package-agreements --accept-source-agreements --disable-interactivity + + - name: Build installer + shell: pwsh + run: | + $version = "${{ github.ref_name }}".TrimStart('v') + $iscc = (Get-ChildItem "$env:LocalAppData\Programs\Inno Setup*\ISCC.exe", "C:\Program Files*\Inno Setup*\ISCC.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1).FullName + & $iscc installer\PayBeat.iss "/DAppVersion=$version" + $setupName = Get-ChildItem "installer-output\*.exe" | Select-Object -First 1 -ExpandProperty Name + echo "SETUP_NAME=installer-output/$setupName" >> $env:GITHUB_ENV + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -71,4 +83,5 @@ jobs: files: | ${{ env.ZIP_NAME }} ${{ env.ZIP_NAME_SELFCONTAINED }} + ${{ env.SETUP_NAME }} generate_release_notes: true diff --git a/CLAUDE.md b/CLAUDE.md index 0195c37..f6c660e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,12 +48,13 @@ WPF floating widget app (.NET 10, MVVM). Shows real-time earnings as a borderles - `StartupService` manages the Windows startup registry entry (`HKCU\...\Run`) for the "Run at startup" setting. - `LocalizationService` handles runtime language switching by swapping `ResourceDictionary` entries in `MergedDictionaries`. - Localization: `Strings.en.xaml` / `Strings.zh-CN.xaml` are swapped into `MergedDictionaries` at startup; UI strings use `{DynamicResource}`. `"auto"` resolves from `CultureInfo.CurrentUICulture`. +- `ThemeService` swaps `Theme.Light.xaml`/`Theme.Dark.xaml` (palette-only resource dictionaries) into `MergedDictionaries` the same way localization does; `"auto"` resolves from the `AppsUseLightTheme` registry value under `HKCU\...\Themes\Personalize`. **Models:** - `SalarySettings` — immutable `record`; defaults: `DailySalary=500`, `WorkStart=09:00`, `WorkEnd=18:00`, `Currency="¥"`, `DisplayMode=Normal`, `AlwaysOnTop=true`, `Opacity=1.0`, `RefreshInterval=1`, `Language="auto"`, `HotkeyModifiers=0x0003` (Ctrl+Alt), `HotkeyVirtualKey=0x58` (X). `MaxDailySalary` caps input at 99,999,999. Stores per-mode `WindowPosition` (Left, Top, ScreenDeviceName). Also carries `LunchBreakEnabled`/`LunchBreakStart`/`LunchBreakEnd`, `WorkOnWeekends`, and tray-balloon reminder toggles (`EnableEndOfDayReminder`/`EndOfDayReminderMinutes`, `EnableMilestoneNotifications`/`MilestoneAmount`). - `EarningsCalculator` — all earnings math is a pure function of `SalarySettings` + `DateTime`; `IsWorkday()` gates weekends, and `EffectiveWorkSeconds()`/`EffectiveElapsedSeconds()` subtract the lunch break window (elapsed time holds steady during the break) before `Calculate()`/`RatePerSecond()`/`WorkdayProgress()` divide by it. -**UI theme:** Catppuccin Mocha dark palette (background `#1E1E2E`, surface `#313244`, text `#CDD6F4`, green accent `#A6E3A1`, blue accent `#89B4FA`). Styles live in `src/PayBeat.App/Resources/Styles.xaml`. UI strings live in `Strings.en.xaml` / `Strings.zh-CN.xaml` and are accessed via `{DynamicResource}`. +**UI theme:** Catppuccin-derived palette, with separate `Theme.Light.xaml`/`Theme.Dark.xaml` dictionaries (dark: background `#1E1E2E`, surface `#313244`, text `#CDD6F4`, green accent `#A6E3A1`, blue accent `#89B4FA`) swapped at runtime by `ThemeService`; structural styles live in `src/PayBeat.App/Resources/Styles.xaml` and reference the palette via `{DynamicResource}`. UI strings live in `Strings.en.xaml` / `Strings.zh-CN.xaml`, also accessed via `{DynamicResource}`. ## Solution Configuration @@ -74,7 +75,9 @@ Artifacts output to `artifacts/bin///` (SDK artifacts layou ## CI / Release -`.github/workflows/ci.yml` has two jobs (Windows runner, .NET 10). `build` runs on every push and just compiles. `release` runs `needs: build` with a job-level `if: startsWith(github.ref, 'refs/tags/v')` — only on a `v*` tag push does it publish both a portable (`--no-self-contained`) and a self-contained `win-x64` build, zip each (`PayBeat--portable-runtime-required-win-x64.zip`, `PayBeat--portable-standalone-win-x64.zip`), and create a GitHub Release via `softprops/action-gh-release`. Versioning is derived from git tags via MinVer (e.g. `v1.2.0`); locally, ensure the tag is reachable from HEAD for a meaningful version. +`.github/workflows/ci.yml` has two jobs (Windows runner, .NET 10). `build` runs on every push and just compiles. `release` runs `needs: build` with a job-level `if: startsWith(github.ref, 'refs/tags/v')` — only on a `v*` tag push does it publish both a portable (`--no-self-contained`) and a self-contained `win-x64` build, zip each (`PayBeat--portable-runtime-required-win-x64.zip`, `PayBeat--portable-standalone-win-x64.zip`), compile `installer/PayBeat.iss` with Inno Setup (installed via Chocolatey) into `PayBeat--setup-win-x64.exe`, and create a GitHub Release via `softprops/action-gh-release` with all three files. Versioning is derived from git tags via MinVer (e.g. `v1.2.0`); locally, ensure the tag is reachable from HEAD for a meaningful version. The installer script itself takes its version from `/DAppVersion=` (tag name with the `v` prefix stripped), not MinVer. + +`installer/PayBeat.iss` packages the self-contained publish output into a per-user installer (`PrivilegesRequired=lowest`, installs under `{localappdata}\Programs\PayBeat`, no UAC prompt). It refuses to install/uninstall while the app is running (checks the `PayBeat_SingleInstance` mutex from `App.xaml.cs`) and cleans up `%APPDATA%\PayBeat` and the HKCU `Run` startup entry on uninstall. Build the publish output first (`dotnet publish ... --self-contained -o publish-selfcontained/`), then compile with `ISCC.exe installer\PayBeat.iss /DAppVersion=`. User settings are persisted to `%APPDATA%\PayBeat\settings.json`. diff --git a/README.md b/README.md index 81b1d5b..88ef4c1 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,11 @@ PayBeat is a borderless, always-on-top Windows widget that shows your real-time ## Download -Grab the latest release from the [Releases page](https://github.com/coldhighsun/PayBeat/releases/latest). Two `win-x64` packages are published per version: +Grab the latest release from the [Releases page](https://github.com/coldhighsun/PayBeat/releases/latest). Three `win-x64` packages are published per version: - `PayBeat--portable-runtime-required-win-x64.zip` — smaller, requires the .NET 10 Desktop Runtime - `PayBeat--portable-standalone-win-x64.zip` — larger, runs standalone with no prerequisites +- `PayBeat--setup-win-x64.exe` — self-contained installer (no runtime prerequisite); installs per-user, adds a Start Menu shortcut, and registers for uninstall ## Build @@ -61,9 +62,13 @@ dotnet publish src/PayBeat.App/PayBeat.App.csproj -c Release # Publish (self-contained) dotnet publish src/PayBeat.App/PayBeat.App.csproj -c Release -r win-x64 --self-contained + +# Build installer (requires Inno Setup 7 and a self-contained publish at publish-selfcontained/) +dotnet publish src/PayBeat.App/PayBeat.App.csproj -c Release -r win-x64 --self-contained -o publish-selfcontained/ +ISCC.exe installer\PayBeat.iss /DAppVersion=1.2.3 ``` -Output goes to `artifacts/bin/PayBeat.App/release/`. CI tags produce both a `PayBeat--portable-runtime-required-win-x64.zip` and a `PayBeat--portable-standalone-win-x64.zip` release artifact. +Output goes to `artifacts/bin/PayBeat.App/release/`. CI tags produce a `PayBeat--portable-runtime-required-win-x64.zip`, a `PayBeat--portable-standalone-win-x64.zip`, and a `PayBeat--setup-win-x64.exe` release artifact. ## Usage @@ -133,10 +138,11 @@ PayBeat 是一款 Windows 桌面悬浮组件,以秒为单位实时显示当天 ## 下载 -前往 [Releases 页面](https://github.com/coldhighsun/PayBeat/releases/latest) 下载最新版本。每个版本会发布两个 `win-x64` 压缩包: +前往 [Releases 页面](https://github.com/coldhighsun/PayBeat/releases/latest) 下载最新版本。每个版本会发布三个 `win-x64` 安装包: - `PayBeat--portable-runtime-required-win-x64.zip` — 体积较小,需预装 .NET 10 Desktop Runtime - `PayBeat--portable-standalone-win-x64.zip` — 体积较大,内置运行时,无需任何前置依赖 +- `PayBeat--setup-win-x64.exe` — 内置运行时的安装程序,无需前置依赖;按当前用户安装,创建开始菜单快捷方式并支持卸载 ## 构建 @@ -152,9 +158,13 @@ dotnet publish src/PayBeat.App/PayBeat.App.csproj -c Release # 发布(独立版) dotnet publish src/PayBeat.App/PayBeat.App.csproj -c Release -r win-x64 --self-contained + +# 生成安装包(需要 Inno Setup 7,且需先生成独立版发布产物至 publish-selfcontained/) +dotnet publish src/PayBeat.App/PayBeat.App.csproj -c Release -r win-x64 --self-contained -o publish-selfcontained/ +ISCC.exe installer\PayBeat.iss /DAppVersion=1.2.3 ``` -产物输出至 `artifacts/bin/PayBeat.App/release/`。CI 打标签时会同时生成 `PayBeat--portable-runtime-required-win-x64.zip` 和 `PayBeat--portable-standalone-win-x64.zip` 两个发布包。 +产物输出至 `artifacts/bin/PayBeat.App/release/`。CI 打标签时会同时生成 `PayBeat--portable-runtime-required-win-x64.zip`、`PayBeat--portable-standalone-win-x64.zip` 和 `PayBeat--setup-win-x64.exe` 三个发布包。 ## 使用 diff --git a/installer/PayBeat.iss b/installer/PayBeat.iss new file mode 100644 index 0000000..840b95d --- /dev/null +++ b/installer/PayBeat.iss @@ -0,0 +1,95 @@ +; Inno Setup 7 script for PayBeat. +; Packages the self-contained win-x64 publish output into a per-user installer. +; Build the publish output first: +; dotnet publish src/PayBeat.App/PayBeat.App.csproj -c Release -r win-x64 --self-contained -o publish-selfcontained/ +; Then compile with: +; ISCC.exe installer\PayBeat.iss /DAppVersion=1.2.3 + +#ifndef AppVersion + #define AppVersion "0.0.0" +#endif + +#define AppName "PayBeat" +#define AppPublisher "coldhighsun" +#define AppUrl "https://github.com/coldhighsun/PayBeat" +#define AppExeName "PayBeat.exe" +#define PublishDir "..\publish-selfcontained" + +[Setup] +AppId={{305F5C59-E76E-43F4-BB60-9C97994A151C} +AppName={#AppName} +AppVersion={#AppVersion} +AppPublisher={#AppPublisher} +AppPublisherURL={#AppUrl} +AppSupportURL={#AppUrl} +AppUpdatesURL={#AppUrl} +DefaultDirName={localappdata}\Programs\{#AppName} +DefaultGroupName={#AppName} +PrivilegesRequired=lowest +DisableProgramGroupPage=yes +OutputBaseFilename=PayBeat-{#AppVersion}-setup-win-x64 +OutputDir=..\installer-output +SetupIconFile=..\src\PayBeat.App\Resources\Icons\PayBeat.ico +UninstallDisplayIcon={app}\{#AppExeName} +Compression=lzma2/max +SolidCompression=yes +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +LicenseFile=..\LICENSE +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" +Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" + +[Files] +Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion + +[Icons] +Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}" +Name: "{group}\{cm:UninstallProgram,{#AppName}}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#AppExeName}"; Description: "{cm:LaunchProgram,{#AppName}}"; Flags: nowait postinstall skipifsilent + +[UninstallDelete] +Type: filesandordirs; Name: "{userappdata}\{#AppName}" + +[Code] +function InitializeSetup(): Boolean; +begin + Result := True; + while CheckForMutexes('PayBeat_SingleInstance') do + begin + if MsgBox('{#AppName} is currently running. Please close it before continuing setup.', mbError, MB_RETRYCANCEL) = IDCANCEL then + begin + Result := False; + Exit; + end; + end; +end; + +function InitializeUninstall(): Boolean; +begin + Result := True; + while CheckForMutexes('PayBeat_SingleInstance') do + begin + if MsgBox('{#AppName} is currently running. Please close it before continuing uninstall.', mbError, MB_RETRYCANCEL) = IDCANCEL then + begin + Result := False; + Exit; + end; + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then + begin + RegDeleteValue(HKEY_CURRENT_USER, 'Software\Microsoft\Windows\CurrentVersion\Run', '{#AppName}'); + end; +end;