Skip to content

Commit bdc4c7e

Browse files
Scaffold C# native app (#280)
* Scaffold C# native app * Port protocol validation to C# * Port pairing state storage to C# * Port command auth to C# * Port settings and pointer profile to C# * Port Bluetooth protocol primitives to C# * Port command executor core to C# * Port startup registration to C# * Add C# Windows input mappings * Add C# Bluetooth status tracker * Port pointer movement execution math to C# * Add C# update service state machine * Add C# Windows update installer launcher * Add C# app lifecycle decisions * Port diagnostics JSONL storage to C# * Add C# GitHub update backend * Wire C# app startup lifecycle * Add C# tray lifecycle shell * Add C# Windows desktop input adapter * Add C# main window copy helpers * Wire C# main window view model * Add C# settings window shell * Add C# settings action controller * Wire C# settings window actions * Wire C# main window update actions * Wire C# startup diagnostics * Add C# control session routing * Add C# Bluetooth control frame processor * Add C# pairing approval manager * Wire C# pairing approvals into main window * Add C# remote pairing session routing * Signal C# main window from second launch * Port Bluetooth GATT host into C# app * Start C# Bluetooth runtime from app * Integrate C# cursor overlay runtime * Add C# Windows packaging scaffolding * Add C# CI package checks * Harden C# installer verification * Expand C# remote command parity coverage * Cover C# update polling guards * Harden C# update backend coverage * Add C# update install confirmation * Use C# app version for dotnet packaging * Expire C# pairing approvals at runtime * Add C# tray status controls * Show C# Bluetooth troubleshooting timestamps * Expand C# Bluetooth troubleshooting summaries * Show paired devices in C# settings * Show C# Bluetooth recent errors * Allow C# settings to forget paired devices * Fix C# cursor overlay startup crash * Restart C# Bluetooth advertising after disconnect * Allow smaller pointer movement scale * Match C# cursor overlay session behavior * Fix C# single-file WPF native extraction * Bring C# main window closer to Electron UI * Refine C# app visual design * Improve C# settings layout design * Restore C# tabbed settings layout * Combine C# pointer and cursor settings * Fix C# cursor overlay persistence behavior * Fix C# settings reload persistence * Expand C# main window card * Disable C# settings minimize maximize * Tighten C# window scrollbars * Reduce C# native scroll scale * Add C# app icon * Widen C# cursor color tiles --------- Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent 74692fc commit bdc4c7e

106 files changed

Lines changed: 15385 additions & 3 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ jobs:
2323
node-version: 25
2424
cache: npm
2525

26+
- name: Setup .NET
27+
uses: actions/setup-dotnet@v5
28+
with:
29+
dotnet-version: 8.0.x
30+
2631
- name: Install dependencies
2732
run: npm ci
2833

@@ -40,3 +45,18 @@ jobs:
4045

4146
- name: Smoke native cursor overlay helper
4247
run: npm run native:smoke-overlay
48+
49+
- name: Restore C# solution
50+
run: npm run dotnet:restore
51+
52+
- name: Build C# solution
53+
run: npm run dotnet:build
54+
55+
- name: Test C# solution
56+
run: npm run dotnet:test
57+
58+
- name: Stage C# Windows package
59+
run: npm run dotnet:package:win:stage
60+
61+
- name: Verify staged C# Windows package
62+
run: npm run dotnet:package:win:verify

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
node_modules/
22
dist/
3+
dist-dotnet/
34
build/
45
!build/
56
build/*
@@ -9,6 +10,8 @@ build/*
910
out/
1011
native/**/bin/
1112
native/**/obj/
13+
src-dotnet/**/bin/
14+
src-dotnet/**/obj/
1215
.certs/*
1316
!.certs/.gitkeep
1417
.env

installer/SwitchifyPc.DotNet.nsi

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
!include "MUI2.nsh"
2+
!include "LogicLib.nsh"
3+
4+
!ifndef VERSION
5+
!define VERSION "0.2.0"
6+
!endif
7+
8+
!ifndef SOURCE_DIR
9+
!define SOURCE_DIR "..\dist-dotnet\win-unpacked"
10+
!endif
11+
12+
!ifndef OUTPUT_EXE
13+
!define OUTPUT_EXE "..\dist-dotnet\Switchify-PC-Setup-${VERSION}-x64.exe"
14+
!endif
15+
16+
Name "Switchify PC"
17+
OutFile "${OUTPUT_EXE}"
18+
InstallDir "$PROGRAMFILES64\Switchify PC"
19+
InstallDirRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "InstallLocation"
20+
RequestExecutionLevel admin
21+
SetCompressor /SOLID lzma
22+
Unicode true
23+
24+
!define MUI_ABORTWARNING
25+
!define MUI_ICON "..\build\icon.ico"
26+
!define MUI_UNICON "..\build\icon.ico"
27+
28+
!insertmacro MUI_PAGE_WELCOME
29+
!insertmacro MUI_PAGE_DIRECTORY
30+
!insertmacro MUI_PAGE_INSTFILES
31+
!define MUI_FINISHPAGE_RUN "$INSTDIR\Switchify PC.exe"
32+
!insertmacro MUI_PAGE_FINISH
33+
34+
!insertmacro MUI_UNPAGE_CONFIRM
35+
!insertmacro MUI_UNPAGE_INSTFILES
36+
37+
!insertmacro MUI_LANGUAGE "English"
38+
39+
Function .onInit
40+
SetRegView 64
41+
FunctionEnd
42+
43+
Section "Switchify PC" SecMain
44+
SetShellVarContext all
45+
SetOutPath "$INSTDIR"
46+
47+
Call PromptForRunningApp
48+
49+
File /r "${SOURCE_DIR}\*.*"
50+
51+
CreateDirectory "$SMPROGRAMS\Switchify PC"
52+
CreateShortCut "$SMPROGRAMS\Switchify PC\Switchify PC.lnk" "$INSTDIR\Switchify PC.exe"
53+
CreateShortCut "$DESKTOP\Switchify PC.lnk" "$INSTDIR\Switchify PC.exe"
54+
55+
WriteUninstaller "$INSTDIR\Uninstall.exe"
56+
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "DisplayName" "Switchify PC"
57+
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "DisplayVersion" "${VERSION}"
58+
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "Publisher" "Switchify"
59+
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "InstallLocation" "$INSTDIR"
60+
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "DisplayIcon" "$INSTDIR\Switchify PC.exe"
61+
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "UninstallString" "$INSTDIR\Uninstall.exe"
62+
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "NoModify" 1
63+
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC" "NoRepair" 1
64+
SectionEnd
65+
66+
Section "Uninstall"
67+
SetShellVarContext all
68+
Call un.PromptForRunningApp
69+
70+
Delete "$DESKTOP\Switchify PC.lnk"
71+
Delete "$SMPROGRAMS\Switchify PC\Switchify PC.lnk"
72+
RMDir "$SMPROGRAMS\Switchify PC"
73+
RMDir /r "$INSTDIR"
74+
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC"
75+
SectionEnd
76+
77+
Function PromptForRunningApp
78+
check:
79+
DetailPrint "Checking whether Switchify PC is running..."
80+
nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq Switchify PC.exe" /NH | find /I "Switchify PC.exe" >NUL'
81+
Pop $0
82+
Pop $1
83+
${If} $0 == 0
84+
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Switchify PC appears to be running. Close Switchify PC, then click Retry to continue installing." IDRETRY check IDCANCEL cancel
85+
cancel:
86+
Abort
87+
${EndIf}
88+
FunctionEnd
89+
90+
Function un.PromptForRunningApp
91+
check:
92+
DetailPrint "Checking whether Switchify PC is running..."
93+
nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq Switchify PC.exe" /NH | find /I "Switchify PC.exe" >NUL'
94+
Pop $0
95+
Pop $1
96+
${If} $0 == 0
97+
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Switchify PC appears to be running. Close Switchify PC, then click Retry to continue uninstalling." IDRETRY check IDCANCEL cancel
98+
cancel:
99+
Abort
100+
${EndIf}
101+
FunctionEnd

package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
"scripts": {
77
"dev": "node scripts/dev.cjs",
88
"build": "electron-vite build",
9+
"dotnet:restore": "dotnet restore src-dotnet/SwitchifyPc.sln",
10+
"dotnet:build": "dotnet build src-dotnet/SwitchifyPc.sln -c Release --no-restore",
11+
"dotnet:test": "dotnet test src-dotnet/SwitchifyPc.sln -c Release --no-build",
12+
"dotnet:publish": "dotnet publish src-dotnet/SwitchifyPc.App/SwitchifyPc.App.csproj -c Release -r win-x64 --self-contained true",
13+
"dotnet:package:win": "node scripts/package-dotnet-win.cjs",
14+
"dotnet:package:win:stage": "node scripts/package-dotnet-win.cjs --stage-only --skip-sign",
15+
"dotnet:package:win:verify": "node scripts/verify-dotnet-package.cjs",
916
"native:build": "node scripts/build-cursor-overlay-helper.cjs",
1017
"native:build-overlay": "npm run native:build",
1118
"native:smoke-overlay": "node scripts/smoke-cursor-overlay-helper.cjs",

scripts/package-dotnet-win.cjs

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
const fs = require('node:fs');
2+
const path = require('node:path');
3+
const crypto = require('node:crypto');
4+
const { spawnSync } = require('node:child_process');
5+
const {
6+
findWindowsSdkTool,
7+
isWindows,
8+
resolveProjectPath,
9+
runTool
10+
} = require('./win-signing-tools.cjs');
11+
const { createSigningArgs } = require('./package-win-after-pack.cjs');
12+
13+
const stageOnly = process.argv.includes('--stage-only');
14+
const skipSign = process.argv.includes('--skip-sign');
15+
const appProjectPath = resolveProjectPath('src-dotnet', 'SwitchifyPc.App', 'SwitchifyPc.App.csproj');
16+
const version = readDotnetAppVersion(appProjectPath);
17+
const publishDir = resolveProjectPath(
18+
'src-dotnet',
19+
'SwitchifyPc.App',
20+
'bin',
21+
'Release',
22+
'net8.0-windows10.0.19041.0',
23+
'win-x64',
24+
'publish'
25+
);
26+
const distDir = resolveProjectPath('dist-dotnet');
27+
const stageDir = path.join(distDir, 'win-unpacked');
28+
const installerName = `Switchify-PC-Setup-${version}-x64.exe`;
29+
const installerPath = path.join(distDir, installerName);
30+
31+
runDotnetPublish();
32+
resetPackageArtifacts();
33+
resetDirectory(stageDir);
34+
copyDirectory(publishDir, stageDir);
35+
verifyStagedApp();
36+
writeBuilderDebug();
37+
38+
const appExe = path.join(stageDir, 'Switchify PC.exe');
39+
if (isWindows() && !skipSign) {
40+
embedUiAccessManifest(appExe);
41+
signFile(appExe, { requireSigning: true });
42+
}
43+
44+
if (!stageOnly) {
45+
buildInstaller();
46+
if (isWindows() && !skipSign) {
47+
signFile(installerPath, { requireSigning: true });
48+
}
49+
writeLatestYml();
50+
}
51+
52+
console.log(stageOnly ? `Staged C# app in ${stageDir}` : `Packaged C# installer at ${installerPath}`);
53+
54+
function runDotnetPublish() {
55+
const result = spawnSync(
56+
'dotnet',
57+
[
58+
'publish',
59+
appProjectPath,
60+
'-c',
61+
'Release',
62+
'-r',
63+
'win-x64',
64+
'--self-contained',
65+
'true'
66+
],
67+
{ stdio: 'inherit' }
68+
);
69+
if (result.status !== 0) {
70+
throw new Error(`dotnet publish failed with exit code ${result.status ?? 'unknown'}.`);
71+
}
72+
}
73+
74+
function resetDirectory(directory) {
75+
fs.rmSync(directory, { recursive: true, force: true });
76+
fs.mkdirSync(directory, { recursive: true });
77+
}
78+
79+
function resetPackageArtifacts() {
80+
fs.mkdirSync(distDir, { recursive: true });
81+
fs.rmSync(path.join(distDir, 'latest.yml'), { force: true });
82+
for (const entry of fs.readdirSync(distDir)) {
83+
if (/^Switchify-PC-Setup-.+-x64\.exe$/i.test(entry)) {
84+
fs.rmSync(path.join(distDir, entry), { force: true });
85+
}
86+
}
87+
}
88+
89+
function copyDirectory(from, to) {
90+
if (!fs.existsSync(from)) {
91+
throw new Error(`Publish directory was not found: ${from}`);
92+
}
93+
94+
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
95+
const source = path.join(from, entry.name);
96+
const target = path.join(to, entry.name);
97+
if (entry.isDirectory()) {
98+
fs.mkdirSync(target, { recursive: true });
99+
copyDirectory(source, target);
100+
} else {
101+
fs.copyFileSync(source, target);
102+
}
103+
}
104+
}
105+
106+
function verifyStagedApp() {
107+
const executable = path.join(stageDir, 'Switchify PC.exe');
108+
if (!fs.existsSync(executable)) {
109+
throw new Error(`Staged C# app executable is missing: ${executable}`);
110+
}
111+
112+
const electronMarkers = ['resources', 'chrome_100_percent.pak', 'ffmpeg.dll'];
113+
for (const marker of electronMarkers) {
114+
if (fs.existsSync(path.join(stageDir, marker))) {
115+
throw new Error(`Staged C# app unexpectedly contains Electron marker: ${marker}`);
116+
}
117+
}
118+
}
119+
120+
function embedUiAccessManifest(executablePath) {
121+
const mtExe = findWindowsSdkTool('mt.exe');
122+
const manifestPath = resolveProjectPath('build', 'win-uiaccess.manifest');
123+
runTool(mtExe, ['-nologo', '-manifest', manifestPath, `-outputresource:${executablePath};#1`]);
124+
}
125+
126+
function signFile(filePath, { requireSigning }) {
127+
const signingArgs = createSigningArgs(filePath, { requireSigning });
128+
if (!signingArgs) return;
129+
130+
const signtoolExe = findWindowsSdkTool('signtool.exe');
131+
runTool(signtoolExe, signingArgs);
132+
}
133+
134+
function buildInstaller() {
135+
const makensis = findMakensis();
136+
fs.rmSync(installerPath, { force: true });
137+
runTool(makensis, [
138+
`/DVERSION=${version}`,
139+
`/DSOURCE_DIR=${stageDir}`,
140+
`/DOUTPUT_EXE=${installerPath}`,
141+
resolveProjectPath('installer', 'SwitchifyPc.DotNet.nsi')
142+
]);
143+
if (!fs.existsSync(installerPath)) {
144+
throw new Error(`NSIS did not create expected installer: ${installerPath}`);
145+
}
146+
}
147+
148+
function findMakensis() {
149+
const override = process.env.SWITCHIFY_MAKENSIS_EXE;
150+
if (override) {
151+
if (!fs.existsSync(override)) {
152+
throw new Error(`SWITCHIFY_MAKENSIS_EXE does not exist: ${override}`);
153+
}
154+
return override;
155+
}
156+
157+
const candidates = [
158+
'C:\\Program Files (x86)\\NSIS\\makensis.exe',
159+
'C:\\Program Files\\NSIS\\makensis.exe'
160+
];
161+
for (const candidate of candidates) {
162+
if (fs.existsSync(candidate)) return candidate;
163+
}
164+
165+
const result = spawnSync('where.exe', ['makensis.exe'], { encoding: 'utf8' });
166+
if (result.status === 0) {
167+
const [first] = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
168+
if (first) return first;
169+
}
170+
171+
throw new Error('Unable to find makensis.exe. Install NSIS or set SWITCHIFY_MAKENSIS_EXE.');
172+
}
173+
174+
function writeLatestYml() {
175+
const sha512 = createSha512Base64(installerPath);
176+
const size = fs.statSync(installerPath).size;
177+
const releaseDate = new Date().toISOString();
178+
const latest = [
179+
`version: ${version}`,
180+
'files:',
181+
` - url: ${installerName}`,
182+
` sha512: ${sha512}`,
183+
` size: ${size}`,
184+
' isAdminRightsRequired: true',
185+
`path: ${installerName}`,
186+
`sha512: ${sha512}`,
187+
'isAdminRightsRequired: true',
188+
`releaseDate: '${releaseDate}'`,
189+
''
190+
].join('\n');
191+
192+
fs.writeFileSync(path.join(distDir, 'latest.yml'), latest);
193+
}
194+
195+
function writeBuilderDebug() {
196+
const content = [
197+
`version: ${version}`,
198+
'packager: dotnet-wpf-nsis',
199+
`stageDir: ${toYamlPath(stageDir)}`,
200+
`publishDir: ${toYamlPath(publishDir)}`,
201+
''
202+
].join('\n');
203+
fs.writeFileSync(path.join(distDir, 'builder-debug.yml'), content);
204+
}
205+
206+
function createSha512Base64(filePath) {
207+
return crypto.createHash('sha512').update(fs.readFileSync(filePath)).digest('base64');
208+
}
209+
210+
function toYamlPath(value) {
211+
return JSON.stringify(value.replace(/\\/g, '/'));
212+
}
213+
214+
function readDotnetAppVersion(projectPath) {
215+
const content = fs.readFileSync(projectPath, 'utf8');
216+
const match = content.match(/<Version>([^<]+)<\/Version>/);
217+
if (!match || !match[1].trim()) {
218+
throw new Error(`Could not read C# app <Version> from ${projectPath}.`);
219+
}
220+
221+
return match[1].trim();
222+
}

0 commit comments

Comments
 (0)