Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
name: Release

# Push a v* tag (matching the app versions) to build the macOS DMG and Windows MSI and
# attach them to a draft GitHub release. Review the draft, then publish it.
# Run manually (Actions -> Release -> Run workflow) to build the installers as workflow
# artifacts without touching releases; pass an existing tag to (re)build its draft release.

on:
push:
tags: ['v*']
# Packaging changes get a full installer build on their PR (artifacts only, no release).
pull_request:
paths:
- .github/workflows/release.yml
- CursorFollowMac/scripts/**
- CursorFollowWin/scripts/**
- CursorFollowWin/installer/**
- CursorFollowWin/CursorFollowWin.csproj
workflow_dispatch:
inputs:
tag:
description: Existing tag to build a draft release for (blank = artifacts only)
required: false

permissions:
contents: read

concurrency:
group: release-${{ github.ref }}-${{ inputs.tag }}
cancel-in-progress: false

env:
TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}

jobs:
macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.TAG || github.ref }}

- name: Import self-signed signing identity
env:
P12_BASE64: ${{ secrets.MAC_SIGNING_P12 }}
P12_PASSWORD: ${{ secrets.MAC_SIGNING_P12_PASSWORD }}
run: |
if [ -z "$P12_BASE64" ]; then
if [ -n "$TAG" ]; then echo "::error::MAC_SIGNING_P12 secret is not set"; exit 1; fi
echo "::warning::No signing secret; building ad-hoc signed (artifacts only)"
echo "SIGN_IDENTITY=-" >> "$GITHUB_ENV"
exit 0
fi
Comment on lines +48 to +53
KC="$RUNNER_TEMP/signing.keychain-db"
KC_PASS="$(openssl rand -hex 24)"
security create-keychain -p "$KC_PASS" "$KC"
security set-keychain-settings -lut 21600 "$KC"
security unlock-keychain -p "$KC_PASS" "$KC"
echo "$P12_BASE64" | base64 --decode > "$RUNNER_TEMP/identity.p12"
security import "$RUNNER_TEMP/identity.p12" -k "$KC" -P "$P12_PASSWORD" -T /usr/bin/codesign
rm -f "$RUNNER_TEMP/identity.p12"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" "$KC" >/dev/null
security list-keychains -d user -s "$KC" $(security list-keychains -d user | tr -d '"')

- name: Build, sign, package, verify
run: ./CursorFollowMac/scripts/package.sh

- uses: actions/upload-artifact@v4
with:
name: macos-dmg
path: build/CursorFollowMac-*.dmg
if-no-files-found: error

windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.TAG || github.ref }}
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Build MSI (unsigned)
shell: pwsh
run: ./CursorFollowWin/scripts/package.ps1 -Rid win-x64
- uses: actions/upload-artifact@v4
with:
name: windows-msi
path: CursorFollowWin/installer/dist/*.msi
if-no-files-found: error

release:
if: ${{ github.event_name == 'push' || inputs.tag != '' }}
needs: [macos, windows]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.TAG }}
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Check installer versions match the tag
run: |
v="${TAG#v}"
ls dist
ls dist/CursorFollowMac-"$v".dmg dist/CursorFollowWin-"$v"-win-x64.msi
- name: Release notes from CHANGELOG.md
run: |
v="${TAG#v}"
awk -v v="$v" '$0 == "## " v {f=1; next} /^## / {f=0} f' CHANGELOG.md > notes.md
test -s notes.md || { echo "::error::No '## $v' section in CHANGELOG.md"; exit 1; }
- name: Create or update draft release
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" dist/* --clobber
gh release edit "$TAG" --notes-file notes.md
else
gh release create "$TAG" dist/* --draft --verify-tag --title "CursorFollow ${TAG#v}" --notes-file notes.md
fi
Comment on lines +120 to +125
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ CursorFollow captures a target window and renders it into a stable, shareable wi
- Auto-follow (cursor-under-window with foreground fallback), live frame telemetry, and single-instance guard.
- Global hotkeys, Start-with-Windows, and capture-quality presets.
- Distributed as a per-user **WiX MSI** installer (no admin required; clean upgrade and uninstall).
- Fixed: Quit could hang, leaving the process and output window running, if the Settings window had been opened ([#5](https://github.com/LoganRupe/CursorFollow/issues/5)).

### Linux

Expand Down
4 changes: 3 additions & 1 deletion CursorFollowMac/CursorFollowMac/OutputWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,9 @@ private final class SurfaceLayerView: NSView {
// MARK: - Overlay View

/// Transparent passthrough host so clicks reach the surface view beneath.
private final class PassthroughHostingView<Content: View>: NSHostingView<Content> {
/// Deliberately non-generic: Xcode 26's optimizer crashes (EarlyPerfInliner) inlining the
/// deinit of a generic private NSHostingView subclass in Release builds.
private final class PassthroughHostingView: NSHostingView<OutputWindowOverlayView> {
override func hitTest(_ point: NSPoint) -> NSView? { nil }
override var isOpaque: Bool { false }
override func viewDidMoveToWindow() {
Expand Down
7 changes: 7 additions & 0 deletions CursorFollowWin/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,13 @@ private void ExitApplication()
_outputWindow?.DisposeCapture();
_singleInstanceMutex?.Dispose();
AppLog.Shutdown();

// Close every window ourselves rather than relying on Application.Exit to do it. With the
// Settings window open (three windows), Exit closed only some of them and returned; the
// window it skipped kept the message loop, and so the process, alive (issue #5).
_settingsWindow?.Close();
_panelWindow?.Close();
_outputWindow?.Close();
Current.Exit();
}

Expand Down
9 changes: 4 additions & 5 deletions CursorFollowWin/CursorFollowWin.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
<Version>0.5.1</Version>
<FileVersion>0.5.1.0</FileVersion>
<AssemblyVersion>0.5.1.0</AssemblyVersion>
<InformationalVersion>1.0</InformationalVersion>
<Company>Logan Rupe</Company>
<Product>CursorFollow</Product>
<Authors>Logan Rupe</Authors>
Expand All @@ -43,10 +42,6 @@
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>

<PropertyGroup Condition="Exists('Properties\PublishProfiles\win-$(Platform).pubxml')">
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
</PropertyGroup>

<ItemGroup>
<Content Include="Assets\SplashScreen.scale-200.png" />
<Content Include="Assets\LockScreenLogo.scale-200.png" />
Expand Down Expand Up @@ -112,6 +107,10 @@

<!-- Publish Properties -->
<PropertyGroup>
<!-- Ship everything the app needs: the .NET runtime and the Windows App SDK runtime go into
the publish folder, so the MSI installs on a stock Windows 11 machine. -->
<SelfContained>true</SelfContained>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun>
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
<!-- Trimming is intentionally OFF. WinUI 3 / XAML / Win2D resolve types via reflection and
Expand Down
11 changes: 4 additions & 7 deletions CursorFollowWin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,7 @@ CursorFollowWin/
├── Package.appxmanifest # MSIX packaging metadata (optional path)
├── Assets/ # App/tile icons
├── Properties/
│ ├── launchSettings.json # Debug launch profiles
│ └── PublishProfiles/ # win-x64 / win-x86 / win-arm64 publish profiles
│ └── launchSettings.json # Debug launch profiles
└── docs/ # Per-phase design + feasibility notes
```

Expand Down Expand Up @@ -239,15 +238,13 @@ Deleting `settings.json` resets the app to defaults.

## Publish

Self-contained publish profiles are provided for each platform:
Publishing is self-contained (the .NET and Windows App SDK runtimes are bundled), set in the csproj:

```powershell
dotnet publish CursorFollowWin.csproj -c Release -p:PublishProfile=win-x64
dotnet publish CursorFollowWin.csproj -c Release -p:PublishProfile=win-arm64
dotnet publish CursorFollowWin.csproj -c Release -p:PublishProfile=win-x86
./scripts/publish.ps1 -Rid win-x64 # or win-arm64 / win-x86
```

These produce a **self-contained** build under
This produces a build under
`bin\Release\net10.0-windows10.0.26100.0\<rid>\publish\`. Release builds enable ReadyToRun
(trimming is intentionally off — it is not safe for WinUI/XAML; see
[docs/Installer.md](docs/Installer.md)).
Expand Down
2 changes: 1 addition & 1 deletion CursorFollowWin/SettingsWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,7 @@ private static UIElement CreateAboutContent()
private static string AppVersion()
{
var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
return version is null ? "1.0" : $"{version.Major}.{version.Minor}";
return version is null ? "?" : $"{version.Major}.{version.Minor}.{version.Build}";
}

private static Button CreateFooterButton(string text, Brush background, Brush foreground)
Expand Down
6 changes: 5 additions & 1 deletion CursorFollowWin/installer/CursorFollowWin.Installer.wixproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@
<DefineConstants>Version=$(Version);PublishDir=$(PublishDir)</DefineConstants>
<!-- Suppress ICE warnings that are expected for a per-user, self-contained payload. -->
<!-- ICE61: expected with AllowSameVersionUpgrades (Maximum version == current). -->
<SuppressIces>ICE38;ICE43;ICE57;ICE61;ICE91</SuppressIces>
<!-- ICE03/ICE64: the self-contained Windows App SDK adds per-language folders of .mui files.
WiX reads odd language ids from the .mui version resources (ICE03), and the harvested
per-user subfolders have no RemoveFile rows (ICE64); RemoveInstallTree in Package.wxs
deletes the whole install folder on uninstall instead. -->
<SuppressIces>ICE03;ICE38;ICE43;ICE57;ICE61;ICE64;ICE91</SuppressIces>
</PropertyGroup>

</Project>
8 changes: 8 additions & 0 deletions CursorFollowWin/installer/Package.wxs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@
Installer has no single-value remove-on-uninstall, and we must not delete
the shared Run key wholesale).
RemoveDataFolder removes %LocalAppData%\CursorFollowWin (settings.json + Logs\).
RemoveInstallTree removes what is left of the install folder after RemoveFiles: the
per-language subfolders of the self-contained Windows App SDK.

Impersonate=yes so HKCU / %LOCALAPPDATA% resolve to the installing user; Return=ignore so a
missing key/folder never fails the uninstall.
Expand All @@ -115,11 +117,17 @@
Directory="INSTALLFOLDER"
ExeCommand="cmd.exe /c rmdir /s /q &quot;%LOCALAPPDATA%\CursorFollowWin&quot;"
Execute="deferred" Impersonate="yes" Return="ignore" />
<CustomAction Id="RemoveInstallTree"
Directory="ProgramsFolder"
ExeCommand="cmd.exe /c rmdir /s /q &quot;[INSTALLFOLDER]&quot;"
Execute="deferred" Impersonate="yes" Return="ignore" />
<InstallExecuteSequence>
<Custom Action="RemoveRunValue" Before="RemoveFiles"
Condition="REMOVE=&quot;ALL&quot; AND NOT UPGRADINGPRODUCTCODE" />
<Custom Action="RemoveDataFolder" After="RemoveRunValue"
Condition="REMOVE=&quot;ALL&quot; AND NOT UPGRADINGPRODUCTCODE" />
<Custom Action="RemoveInstallTree" After="RemoveFiles"
Condition="REMOVE=&quot;ALL&quot; AND NOT UPGRADINGPRODUCTCODE" />
</InstallExecuteSequence>

</Package>
Expand Down
16 changes: 11 additions & 5 deletions CursorFollowWin/scripts/publish.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
Produce a self-contained, ReadyToRun publish of CursorFollowWin for one runtime.

.DESCRIPTION
Wraps `dotnet publish` with the project's publish profile. Trimming is disabled in the
Wraps `dotnet publish`; the self-contained/ReadyToRun settings live in the csproj. Trimming is disabled in the
project (WinUI/XAML reflection is not trim-safe); this script just selects the RID and
configuration and returns the absolute publish path so package.ps1 can harvest it.

Expand Down Expand Up @@ -33,12 +33,12 @@ $platform = @{ 'win-x64' = 'x64'; 'win-x86' = 'x86'; 'win-arm64' = 'ARM64' }[$Ri
Write-Host "==> Publishing $Configuration / $Rid (self-contained, ReadyToRun)" -ForegroundColor Cyan
# Route dotnet's stdout to the host so it does not pollute this script's success stream
# (callers capture the returned publish path).
dotnet publish $proj -c $Configuration -p:Platform=$platform -p:PublishProfile="win-$platform" --nologo | Out-Host
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed (exit $LASTEXITCODE)." }

# Path mirrors the publish profile's PublishDir: bin\<Config>\<tfm>\<rid>\publish
# Publish path: bin\<Config>\<tfm>\<rid>\publish (pinned with -o; the platform-specific default
# would otherwise insert a bin\<platform>\ level).
$tfm = 'net10.0-windows10.0.26100.0'
$publishDir = Join-Path $repo "bin/$Configuration/$tfm/$Rid/publish"
dotnet publish $proj -c $Configuration -p:Platform=$platform -r $Rid -o $publishDir --nologo | Out-Host
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed (exit $LASTEXITCODE)." }
if (-not (Test-Path $publishDir)) { throw "Publish folder not found: $publishDir" }

$exe = Join-Path $publishDir 'CursorFollowWin.exe'
Expand All @@ -47,6 +47,12 @@ if (-not (Test-Path $exe)) { throw "Published exe missing: $exe" }
if (-not (Test-Path (Join-Path $publishDir 'CursorFollowWin.pri'))) {
throw "Root resources PRI (CursorFollowWin.pri) missing from publish — the app would fail to load XAML."
}
# Self-contained guard: without these the MSI would need a separately installed runtime.
foreach ($dll in 'coreclr.dll', 'Microsoft.WindowsAppRuntime.dll') {
if (-not (Test-Path (Join-Path $publishDir $dll))) {
throw "$dll missing from publish — the build is not self-contained."
}
}

$resolved = (Resolve-Path $publishDir).Path
Write-Host "==> Publish OK: $resolved" -ForegroundColor Green
Expand Down
7 changes: 7 additions & 0 deletions Docs/Distribution/Packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ The Windows installer is covered in [CursorFollowWin/docs/Installer.md](../../Cu
password and choose **Always Allow**.
3. Optional, nicer DMG layout: `brew install create-dmg`.

For CI, the release workflow imports the same identity from two repository secrets:

```bash
base64 -i CursorFollowSelfSigned.p12 | gh secret set MAC_SIGNING_P12
tr -d '\n' < CursorFollowSelfSigned.p12.password | gh secret set MAC_SIGNING_P12_PASSWORD
```

To restore the identity on a new build machine, double-click the `.p12` (or
`security import CursorFollowSelfSigned.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign`).

Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,13 @@ The general reset is the same everywhere — quit your meeting app and CursorFol

### Releasing

Releases are published manually to [GitHub Releases](https://github.com/LoganRupe/CursorFollow/releases). For each version:
GitHub Actions builds the installers ([.github/workflows/release.yml](.github/workflows/release.yml)):

1. **macOS** — run `./CursorFollowMac/scripts/package.sh` to produce a self-signed `CursorFollowMac-<version>.dmg` (details in [Docs/Distribution/Packaging.md](Docs/Distribution/Packaging.md)).
2. **Windows** — build the `.msi` (see [Windows build](CursorFollowWin/README.md#build)).
3. Create a new GitHub release, tag the version (e.g. `v0.5.1`), and upload both the `.dmg` and `.msi` as release assets.
1. Bump the version on every platform (`MARKETING_VERSION` in the Xcode project, `Version`/`FileVersion`/`AssemblyVersion` in `CursorFollowWin.csproj`, `VERSION` in `CursorFollowLinux/Makefile`) and move the **Unreleased** notes in `CHANGELOG.md` under a `## <version>` heading.
2. Merge to `main`, then tag and push: `git tag -a v0.5.2 -m "CursorFollow 0.5.2" && git push origin v0.5.2`.
3. The workflow builds the signed macOS DMG and the Windows MSI and attaches both to a **draft** release, with the changelog section as notes. Review it, then publish.

macOS signing in CI uses the self-signed identity from two repository secrets, `MAC_SIGNING_P12` (base64 of the `.p12`) and `MAC_SIGNING_P12_PASSWORD`. See [Docs/Distribution/Packaging.md](Docs/Distribution/Packaging.md). Local builds still work: `./CursorFollowMac/scripts/package.sh` and `./CursorFollowWin/scripts/package.ps1`.

## License

Expand Down
Loading