Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
7d25171
Scaffold C# native app
OwenMcGirr Jun 29, 2026
77c8198
Port protocol validation to C#
OwenMcGirr Jun 29, 2026
4abd4fe
Port pairing state storage to C#
OwenMcGirr Jun 29, 2026
48e6739
Port command auth to C#
OwenMcGirr Jun 29, 2026
c109089
Port settings and pointer profile to C#
OwenMcGirr Jun 29, 2026
140bc64
Port Bluetooth protocol primitives to C#
OwenMcGirr Jun 29, 2026
4570811
Port command executor core to C#
OwenMcGirr Jun 29, 2026
045a9ad
Port startup registration to C#
OwenMcGirr Jun 29, 2026
9598e14
Add C# Windows input mappings
OwenMcGirr Jun 29, 2026
783840e
Add C# Bluetooth status tracker
OwenMcGirr Jun 29, 2026
3032879
Port pointer movement execution math to C#
OwenMcGirr Jun 29, 2026
3df7551
Add C# update service state machine
OwenMcGirr Jun 29, 2026
3e7bf50
Add C# Windows update installer launcher
OwenMcGirr Jun 29, 2026
257740d
Add C# app lifecycle decisions
OwenMcGirr Jun 29, 2026
e6968d8
Port diagnostics JSONL storage to C#
OwenMcGirr Jun 29, 2026
418292a
Add C# GitHub update backend
OwenMcGirr Jun 29, 2026
f5fe4c4
Wire C# app startup lifecycle
OwenMcGirr Jun 29, 2026
408a126
Add C# tray lifecycle shell
OwenMcGirr Jun 29, 2026
f01e009
Add C# Windows desktop input adapter
OwenMcGirr Jun 29, 2026
ad5892a
Add C# main window copy helpers
OwenMcGirr Jun 29, 2026
ced7449
Wire C# main window view model
OwenMcGirr Jun 29, 2026
3de11ea
Add C# settings window shell
OwenMcGirr Jun 29, 2026
51e328a
Add C# settings action controller
OwenMcGirr Jun 29, 2026
a3658f1
Wire C# settings window actions
OwenMcGirr Jun 29, 2026
a6b5a5d
Wire C# main window update actions
OwenMcGirr Jun 29, 2026
4db3512
Wire C# startup diagnostics
OwenMcGirr Jun 29, 2026
b8b194b
Add C# control session routing
OwenMcGirr Jun 29, 2026
9988386
Add C# Bluetooth control frame processor
OwenMcGirr Jun 29, 2026
6b6b494
Add C# pairing approval manager
OwenMcGirr Jun 29, 2026
69aebc0
Wire C# pairing approvals into main window
OwenMcGirr Jun 29, 2026
9223706
Add C# remote pairing session routing
OwenMcGirr Jun 29, 2026
f534055
Signal C# main window from second launch
OwenMcGirr Jun 29, 2026
f383dc0
Port Bluetooth GATT host into C# app
OwenMcGirr Jun 29, 2026
92b7283
Start C# Bluetooth runtime from app
OwenMcGirr Jun 29, 2026
eda8ff9
Integrate C# cursor overlay runtime
OwenMcGirr Jun 29, 2026
74a5c76
Add C# Windows packaging scaffolding
OwenMcGirr Jun 29, 2026
cf7103f
Add C# CI package checks
OwenMcGirr Jun 29, 2026
aa8fabc
Harden C# installer verification
OwenMcGirr Jun 29, 2026
b4553ca
Expand C# remote command parity coverage
OwenMcGirr Jun 29, 2026
b915bc8
Cover C# update polling guards
OwenMcGirr Jun 29, 2026
c553b38
Harden C# update backend coverage
OwenMcGirr Jun 29, 2026
e08d24f
Add C# update install confirmation
OwenMcGirr Jun 29, 2026
f5f0a13
Use C# app version for dotnet packaging
OwenMcGirr Jun 29, 2026
e068e6b
Expire C# pairing approvals at runtime
OwenMcGirr Jun 29, 2026
bd5716c
Add C# tray status controls
OwenMcGirr Jun 29, 2026
5851a60
Show C# Bluetooth troubleshooting timestamps
OwenMcGirr Jun 29, 2026
ed37b14
Expand C# Bluetooth troubleshooting summaries
OwenMcGirr Jun 29, 2026
fdfe8bd
Show paired devices in C# settings
OwenMcGirr Jun 29, 2026
b3fecab
Show C# Bluetooth recent errors
OwenMcGirr Jun 29, 2026
ab2fcc3
Allow C# settings to forget paired devices
OwenMcGirr Jun 29, 2026
0ceebae
Fix C# cursor overlay startup crash
OwenMcGirr Jun 29, 2026
6fd2987
Restart C# Bluetooth advertising after disconnect
OwenMcGirr Jun 29, 2026
388ca20
Allow smaller pointer movement scale
OwenMcGirr Jun 29, 2026
24c01ba
Match C# cursor overlay session behavior
OwenMcGirr Jun 29, 2026
a7b57a4
Fix C# single-file WPF native extraction
OwenMcGirr Jun 29, 2026
f8eef01
Bring C# main window closer to Electron UI
OwenMcGirr Jun 29, 2026
9273702
Refine C# app visual design
OwenMcGirr Jun 29, 2026
b129795
Improve C# settings layout design
OwenMcGirr Jun 29, 2026
fc058be
Restore C# tabbed settings layout
OwenMcGirr Jun 29, 2026
3e2c06a
Combine C# pointer and cursor settings
OwenMcGirr Jun 29, 2026
feb9b05
Fix C# cursor overlay persistence behavior
OwenMcGirr Jun 30, 2026
eee1d5e
Fix C# settings reload persistence
OwenMcGirr Jun 30, 2026
ad4711e
Expand C# main window card
OwenMcGirr Jun 30, 2026
ab140ea
Disable C# settings minimize maximize
OwenMcGirr Jun 30, 2026
0103718
Tighten C# window scrollbars
OwenMcGirr Jun 30, 2026
6d2fab0
Reduce C# native scroll scale
OwenMcGirr Jun 30, 2026
4fc77b5
Add C# app icon
OwenMcGirr Jun 30, 2026
b216664
Widen C# cursor color tiles
OwenMcGirr Jun 30, 2026
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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ jobs:
node-version: 25
cache: npm

- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 8.0.x

- name: Install dependencies
run: npm ci

Expand All @@ -40,3 +45,18 @@ jobs:

- name: Smoke native cursor overlay helper
run: npm run native:smoke-overlay

- name: Restore C# solution
run: npm run dotnet:restore

- name: Build C# solution
run: npm run dotnet:build

- name: Test C# solution
run: npm run dotnet:test

- name: Stage C# Windows package
run: npm run dotnet:package:win:stage

- name: Verify staged C# Windows package
run: npm run dotnet:package:win:verify
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/
dist/
dist-dotnet/
build/
!build/
build/*
Expand All @@ -9,6 +10,8 @@ build/*
out/
native/**/bin/
native/**/obj/
src-dotnet/**/bin/
src-dotnet/**/obj/
.certs/*
!.certs/.gitkeep
.env
Expand Down
101 changes: 101 additions & 0 deletions installer/SwitchifyPc.DotNet.nsi
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
!include "MUI2.nsh"
!include "LogicLib.nsh"

!ifndef VERSION
!define VERSION "0.2.0"
!endif

!ifndef SOURCE_DIR
!define SOURCE_DIR "..\dist-dotnet\win-unpacked"
!endif

!ifndef OUTPUT_EXE
!define OUTPUT_EXE "..\dist-dotnet\Switchify-PC-Setup-${VERSION}-x64.exe"
!endif

Name "Switchify PC"
OutFile "${OUTPUT_EXE}"
InstallDir "$PROGRAMFILES64\Switchify PC"
InstallDirRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "InstallLocation"
RequestExecutionLevel admin
SetCompressor /SOLID lzma
Unicode true

!define MUI_ABORTWARNING
!define MUI_ICON "..\build\icon.ico"
!define MUI_UNICON "..\build\icon.ico"

!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!define MUI_FINISHPAGE_RUN "$INSTDIR\Switchify PC.exe"
!insertmacro MUI_PAGE_FINISH

!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES

!insertmacro MUI_LANGUAGE "English"

Function .onInit
SetRegView 64
FunctionEnd

Section "Switchify PC" SecMain
SetShellVarContext all
SetOutPath "$INSTDIR"

Call PromptForRunningApp

File /r "${SOURCE_DIR}\*.*"

CreateDirectory "$SMPROGRAMS\Switchify PC"
CreateShortCut "$SMPROGRAMS\Switchify PC\Switchify PC.lnk" "$INSTDIR\Switchify PC.exe"
CreateShortCut "$DESKTOP\Switchify PC.lnk" "$INSTDIR\Switchify PC.exe"

WriteUninstaller "$INSTDIR\Uninstall.exe"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "DisplayName" "Switchify PC"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "Publisher" "Switchify"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "InstallLocation" "$INSTDIR"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "DisplayIcon" "$INSTDIR\Switchify PC.exe"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "UninstallString" "$INSTDIR\Uninstall.exe"
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "NoModify" 1
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "NoRepair" 1
SectionEnd

Section "Uninstall"
SetShellVarContext all
Call un.PromptForRunningApp

Delete "$DESKTOP\Switchify PC.lnk"
Delete "$SMPROGRAMS\Switchify PC\Switchify PC.lnk"
RMDir "$SMPROGRAMS\Switchify PC"
RMDir /r "$INSTDIR"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC"
SectionEnd

Function PromptForRunningApp
check:
DetailPrint "Checking whether Switchify PC is running..."
nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq Switchify PC.exe" /NH | find /I "Switchify PC.exe" >NUL'
Pop $0
Pop $1
${If} $0 == 0
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Switchify PC appears to be running. Close Switchify PC, then click Retry to continue installing." IDRETRY check IDCANCEL cancel
cancel:
Abort
${EndIf}
FunctionEnd

Function un.PromptForRunningApp
check:
DetailPrint "Checking whether Switchify PC is running..."
nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq Switchify PC.exe" /NH | find /I "Switchify PC.exe" >NUL'
Pop $0
Pop $1
${If} $0 == 0
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Switchify PC appears to be running. Close Switchify PC, then click Retry to continue uninstalling." IDRETRY check IDCANCEL cancel
cancel:
Abort
${EndIf}
FunctionEnd
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
"scripts": {
"dev": "node scripts/dev.cjs",
"build": "electron-vite build",
"dotnet:restore": "dotnet restore src-dotnet/SwitchifyPc.sln",
"dotnet:build": "dotnet build src-dotnet/SwitchifyPc.sln -c Release --no-restore",
"dotnet:test": "dotnet test src-dotnet/SwitchifyPc.sln -c Release --no-build",
"dotnet:publish": "dotnet publish src-dotnet/SwitchifyPc.App/SwitchifyPc.App.csproj -c Release -r win-x64 --self-contained true",
"dotnet:package:win": "node scripts/package-dotnet-win.cjs",
"dotnet:package:win:stage": "node scripts/package-dotnet-win.cjs --stage-only --skip-sign",
"dotnet:package:win:verify": "node scripts/verify-dotnet-package.cjs",
"native:build": "node scripts/build-cursor-overlay-helper.cjs",
"native:build-overlay": "npm run native:build",
"native:smoke-overlay": "node scripts/smoke-cursor-overlay-helper.cjs",
Expand Down
222 changes: 222 additions & 0 deletions scripts/package-dotnet-win.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { spawnSync } = require('node:child_process');
const {
findWindowsSdkTool,
isWindows,
resolveProjectPath,
runTool
} = require('./win-signing-tools.cjs');
const { createSigningArgs } = require('./package-win-after-pack.cjs');

const stageOnly = process.argv.includes('--stage-only');
const skipSign = process.argv.includes('--skip-sign');
const appProjectPath = resolveProjectPath('src-dotnet', 'SwitchifyPc.App', 'SwitchifyPc.App.csproj');
const version = readDotnetAppVersion(appProjectPath);
const publishDir = resolveProjectPath(
'src-dotnet',
'SwitchifyPc.App',
'bin',
'Release',
'net8.0-windows10.0.19041.0',
'win-x64',
'publish'
);
const distDir = resolveProjectPath('dist-dotnet');
const stageDir = path.join(distDir, 'win-unpacked');
const installerName = `Switchify-PC-Setup-${version}-x64.exe`;
const installerPath = path.join(distDir, installerName);

runDotnetPublish();
resetPackageArtifacts();
resetDirectory(stageDir);
copyDirectory(publishDir, stageDir);
verifyStagedApp();
writeBuilderDebug();

const appExe = path.join(stageDir, 'Switchify PC.exe');
if (isWindows() && !skipSign) {
embedUiAccessManifest(appExe);
signFile(appExe, { requireSigning: true });
}

if (!stageOnly) {
buildInstaller();
if (isWindows() && !skipSign) {
signFile(installerPath, { requireSigning: true });
}
writeLatestYml();
}

console.log(stageOnly ? `Staged C# app in ${stageDir}` : `Packaged C# installer at ${installerPath}`);

function runDotnetPublish() {
const result = spawnSync(
'dotnet',
[
'publish',
appProjectPath,
'-c',
'Release',
'-r',
'win-x64',
'--self-contained',
'true'
],
{ stdio: 'inherit' }
);
if (result.status !== 0) {
throw new Error(`dotnet publish failed with exit code ${result.status ?? 'unknown'}.`);
}
}

function resetDirectory(directory) {
fs.rmSync(directory, { recursive: true, force: true });
fs.mkdirSync(directory, { recursive: true });
}

function resetPackageArtifacts() {
fs.mkdirSync(distDir, { recursive: true });
fs.rmSync(path.join(distDir, 'latest.yml'), { force: true });
for (const entry of fs.readdirSync(distDir)) {
if (/^Switchify-PC-Setup-.+-x64\.exe$/i.test(entry)) {
fs.rmSync(path.join(distDir, entry), { force: true });
}
}
}

function copyDirectory(from, to) {
if (!fs.existsSync(from)) {
throw new Error(`Publish directory was not found: ${from}`);
}

for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
const source = path.join(from, entry.name);
const target = path.join(to, entry.name);
if (entry.isDirectory()) {
fs.mkdirSync(target, { recursive: true });
copyDirectory(source, target);
} else {
fs.copyFileSync(source, target);
}
}
}

function verifyStagedApp() {
const executable = path.join(stageDir, 'Switchify PC.exe');
if (!fs.existsSync(executable)) {
throw new Error(`Staged C# app executable is missing: ${executable}`);
}

const electronMarkers = ['resources', 'chrome_100_percent.pak', 'ffmpeg.dll'];
for (const marker of electronMarkers) {
if (fs.existsSync(path.join(stageDir, marker))) {
throw new Error(`Staged C# app unexpectedly contains Electron marker: ${marker}`);
}
}
}

function embedUiAccessManifest(executablePath) {
const mtExe = findWindowsSdkTool('mt.exe');
const manifestPath = resolveProjectPath('build', 'win-uiaccess.manifest');
runTool(mtExe, ['-nologo', '-manifest', manifestPath, `-outputresource:${executablePath};#1`]);
}

function signFile(filePath, { requireSigning }) {
const signingArgs = createSigningArgs(filePath, { requireSigning });
if (!signingArgs) return;

const signtoolExe = findWindowsSdkTool('signtool.exe');
runTool(signtoolExe, signingArgs);
}

function buildInstaller() {
const makensis = findMakensis();
fs.rmSync(installerPath, { force: true });
runTool(makensis, [
`/DVERSION=${version}`,
`/DSOURCE_DIR=${stageDir}`,
`/DOUTPUT_EXE=${installerPath}`,
resolveProjectPath('installer', 'SwitchifyPc.DotNet.nsi')
]);
if (!fs.existsSync(installerPath)) {
throw new Error(`NSIS did not create expected installer: ${installerPath}`);
}
}

function findMakensis() {
const override = process.env.SWITCHIFY_MAKENSIS_EXE;
if (override) {
if (!fs.existsSync(override)) {
throw new Error(`SWITCHIFY_MAKENSIS_EXE does not exist: ${override}`);
}
return override;
}

const candidates = [
'C:\\Program Files (x86)\\NSIS\\makensis.exe',
'C:\\Program Files\\NSIS\\makensis.exe'
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}

const result = spawnSync('where.exe', ['makensis.exe'], { encoding: 'utf8' });
if (result.status === 0) {
const [first] = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
if (first) return first;
}

throw new Error('Unable to find makensis.exe. Install NSIS or set SWITCHIFY_MAKENSIS_EXE.');
}

function writeLatestYml() {
const sha512 = createSha512Base64(installerPath);
const size = fs.statSync(installerPath).size;
const releaseDate = new Date().toISOString();
const latest = [
`version: ${version}`,
'files:',
` - url: ${installerName}`,
` sha512: ${sha512}`,
` size: ${size}`,
' isAdminRightsRequired: true',
`path: ${installerName}`,
`sha512: ${sha512}`,
'isAdminRightsRequired: true',
`releaseDate: '${releaseDate}'`,
''
].join('\n');

fs.writeFileSync(path.join(distDir, 'latest.yml'), latest);
}

function writeBuilderDebug() {
const content = [
`version: ${version}`,
'packager: dotnet-wpf-nsis',
`stageDir: ${toYamlPath(stageDir)}`,
`publishDir: ${toYamlPath(publishDir)}`,
''
].join('\n');
fs.writeFileSync(path.join(distDir, 'builder-debug.yml'), content);
}

function createSha512Base64(filePath) {
return crypto.createHash('sha512').update(fs.readFileSync(filePath)).digest('base64');
}

function toYamlPath(value) {
return JSON.stringify(value.replace(/\\/g, '/'));
}

function readDotnetAppVersion(projectPath) {
const content = fs.readFileSync(projectPath, 'utf8');
const match = content.match(/<Version>([^<]+)<\/Version>/);
if (!match || !match[1].trim()) {
throw new Error(`Could not read C# app <Version> from ${projectPath}.`);
}

return match[1].trim();
}
Loading