diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e447763..7ceea3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/.gitignore b/.gitignore index 30d91d4..16d969e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +dist-dotnet/ build/ !build/ build/* @@ -9,6 +10,8 @@ build/* out/ native/**/bin/ native/**/obj/ +src-dotnet/**/bin/ +src-dotnet/**/obj/ .certs/* !.certs/.gitkeep .env diff --git a/installer/SwitchifyPc.DotNet.nsi b/installer/SwitchifyPc.DotNet.nsi new file mode 100644 index 0000000..182f375 --- /dev/null +++ b/installer/SwitchifyPc.DotNet.nsi @@ -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 diff --git a/package.json b/package.json index 54727eb..334eb71 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/package-dotnet-win.cjs b/scripts/package-dotnet-win.cjs new file mode 100644 index 0000000..5c51e06 --- /dev/null +++ b/scripts/package-dotnet-win.cjs @@ -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>/); + if (!match || !match[1].trim()) { + throw new Error(`Could not read C# app from ${projectPath}.`); + } + + return match[1].trim(); +} diff --git a/scripts/verify-dotnet-package.cjs b/scripts/verify-dotnet-package.cjs new file mode 100644 index 0000000..0ba99b4 --- /dev/null +++ b/scripts/verify-dotnet-package.cjs @@ -0,0 +1,89 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { resolveProjectPath } = require('./win-signing-tools.cjs'); + +const distDir = resolveProjectPath('dist-dotnet'); +const stageDir = path.join(distDir, 'win-unpacked'); +const appExe = path.join(stageDir, 'Switchify PC.exe'); +const builderDebug = path.join(distDir, 'builder-debug.yml'); +const latestYml = path.join(distDir, 'latest.yml'); +const appProjectPath = resolveProjectPath('src-dotnet', 'SwitchifyPc.App', 'SwitchifyPc.App.csproj'); +const appVersion = readDotnetAppVersion(appProjectPath); +const expectedInstaller = path.join(distDir, `Switchify-PC-Setup-${appVersion}-x64.exe`); +const installerScript = resolveProjectPath('installer', 'SwitchifyPc.DotNet.nsi'); + +assertExists(appExe, 'staged C# app executable'); +assertExists(builderDebug, 'C# builder debug metadata'); +assertInstallerScript(); +assertNoElectronRuntime(); + +if (fs.existsSync(expectedInstaller) || fs.existsSync(latestYml)) { + assertExists(expectedInstaller, 'C# NSIS installer'); + assertExists(latestYml, 'C# updater metadata'); + const latest = fs.readFileSync(latestYml, 'utf8'); + assertIncludes(latest, `version: ${appVersion}`, 'latest.yml version'); + assertIncludes(latest, `path: ${path.basename(expectedInstaller)}`, 'latest.yml installer path'); + assertRegex(latest, /^isAdminRightsRequired:\s*true\s*$/m, 'top-level admin metadata'); + assertRegex(latest, /^ isAdminRightsRequired:\s*true\s*$/m, 'file-entry admin metadata'); +} + +console.log('C# package verification passed.'); + +function assertExists(filePath, label) { + if (!fs.existsSync(filePath)) { + throw new Error(`${label} was not found: ${filePath}`); + } +} + +function assertNoElectronRuntime() { + const electronMarkers = [ + path.join(stageDir, 'resources', 'app.asar'), + path.join(stageDir, 'chrome_100_percent.pak'), + path.join(stageDir, 'ffmpeg.dll'), + path.join(stageDir, 'resources', 'electron.asar') + ]; + + for (const marker of electronMarkers) { + if (fs.existsSync(marker)) { + throw new Error(`C# package contains Electron runtime marker: ${marker}`); + } + } +} + +function assertInstallerScript() { + assertExists(installerScript, 'C# NSIS installer script'); + const content = fs.readFileSync(installerScript, 'utf8'); + assertIncludes(content, 'RequestExecutionLevel admin', 'installer elevation requirement'); + assertIncludes(content, 'InstallDir "$PROGRAMFILES64\\Switchify PC"', 'installer Program Files target'); + assertIncludes(content, '!define MUI_FINISHPAGE_RUN "$INSTDIR\\Switchify PC.exe"', 'installer run-after-finish behavior'); + assertIncludes(content, 'Call PromptForRunningApp', 'installer running-app prompt'); + assertIncludes(content, 'Call un.PromptForRunningApp', 'uninstaller running-app prompt'); + assertIncludes(content, 'find /I "Switchify PC.exe"', 'installer process detection'); + assertIncludes( + content, + 'Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Switchify PC', + 'installer uninstall registry key' + ); +} + +function assertIncludes(content, expected, label) { + if (!content.includes(expected)) { + throw new Error(`${label} is missing ${expected}.`); + } +} + +function assertRegex(content, regex, label) { + if (!regex.test(content)) { + throw new Error(`${label} is missing.`); + } +} + +function readDotnetAppVersion(projectPath) { + const content = fs.readFileSync(projectPath, 'utf8'); + const match = content.match(/([^<]+)<\/Version>/); + if (!match || !match[1].trim()) { + throw new Error(`Could not read C# app from ${projectPath}.`); + } + + return match[1].trim(); +} diff --git a/src-dotnet/Directory.Build.props b/src-dotnet/Directory.Build.props new file mode 100644 index 0000000..c037083 --- /dev/null +++ b/src-dotnet/Directory.Build.props @@ -0,0 +1,8 @@ + + + net8.0-windows10.0.19041.0 + enable + enable + latest + + diff --git a/src-dotnet/SwitchifyPc.App/App.xaml b/src-dotnet/SwitchifyPc.App/App.xaml new file mode 100644 index 0000000..a3307d5 --- /dev/null +++ b/src-dotnet/SwitchifyPc.App/App.xaml @@ -0,0 +1,6 @@ + + + + diff --git a/src-dotnet/SwitchifyPc.App/App.xaml.cs b/src-dotnet/SwitchifyPc.App/App.xaml.cs new file mode 100644 index 0000000..5f8634c --- /dev/null +++ b/src-dotnet/SwitchifyPc.App/App.xaml.cs @@ -0,0 +1,461 @@ +using System.Windows; +using System.Windows.Threading; +using System.IO; +using System.Net.Http; +using System.Reflection; +using SwitchifyPc.Core.Bluetooth; +using SwitchifyPc.Core.Control; +using SwitchifyPc.Core.Input; +using SwitchifyPc.Core.Pairing; +using SwitchifyPc.Core.Settings; +using SwitchifyPc.Core.AppLifecycle; +using SwitchifyPc.Core.Diagnostics; +using SwitchifyPc.Core.Startup; +using SwitchifyPc.Core.Ui; +using SwitchifyPc.Core.Updates; +using SwitchifyPc.Windows.AppLifecycle; +using SwitchifyPc.Windows.Bluetooth; +using SwitchifyPc.Windows.CursorOverlay; +using SwitchifyPc.Windows.Input; +using SwitchifyPc.Windows.Startup; +using SwitchifyPc.Windows.Updates; +using SwitchifyPc.Protocol; + +namespace SwitchifyPc.App; + +public partial class App : System.Windows.Application +{ + private static readonly string CurrentVersion = typeof(App).Assembly + .GetCustomAttribute()?.InformationalVersion + .Split('+', 2, StringSplitOptions.RemoveEmptyEntries)[0] ?? "0.2.0"; + + private SingleInstanceService? singleInstance; + private WindowsExistingInstanceSignal? existingInstanceSignal; + private NativeTrayIcon? trayIcon; + private SettingsWindow? settingsWindow; + private MainWindowViewModel mainWindowViewModel = new(); + private UpdateService? updateService; + private PairingApprovalManager? pairingApprovalManager; + private BluetoothStatusTracker? bluetoothStatusTracker; + private WindowsBluetoothGattServer? bluetoothServer; + private BluetoothRemoteFrameProcessor? bluetoothFrameProcessor; + private DesktopCommandExecutor? commandExecutor; + private WindowsCursorOverlayNotifier? cursorOverlay; + private DispatcherTimer? pairingExpiryTimer; + private bool isQuitting; + + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + ShutdownMode = ShutdownMode.OnExplicitShutdown; + singleInstance = new SingleInstanceService(new WindowsSingleInstanceLockFactory()); + AppLaunchOptions launchOptions = AppLaunchOptions.Parse(e.Args); + SingleInstanceDecision decision = singleInstance.Start(launchOptions); + + if (!decision.IsPrimaryInstance) + { + if (decision.ExistingInstanceAction == ExistingInstanceAction.ShowMainWindow) + { + using WindowsExistingInstanceSignal signal = new(); + signal.SignalShowMainWindow(); + } + + Shutdown(); + return; + } + + existingInstanceSignal = new WindowsExistingInstanceSignal(); + existingInstanceSignal.Start(() => Dispatcher.BeginInvoke(ShowMainWindow)); + updateService = CreateUpdateService(); + pairingApprovalManager = CreatePairingApprovalManager(); + bluetoothStatusTracker = new BluetoothStatusTracker(onStatusChanged: UpdateBluetoothState); + RefreshPairingApprovals(); + updateService.StartAutomaticUpdateChecks(); + StartPairingExpiryTimer(); + _ = StartBluetoothAsync(); + _ = RecordStartupDiagnosticsAsync(e.Args, launchOptions.StartHidden); + trayIcon = new NativeTrayIcon( + ShowMainWindow, + ShowSettingsWindow, + TrayStatusText, + CanDisconnectBluetoothDevices, + DisconnectBluetoothDevices, + QuitApplication); + + if (decision.ShowMainWindow) + { + ShowMainWindow(); + } + } + + protected override void OnExit(ExitEventArgs e) + { + singleInstance?.Stop(); + singleInstance = null; + existingInstanceSignal?.Dispose(); + existingInstanceSignal = null; + updateService?.StopAutomaticUpdateChecks(); + updateService = null; + pairingExpiryTimer?.Stop(); + pairingExpiryTimer = null; + bluetoothServer?.Dispose(); + bluetoothServer = null; + cursorOverlay?.Dispose(); + cursorOverlay = null; + commandExecutor = null; + bluetoothFrameProcessor = null; + bluetoothStatusTracker = null; + pairingApprovalManager = null; + trayIcon?.Dispose(); + trayIcon = null; + settingsWindow = null; + base.OnExit(e); + } + + private void ShowMainWindow() + { + Window window = MainWindow ?? CreateMainWindow(); + MainWindow = window; + + window.Show(); + window.WindowState = WindowState.Normal; + window.Activate(); + } + + private Window CreateMainWindow() + { + mainWindowViewModel.SetUpdateState(updateService?.GetState() ?? UpdateState.CreateInitial(CurrentVersion)); + RefreshPairingApprovals(); + SwitchifyPc.App.MainWindow window = new( + mainWindowViewModel, + ShowSettingsWindow, + ShowSettingsWindow, + DisconnectBluetoothDevices, + AcceptPairingApprovalAsync, + RejectPairingApproval); + window.Closing += (_, eventArgs) => + { + if (isQuitting) return; + eventArgs.Cancel = true; + window.Hide(); + }; + + return window; + } + + private void QuitApplication() + { + isQuitting = true; + Shutdown(); + } + + private string TrayStatusText() + { + BluetoothStatus status = bluetoothStatusTracker?.Status ?? BluetoothStatusModel.DefaultStatus; + return $"Status: {MainWindowCopy.BluetoothStatusLabel(status)}"; + } + + private bool CanDisconnectBluetoothDevices() + { + return bluetoothStatusTracker?.Status.ConnectedClientCount > 0; + } + + private void DisconnectBluetoothDevices() + { + bluetoothServer?.DisconnectAll(); + } + + private void ShowSettingsWindow() + { + settingsWindow ??= CreateSettingsWindow(); + CenterWindow(settingsWindow); + settingsWindow.Show(); + settingsWindow.WindowState = WindowState.Normal; + settingsWindow.Activate(); + } + + private SettingsWindow CreateSettingsWindow() + { + SettingsWindow window = new(CreateSettingsController()); + window.Closing += (_, eventArgs) => + { + if (isQuitting) return; + eventArgs.Cancel = true; + window.Hide(); + }; + + return window; + } + + private static void CenterWindow(Window window) + { + window.Left = SystemParameters.WorkArea.Left + (SystemParameters.WorkArea.Width - window.Width) / 2; + window.Top = SystemParameters.WorkArea.Top + (SystemParameters.WorkArea.Height - window.Height) / 2; + } + + private SettingsController CreateSettingsController() + { + SettingsViewModel viewModel = new(); + string userDataDirectory = UserDataDirectory(); + return new SettingsController( + viewModel, + CreateStartupService(), + new JsonPointerMovementSettingsStore(Path.Combine(userDataDirectory, "pointer-movement-settings.json")), + new JsonCursorOverlaySettingsStore(Path.Combine(userDataDirectory, "cursor-overlay-settings.json")), + updateService ?? CreateUpdateService(), + new JsonPairingStore(Path.Combine(userDataDirectory, "pairing-state.json"))); + } + + private SystemStartupService CreateStartupService() + { + return new SystemStartupService( + platform: "win32", + isPackaged: IsInstalledApp(), + executablePath: Environment.ProcessPath ?? string.Empty, + startupRegistry: new WindowsStartupRegistry(), + startupCommandFor: WindowsStartupRegistry.StartupCommandFor); + } + + private UpdateService CreateUpdateService() + { + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string cacheDirectory = Path.Combine(localAppData, "switchify-pc-updater"); + return new UpdateService(new UpdateServiceOptions( + CurrentVersion: CurrentVersion, + IsPackaged: IsInstalledApp(), + Platform: "win32", + Backend: new GitHubReleaseUpdateBackend(new HttpClient(), "switchifyapp", "switchify-pc", cacheDirectory), + InstallerLauncher: new WindowsUpdateInstallerLauncher(), + OnStateChanged: UpdateMainWindowState)); + } + + private PairingApprovalManager CreatePairingApprovalManager() + { + return new PairingApprovalManager(new JsonPairingStore(Path.Combine(UserDataDirectory(), "pairing-state.json"))); + } + + private async Task StartBluetoothAsync() + { + try + { + bluetoothStatusTracker?.SetStarting(); + string userDataDirectory = UserDataDirectory(); + JsonPairingStore pairingStore = new(Path.Combine(userDataDirectory, "pairing-state.json")); + string desktopId = await new PairingManager(pairingStore).GetDesktopIdAsync(); + JsonPointerMovementSettingsStore pointerSettingsStore = new(Path.Combine(userDataDirectory, "pointer-movement-settings.json")); + JsonCursorOverlaySettingsStore cursorOverlaySettingsStore = new(Path.Combine(userDataDirectory, "cursor-overlay-settings.json")); + SendInputWindowsNativeInput nativeInput = new(); + WindowsDesktopInputAdapter inputAdapter = new(nativeInput, pointerSettingsStore.Load()); + cursorOverlay = new WindowsCursorOverlayNotifier(nativeInput, cursorOverlaySettingsStore); + commandExecutor = new DesktopCommandExecutor(inputAdapter, cursorOverlay); + ControlSession controlSession = new( + new CommandAuthValidator(pairingStore), + commandExecutor, + new WindowsPointerProfileProvider(nativeInput, pointerSettingsStore)); + + pairingApprovalManager ??= new PairingApprovalManager(pairingStore); + RemoteControlSession remoteSession = new( + new PairingManager(pairingStore), + pairingApprovalManager, + controlSession, + () => Dispatcher.BeginInvoke(RefreshPairingApprovals)); + bluetoothFrameProcessor = new BluetoothRemoteFrameProcessor(remoteSession); + bluetoothServer = new WindowsBluetoothGattServer(HandleBluetoothEvent); + await bluetoothServer.StartAsync(WindowsBluetoothGattServerOptions.CreateDefault("Switchify PC", desktopId)); + } + catch (Exception error) + { + bluetoothStatusTracker?.SetError(error.Message); + } + } + + private void HandleBluetoothEvent(BluetoothHelperEvent helperEvent) + { + Dispatcher.BeginInvoke(async () => + { + if (bluetoothStatusTracker is null) return; + + switch (helperEvent) + { + case BluetoothReadyEvent: + bluetoothStatusTracker.SetReady(); + break; + case BluetoothUnavailableEvent unavailable: + bluetoothStatusTracker.SetUnavailable(unavailable.Reason); + break; + case BluetoothConnectedEvent connected: + bluetoothStatusTracker.AddConnection(connected.ConnectionId); + break; + case BluetoothDisconnectedEvent disconnected: + BluetoothStatus status = bluetoothStatusTracker.RemoveConnection(disconnected.ConnectionId, disconnected.Reason); + bluetoothFrameProcessor?.RemoveConnection(disconnected.ConnectionId); + if (status.ConnectedClientCount == 0) + { + if (commandExecutor is not null) + { + await commandExecutor.ReleaseHeldMouseButtonsAsync(); + } + + cursorOverlay?.EndControlSession(); + } + break; + case BluetoothDiagnosticEvent diagnostic: + bluetoothStatusTracker.RecordDiagnostic(diagnostic.Event); + break; + case BluetoothSystemStatusEvent systemStatus: + bluetoothStatusTracker.SetSystemStatus(systemStatus); + break; + case BluetoothErrorEvent error: + bluetoothStatusTracker.SetError(error.Reason); + break; + case BluetoothMessageEvent message: + await HandleBluetoothMessageAsync(message); + break; + } + }); + } + + private async Task HandleBluetoothMessageAsync(BluetoothMessageEvent message) + { + if (bluetoothFrameProcessor is null || bluetoothServer is null) return; + + BluetoothRemoteFrameResult result = await bluetoothFrameProcessor.AcceptAsync(message.ConnectionId, message.Frame); + if (!result.MessageComplete) return; + if (result.ErrorReason is not null) + { + bluetoothStatusTracker?.SetError(result.ErrorReason); + return; + } + + await SendBluetoothOutputsAsync(result.OutgoingMessages); + } + + private async Task AcceptPairingApprovalAsync(string requestId) + { + if (bluetoothFrameProcessor is not null && bluetoothServer is not null) + { + await SendBluetoothOutputsAsync(await bluetoothFrameProcessor.AcceptPairingRequestAsync(requestId)); + } + else + { + if (pairingApprovalManager is null) return; + await pairingApprovalManager.AcceptAsync(requestId); + } + + RefreshPairingApprovals(); + } + + private void RejectPairingApproval(string requestId) + { + if (bluetoothFrameProcessor is not null && bluetoothServer is not null) + { + _ = SendBluetoothOutputsAsync(bluetoothFrameProcessor.RejectPairingRequest(requestId)); + } + else + { + pairingApprovalManager?.Reject(requestId); + } + + RefreshPairingApprovals(); + } + + private void StartPairingExpiryTimer() + { + pairingExpiryTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(5) + }; + pairingExpiryTimer.Tick += async (_, _) => + { + if (bluetoothFrameProcessor is not null) + { + await SendBluetoothOutputsAsync(bluetoothFrameProcessor.ExpirePendingPairingRequests()); + bluetoothFrameProcessor.ClearExpiredPartials(); + } + + RefreshPairingApprovals(); + }; + pairingExpiryTimer.Start(); + } + + private async Task SendBluetoothOutputsAsync(IReadOnlyList outputs) + { + if (bluetoothServer is null) return; + + foreach (BluetoothRemoteFrameOutput output in outputs) + { + foreach (BluetoothFrame frame in output.ResponseFrames) + { + await bluetoothServer.SendAsync(output.ConnectionId, frame); + } + + if (output.CloseConnection) + { + bluetoothServer.Disconnect(output.ConnectionId); + } + } + } + + private void RefreshPairingApprovals() + { + mainWindowViewModel.SetPairingApprovals( + pairingApprovalManager?.ListPendingRequestViews() ?? []); + } + + private static string UserDataDirectory() + { + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + return Path.Combine(appData, "switchify-pc"); + } + + private static bool IsInstalledApp() + { + string? executablePath = Environment.ProcessPath; + if (string.IsNullOrWhiteSpace(executablePath)) return false; + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + return executablePath.StartsWith(programFiles, StringComparison.OrdinalIgnoreCase); + } + + private void UpdateMainWindowState(UpdateState state) + { + Dispatcher.BeginInvoke(() => mainWindowViewModel.SetUpdateState(state)); + } + + private void UpdateBluetoothState(BluetoothStatus status) + { + DesktopUiState desktopState = status.Status switch + { + "connected" => DesktopUiState.Connected, + "ready" => DesktopUiState.WaitingForDevice, + "starting" => DesktopUiState.Starting, + "stopped" or "disabled" => DesktopUiState.NotRunning, + "error" => DesktopUiState.ServerError, + _ => DesktopUiState.Starting + }; + Dispatcher.BeginInvoke(() => mainWindowViewModel.SetBluetoothState(desktopState, status)); + } + + private async Task RecordStartupDiagnosticsAsync(string[] argv, bool startHidden) + { + try + { + SystemStartupSettings startupSettings = await CreateStartupService().GetSettingsAsync(); + JsonlDiagnostics.AppendStartupDiagnostics( + Path.Combine(UserDataDirectory(), "startup-diagnostics.jsonl"), + new StartupDiagnosticsEntry( + StartedAt: DateTimeOffset.UtcNow.ToString("O"), + Version: CurrentVersion, + IsPackaged: IsInstalledApp(), + Platform: "win32", + ExecutablePath: Environment.ProcessPath ?? string.Empty, + Argv: argv, + StartHidden: startHidden, + StartupRegistration: JsonlDiagnostics.RegistrationFromSettings(startupSettings))); + } + catch + { + // Diagnostics must not block app startup. + } + } +} diff --git a/src-dotnet/SwitchifyPc.App/AssemblyInfo.cs b/src-dotnet/SwitchifyPc.App/AssemblyInfo.cs new file mode 100644 index 0000000..cc29e7f --- /dev/null +++ b/src-dotnet/SwitchifyPc.App/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly:ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/src-dotnet/SwitchifyPc.App/MainWindow.xaml b/src-dotnet/SwitchifyPc.App/MainWindow.xaml new file mode 100644 index 0000000..e82c560 --- /dev/null +++ b/src-dotnet/SwitchifyPc.App/MainWindow.xaml @@ -0,0 +1,421 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +