diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7ceea3f..c6c744c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,21 +31,6 @@ jobs:
- name: Install dependencies
run: npm ci
- - name: Typecheck
- run: npm run typecheck
-
- - name: Test
- run: npm test
-
- - name: Build
- run: npm run build
-
- - name: Build native cursor overlay helper
- run: npm run native:build-overlay
-
- - name: Smoke native cursor overlay helper
- run: npm run native:smoke-overlay
-
- name: Restore C# solution
run: npm run dotnet:restore
diff --git a/electron-builder.yml b/electron-builder.yml
deleted file mode 100644
index 60d990e..0000000
--- a/electron-builder.yml
+++ /dev/null
@@ -1,53 +0,0 @@
-appId: app.switchify.pc
-productName: Switchify PC
-copyright: Copyright © 2026 Owen McGirr
-
-directories:
- output: dist
- buildResources: build
-
-afterPack: scripts/package-win-after-pack.cjs
-afterAllArtifactBuild: scripts/sign-win-artifacts.cjs
-
-publish:
- provider: github
- owner: switchifyapp
- repo: switchify-pc
- releaseType: release
-
-files:
- - out/**/*
- - package.json
-
-asar: true
-asarUnpack:
- - node_modules/@nut-tree-fork/libnut-win32/**/*
-
-extraResources:
- - from: build/icon.png
- to: icon.png
- - from: build/native/cursor-overlay-helper/win-x64/SwitchifyCursorOverlay.exe
- to: native/SwitchifyCursorOverlay.exe
- - from: build/native/bluetooth-transport-helper/win-x64/SwitchifyBluetoothTransport.exe
- to: native/SwitchifyBluetoothTransport.exe
- - from: build/native/text-input-helper/win-x64/SwitchifyTextInput.exe
- to: native/SwitchifyTextInput.exe
-
-win:
- icon: build/icon.ico
- signAndEditExecutable: false
- target:
- - target: dir
- arch:
- - x64
- - target: nsis
- arch:
- - x64
-
-nsis:
- oneClick: false
- perMachine: true
- allowElevation: true
- runAfterFinish: true
- differentialPackage: false
- artifactName: Switchify-PC-Setup-${version}-${arch}.${ext}
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
deleted file mode 100644
index 63374fc..0000000
--- a/electron.vite.config.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
-import react from '@vitejs/plugin-react';
-import { resolve } from 'node:path';
-
-export default defineConfig({
- main: {
- plugins: [externalizeDepsPlugin()],
- build: {
- rollupOptions: {
- input: resolve(__dirname, 'src/main/index.ts')
- }
- }
- },
- preload: {
- plugins: [externalizeDepsPlugin()],
- build: {
- rollupOptions: {
- input: resolve(__dirname, 'src/preload/index.ts')
- }
- }
- },
- renderer: {
- root: __dirname,
- plugins: [react()],
- build: {
- rollupOptions: {
- input: resolve(__dirname, 'index.html')
- }
- }
- }
-});
diff --git a/index.html b/index.html
deleted file mode 100644
index f126d6f..0000000
--- a/index.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
- Switchify PC
-
-
-
-
-
-
diff --git a/native/bluetooth-transport-helper/BluetoothGattBridge.cs b/native/bluetooth-transport-helper/BluetoothGattBridge.cs
deleted file mode 100644
index d598c68..0000000
--- a/native/bluetooth-transport-helper/BluetoothGattBridge.cs
+++ /dev/null
@@ -1,561 +0,0 @@
-using System.Runtime.InteropServices.WindowsRuntime;
-using System.Text;
-using System.Text.Json;
-using Windows.Devices.Bluetooth;
-using Windows.Devices.Bluetooth.GenericAttributeProfile;
-using Windows.Devices.Radios;
-using Windows.Storage.Streams;
-
-namespace SwitchifyBluetoothTransport;
-
-internal sealed class BluetoothGattBridge
-{
- private const string ConnectionId = "ble";
- private static readonly TimeSpan DisconnectGracePeriod = TimeSpan.FromSeconds(10);
- private static readonly TimeSpan SystemStatusPollInterval = TimeSpan.FromSeconds(5);
-
- private GattServiceProvider? serviceProvider;
- private GattLocalCharacteristic? txCharacteristic;
- private GattLocalCharacteristic? rxCharacteristic;
- private GattLocalCharacteristic? statusCharacteristic;
- private string statusPayload = "{}";
- private bool connected;
- private CancellationTokenSource? disconnectGrace;
- private StartCommand? activeStartCommand;
- private BluetoothAdapter? currentAdapter;
- private Radio? currentRadio;
- private CancellationTokenSource? systemStatusPolling;
- private string? lastSystemStatusKey;
- private bool restartInProgress;
-
- public async Task StartAsync(StartCommand command)
- {
- Stop();
- activeStartCommand = command;
- var snapshot = await StartSystemStatusMonitoringAsync();
- EmitSystemStatus(snapshot, force: true);
-
- if (!snapshot.AdapterPresent)
- {
- HelperProtocol.WriteEvent(new { type = "unavailable", reason = "unsupported" });
- return;
- }
-
- if (snapshot.IsLowEnergySupported != true || snapshot.IsPeripheralRoleSupported != true)
- {
- HelperProtocol.WriteEvent(new { type = "unavailable", reason = "unsupported" });
- return;
- }
-
- if (snapshot.RadioState is "off" or "disabled")
- {
- HelperProtocol.WriteEvent(new { type = "unavailable", reason = "adapter_off" });
- return;
- }
-
- await StartAdvertisingAsync(command, restarted: false);
- }
-
- public void Stop()
- {
- StopSystemStatusMonitoring();
- activeStartCommand = null;
- CancelDisconnectGrace();
- if (connected)
- {
- EmitDisconnected("helper_stopped");
- }
-
- StopAdvertisingOnly();
- }
-
- public async Task SendAsync(SendCommand command)
- {
- if (command.ConnectionId != ConnectionId || txCharacteristic is null)
- {
- return;
- }
-
- var json = JsonSerializer.Serialize(command.Frame, HelperProtocol.JsonOptions);
- var bytes = Encoding.UTF8.GetBytes(json);
- await txCharacteristic.NotifyValueAsync(bytes.AsBuffer());
- }
-
- public void Disconnect(string connectionId)
- {
- if (connectionId != ConnectionId || !connected)
- {
- return;
- }
-
- CancelDisconnectGrace();
- EmitDisconnected("pc_requested");
- }
-
- private async Task StartAdvertisingAsync(StartCommand command, bool restarted)
- {
- statusPayload = JsonSerializer.Serialize(
- new
- {
- protocolVersion = 1,
- displayName = command.DisplayName,
- desktopId = command.DesktopId
- },
- HelperProtocol.JsonOptions
- );
-
- var providerResult = await GattServiceProvider.CreateAsync(Guid.Parse(command.ServiceUuid));
- if (providerResult.Error != BluetoothError.Success || providerResult.ServiceProvider is null)
- {
- HelperProtocol.WriteEvent(new { type = "unavailable", reason = "startup_failed" });
- return;
- }
-
- serviceProvider = providerResult.ServiceProvider;
- rxCharacteristic = await CreateRxCharacteristicAsync(Guid.Parse(command.RxCharacteristicUuid));
- txCharacteristic = await CreateTxCharacteristicAsync(Guid.Parse(command.TxCharacteristicUuid));
- statusCharacteristic = await CreateStatusCharacteristicAsync(Guid.Parse(command.StatusCharacteristicUuid));
-
- if (rxCharacteristic is null || txCharacteristic is null || statusCharacteristic is null)
- {
- HelperProtocol.WriteEvent(new { type = "unavailable", reason = "startup_failed" });
- Stop();
- return;
- }
-
- serviceProvider.StartAdvertising(
- new GattServiceProviderAdvertisingParameters
- {
- IsConnectable = true,
- IsDiscoverable = true
- }
- );
-
- HelperProtocol.WriteEvent(new { type = "diagnostic", @event = restarted ? "advertising_restarted" : "advertising_started" });
- HelperProtocol.WriteEvent(new { type = "ready" });
- }
-
- private void StopAdvertisingOnly()
- {
- serviceProvider?.StopAdvertising();
- serviceProvider = null;
- rxCharacteristic = null;
- txCharacteristic = null;
- statusCharacteristic = null;
- }
-
- private async Task StartSystemStatusMonitoringAsync()
- {
- StopSystemStatusMonitoring();
- systemStatusPolling = new CancellationTokenSource();
- var token = systemStatusPolling.Token;
- var snapshot = await ReadAdapterSnapshotAsync();
-
- _ = Task.Run(async () =>
- {
- while (!token.IsCancellationRequested)
- {
- try
- {
- await Task.Delay(SystemStatusPollInterval, token);
- if (token.IsCancellationRequested)
- {
- return;
- }
-
- var current = await ReadAdapterSnapshotAsync();
- await HandleSystemStatusChangeAsync(current);
- }
- catch (OperationCanceledException)
- {
- return;
- }
- catch
- {
- var unknown = new AdapterSnapshot(false, "unknown", null, null);
- await HandleSystemStatusChangeAsync(unknown);
- }
- }
- }, token);
-
- return snapshot;
- }
-
- private void StopSystemStatusMonitoring()
- {
- systemStatusPolling?.Cancel();
- systemStatusPolling?.Dispose();
- systemStatusPolling = null;
- DetachRadioStateChanged();
- currentAdapter = null;
- lastSystemStatusKey = null;
- }
-
- private async Task ReadAdapterSnapshotAsync()
- {
- try
- {
- var adapter = await BluetoothAdapter.GetDefaultAsync();
- if (adapter is null)
- {
- DetachRadioStateChanged();
- currentAdapter = null;
- return new AdapterSnapshot(false, "unknown", null, null);
- }
-
- currentAdapter = adapter;
- var radio = await adapter.GetRadioAsync();
- if (!ReferenceEquals(currentRadio, radio))
- {
- DetachRadioStateChanged();
- if (radio is not null)
- {
- AttachRadioStateChanged(radio);
- }
- }
-
- return new AdapterSnapshot(
- true,
- RadioStateToProtocol(radio?.State),
- adapter.IsLowEnergySupported,
- adapter.IsPeripheralRoleSupported
- );
- }
- catch
- {
- return new AdapterSnapshot(false, "unknown", null, null);
- }
- }
-
- private void AttachRadioStateChanged(Radio radio)
- {
- currentRadio = radio;
- currentRadio.StateChanged += OnRadioStateChanged;
- }
-
- private void DetachRadioStateChanged()
- {
- if (currentRadio is not null)
- {
- currentRadio.StateChanged -= OnRadioStateChanged;
- currentRadio = null;
- }
- }
-
- private async void OnRadioStateChanged(Radio sender, object args)
- {
- var snapshot = await ReadAdapterSnapshotAsync();
- await HandleSystemStatusChangeAsync(snapshot);
- }
-
- private void EmitSystemStatus(AdapterSnapshot snapshot, bool force = false)
- {
- var key = $"{snapshot.AdapterPresent}|{snapshot.RadioState}|{snapshot.IsLowEnergySupported}|{snapshot.IsPeripheralRoleSupported}";
- if (!force && key == lastSystemStatusKey)
- {
- return;
- }
-
- lastSystemStatusKey = key;
- HelperProtocol.WriteEvent(new
- {
- type = "systemStatus",
- adapterPresent = snapshot.AdapterPresent,
- radioState = snapshot.RadioState,
- isLowEnergySupported = snapshot.IsLowEnergySupported,
- isPeripheralRoleSupported = snapshot.IsPeripheralRoleSupported
- });
- }
-
- private async Task HandleSystemStatusChangeAsync(AdapterSnapshot snapshot)
- {
- var previousKey = lastSystemStatusKey;
- EmitSystemStatus(snapshot);
- var changed = previousKey != lastSystemStatusKey;
- if (!changed)
- {
- return;
- }
-
- if (!snapshot.AdapterPresent || snapshot.IsLowEnergySupported != true || snapshot.IsPeripheralRoleSupported != true)
- {
- if (connected)
- {
- EmitDisconnected("adapter_off");
- }
- StopAdvertisingOnly();
- HelperProtocol.WriteEvent(new { type = "unavailable", reason = "unsupported" });
- return;
- }
-
- if (snapshot.RadioState is "off" or "disabled")
- {
- HelperProtocol.WriteEvent(new { type = "diagnostic", @event = "system_radio_off" });
- if (connected)
- {
- EmitDisconnected("adapter_off");
- }
- StopAdvertisingOnly();
- HelperProtocol.WriteEvent(new { type = "unavailable", reason = "adapter_off" });
- return;
- }
-
- if (snapshot.RadioState == "on")
- {
- HelperProtocol.WriteEvent(new { type = "diagnostic", @event = "system_radio_on" });
- await RestartAdvertisingIfPossibleAsync();
- }
- }
-
- private async Task RestartAdvertisingIfPossibleAsync()
- {
- if (restartInProgress || activeStartCommand is null || serviceProvider is not null)
- {
- return;
- }
-
- restartInProgress = true;
- try
- {
- await StartAdvertisingAsync(activeStartCommand, restarted: true);
- }
- finally
- {
- restartInProgress = false;
- }
- }
-
- private async Task CreateRxCharacteristicAsync(Guid uuid)
- {
- if (serviceProvider is null)
- {
- return null;
- }
-
- var result = await serviceProvider.Service.CreateCharacteristicAsync(
- uuid,
- new GattLocalCharacteristicParameters
- {
- CharacteristicProperties = GattCharacteristicProperties.Write | GattCharacteristicProperties.WriteWithoutResponse,
- WriteProtectionLevel = GattProtectionLevel.Plain,
- UserDescription = "Switchify RX"
- }
- );
-
- if (result.Error != BluetoothError.Success || result.Characteristic is null)
- {
- return null;
- }
-
- result.Characteristic.WriteRequested += OnWriteRequested;
- return result.Characteristic;
- }
-
- private async Task CreateTxCharacteristicAsync(Guid uuid)
- {
- if (serviceProvider is null)
- {
- return null;
- }
-
- var result = await serviceProvider.Service.CreateCharacteristicAsync(
- uuid,
- new GattLocalCharacteristicParameters
- {
- CharacteristicProperties = GattCharacteristicProperties.Notify,
- UserDescription = "Switchify TX"
- }
- );
-
- if (result.Error != BluetoothError.Success || result.Characteristic is null)
- {
- return null;
- }
-
- result.Characteristic.SubscribedClientsChanged += (_, _) =>
- {
- var hasSubscribers = result.Characteristic.SubscribedClients.Count > 0;
- if (hasSubscribers)
- {
- MarkConnected("subscribed");
- }
- else if (!hasSubscribers && connected)
- {
- HelperProtocol.WriteEvent(new { type = "diagnostic", @event = "unsubscribed" });
- StartDisconnectGrace();
- }
- };
-
- return result.Characteristic;
- }
-
- private async Task CreateStatusCharacteristicAsync(Guid uuid)
- {
- if (serviceProvider is null)
- {
- return null;
- }
-
- var result = await serviceProvider.Service.CreateCharacteristicAsync(
- uuid,
- new GattLocalCharacteristicParameters
- {
- CharacteristicProperties = GattCharacteristicProperties.Read,
- ReadProtectionLevel = GattProtectionLevel.Plain,
- UserDescription = "Switchify status"
- }
- );
-
- if (result.Error != BluetoothError.Success || result.Characteristic is null)
- {
- return null;
- }
-
- result.Characteristic.ReadRequested += OnReadRequested;
- return result.Characteristic;
- }
-
- private async void OnWriteRequested(GattLocalCharacteristic sender, GattWriteRequestedEventArgs args)
- {
- var deferral = args.GetDeferral();
- try
- {
- var request = await args.GetRequestAsync();
- if (request is null)
- {
- return;
- }
-
- var bytes = request.Value.ToArray();
- var json = Encoding.UTF8.GetString(bytes);
- var frame = JsonSerializer.Deserialize(json, HelperProtocol.JsonOptions);
- if (frame is null)
- {
- request.Respond();
- HelperProtocol.WriteEvent(new { type = "error", reason = "invalid_frame" });
- return;
- }
-
- request.Respond();
- MarkConnected("write_received");
- HelperProtocol.WriteEvent(new { type = "message", connectionId = ConnectionId, frame });
- }
- catch
- {
- HelperProtocol.WriteEvent(new { type = "error", reason = "write_failed" });
- }
- finally
- {
- deferral.Complete();
- }
- }
-
- private async void OnReadRequested(GattLocalCharacteristic sender, GattReadRequestedEventArgs args)
- {
- var deferral = args.GetDeferral();
- try
- {
- var request = await args.GetRequestAsync();
- if (request is not null)
- {
- request.RespondWithValue(Encoding.UTF8.GetBytes(statusPayload).AsBuffer());
- }
- }
- catch
- {
- HelperProtocol.WriteEvent(new { type = "error", reason = "read_failed" });
- }
- finally
- {
- deferral.Complete();
- }
- }
-
- private void MarkConnected(string eventName)
- {
- CancelDisconnectGrace();
- if (!connected)
- {
- connected = true;
- HelperProtocol.WriteEvent(new { type = "connected", connectionId = ConnectionId, label = "Bluetooth device" });
- }
- HelperProtocol.WriteEvent(new { type = "diagnostic", @event = eventName });
- }
-
- private void StartDisconnectGrace()
- {
- if (disconnectGrace is not null)
- {
- return;
- }
-
- disconnectGrace = new CancellationTokenSource();
- var grace = disconnectGrace;
- var token = grace.Token;
- HelperProtocol.WriteEvent(new { type = "diagnostic", @event = "unsubscribe_grace_started" });
- _ = Task.Run(async () =>
- {
- try
- {
- await Task.Delay(DisconnectGracePeriod, token);
- if (!token.IsCancellationRequested && connected)
- {
- HelperProtocol.WriteEvent(new { type = "diagnostic", @event = "unsubscribe_grace_timed_out" });
- EmitDisconnected("notification_unsubscribed");
- }
- }
- catch (OperationCanceledException)
- {
- // Expected when the client writes or resubscribes before the grace period expires.
- }
- finally
- {
- if (ReferenceEquals(disconnectGrace, grace))
- {
- grace.Dispose();
- disconnectGrace = null;
- }
- }
- });
- }
-
- private void CancelDisconnectGrace()
- {
- var pending = disconnectGrace;
- if (pending is null)
- {
- return;
- }
-
- disconnectGrace = null;
- pending.Cancel();
- pending.Dispose();
- HelperProtocol.WriteEvent(new { type = "diagnostic", @event = "unsubscribe_grace_cancelled" });
- }
-
- private void EmitDisconnected(string reason)
- {
- connected = false;
- disconnectGrace?.Dispose();
- disconnectGrace = null;
- HelperProtocol.WriteEvent(new { type = "disconnected", connectionId = ConnectionId, reason });
- }
-
- private static string RadioStateToProtocol(RadioState? state)
- {
- return state switch
- {
- RadioState.On => "on",
- RadioState.Off => "off",
- RadioState.Disabled => "disabled",
- _ => "unknown"
- };
- }
-
- private sealed record AdapterSnapshot(
- bool AdapterPresent,
- string RadioState,
- bool? IsLowEnergySupported,
- bool? IsPeripheralRoleSupported
- );
-}
diff --git a/native/bluetooth-transport-helper/HelperProtocol.cs b/native/bluetooth-transport-helper/HelperProtocol.cs
deleted file mode 100644
index e4852e6..0000000
--- a/native/bluetooth-transport-helper/HelperProtocol.cs
+++ /dev/null
@@ -1,68 +0,0 @@
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace SwitchifyBluetoothTransport;
-
-internal static class HelperProtocol
-{
- public static readonly JsonSerializerOptions JsonOptions = new()
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
- };
-
- public static void WriteEvent(object value)
- {
- Console.Out.WriteLine(JsonSerializer.Serialize(value, JsonOptions));
- Console.Out.Flush();
- }
-
- public static HelperCommand? ParseCommand(string line)
- {
- using var document = JsonDocument.Parse(line);
- if (!document.RootElement.TryGetProperty("type", out var typeElement))
- {
- return null;
- }
-
- var type = typeElement.GetString();
- return type switch
- {
- "start" => JsonSerializer.Deserialize(line, JsonOptions),
- "stop" => new StopCommand(),
- "send" => JsonSerializer.Deserialize(line, JsonOptions),
- "disconnect" => JsonSerializer.Deserialize(line, JsonOptions),
- "shutdown" => new ShutdownCommand(),
- _ => null
- };
- }
-}
-
-internal abstract record HelperCommand(string Type);
-
-internal sealed record StartCommand(
- string ServiceUuid,
- string RxCharacteristicUuid,
- string TxCharacteristicUuid,
- string StatusCharacteristicUuid,
- string DisplayName,
- string DesktopId
-) : HelperCommand("start");
-
-internal sealed record StopCommand() : HelperCommand("stop");
-
-internal sealed record ShutdownCommand() : HelperCommand("shutdown");
-
-internal sealed record DisconnectCommand(string ConnectionId) : HelperCommand("disconnect");
-
-internal sealed record SendCommand(string ConnectionId, BluetoothFrame Frame) : HelperCommand("send");
-
-internal sealed record BluetoothFrame(
- int Version,
- string MessageId,
- int Sequence,
- bool IsFinal,
- int TotalBytes,
- string PayloadBase64
-);
-
diff --git a/native/bluetooth-transport-helper/Program.cs b/native/bluetooth-transport-helper/Program.cs
deleted file mode 100644
index e08fff3..0000000
--- a/native/bluetooth-transport-helper/Program.cs
+++ /dev/null
@@ -1,69 +0,0 @@
-using SwitchifyBluetoothTransport;
-
-using var shutdown = new CancellationTokenSource();
-var service = new BluetoothGattBridge();
-
-Console.CancelKeyPress += (_, eventArgs) =>
-{
- eventArgs.Cancel = true;
- shutdown.Cancel();
-};
-
-try
-{
- while (!shutdown.IsCancellationRequested)
- {
- var line = await Console.In.ReadLineAsync(shutdown.Token);
- if (line is null)
- {
- break;
- }
-
- if (string.IsNullOrWhiteSpace(line))
- {
- continue;
- }
-
- HelperCommand? command;
- try
- {
- command = HelperProtocol.ParseCommand(line);
- }
- catch
- {
- HelperProtocol.WriteEvent(new { type = "error", reason = "invalid_command" });
- continue;
- }
-
- switch (command)
- {
- case StartCommand start:
- await service.StartAsync(start);
- break;
- case StopCommand:
- service.Stop();
- break;
- case SendCommand send:
- await service.SendAsync(send);
- break;
- case DisconnectCommand disconnect:
- service.Disconnect(disconnect.ConnectionId);
- break;
- case ShutdownCommand:
- shutdown.Cancel();
- break;
- default:
- HelperProtocol.WriteEvent(new { type = "error", reason = "unsupported_command" });
- break;
- }
- }
-}
-catch (OperationCanceledException)
-{
- // Normal shutdown.
-}
-finally
-{
- service.Stop();
-}
-
diff --git a/native/bluetooth-transport-helper/SwitchifyBluetoothTransport.csproj b/native/bluetooth-transport-helper/SwitchifyBluetoothTransport.csproj
deleted file mode 100644
index 9ae8f13..0000000
--- a/native/bluetooth-transport-helper/SwitchifyBluetoothTransport.csproj
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- Exe
- net8.0-windows10.0.19041.0
- enable
- enable
- SwitchifyBluetoothTransport
- SwitchifyBluetoothTransport
-
-
-
diff --git a/native/cursor-overlay-helper/CursorOverlayHelper.csproj b/native/cursor-overlay-helper/CursorOverlayHelper.csproj
deleted file mode 100644
index 4f80355..0000000
--- a/native/cursor-overlay-helper/CursorOverlayHelper.csproj
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
- WinExe
- net8.0-windows
- true
- enable
- enable
- true
- SwitchifyCursorOverlay
- Switchify.CursorOverlay
- win-x64
- true
- true
- false
-
-
diff --git a/native/cursor-overlay-helper/NativeMethods.cs b/native/cursor-overlay-helper/NativeMethods.cs
deleted file mode 100644
index 3e9c463..0000000
--- a/native/cursor-overlay-helper/NativeMethods.cs
+++ /dev/null
@@ -1,108 +0,0 @@
-using System.Runtime.InteropServices;
-
-namespace Switchify.CursorOverlay;
-
-internal static partial class NativeMethods
-{
- internal const int GWL_EXSTYLE = -20;
- internal const int WS_EX_LAYERED = 0x00080000;
- internal const int WS_EX_TRANSPARENT = 0x00000020;
- internal const int WS_EX_TOPMOST = 0x00000008;
- internal const int WS_EX_TOOLWINDOW = 0x00000080;
- internal const int WS_EX_NOACTIVATE = 0x08000000;
-
- internal static readonly IntPtr HWND_TOPMOST = new(-1);
-
- internal const uint SWP_NOSIZE = 0x0001;
- internal const uint SWP_NOMOVE = 0x0002;
- internal const uint SWP_NOACTIVATE = 0x0010;
- internal const uint SWP_SHOWWINDOW = 0x0040;
- internal const int ULW_ALPHA = 0x00000002;
- internal const byte AC_SRC_OVER = 0x00;
- internal const byte AC_SRC_ALPHA = 0x01;
-
- [StructLayout(LayoutKind.Sequential)]
- internal struct Point
- {
- internal int X;
- internal int Y;
-
- internal Point(int x, int y)
- {
- X = x;
- Y = y;
- }
- }
-
- [StructLayout(LayoutKind.Sequential)]
- internal struct Size
- {
- internal int Cx;
- internal int Cy;
-
- internal Size(int cx, int cy)
- {
- Cx = cx;
- Cy = cy;
- }
- }
-
- [StructLayout(LayoutKind.Sequential, Pack = 1)]
- internal struct BlendFunction
- {
- internal byte BlendOp;
- internal byte BlendFlags;
- internal byte SourceConstantAlpha;
- internal byte AlphaFormat;
- }
-
- [LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
- internal static partial IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);
-
- [LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
- internal static partial IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
-
- [LibraryImport("user32.dll")]
- [return: MarshalAs(UnmanagedType.Bool)]
- internal static partial bool SetWindowPos(
- IntPtr hWnd,
- IntPtr hWndInsertAfter,
- int x,
- int y,
- int cx,
- int cy,
- uint uFlags);
-
- [LibraryImport("user32.dll")]
- [return: MarshalAs(UnmanagedType.Bool)]
- internal static partial bool UpdateLayeredWindow(
- IntPtr hWnd,
- IntPtr hdcDst,
- ref Point pptDst,
- ref Size psize,
- IntPtr hdcSrc,
- ref Point pptSrc,
- int crKey,
- ref BlendFunction pblend,
- int dwFlags);
-
- [LibraryImport("user32.dll")]
- internal static partial IntPtr GetDC(IntPtr hWnd);
-
- [LibraryImport("user32.dll")]
- internal static partial int ReleaseDC(IntPtr hWnd, IntPtr hdc);
-
- [LibraryImport("gdi32.dll")]
- internal static partial IntPtr CreateCompatibleDC(IntPtr hdc);
-
- [LibraryImport("gdi32.dll")]
- internal static partial IntPtr SelectObject(IntPtr hdc, IntPtr hgdiobj);
-
- [LibraryImport("gdi32.dll")]
- [return: MarshalAs(UnmanagedType.Bool)]
- internal static partial bool DeleteObject(IntPtr hObject);
-
- [LibraryImport("gdi32.dll")]
- [return: MarshalAs(UnmanagedType.Bool)]
- internal static partial bool DeleteDC(IntPtr hdc);
-}
diff --git a/native/cursor-overlay-helper/OverlayCommand.cs b/native/cursor-overlay-helper/OverlayCommand.cs
deleted file mode 100644
index d150182..0000000
--- a/native/cursor-overlay-helper/OverlayCommand.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace Switchify.CursorOverlay;
-
-internal sealed class OverlayCommand
-{
- [JsonPropertyName("type")]
- public string? Type { get; init; }
-
- [JsonPropertyName("event")]
- public string? Event { get; init; }
-
- [JsonPropertyName("x")]
- public double X { get; init; }
-
- [JsonPropertyName("y")]
- public double Y { get; init; }
-
- [JsonPropertyName("size")]
- public int Size { get; init; }
-
- [JsonPropertyName("durationMs")]
- public int DurationMs { get; init; }
-
- [JsonPropertyName("crosshairs")]
- public bool Crosshairs { get; init; }
-
- [JsonPropertyName("persistent")]
- public bool Persistent { get; init; }
-
- [JsonPropertyName("color")]
- public OverlayColor? Color { get; init; }
-}
-
-internal sealed class OverlayColor
-{
- [JsonPropertyName("red")]
- public int Red { get; init; }
-
- [JsonPropertyName("green")]
- public int Green { get; init; }
-
- [JsonPropertyName("blue")]
- public int Blue { get; init; }
-}
diff --git a/native/cursor-overlay-helper/OverlayForm.cs b/native/cursor-overlay-helper/OverlayForm.cs
deleted file mode 100644
index 69f37b7..0000000
--- a/native/cursor-overlay-helper/OverlayForm.cs
+++ /dev/null
@@ -1,331 +0,0 @@
-using System.Drawing.Drawing2D;
-
-namespace Switchify.CursorOverlay;
-
-internal sealed class OverlayForm : Form
-{
- private const int DefaultWindowSize = 128;
- private const int DefaultDurationMs = 900;
- private const int ClickPulseMs = 180;
- private const int CrosshairThickness = 2;
- private static readonly Color DefaultOverlayColor = Color.FromArgb(211, 47, 47);
-
- private readonly System.Windows.Forms.Timer hideTimer = new();
- private readonly System.Windows.Forms.Timer animationTimer = new();
- private readonly CrosshairLineForm horizontalCrosshair = new();
- private readonly CrosshairLineForm verticalCrosshair = new();
- private DateTime clickPulseStartedAt = DateTime.MinValue;
- private bool isClickPulse;
- private bool isDragActive;
- private int ringWindowSize = DefaultWindowSize;
- private PointF cursorCenter = new(DefaultWindowSize / 2.0f, DefaultWindowSize / 2.0f);
- private Color overlayColor = DefaultOverlayColor;
-
- internal OverlayForm()
- {
- AutoScaleMode = AutoScaleMode.None;
- BackColor = Color.Black;
- ClientSize = new Size(DefaultWindowSize, DefaultWindowSize);
- ControlBox = false;
- FormBorderStyle = FormBorderStyle.None;
- MaximizeBox = false;
- MinimizeBox = false;
- Name = "SwitchifyCursorOverlay";
- ShowIcon = false;
- ShowInTaskbar = false;
- StartPosition = FormStartPosition.Manual;
- TopMost = true;
-
- hideTimer.Tick += (_, _) => HideOverlay();
- animationTimer.Interval = 16;
- animationTimer.Tick += (_, _) => TickAnimation();
- }
-
- protected override bool ShowWithoutActivation => true;
-
- protected override CreateParams CreateParams
- {
- get
- {
- CreateParams createParams = base.CreateParams;
- createParams.ExStyle |=
- NativeMethods.WS_EX_LAYERED |
- NativeMethods.WS_EX_TRANSPARENT |
- NativeMethods.WS_EX_TOPMOST |
- NativeMethods.WS_EX_TOOLWINDOW |
- NativeMethods.WS_EX_NOACTIVATE;
- return createParams;
- }
- }
-
- internal void ShowOverlay(OverlayCommand command)
- {
- int size = command.Size > 0 ? command.Size : DefaultWindowSize;
- int durationMs = command.DurationMs > 0 ? command.DurationMs : DefaultDurationMs;
- Point cursor = new((int)Math.Round(command.X), (int)Math.Round(command.Y));
- Screen display = Screen.FromPoint(cursor);
- Rectangle displayBounds = display.Bounds;
- ringWindowSize = size;
- overlayColor = ResolveOverlayColor(command);
-
- ClientSize = new Size(size, size);
- Location = new Point(
- (int)Math.Round(command.X - size / 2.0),
- (int)Math.Round(command.Y - size / 2.0));
- cursorCenter = new PointF(size / 2.0f, size / 2.0f);
-
- isClickPulse = string.Equals(command.Event, "click", StringComparison.OrdinalIgnoreCase);
- isDragActive = string.Equals(command.Event, "drag", StringComparison.OrdinalIgnoreCase);
- clickPulseStartedAt = isClickPulse ? DateTime.UtcNow : DateTime.MinValue;
-
- if (!RenderLayeredOverlay())
- {
- HideOverlay();
- return;
- }
-
- ApplyTopMostNoActivate();
- ShowCrosshairs(command.Crosshairs, cursor, displayBounds, overlayColor);
-
- hideTimer.Stop();
- if (!command.Persistent)
- {
- hideTimer.Interval = durationMs;
- hideTimer.Start();
- }
- animationTimer.Start();
- }
-
- internal void HideOverlay()
- {
- hideTimer.Stop();
- animationTimer.Stop();
- isClickPulse = false;
- isDragActive = false;
- horizontalCrosshair.Hide();
- verticalCrosshair.Hide();
- Hide();
- }
-
- private float ResolvePulseScale()
- {
- if (!isClickPulse)
- {
- return 1.0f;
- }
-
- double elapsedMs = (DateTime.UtcNow - clickPulseStartedAt).TotalMilliseconds;
- if (elapsedMs >= ClickPulseMs)
- {
- isClickPulse = false;
- return 1.0f;
- }
-
- double progress = Math.Clamp(elapsedMs / ClickPulseMs, 0.0, 1.0);
- return (float)(0.82 + progress * 0.36);
- }
-
- private void TickAnimation()
- {
- if (isClickPulse)
- {
- _ = RenderLayeredOverlay();
- return;
- }
-
- animationTimer.Stop();
- }
-
- private bool RenderLayeredOverlay()
- {
- using Bitmap bitmap = new(ClientSize.Width, ClientSize.Height, System.Drawing.Imaging.PixelFormat.Format32bppPArgb);
- using (Graphics graphics = Graphics.FromImage(bitmap))
- {
- graphics.SmoothingMode = SmoothingMode.AntiAlias;
- graphics.Clear(Color.Transparent);
-
- float scale = ResolvePulseScale();
- float ringDiameter = ringWindowSize * 0.5625f * scale;
- float ringStroke = Math.Max(4.0f, ringWindowSize * 0.039f);
- float outerStroke = Math.Max(18.0f, ringWindowSize * 0.1875f) * scale;
- float centerX = cursorCenter.X;
- float centerY = cursorCenter.Y;
- float ringX = centerX - ringDiameter / 2.0f;
- float ringY = centerY - ringDiameter / 2.0f;
- float dragDotDiameter = ringDiameter * 0.22f;
- float dragDotX = centerX - dragDotDiameter / 2.0f;
- float dragDotY = centerY - dragDotDiameter / 2.0f;
-
- using Pen glow = new(
- Color.FromArgb(isDragActive ? 66 : 62, overlayColor.R, overlayColor.G, overlayColor.B),
- isDragActive ? outerStroke * 1.08f : outerStroke);
- using Pen ring = new(
- Color.FromArgb(250, overlayColor.R, overlayColor.G, overlayColor.B),
- isDragActive ? ringStroke + 1.0f : ringStroke);
- using SolidBrush dragDot = new(Color.FromArgb(240, overlayColor.R, overlayColor.G, overlayColor.B));
-
- graphics.DrawEllipse(glow, ringX, ringY, ringDiameter, ringDiameter);
- graphics.DrawEllipse(ring, ringX, ringY, ringDiameter, ringDiameter);
- if (isDragActive)
- {
- graphics.FillEllipse(dragDot, dragDotX, dragDotY, dragDotDiameter, dragDotDiameter);
- }
- }
-
- IntPtr screenDc = NativeMethods.GetDC(IntPtr.Zero);
- IntPtr memoryDc = NativeMethods.CreateCompatibleDC(screenDc);
- IntPtr bitmapHandle = bitmap.GetHbitmap(Color.FromArgb(0));
- IntPtr oldBitmap = NativeMethods.SelectObject(memoryDc, bitmapHandle);
-
- try
- {
- NativeMethods.Point destination = new(Location.X, Location.Y);
- NativeMethods.Size size = new(ClientSize.Width, ClientSize.Height);
- NativeMethods.Point source = new(0, 0);
- NativeMethods.BlendFunction blend = new()
- {
- BlendOp = NativeMethods.AC_SRC_OVER,
- BlendFlags = 0,
- SourceConstantAlpha = 255,
- AlphaFormat = NativeMethods.AC_SRC_ALPHA
- };
-
- return NativeMethods.UpdateLayeredWindow(
- Handle,
- screenDc,
- ref destination,
- ref size,
- memoryDc,
- ref source,
- 0,
- ref blend,
- NativeMethods.ULW_ALPHA);
- }
- finally
- {
- NativeMethods.SelectObject(memoryDc, oldBitmap);
- NativeMethods.DeleteObject(bitmapHandle);
- NativeMethods.DeleteDC(memoryDc);
- NativeMethods.ReleaseDC(IntPtr.Zero, screenDc);
- }
- }
-
- private void ApplyTopMostNoActivate()
- {
- NativeMethods.SetWindowPos(
- Handle,
- NativeMethods.HWND_TOPMOST,
- 0,
- 0,
- 0,
- 0,
- NativeMethods.SWP_NOMOVE |
- NativeMethods.SWP_NOSIZE |
- NativeMethods.SWP_NOACTIVATE |
- NativeMethods.SWP_SHOWWINDOW);
- }
-
- private void ShowCrosshairs(bool enabled, Point cursor, Rectangle displayBounds, Color color)
- {
- if (!enabled)
- {
- horizontalCrosshair.Hide();
- verticalCrosshair.Hide();
- return;
- }
-
- horizontalCrosshair.ShowLine(
- new Rectangle(
- displayBounds.Left,
- cursor.Y - CrosshairThickness / 2,
- displayBounds.Width,
- CrosshairThickness),
- color);
- verticalCrosshair.ShowLine(
- new Rectangle(
- cursor.X - CrosshairThickness / 2,
- displayBounds.Top,
- CrosshairThickness,
- displayBounds.Height),
- color);
- }
-
- private static Color ResolveOverlayColor(OverlayCommand command)
- {
- if (command.Color is null)
- {
- return DefaultOverlayColor;
- }
-
- return Color.FromArgb(
- ClampColorChannel(command.Color.Red),
- ClampColorChannel(command.Color.Green),
- ClampColorChannel(command.Color.Blue));
- }
-
- private static int ClampColorChannel(int channel)
- {
- return Math.Clamp(channel, 0, 255);
- }
-}
-
-internal sealed class CrosshairLineForm : Form
-{
- internal CrosshairLineForm()
- {
- AutoScaleMode = AutoScaleMode.None;
- BackColor = Color.FromArgb(211, 47, 47);
- ControlBox = false;
- FormBorderStyle = FormBorderStyle.None;
- MaximizeBox = false;
- MinimizeBox = false;
- Name = "SwitchifyCursorCrosshair";
- Opacity = 0.72;
- ShowIcon = false;
- ShowInTaskbar = false;
- StartPosition = FormStartPosition.Manual;
- TopMost = true;
- }
-
- protected override bool ShowWithoutActivation => true;
-
- protected override CreateParams CreateParams
- {
- get
- {
- CreateParams createParams = base.CreateParams;
- createParams.ExStyle |=
- NativeMethods.WS_EX_TRANSPARENT |
- NativeMethods.WS_EX_TOPMOST |
- NativeMethods.WS_EX_TOOLWINDOW |
- NativeMethods.WS_EX_NOACTIVATE;
- return createParams;
- }
- }
-
- internal void ShowLine(Rectangle bounds, Color color)
- {
- BackColor = color;
- Bounds = bounds;
- if (!Visible)
- {
- Show();
- }
- ApplyTopMostNoActivate();
- }
-
- private void ApplyTopMostNoActivate()
- {
- NativeMethods.SetWindowPos(
- Handle,
- NativeMethods.HWND_TOPMOST,
- 0,
- 0,
- 0,
- 0,
- NativeMethods.SWP_NOMOVE |
- NativeMethods.SWP_NOSIZE |
- NativeMethods.SWP_NOACTIVATE |
- NativeMethods.SWP_SHOWWINDOW);
- }
-}
diff --git a/native/cursor-overlay-helper/Program.cs b/native/cursor-overlay-helper/Program.cs
deleted file mode 100644
index bbf801c..0000000
--- a/native/cursor-overlay-helper/Program.cs
+++ /dev/null
@@ -1,116 +0,0 @@
-using System.Text.Json;
-
-namespace Switchify.CursorOverlay;
-
-internal static class Program
-{
- private static readonly JsonSerializerOptions JsonOptions = new()
- {
- PropertyNameCaseInsensitive = true
- };
-
- [STAThread]
- private static void Main()
- {
- Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);
- ApplicationConfiguration.Initialize();
- SynchronizationContext uiContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
-
- using OverlayForm overlayForm = new();
- using CancellationTokenSource cancellation = new();
-
- Task.Run(() => ReadCommands(overlayForm, uiContext, cancellation.Token));
- Console.Out.WriteLine("""{"type":"ready"}""");
- Console.Out.Flush();
-
- Application.Run();
- cancellation.Cancel();
- }
-
- private static async Task ReadCommands(
- OverlayForm overlayForm,
- SynchronizationContext uiContext,
- CancellationToken cancellationToken)
- {
- try
- {
- while (!cancellationToken.IsCancellationRequested)
- {
- string? line = await Console.In.ReadLineAsync(cancellationToken);
- if (line is null)
- {
- BeginExit(uiContext);
- return;
- }
-
- HandleCommandLine(overlayForm, uiContext, line);
- }
- }
- catch (OperationCanceledException)
- {
- }
- catch (Exception error)
- {
- WriteError("internal_error", error.Message);
- BeginExit(uiContext);
- }
- }
-
- private static void HandleCommandLine(OverlayForm overlayForm, SynchronizationContext uiContext, string line)
- {
- OverlayCommand? command;
- try
- {
- command = JsonSerializer.Deserialize(line, JsonOptions);
- }
- catch (JsonException error)
- {
- WriteError("invalid_command", error.Message);
- return;
- }
-
- if (command?.Type is null)
- {
- WriteError("invalid_command", "Command type is required.");
- return;
- }
-
- switch (command.Type)
- {
- case "show":
- if (command.Event is not ("move" or "click" or "drag"))
- {
- WriteError("invalid_command", "Show command event must be move, click, or drag.");
- return;
- }
-
- uiContext.Post(_ => overlayForm.ShowOverlay(command), null);
- break;
- case "hide":
- uiContext.Post(_ => overlayForm.HideOverlay(), null);
- break;
- case "shutdown":
- BeginExit(uiContext);
- break;
- default:
- WriteError("invalid_command", "Unsupported command type.");
- break;
- }
- }
-
- private static void BeginExit(SynchronizationContext uiContext)
- {
- uiContext.Post(_ => Application.Exit(), null);
- }
-
- private static void WriteError(string code, string message)
- {
- Console.Out.WriteLine(JsonSerializer.Serialize(new
- {
- type = "error",
- code,
- message
- }));
- Console.Out.Flush();
- }
-}
diff --git a/native/text-input-helper/NativeMethods.cs b/native/text-input-helper/NativeMethods.cs
deleted file mode 100644
index 1582b75..0000000
--- a/native/text-input-helper/NativeMethods.cs
+++ /dev/null
@@ -1,103 +0,0 @@
-using System.ComponentModel;
-using System.Runtime.InteropServices;
-
-namespace Switchify.TextInput;
-
-internal static partial class NativeMethods
-{
- private const uint INPUT_KEYBOARD = 1;
- private const uint KEYEVENTF_KEYUP = 0x0002;
- private const uint KEYEVENTF_UNICODE = 0x0004;
- private const int MaxInputRecordsPerBatch = 128;
-
- internal static int SendUnicodeText(string text)
- {
- if (text.Length == 0)
- {
- return 0;
- }
-
- int sentEvents = 0;
- List batch = new(MaxInputRecordsPerBatch);
-
- foreach (char codeUnit in text)
- {
- batch.Add(CreateUnicodeInput(codeUnit, keyUp: false));
- batch.Add(CreateUnicodeInput(codeUnit, keyUp: true));
-
- if (batch.Count >= MaxInputRecordsPerBatch)
- {
- sentEvents += SendBatch(batch);
- batch.Clear();
- }
- }
-
- if (batch.Count > 0)
- {
- sentEvents += SendBatch(batch);
- }
-
- return sentEvents;
- }
-
- private static int SendBatch(IReadOnlyList inputs)
- {
- INPUT[] batch = inputs.ToArray();
- uint sent = SendInput((uint)batch.Length, batch, Marshal.SizeOf());
- if (sent != batch.Length)
- {
- int errorCode = Marshal.GetLastPInvokeError();
- throw new Win32Exception(
- errorCode,
- $"SendInput inserted {sent} of {batch.Length} events. Input may be blocked by Windows integrity level restrictions.");
- }
-
- return (int)sent;
- }
-
- private static INPUT CreateUnicodeInput(char codeUnit, bool keyUp)
- {
- return new INPUT
- {
- Type = INPUT_KEYBOARD,
- InputUnion = new INPUTUNION
- {
- KeyboardInput = new KEYBDINPUT
- {
- VirtualKey = 0,
- ScanCode = codeUnit,
- Flags = KEYEVENTF_UNICODE | (keyUp ? KEYEVENTF_KEYUP : 0),
- Time = 0,
- ExtraInfo = UIntPtr.Zero
- }
- }
- };
- }
-
- [LibraryImport("user32.dll", SetLastError = true)]
- private static partial uint SendInput(uint inputCount, [In] INPUT[] inputs, int inputSize);
-
- [StructLayout(LayoutKind.Sequential)]
- private struct INPUT
- {
- internal uint Type;
- internal INPUTUNION InputUnion;
- }
-
- [StructLayout(LayoutKind.Explicit, Size = 32)]
- private struct INPUTUNION
- {
- [FieldOffset(0)]
- internal KEYBDINPUT KeyboardInput;
- }
-
- [StructLayout(LayoutKind.Sequential)]
- private struct KEYBDINPUT
- {
- internal ushort VirtualKey;
- internal ushort ScanCode;
- internal uint Flags;
- internal uint Time;
- internal UIntPtr ExtraInfo;
- }
-}
diff --git a/native/text-input-helper/Program.cs b/native/text-input-helper/Program.cs
deleted file mode 100644
index 2a5facd..0000000
--- a/native/text-input-helper/Program.cs
+++ /dev/null
@@ -1,108 +0,0 @@
-using System.Text.Json;
-
-namespace Switchify.TextInput;
-
-internal static class Program
-{
- private static readonly JsonSerializerOptions JsonOptions = new()
- {
- PropertyNameCaseInsensitive = true
- };
-
- private static async Task Main()
- {
- Console.Out.WriteLine("""{"type":"ready"}""");
- Console.Out.Flush();
-
- while (true)
- {
- string? line = await Console.In.ReadLineAsync();
- if (line is null)
- {
- return;
- }
-
- if (!HandleCommandLine(line))
- {
- return;
- }
- }
- }
-
- private static bool HandleCommandLine(string line)
- {
- TextInputCommand? command;
- try
- {
- command = JsonSerializer.Deserialize(line, JsonOptions);
- }
- catch (JsonException error)
- {
- WriteError(null, "invalid_command", error.Message);
- return true;
- }
-
- if (command?.Type is null)
- {
- WriteError(command?.Id, "invalid_command", "Command type is required.");
- return true;
- }
-
- switch (command.Type)
- {
- case "typeText":
- HandleTypeText(command);
- return true;
- case "shutdown":
- return false;
- default:
- WriteError(command.Id, "invalid_command", "Unsupported command type.");
- return true;
- }
- }
-
- private static void HandleTypeText(TextInputCommand command)
- {
- if (string.IsNullOrWhiteSpace(command.Id))
- {
- WriteError(null, "invalid_command", "Command id is required.");
- return;
- }
-
- if (command.Text is null)
- {
- WriteError(command.Id, "invalid_command", "Command text is required.");
- return;
- }
-
- try
- {
- int sentEvents = NativeMethods.SendUnicodeText(command.Text);
- Console.Out.WriteLine(JsonSerializer.Serialize(new
- {
- type = "result",
- id = command.Id,
- ok = true,
- sentEvents
- }));
- Console.Out.Flush();
- }
- catch (Exception error)
- {
- WriteError(command.Id, "send_input_failed", error.Message);
- }
- }
-
- private static void WriteError(string? id, string code, string message)
- {
- Console.Out.WriteLine(JsonSerializer.Serialize(new
- {
- type = "error",
- id,
- ok = false,
- code,
- message
- }));
- Console.Out.Flush();
- }
-}
diff --git a/native/text-input-helper/TextInputCommand.cs b/native/text-input-helper/TextInputCommand.cs
deleted file mode 100644
index 4e074bb..0000000
--- a/native/text-input-helper/TextInputCommand.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace Switchify.TextInput;
-
-internal sealed class TextInputCommand
-{
- public string? Type { get; set; }
- public string? Id { get; set; }
- public string? Text { get; set; }
-}
diff --git a/native/text-input-helper/TextInputHelper.csproj b/native/text-input-helper/TextInputHelper.csproj
deleted file mode 100644
index be15d10..0000000
--- a/native/text-input-helper/TextInputHelper.csproj
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
- Exe
- net8.0-windows
- enable
- enable
- true
- SwitchifyTextInput
- Switchify.TextInput
- win-x64
- true
- true
- false
-
-
diff --git a/package-lock.json b/package-lock.json
index a373d3b..3eee9a0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6821 +1,13 @@
{
"name": "switchify-pc",
- "version": "0.1.20",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "switchify-pc",
- "version": "0.1.20",
- "license": "AGPL-3.0-or-later",
- "dependencies": {
- "@nut-tree-fork/libnut-win32": "^2.7.5",
- "electron-updater": "^6.8.9",
- "lucide-react": "^1.21.0"
- },
- "devDependencies": {
- "@types/node": "^25.9.1",
- "@types/react": "^19.2.15",
- "@types/react-dom": "^19.2.3",
- "@vitejs/plugin-react": "^5.2.0",
- "electron": "^39.8.10",
- "electron-builder": "^26.8.1",
- "electron-vite": "^5.0.0",
- "react": "^19.2.6",
- "react-dom": "^19.2.6",
- "typescript": "^6.0.3",
- "vite": "^7.3.3",
- "vitest": "^4.1.8"
- }
- },
- "node_modules/@babel/code-frame": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
- "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-validator-identifier": "^7.29.7",
- "js-tokens": "^4.0.0",
- "picocolors": "^1.1.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/compat-data": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
- "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/core": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
- "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/generator": "^7.29.7",
- "@babel/helper-compilation-targets": "^7.29.7",
- "@babel/helper-module-transforms": "^7.29.7",
- "@babel/helpers": "^7.29.7",
- "@babel/parser": "^7.29.7",
- "@babel/template": "^7.29.7",
- "@babel/traverse": "^7.29.7",
- "@babel/types": "^7.29.7",
- "@jridgewell/remapping": "^2.3.5",
- "convert-source-map": "^2.0.0",
- "debug": "^4.1.0",
- "gensync": "^1.0.0-beta.2",
- "json5": "^2.2.3",
- "semver": "^6.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/babel"
- }
- },
- "node_modules/@babel/generator": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
- "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.29.7",
- "@babel/types": "^7.29.7",
- "@jridgewell/gen-mapping": "^0.3.12",
- "@jridgewell/trace-mapping": "^0.3.28",
- "jsesc": "^3.0.2"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-compilation-targets": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
- "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/compat-data": "^7.29.7",
- "@babel/helper-validator-option": "^7.29.7",
- "browserslist": "^4.24.0",
- "lru-cache": "^5.1.1",
- "semver": "^6.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-globals": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
- "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-module-imports": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
- "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/traverse": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-module-transforms": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
- "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-module-imports": "^7.29.7",
- "@babel/helper-validator-identifier": "^7.29.7",
- "@babel/traverse": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0"
- }
- },
- "node_modules/@babel/helper-plugin-utils": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
- "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-string-parser": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
- "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-identifier": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
- "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-option": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
- "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helpers": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
- "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/template": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/parser": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
- "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.29.7"
- },
- "bin": {
- "parser": "bin/babel-parser.js"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@babel/plugin-transform-arrow-functions": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
- "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-transform-react-jsx-self": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
- "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-transform-react-jsx-source": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
- "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/template": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
- "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/parser": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/traverse": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
- "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/generator": "^7.29.7",
- "@babel/helper-globals": "^7.29.7",
- "@babel/parser": "^7.29.7",
- "@babel/template": "^7.29.7",
- "@babel/types": "^7.29.7",
- "debug": "^4.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/types": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
- "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-string-parser": "^7.29.7",
- "@babel/helper-validator-identifier": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@develar/schema-utils": {
- "version": "2.6.5",
- "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
- "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ajv": "^6.12.0",
- "ajv-keywords": "^3.4.1"
- },
- "engines": {
- "node": ">= 8.9.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/webpack"
- }
- },
- "node_modules/@electron/asar": {
- "version": "3.4.1",
- "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
- "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "commander": "^5.0.0",
- "glob": "^7.1.6",
- "minimatch": "^3.0.4"
- },
- "bin": {
- "asar": "bin/asar.js"
- },
- "engines": {
- "node": ">=10.12.0"
- }
- },
- "node_modules/@electron/asar/node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@electron/asar/node_modules/brace-expansion": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "node_modules/@electron/asar/node_modules/minimatch": {
- "version": "3.1.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
- "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/@electron/fuses": {
- "version": "1.8.0",
- "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz",
- "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "chalk": "^4.1.1",
- "fs-extra": "^9.0.1",
- "minimist": "^1.2.5"
- },
- "bin": {
- "electron-fuses": "dist/bin.js"
- }
- },
- "node_modules/@electron/fuses/node_modules/fs-extra": {
- "version": "9.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
- "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "at-least-node": "^1.0.0",
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@electron/fuses/node_modules/jsonfile": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
- "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "universalify": "^2.0.0"
- },
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/@electron/fuses/node_modules/universalify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
- "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/@electron/get": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
- "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "debug": "^4.1.1",
- "env-paths": "^2.2.0",
- "fs-extra": "^8.1.0",
- "got": "^11.8.5",
- "progress": "^2.0.3",
- "semver": "^6.2.0",
- "sumchecker": "^3.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "optionalDependencies": {
- "global-agent": "^3.0.0"
- }
- },
- "node_modules/@electron/notarize": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz",
- "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "debug": "^4.1.1",
- "fs-extra": "^9.0.1",
- "promise-retry": "^2.0.1"
- },
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/@electron/notarize/node_modules/fs-extra": {
- "version": "9.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
- "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "at-least-node": "^1.0.0",
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@electron/notarize/node_modules/jsonfile": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
- "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "universalify": "^2.0.0"
- },
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/@electron/notarize/node_modules/universalify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
- "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/@electron/osx-sign": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz",
- "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "compare-version": "^0.1.2",
- "debug": "^4.3.4",
- "fs-extra": "^10.0.0",
- "isbinaryfile": "^4.0.8",
- "minimist": "^1.2.6",
- "plist": "^3.0.5"
- },
- "bin": {
- "electron-osx-flat": "bin/electron-osx-flat.js",
- "electron-osx-sign": "bin/electron-osx-sign.js"
- },
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/@electron/osx-sign/node_modules/fs-extra": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
- "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@electron/osx-sign/node_modules/isbinaryfile": {
- "version": "4.0.10",
- "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz",
- "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/gjtorikian/"
- }
- },
- "node_modules/@electron/osx-sign/node_modules/jsonfile": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
- "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "universalify": "^2.0.0"
- },
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/@electron/osx-sign/node_modules/universalify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
- "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/@electron/rebuild": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz",
- "integrity": "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@malept/cross-spawn-promise": "^2.0.0",
- "debug": "^4.1.1",
- "node-abi": "^4.2.0",
- "node-api-version": "^0.2.1",
- "node-gyp": "^12.2.0",
- "read-binary-file-arch": "^1.0.6"
- },
- "bin": {
- "electron-rebuild": "lib/cli.js"
- },
- "engines": {
- "node": ">=22.12.0"
- }
- },
- "node_modules/@electron/universal": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz",
- "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@electron/asar": "^3.3.1",
- "@malept/cross-spawn-promise": "^2.0.0",
- "debug": "^4.3.1",
- "dir-compare": "^4.2.0",
- "fs-extra": "^11.1.1",
- "minimatch": "^9.0.3",
- "plist": "^3.1.0"
- },
- "engines": {
- "node": ">=16.4"
- }
- },
- "node_modules/@electron/universal/node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@electron/universal/node_modules/brace-expansion": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
- "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/@electron/universal/node_modules/fs-extra": {
- "version": "11.3.5",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
- "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
- },
- "engines": {
- "node": ">=14.14"
- }
- },
- "node_modules/@electron/universal/node_modules/jsonfile": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
- "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "universalify": "^2.0.0"
- },
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/@electron/universal/node_modules/minimatch": {
- "version": "9.0.9",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
- "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.2"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/@electron/universal/node_modules/universalify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
- "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/@electron/windows-sign": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz",
- "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==",
- "dev": true,
- "license": "BSD-2-Clause",
- "optional": true,
- "dependencies": {
- "cross-dirname": "^0.1.0",
- "debug": "^4.3.4",
- "fs-extra": "^11.1.1",
- "minimist": "^1.2.8",
- "postject": "^1.0.0-alpha.6"
- },
- "bin": {
- "electron-windows-sign": "bin/electron-windows-sign.js"
- },
- "engines": {
- "node": ">=14.14"
- }
- },
- "node_modules/@electron/windows-sign/node_modules/fs-extra": {
- "version": "11.3.5",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
- "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
- },
- "engines": {
- "node": ">=14.14"
- }
- },
- "node_modules/@electron/windows-sign/node_modules/jsonfile": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
- "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "universalify": "^2.0.0"
- },
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/@electron/windows-sign/node_modules/universalify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
- "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
- "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
- "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
- "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
- "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
- "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
- "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
- "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
- "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
- "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
- "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
- "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
- "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
- "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
- "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
- "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
- "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
- "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
- "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
- "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
- "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
- "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
- "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
- "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
- "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
- "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
- "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@isaacs/fs-minipass": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
- "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "minipass": "^7.0.4"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.13",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
- "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/remapping": {
- "version": "2.3.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
- "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
- "node_modules/@malept/cross-spawn-promise": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",
- "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==",
- "dev": true,
- "funding": [
- {
- "type": "individual",
- "url": "https://github.com/sponsors/malept"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund"
- }
- ],
- "license": "Apache-2.0",
- "dependencies": {
- "cross-spawn": "^7.0.1"
- },
- "engines": {
- "node": ">= 12.13.0"
- }
- },
- "node_modules/@malept/flatpak-bundler": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz",
- "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "debug": "^4.1.1",
- "fs-extra": "^9.0.0",
- "lodash": "^4.17.15",
- "tmp-promise": "^3.0.2"
- },
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": {
- "version": "9.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
- "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "at-least-node": "^1.0.0",
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
- "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "universalify": "^2.0.0"
- },
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/@malept/flatpak-bundler/node_modules/universalify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
- "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/@nut-tree-fork/libnut-win32": {
- "version": "2.7.5",
- "resolved": "https://registry.npmjs.org/@nut-tree-fork/libnut-win32/-/libnut-win32-2.7.5.tgz",
- "integrity": "sha512-yqC87zvmFcDPwFrRU40DYhN0xmEVM3aSkOuyF0IX+y1x+HWSu/i0PNklATpPBhGid3QVb/TOHuVoaraMrUFCNw==",
- "cpu": [
- "x64",
- "arm64"
- ],
- "license": "Apache-2.0",
- "os": [
- "darwin",
- "linux",
- "win32"
- ],
- "dependencies": {
- "bindings": "1.5.0"
- },
- "engines": {
- "node": ">=10.15.3"
- },
- "optionalDependencies": {
- "@nut-tree-fork/node-mac-permissions": "2.2.1"
- }
- },
- "node_modules/@nut-tree-fork/node-mac-permissions": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/@nut-tree-fork/node-mac-permissions/-/node-mac-permissions-2.2.1.tgz",
- "integrity": "sha512-iSfOTDiBZ7VDa17PoQje5rUaZSvSAaq+XEyXCmhPuQwV5XuNU02Grv6oFhsdpz89w7+UvB/8KX/cX5IYQ5o2Bw==",
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "dependencies": {
- "bindings": "1.5.0",
- "node-addon-api": "5.0.0"
- }
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-rc.3",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
- "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
- "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
- "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
- "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
- "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
- "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
- "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
- "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
- "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
- "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
- "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
- "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
- "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
- "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
- "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
- "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
- "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
- "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
- "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
- "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
- "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ]
- },
- "node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
- "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ]
- },
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
- "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
- "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
- "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
- "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@sindresorhus/is": {
- "version": "4.6.0",
- "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
- "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/is?sponsor=1"
- }
- },
- "node_modules/@standard-schema/spec": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
- "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@szmarczak/http-timer": {
- "version": "4.0.6",
- "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
- "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "defer-to-connect": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@types/babel__core": {
- "version": "7.20.5",
- "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
- "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.20.7",
- "@babel/types": "^7.20.7",
- "@types/babel__generator": "*",
- "@types/babel__template": "*",
- "@types/babel__traverse": "*"
- }
- },
- "node_modules/@types/babel__generator": {
- "version": "7.27.0",
- "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
- "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.0.0"
- }
- },
- "node_modules/@types/babel__template": {
- "version": "7.4.4",
- "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
- "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.1.0",
- "@babel/types": "^7.0.0"
- }
- },
- "node_modules/@types/babel__traverse": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
- "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.28.2"
- }
- },
- "node_modules/@types/cacheable-request": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
- "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/http-cache-semantics": "*",
- "@types/keyv": "^3.1.4",
- "@types/node": "*",
- "@types/responselike": "^1.0.0"
- }
- },
- "node_modules/@types/chai": {
- "version": "5.2.3",
- "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
- "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/deep-eql": "*",
- "assertion-error": "^2.0.1"
- }
- },
- "node_modules/@types/debug": {
- "version": "4.1.13",
- "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
- "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/ms": "*"
- }
- },
- "node_modules/@types/deep-eql": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
- "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/estree": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
- "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/fs-extra": {
- "version": "9.0.13",
- "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
- "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/node": "*"
- }
- },
- "node_modules/@types/http-cache-semantics": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
- "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/keyv": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
- "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/node": "*"
- }
- },
- "node_modules/@types/ms": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
- "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/node": {
- "version": "25.9.1",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
- "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "undici-types": ">=7.24.0 <7.24.7"
- }
- },
- "node_modules/@types/plist": {
- "version": "3.0.5",
- "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz",
- "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@types/node": "*",
- "xmlbuilder": ">=11.0.1"
- }
- },
- "node_modules/@types/react": {
- "version": "19.2.16",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz",
- "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "csstype": "^3.2.2"
- }
- },
- "node_modules/@types/react-dom": {
- "version": "19.2.3",
- "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
- "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "^19.2.0"
- }
- },
- "node_modules/@types/responselike": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
- "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/node": "*"
- }
- },
- "node_modules/@types/verror": {
- "version": "1.10.11",
- "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
- "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
- "node_modules/@types/yauzl": {
- "version": "2.10.3",
- "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
- "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@types/node": "*"
- }
- },
- "node_modules/@vitejs/plugin-react": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
- "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/core": "^7.29.0",
- "@babel/plugin-transform-react-jsx-self": "^7.27.1",
- "@babel/plugin-transform-react-jsx-source": "^7.27.1",
- "@rolldown/pluginutils": "1.0.0-rc.3",
- "@types/babel__core": "^7.20.5",
- "react-refresh": "^0.18.0"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "peerDependencies": {
- "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
- }
- },
- "node_modules/@vitest/expect": {
- "version": "4.1.8",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
- "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@standard-schema/spec": "^1.1.0",
- "@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.8",
- "@vitest/utils": "4.1.8",
- "chai": "^6.2.2",
- "tinyrainbow": "^3.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/mocker": {
- "version": "4.1.8",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
- "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/spy": "4.1.8",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.21"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "msw": "^2.4.9",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "msw": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
- }
- },
- "node_modules/@vitest/pretty-format": {
- "version": "4.1.8",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
- "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tinyrainbow": "^3.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/runner": {
- "version": "4.1.8",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
- "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/utils": "4.1.8",
- "pathe": "^2.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/snapshot": {
- "version": "4.1.8",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
- "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "4.1.8",
- "@vitest/utils": "4.1.8",
- "magic-string": "^0.30.21",
- "pathe": "^2.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/spy": {
- "version": "4.1.8",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
- "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/utils": {
- "version": "4.1.8",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
- "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "4.1.8",
- "convert-source-map": "^2.0.0",
- "tinyrainbow": "^3.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@xmldom/xmldom": {
- "version": "0.8.13",
- "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
- "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10.0.0"
- }
- },
- "node_modules/7zip-bin": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
- "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/abbrev": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
- "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": "^20.17.0 || >=22.9.0"
- }
- },
- "node_modules/agent-base": {
- "version": "7.1.4",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
- "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 14"
- }
- },
- "node_modules/ajv": {
- "version": "6.15.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
- "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "fast-deep-equal": "^3.1.1",
- "fast-json-stable-stringify": "^2.0.0",
- "json-schema-traverse": "^0.4.1",
- "uri-js": "^4.2.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
- "node_modules/ajv-keywords": {
- "version": "3.5.2",
- "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
- "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "ajv": "^6.9.1"
- }
- },
- "node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/app-builder-bin": {
- "version": "5.0.0-alpha.12",
- "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz",
- "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/app-builder-lib": {
- "version": "26.8.1",
- "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.8.1.tgz",
- "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@develar/schema-utils": "~2.6.5",
- "@electron/asar": "3.4.1",
- "@electron/fuses": "^1.8.0",
- "@electron/get": "^3.0.0",
- "@electron/notarize": "2.5.0",
- "@electron/osx-sign": "1.3.3",
- "@electron/rebuild": "^4.0.3",
- "@electron/universal": "2.0.3",
- "@malept/flatpak-bundler": "^0.4.0",
- "@types/fs-extra": "9.0.13",
- "async-exit-hook": "^2.0.1",
- "builder-util": "26.8.1",
- "builder-util-runtime": "9.5.1",
- "chromium-pickle-js": "^0.2.0",
- "ci-info": "4.3.1",
- "debug": "^4.3.4",
- "dotenv": "^16.4.5",
- "dotenv-expand": "^11.0.6",
- "ejs": "^3.1.8",
- "electron-publish": "26.8.1",
- "fs-extra": "^10.1.0",
- "hosted-git-info": "^4.1.0",
- "isbinaryfile": "^5.0.0",
- "jiti": "^2.4.2",
- "js-yaml": "^4.1.0",
- "json5": "^2.2.3",
- "lazy-val": "^1.0.5",
- "minimatch": "^10.0.3",
- "plist": "3.1.0",
- "proper-lockfile": "^4.1.2",
- "resedit": "^1.7.0",
- "semver": "~7.7.3",
- "tar": "^7.5.7",
- "temp-file": "^3.4.0",
- "tiny-async-pool": "1.3.0",
- "which": "^5.0.0"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "peerDependencies": {
- "dmg-builder": "26.8.1",
- "electron-builder-squirrel-windows": "26.8.1"
- }
- },
- "node_modules/app-builder-lib/node_modules/@electron/get": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz",
- "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "debug": "^4.1.1",
- "env-paths": "^2.2.0",
- "fs-extra": "^8.1.0",
- "got": "^11.8.5",
- "progress": "^2.0.3",
- "semver": "^6.2.0",
- "sumchecker": "^3.0.1"
- },
- "engines": {
- "node": ">=14"
- },
- "optionalDependencies": {
- "global-agent": "^3.0.0"
- }
- },
- "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
- "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^4.0.0",
- "universalify": "^0.1.0"
- },
- "engines": {
- "node": ">=6 <7 || >=8"
- }
- },
- "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- }
- },
- "node_modules/app-builder-lib/node_modules/ci-info": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz",
- "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/sibiraj-s"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/app-builder-lib/node_modules/fs-extra": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
- "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/jsonfile": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
- "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "universalify": "^2.0.0"
- },
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/universalify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
- "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/app-builder-lib/node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/argparse": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
- "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "license": "Python-2.0"
- },
- "node_modules/assert-plus": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
- "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=0.8"
- }
- },
- "node_modules/assertion-error": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
- "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/astral-regex": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
- "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/async": {
- "version": "3.2.6",
- "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
- "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/async-exit-hook": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz",
- "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.12.0"
- }
- },
- "node_modules/asynckit": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/at-least-node": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
- "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">= 4.0.0"
- }
- },
- "node_modules/balanced-match": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
- "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/base64-js": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
- "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
- "node_modules/baseline-browser-mapping": {
- "version": "2.10.33",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
- "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "baseline-browser-mapping": "dist/cli.cjs"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/bindings": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
- "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
- "license": "MIT",
- "dependencies": {
- "file-uri-to-path": "1.0.0"
- }
- },
- "node_modules/boolean": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
- "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
- "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
- "node_modules/brace-expansion": {
- "version": "5.0.6",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
- "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^4.0.2"
- },
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/browserslist": {
- "version": "4.28.2",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
- "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/browserslist"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "baseline-browser-mapping": "^2.10.12",
- "caniuse-lite": "^1.0.30001782",
- "electron-to-chromium": "^1.5.328",
- "node-releases": "^2.0.36",
- "update-browserslist-db": "^1.2.3"
- },
- "bin": {
- "browserslist": "cli.js"
- },
- "engines": {
- "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
- }
- },
- "node_modules/buffer": {
- "version": "5.7.1",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
- "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "base64-js": "^1.3.1",
- "ieee754": "^1.1.13"
- }
- },
- "node_modules/buffer-crc32": {
- "version": "0.2.13",
- "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
- "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "*"
- }
- },
- "node_modules/buffer-from": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
- "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/builder-util": {
- "version": "26.8.1",
- "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz",
- "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/debug": "^4.1.6",
- "7zip-bin": "~5.2.0",
- "app-builder-bin": "5.0.0-alpha.12",
- "builder-util-runtime": "9.5.1",
- "chalk": "^4.1.2",
- "cross-spawn": "^7.0.6",
- "debug": "^4.3.4",
- "fs-extra": "^10.1.0",
- "http-proxy-agent": "^7.0.0",
- "https-proxy-agent": "^7.0.0",
- "js-yaml": "^4.1.0",
- "sanitize-filename": "^1.6.3",
- "source-map-support": "^0.5.19",
- "stat-mode": "^1.0.0",
- "temp-file": "^3.4.0",
- "tiny-async-pool": "1.3.0"
- }
- },
- "node_modules/builder-util-runtime": {
- "version": "9.5.1",
- "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz",
- "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "debug": "^4.3.4",
- "sax": "^1.2.4"
- },
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/builder-util/node_modules/fs-extra": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
- "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/builder-util/node_modules/jsonfile": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
- "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "universalify": "^2.0.0"
- },
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/builder-util/node_modules/universalify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
- "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/cac": {
- "version": "6.7.14",
- "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
- "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/cacheable-lookup": {
- "version": "5.0.4",
- "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
- "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10.6.0"
- }
- },
- "node_modules/cacheable-request": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
- "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "clone-response": "^1.0.2",
- "get-stream": "^5.1.0",
- "http-cache-semantics": "^4.0.0",
- "keyv": "^4.0.0",
- "lowercase-keys": "^2.0.0",
- "normalize-url": "^6.0.1",
- "responselike": "^2.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/call-bind-apply-helpers": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
- "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/caniuse-lite": {
- "version": "1.0.30001793",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
- "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "CC-BY-4.0"
- },
- "node_modules/chai": {
- "version": "6.2.2",
- "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
- "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/chalk": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
- "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.1.0",
- "supports-color": "^7.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
- }
- },
- "node_modules/chownr": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
- "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/chromium-pickle-js": {
"version": "0.2.0",
- "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz",
- "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/ci-info": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
- "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/sibiraj-s"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/cli-truncate": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
- "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "slice-ansi": "^3.0.0",
- "string-width": "^4.2.0"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/cliui": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
- "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "string-width": "^4.2.0",
- "strip-ansi": "^6.0.1",
- "wrap-ansi": "^7.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/clone-response": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
- "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mimic-response": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/color-convert": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-name": "~1.1.4"
- },
- "engines": {
- "node": ">=7.0.0"
- }
- },
- "node_modules/color-name": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/combined-stream": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
- "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "delayed-stream": "~1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/commander": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
- "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/compare-version": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz",
- "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/concat-map": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
- "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/convert-source-map": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
- "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/core-util-is": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
- "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
- "node_modules/crc": {
- "version": "3.8.0",
- "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz",
- "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "buffer": "^5.1.0"
- }
- },
- "node_modules/cross-dirname": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
- "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
- "node_modules/cross-spawn": {
- "version": "7.0.6",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
- "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "path-key": "^3.1.0",
- "shebang-command": "^2.0.0",
- "which": "^2.0.1"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/cross-spawn/node_modules/isexe": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/cross-spawn/node_modules/which": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
- "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "isexe": "^2.0.0"
- },
- "bin": {
- "node-which": "bin/node-which"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/csstype": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/decompress-response": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
- "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mimic-response": "^3.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/decompress-response/node_modules/mimic-response": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
- "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/defer-to-connect": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
- "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/define-data-property": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
- "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "es-define-property": "^1.0.0",
- "es-errors": "^1.3.0",
- "gopd": "^1.0.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/define-properties": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
- "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "define-data-property": "^1.0.1",
- "has-property-descriptors": "^1.0.0",
- "object-keys": "^1.1.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/delayed-stream": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
- "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/detect-node": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
- "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
- "node_modules/dir-compare": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
- "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "minimatch": "^3.0.5",
- "p-limit": "^3.1.0 "
- }
- },
- "node_modules/dir-compare/node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/dir-compare/node_modules/brace-expansion": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "node_modules/dir-compare/node_modules/minimatch": {
- "version": "3.1.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
- "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/dmg-builder": {
- "version": "26.8.1",
- "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz",
- "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "app-builder-lib": "26.8.1",
- "builder-util": "26.8.1",
- "fs-extra": "^10.1.0",
- "iconv-lite": "^0.6.2",
- "js-yaml": "^4.1.0"
- },
- "optionalDependencies": {
- "dmg-license": "^1.0.11"
- }
- },
- "node_modules/dmg-builder/node_modules/fs-extra": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
- "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/dmg-builder/node_modules/jsonfile": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
- "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "universalify": "^2.0.0"
- },
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/dmg-builder/node_modules/universalify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
- "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/dmg-license": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz",
- "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "dependencies": {
- "@types/plist": "^3.0.1",
- "@types/verror": "^1.10.3",
- "ajv": "^6.10.0",
- "crc": "^3.8.0",
- "iconv-corefoundation": "^1.1.7",
- "plist": "^3.0.4",
- "smart-buffer": "^4.0.2",
- "verror": "^1.10.0"
- },
- "bin": {
- "dmg-license": "bin/dmg-license.js"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/dotenv": {
- "version": "16.6.1",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
- "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://dotenvx.com"
- }
- },
- "node_modules/dotenv-expand": {
- "version": "11.0.7",
- "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz",
- "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "dotenv": "^16.4.5"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://dotenvx.com"
- }
- },
- "node_modules/dunder-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
- "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.1",
- "es-errors": "^1.3.0",
- "gopd": "^1.2.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/ejs": {
- "version": "3.1.10",
- "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
- "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "jake": "^10.8.5"
- },
- "bin": {
- "ejs": "bin/cli.js"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/electron": {
- "version": "39.8.10",
- "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.10.tgz",
- "integrity": "sha512-zbYtGPYUI7PzqLAzkk21Rk6j67WN0hxn0Mq/njErZo1d0HSf33is4f8ICI5fMLy5vYe0JtCtM5sYunNOaochSQ==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "dependencies": {
- "@electron/get": "^2.0.0",
- "@types/node": "^22.7.7",
- "extract-zip": "^2.0.1"
- },
- "bin": {
- "electron": "cli.js"
- },
- "engines": {
- "node": ">= 12.20.55"
- }
- },
- "node_modules/electron-builder": {
- "version": "26.8.1",
- "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz",
- "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "app-builder-lib": "26.8.1",
- "builder-util": "26.8.1",
- "builder-util-runtime": "9.5.1",
- "chalk": "^4.1.2",
- "ci-info": "^4.2.0",
- "dmg-builder": "26.8.1",
- "fs-extra": "^10.1.0",
- "lazy-val": "^1.0.5",
- "simple-update-notifier": "2.0.0",
- "yargs": "^17.6.2"
- },
- "bin": {
- "electron-builder": "cli.js",
- "install-app-deps": "install-app-deps.js"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/electron-builder-squirrel-windows": {
- "version": "26.8.1",
- "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.8.1.tgz",
- "integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "app-builder-lib": "26.8.1",
- "builder-util": "26.8.1",
- "electron-winstaller": "5.4.0"
- }
- },
- "node_modules/electron-builder/node_modules/fs-extra": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
- "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/electron-builder/node_modules/jsonfile": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
- "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "universalify": "^2.0.0"
- },
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/electron-builder/node_modules/universalify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
- "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/electron-publish": {
- "version": "26.8.1",
- "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz",
- "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/fs-extra": "^9.0.11",
- "builder-util": "26.8.1",
- "builder-util-runtime": "9.5.1",
- "chalk": "^4.1.2",
- "form-data": "^4.0.5",
- "fs-extra": "^10.1.0",
- "lazy-val": "^1.0.5",
- "mime": "^2.5.2"
- }
- },
- "node_modules/electron-publish/node_modules/fs-extra": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
- "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/electron-publish/node_modules/jsonfile": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
- "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "universalify": "^2.0.0"
- },
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/electron-publish/node_modules/universalify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
- "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/electron-to-chromium": {
- "version": "1.5.367",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.367.tgz",
- "integrity": "sha512-4Mk/mrynCNQ+atY40D3UpmhLWB6AHMbYMlIrPhHcMF6x0L7O0b052FCAsxw1LlaR++UFuNg3D/A6XCuGDa0guQ==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/electron-updater": {
- "version": "6.8.9",
- "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz",
- "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==",
- "license": "MIT",
- "dependencies": {
- "builder-util-runtime": "9.7.0",
- "fs-extra": "^10.1.0",
- "js-yaml": "^4.1.0",
- "lazy-val": "^1.0.5",
- "lodash.escaperegexp": "^4.1.2",
- "lodash.isequal": "^4.5.0",
- "semver": "~7.7.3",
- "tiny-typed-emitter": "^2.1.0"
- }
- },
- "node_modules/electron-updater/node_modules/builder-util-runtime": {
- "version": "9.7.0",
- "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz",
- "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==",
- "license": "MIT",
- "dependencies": {
- "debug": "^4.3.4",
- "sax": "^1.2.4"
- },
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/electron-updater/node_modules/fs-extra": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
- "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/electron-updater/node_modules/jsonfile": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
- "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
- "license": "MIT",
- "dependencies": {
- "universalify": "^2.0.0"
- },
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/electron-updater/node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/electron-updater/node_modules/universalify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
- "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
- "license": "MIT",
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/electron-vite": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-5.0.0.tgz",
- "integrity": "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/core": "^7.28.4",
- "@babel/plugin-transform-arrow-functions": "^7.27.1",
- "cac": "^6.7.14",
- "esbuild": "^0.25.11",
- "magic-string": "^0.30.19",
- "picocolors": "^1.1.1"
- },
- "bin": {
- "electron-vite": "bin/electron-vite.js"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "peerDependencies": {
- "@swc/core": "^1.0.0",
- "vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
- },
- "peerDependenciesMeta": {
- "@swc/core": {
- "optional": true
- }
- }
- },
- "node_modules/electron-winstaller": {
- "version": "5.4.0",
- "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
- "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "dependencies": {
- "@electron/asar": "^3.2.1",
- "debug": "^4.1.1",
- "fs-extra": "^7.0.1",
- "lodash": "^4.17.21",
- "temp": "^0.9.0"
- },
- "engines": {
- "node": ">=8.0.0"
- },
- "optionalDependencies": {
- "@electron/windows-sign": "^1.1.2"
- }
- },
- "node_modules/electron-winstaller/node_modules/fs-extra": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
- "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.1.2",
- "jsonfile": "^4.0.0",
- "universalify": "^0.1.0"
- },
- "engines": {
- "node": ">=6 <7 || >=8"
- }
- },
- "node_modules/electron/node_modules/@types/node": {
- "version": "22.19.19",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
- "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "undici-types": "~6.21.0"
- }
- },
- "node_modules/electron/node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/end-of-stream": {
- "version": "1.4.5",
- "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
- "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "once": "^1.4.0"
- }
- },
- "node_modules/env-paths": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
- "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/err-code": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
- "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/es-define-property": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
- "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-errors": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-module-lexer": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
- "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/es-object-atoms": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
- "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-set-tostringtag": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
- "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "get-intrinsic": "^1.2.6",
- "has-tostringtag": "^1.0.2",
- "hasown": "^2.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es6-error": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
- "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
- "node_modules/esbuild": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
- "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.12",
- "@esbuild/android-arm": "0.25.12",
- "@esbuild/android-arm64": "0.25.12",
- "@esbuild/android-x64": "0.25.12",
- "@esbuild/darwin-arm64": "0.25.12",
- "@esbuild/darwin-x64": "0.25.12",
- "@esbuild/freebsd-arm64": "0.25.12",
- "@esbuild/freebsd-x64": "0.25.12",
- "@esbuild/linux-arm": "0.25.12",
- "@esbuild/linux-arm64": "0.25.12",
- "@esbuild/linux-ia32": "0.25.12",
- "@esbuild/linux-loong64": "0.25.12",
- "@esbuild/linux-mips64el": "0.25.12",
- "@esbuild/linux-ppc64": "0.25.12",
- "@esbuild/linux-riscv64": "0.25.12",
- "@esbuild/linux-s390x": "0.25.12",
- "@esbuild/linux-x64": "0.25.12",
- "@esbuild/netbsd-arm64": "0.25.12",
- "@esbuild/netbsd-x64": "0.25.12",
- "@esbuild/openbsd-arm64": "0.25.12",
- "@esbuild/openbsd-x64": "0.25.12",
- "@esbuild/openharmony-arm64": "0.25.12",
- "@esbuild/sunos-x64": "0.25.12",
- "@esbuild/win32-arm64": "0.25.12",
- "@esbuild/win32-ia32": "0.25.12",
- "@esbuild/win32-x64": "0.25.12"
- }
- },
- "node_modules/escalade": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
- "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/escape-string-regexp": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
- "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/estree-walker": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
- "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0"
- }
- },
- "node_modules/expect-type": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
- "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/exponential-backoff": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
- "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==",
- "dev": true,
- "license": "Apache-2.0"
- },
- "node_modules/extract-zip": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
- "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "debug": "^4.1.1",
- "get-stream": "^5.1.0",
- "yauzl": "^2.10.0"
- },
- "bin": {
- "extract-zip": "cli.js"
- },
- "engines": {
- "node": ">= 10.17.0"
- },
- "optionalDependencies": {
- "@types/yauzl": "^2.9.1"
- }
- },
- "node_modules/extsprintf": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz",
- "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==",
- "dev": true,
- "engines": [
- "node >=0.6.0"
- ],
- "license": "MIT",
- "optional": true
- },
- "node_modules/fast-deep-equal": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-json-stable-stringify": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
- "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fd-slicer": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
- "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "pend": "~1.2.0"
- }
- },
- "node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/file-uri-to-path": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
- "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
- "license": "MIT"
- },
- "node_modules/filelist": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
- "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "minimatch": "^5.0.1"
- }
- },
- "node_modules/filelist/node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/filelist/node_modules/brace-expansion": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
- "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/filelist/node_modules/minimatch": {
- "version": "5.1.9",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
- "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/form-data": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
- "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "es-set-tostringtag": "^2.1.0",
- "hasown": "^2.0.2",
- "mime-types": "^2.1.12"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/fs-extra": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
- "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^4.0.0",
- "universalify": "^0.1.0"
- },
- "engines": {
- "node": ">=6 <7 || >=8"
- }
- },
- "node_modules/fs.realpath": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
- "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/function-bind": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
- "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/gensync": {
- "version": "1.0.0-beta.2",
- "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
- "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/get-caller-file": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
- "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": "6.* || 8.* || >= 10.*"
- }
- },
- "node_modules/get-intrinsic": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
- "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.2",
- "es-define-property": "^1.0.1",
- "es-errors": "^1.3.0",
- "es-object-atoms": "^1.1.1",
- "function-bind": "^1.1.2",
- "get-proto": "^1.0.1",
- "gopd": "^1.2.0",
- "has-symbols": "^1.1.0",
- "hasown": "^2.0.2",
- "math-intrinsics": "^1.1.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
- "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "dunder-proto": "^1.0.1",
- "es-object-atoms": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/get-stream": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
- "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "pump": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/glob": {
- "version": "7.2.3",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
- "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
- "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "fs.realpath": "^1.0.0",
- "inflight": "^1.0.4",
- "inherits": "2",
- "minimatch": "^3.1.1",
- "once": "^1.3.0",
- "path-is-absolute": "^1.0.0"
- },
- "engines": {
- "node": "*"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/glob/node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/glob/node_modules/brace-expansion": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "node_modules/glob/node_modules/minimatch": {
- "version": "3.1.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
- "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/global-agent": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
- "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
- "dev": true,
- "license": "BSD-3-Clause",
- "optional": true,
- "dependencies": {
- "boolean": "^3.0.1",
- "es6-error": "^4.1.1",
- "matcher": "^3.0.0",
- "roarr": "^2.15.3",
- "semver": "^7.3.2",
- "serialize-error": "^7.0.1"
- },
- "engines": {
- "node": ">=10.0"
- }
- },
- "node_modules/global-agent/node_modules/semver": {
- "version": "7.8.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
- "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
- "dev": true,
- "license": "ISC",
- "optional": true,
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/globalthis": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
- "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "define-properties": "^1.2.1",
- "gopd": "^1.0.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/gopd": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
- "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/got": {
- "version": "11.8.6",
- "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
- "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@sindresorhus/is": "^4.0.0",
- "@szmarczak/http-timer": "^4.0.5",
- "@types/cacheable-request": "^6.0.1",
- "@types/responselike": "^1.0.0",
- "cacheable-lookup": "^5.0.3",
- "cacheable-request": "^7.0.2",
- "decompress-response": "^6.0.0",
- "http2-wrapper": "^1.0.0-beta.5.2",
- "lowercase-keys": "^2.0.0",
- "p-cancelable": "^2.0.0",
- "responselike": "^2.0.0"
- },
- "engines": {
- "node": ">=10.19.0"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/got?sponsor=1"
- }
- },
- "node_modules/graceful-fs": {
- "version": "4.2.11",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
- "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
- "license": "ISC"
- },
- "node_modules/has-flag": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
- "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/has-property-descriptors": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
- "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "es-define-property": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-symbols": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
- "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-tostringtag": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
- "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-symbols": "^1.0.3"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/hasown": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
- "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/hosted-git-info": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
- "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "lru-cache": "^6.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/hosted-git-info/node_modules/lru-cache": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
- "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/hosted-git-info/node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/http-cache-semantics": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
- "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
- "dev": true,
- "license": "BSD-2-Clause"
- },
- "node_modules/http-proxy-agent": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
- "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "agent-base": "^7.1.0",
- "debug": "^4.3.4"
- },
- "engines": {
- "node": ">= 14"
- }
- },
- "node_modules/http2-wrapper": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
- "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "quick-lru": "^5.1.1",
- "resolve-alpn": "^1.0.0"
- },
- "engines": {
- "node": ">=10.19.0"
- }
- },
- "node_modules/https-proxy-agent": {
- "version": "7.0.6",
- "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
- "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "agent-base": "^7.1.2",
- "debug": "4"
- },
- "engines": {
- "node": ">= 14"
- }
- },
- "node_modules/iconv-corefoundation": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
- "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "dependencies": {
- "cli-truncate": "^2.1.0",
- "node-addon-api": "^1.6.3"
- },
- "engines": {
- "node": "^8.11.2 || >=10"
- }
- },
- "node_modules/iconv-corefoundation/node_modules/node-addon-api": {
- "version": "1.7.2",
- "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz",
- "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
- "node_modules/iconv-lite": {
- "version": "0.6.3",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
- "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "safer-buffer": ">= 2.1.2 < 3.0.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/ieee754": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "BSD-3-Clause",
- "optional": true
- },
- "node_modules/inflight": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
- "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
- "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "once": "^1.3.0",
- "wrappy": "1"
- }
- },
- "node_modules/inherits": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/is-fullwidth-code-point": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
- "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/isbinaryfile": {
- "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz",
- "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 18.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/gjtorikian/"
- }
- },
- "node_modules/isexe": {
- "version": "3.1.5",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
- "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/jake": {
- "version": "10.9.4",
- "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
- "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "async": "^3.2.6",
- "filelist": "^1.0.4",
- "picocolors": "^1.1.1"
- },
- "bin": {
- "jake": "bin/cli.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/jiti": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
- "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "jiti": "lib/jiti-cli.mjs"
- }
- },
- "node_modules/js-tokens": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
- "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/js-yaml": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
- "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/puzrin"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/nodeca"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "argparse": "^2.0.1"
- },
- "bin": {
- "js-yaml": "bin/js-yaml.js"
- }
- },
- "node_modules/jsesc": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
- "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "jsesc": "bin/jsesc"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/json-buffer": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
- "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-schema-traverse": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
- "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-stringify-safe": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
- "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
- "dev": true,
- "license": "ISC",
- "optional": true
- },
- "node_modules/json5": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
- "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "json5": "lib/cli.js"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/jsonfile": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
- "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
- "dev": true,
- "license": "MIT",
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/keyv": {
- "version": "4.5.4",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
- "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "json-buffer": "3.0.1"
- }
- },
- "node_modules/lazy-val": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
- "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
- "license": "MIT"
- },
- "node_modules/lodash": {
- "version": "4.18.1",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
- "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/lodash.escaperegexp": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
- "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
- "license": "MIT"
- },
- "node_modules/lodash.isequal": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
- "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
- "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
- "license": "MIT"
- },
- "node_modules/lowercase-keys": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
- "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/lru-cache": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
- "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "yallist": "^3.0.2"
- }
- },
- "node_modules/lucide-react": {
- "version": "1.21.0",
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz",
- "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==",
- "license": "ISC",
- "peerDependencies": {
- "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- }
- },
- "node_modules/magic-string": {
- "version": "0.30.21",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
- "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.5"
- }
- },
- "node_modules/matcher": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
- "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "escape-string-regexp": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/math-intrinsics": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
- "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/mime": {
- "version": "2.6.0",
- "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
- "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "mime": "cli.js"
- },
- "engines": {
- "node": ">=4.0.0"
- }
- },
- "node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mime-db": "1.52.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mimic-response": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
- "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/minimatch": {
- "version": "10.2.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
- "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "brace-expansion": "^5.0.5"
- },
- "engines": {
- "node": "18 || 20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/minimist": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
- "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/minipass": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
- "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": ">=16 || 14 >=14.17"
- }
- },
- "node_modules/minizlib": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
- "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "minipass": "^7.1.2"
- },
- "engines": {
- "node": ">= 18"
- }
- },
- "node_modules/mkdirp": {
- "version": "0.5.6",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
- "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "minimist": "^1.2.6"
- },
- "bin": {
- "mkdirp": "bin/cmd.js"
- }
- },
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
- },
- "node_modules/nanoid": {
- "version": "3.3.12",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
- "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/node-abi": {
- "version": "4.31.0",
- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.31.0.tgz",
- "integrity": "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "semver": "^7.6.3"
- },
- "engines": {
- "node": ">=22.12.0"
- }
- },
- "node_modules/node-abi/node_modules/semver": {
- "version": "7.8.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
- "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/node-addon-api": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.0.0.tgz",
- "integrity": "sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA==",
- "license": "MIT",
- "optional": true
- },
- "node_modules/node-api-version": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz",
- "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "semver": "^7.3.5"
- }
- },
- "node_modules/node-api-version/node_modules/semver": {
- "version": "7.8.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
- "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/node-gyp": {
- "version": "12.3.0",
- "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz",
- "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "env-paths": "^2.2.0",
- "exponential-backoff": "^3.1.1",
- "graceful-fs": "^4.2.6",
- "nopt": "^9.0.0",
- "proc-log": "^6.0.0",
- "semver": "^7.3.5",
- "tar": "^7.5.4",
- "tinyglobby": "^0.2.12",
- "undici": "^6.25.0",
- "which": "^6.0.0"
- },
- "bin": {
- "node-gyp": "bin/node-gyp.js"
- },
- "engines": {
- "node": "^20.17.0 || >=22.9.0"
- }
- },
- "node_modules/node-gyp/node_modules/isexe": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
- "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/node-gyp/node_modules/semver": {
- "version": "7.8.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
- "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/node-gyp/node_modules/which": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz",
- "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "isexe": "^4.0.0"
- },
- "bin": {
- "node-which": "bin/which.js"
- },
- "engines": {
- "node": "^20.17.0 || >=22.9.0"
- }
- },
- "node_modules/node-releases": {
- "version": "2.0.47",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
- "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/nopt": {
- "version": "9.0.0",
- "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz",
- "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "abbrev": "^4.0.0"
- },
- "bin": {
- "nopt": "bin/nopt.js"
- },
- "engines": {
- "node": "^20.17.0 || >=22.9.0"
- }
- },
- "node_modules/normalize-url": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
- "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/object-keys": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
- "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/obug": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz",
- "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==",
- "dev": true,
- "funding": [
- "https://github.com/sponsors/sxzz",
- "https://opencollective.com/debug"
- ],
- "license": "MIT",
- "engines": {
- "node": ">=12.20.0"
- }
- },
- "node_modules/once": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "wrappy": "1"
- }
- },
- "node_modules/p-cancelable": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
- "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/p-limit": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
- "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "yocto-queue": "^0.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/path-is-absolute": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
- "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/path-key": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
- "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/pathe": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
- "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/pe-library": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz",
- "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12",
- "npm": ">=6"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/jet2jet"
- }
- },
- "node_modules/pend": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
- "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/picomatch": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/plist": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
- "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@xmldom/xmldom": "^0.8.8",
- "base64-js": "^1.5.1",
- "xmlbuilder": "^15.1.1"
- },
- "engines": {
- "node": ">=10.4.0"
- }
- },
- "node_modules/postcss": {
- "version": "8.5.15",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
- "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.12",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/postject": {
- "version": "1.0.0-alpha.6",
- "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
- "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "commander": "^9.4.0"
- },
- "bin": {
- "postject": "dist/cli.js"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/postject/node_modules/commander": {
- "version": "9.5.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
- "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": "^12.20.0 || >=14"
- }
- },
- "node_modules/proc-log": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
- "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": "^20.17.0 || >=22.9.0"
- }
- },
- "node_modules/progress": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
- "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/promise-retry": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz",
- "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "err-code": "^2.0.2",
- "retry": "^0.12.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/proper-lockfile": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
- "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.4",
- "retry": "^0.12.0",
- "signal-exit": "^3.0.2"
- }
- },
- "node_modules/pump": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
- "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "end-of-stream": "^1.1.0",
- "once": "^1.3.1"
- }
- },
- "node_modules/punycode": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
- "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/quick-lru": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
- "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/react": {
- "version": "19.2.7",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
- "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/react-dom": {
- "version": "19.2.7",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
- "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "scheduler": "^0.27.0"
- },
- "peerDependencies": {
- "react": "^19.2.7"
- }
- },
- "node_modules/react-refresh": {
- "version": "0.18.0",
- "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
- "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/read-binary-file-arch": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
- "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "debug": "^4.3.4"
- },
- "bin": {
- "read-binary-file-arch": "cli.js"
- }
- },
- "node_modules/require-directory": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
- "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/resedit": {
- "version": "1.7.2",
- "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
- "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "pe-library": "^0.4.1"
- },
- "engines": {
- "node": ">=12",
- "npm": ">=6"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/jet2jet"
- }
- },
- "node_modules/resolve-alpn": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
- "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/responselike": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
- "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "lowercase-keys": "^2.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/retry": {
- "version": "0.12.0",
- "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
- "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
- "node_modules/rimraf": {
- "version": "2.6.3",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
- "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
- "deprecated": "Rimraf versions prior to v4 are no longer supported",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "glob": "^7.1.3"
- },
- "bin": {
- "rimraf": "bin.js"
- }
- },
- "node_modules/roarr": {
- "version": "2.15.4",
- "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
- "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
- "dev": true,
- "license": "BSD-3-Clause",
- "optional": true,
- "dependencies": {
- "boolean": "^3.0.1",
- "detect-node": "^2.0.4",
- "globalthis": "^1.0.1",
- "json-stringify-safe": "^5.0.1",
- "semver-compare": "^1.0.0",
- "sprintf-js": "^1.1.2"
- },
- "engines": {
- "node": ">=8.0"
- }
- },
- "node_modules/rollup": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
- "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "1.0.9"
- },
- "bin": {
- "rollup": "dist/bin/rollup"
- },
- "engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
- },
- "optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.61.1",
- "@rollup/rollup-android-arm64": "4.61.1",
- "@rollup/rollup-darwin-arm64": "4.61.1",
- "@rollup/rollup-darwin-x64": "4.61.1",
- "@rollup/rollup-freebsd-arm64": "4.61.1",
- "@rollup/rollup-freebsd-x64": "4.61.1",
- "@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
- "@rollup/rollup-linux-arm-musleabihf": "4.61.1",
- "@rollup/rollup-linux-arm64-gnu": "4.61.1",
- "@rollup/rollup-linux-arm64-musl": "4.61.1",
- "@rollup/rollup-linux-loong64-gnu": "4.61.1",
- "@rollup/rollup-linux-loong64-musl": "4.61.1",
- "@rollup/rollup-linux-ppc64-gnu": "4.61.1",
- "@rollup/rollup-linux-ppc64-musl": "4.61.1",
- "@rollup/rollup-linux-riscv64-gnu": "4.61.1",
- "@rollup/rollup-linux-riscv64-musl": "4.61.1",
- "@rollup/rollup-linux-s390x-gnu": "4.61.1",
- "@rollup/rollup-linux-x64-gnu": "4.61.1",
- "@rollup/rollup-linux-x64-musl": "4.61.1",
- "@rollup/rollup-openbsd-x64": "4.61.1",
- "@rollup/rollup-openharmony-arm64": "4.61.1",
- "@rollup/rollup-win32-arm64-msvc": "4.61.1",
- "@rollup/rollup-win32-ia32-msvc": "4.61.1",
- "@rollup/rollup-win32-x64-gnu": "4.61.1",
- "@rollup/rollup-win32-x64-msvc": "4.61.1",
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/safer-buffer": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
- "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/sanitize-filename": {
- "version": "1.6.4",
- "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz",
- "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==",
- "dev": true,
- "license": "WTFPL OR ISC",
- "dependencies": {
- "truncate-utf8-bytes": "^1.0.0"
- }
- },
- "node_modules/sax": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
- "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": ">=11.0.0"
- }
- },
- "node_modules/scheduler": {
- "version": "0.27.0",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
- "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- }
- },
- "node_modules/semver-compare": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
- "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
- "node_modules/serialize-error": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
- "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "type-fest": "^0.13.1"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/shebang-command": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
- "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "shebang-regex": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/shebang-regex": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
- "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/siginfo": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
- "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/signal-exit": {
- "version": "3.0.7",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
- "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/simple-update-notifier": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
- "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "semver": "^7.5.3"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/simple-update-notifier/node_modules/semver": {
- "version": "7.8.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
- "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/slice-ansi": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
- "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "ansi-styles": "^4.0.0",
- "astral-regex": "^2.0.0",
- "is-fullwidth-code-point": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/smart-buffer": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
- "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">= 6.0.0",
- "npm": ">= 3.0.0"
- }
- },
- "node_modules/source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/source-map-support": {
- "version": "0.5.21",
- "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
- "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "buffer-from": "^1.0.0",
- "source-map": "^0.6.0"
- }
- },
- "node_modules/sprintf-js": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
- "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "optional": true
- },
- "node_modules/stackback": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
- "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/stat-mode": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz",
- "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/std-env": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
- "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/sumchecker": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
- "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "debug": "^4.1.0"
- },
- "engines": {
- "node": ">= 8.0"
- }
- },
- "node_modules/supports-color": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
- "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-flag": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/tar": {
- "version": "7.5.16",
- "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz",
- "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "@isaacs/fs-minipass": "^4.0.0",
- "chownr": "^3.0.0",
- "minipass": "^7.1.2",
- "minizlib": "^3.1.0",
- "yallist": "^5.0.0"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tar/node_modules/yallist": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
- "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/temp": {
- "version": "0.9.4",
- "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz",
- "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mkdirp": "^0.5.1",
- "rimraf": "~2.6.2"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/temp-file": {
- "version": "3.4.0",
- "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz",
- "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "async-exit-hook": "^2.0.1",
- "fs-extra": "^10.0.0"
- }
- },
- "node_modules/temp-file/node_modules/fs-extra": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
- "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/temp-file/node_modules/jsonfile": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
- "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "universalify": "^2.0.0"
- },
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/temp-file/node_modules/universalify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
- "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/tiny-async-pool": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
- "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "semver": "^5.5.0"
- }
- },
- "node_modules/tiny-async-pool/node_modules/semver": {
- "version": "5.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
- "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver"
- }
- },
- "node_modules/tiny-typed-emitter": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
- "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
- "license": "MIT"
- },
- "node_modules/tinybench": {
- "version": "2.9.0",
- "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
- "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tinyexec": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
- "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tinyglobby": {
- "version": "0.2.17",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
- "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.4"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
- }
- },
- "node_modules/tinyrainbow": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
- "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/tmp": {
- "version": "0.2.7",
- "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
- "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.14"
- }
- },
- "node_modules/tmp-promise": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz",
- "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tmp": "^0.2.0"
- }
- },
- "node_modules/truncate-utf8-bytes": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
- "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==",
- "dev": true,
- "license": "WTFPL",
- "dependencies": {
- "utf8-byte-length": "^1.0.1"
- }
- },
- "node_modules/type-fest": {
- "version": "0.13.1",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
- "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
- "dev": true,
- "license": "(MIT OR CC0-1.0)",
- "optional": true,
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/typescript": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
- "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/undici": {
- "version": "6.26.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz",
- "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18.17"
- }
- },
- "node_modules/undici-types": {
- "version": "7.24.6",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
- "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/universalify": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
- "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 4.0.0"
- }
- },
- "node_modules/update-browserslist-db": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
- "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/browserslist"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "escalade": "^3.2.0",
- "picocolors": "^1.1.1"
- },
- "bin": {
- "update-browserslist-db": "cli.js"
- },
- "peerDependencies": {
- "browserslist": ">= 4.21.0"
- }
- },
- "node_modules/uri-js": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
- "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "punycode": "^2.1.0"
- }
- },
- "node_modules/utf8-byte-length": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
- "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==",
- "dev": true,
- "license": "(WTFPL OR MIT)"
- },
- "node_modules/verror": {
- "version": "1.10.1",
- "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
- "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "assert-plus": "^1.0.0",
- "core-util-is": "1.0.2",
- "extsprintf": "^1.2.0"
- },
- "engines": {
- "node": ">=0.6.0"
- }
- },
- "node_modules/vite": {
- "version": "7.3.5",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
- "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "esbuild": "^0.27.0",
- "fdir": "^6.5.0",
- "picomatch": "^4.0.3",
- "postcss": "^8.5.6",
- "rollup": "^4.43.0",
- "tinyglobby": "^0.2.15"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^20.19.0 || >=22.12.0",
- "jiti": ">=1.21.0",
- "less": "^4.0.0",
- "lightningcss": "^1.21.0",
- "sass": "^1.70.0",
- "sass-embedded": "^1.70.0",
- "stylus": ">=0.54.8",
- "sugarss": "^5.0.0",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "jiti": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "lightningcss": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "sass-embedded": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
- }
- }
- },
- "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
- "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/android-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
- "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/android-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
- "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/android-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
- "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/darwin-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
- "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/darwin-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
- "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
- "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/freebsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
- "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
- "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
- "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
- "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-loong64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
- "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-mips64el": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
- "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
- "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-riscv64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
- "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-s390x": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
- "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/linux-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
- "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/netbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
- "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/openbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
- "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/openharmony-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
- "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/sunos-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
- "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/win32-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
- "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/win32-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
- "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/@esbuild/win32-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
- "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vite/node_modules/esbuild": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
- "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.27.7",
- "@esbuild/android-arm": "0.27.7",
- "@esbuild/android-arm64": "0.27.7",
- "@esbuild/android-x64": "0.27.7",
- "@esbuild/darwin-arm64": "0.27.7",
- "@esbuild/darwin-x64": "0.27.7",
- "@esbuild/freebsd-arm64": "0.27.7",
- "@esbuild/freebsd-x64": "0.27.7",
- "@esbuild/linux-arm": "0.27.7",
- "@esbuild/linux-arm64": "0.27.7",
- "@esbuild/linux-ia32": "0.27.7",
- "@esbuild/linux-loong64": "0.27.7",
- "@esbuild/linux-mips64el": "0.27.7",
- "@esbuild/linux-ppc64": "0.27.7",
- "@esbuild/linux-riscv64": "0.27.7",
- "@esbuild/linux-s390x": "0.27.7",
- "@esbuild/linux-x64": "0.27.7",
- "@esbuild/netbsd-arm64": "0.27.7",
- "@esbuild/netbsd-x64": "0.27.7",
- "@esbuild/openbsd-arm64": "0.27.7",
- "@esbuild/openbsd-x64": "0.27.7",
- "@esbuild/openharmony-arm64": "0.27.7",
- "@esbuild/sunos-x64": "0.27.7",
- "@esbuild/win32-arm64": "0.27.7",
- "@esbuild/win32-ia32": "0.27.7",
- "@esbuild/win32-x64": "0.27.7"
- }
- },
- "node_modules/vitest": {
- "version": "4.1.8",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
- "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/expect": "4.1.8",
- "@vitest/mocker": "4.1.8",
- "@vitest/pretty-format": "4.1.8",
- "@vitest/runner": "4.1.8",
- "@vitest/snapshot": "4.1.8",
- "@vitest/spy": "4.1.8",
- "@vitest/utils": "4.1.8",
- "es-module-lexer": "^2.0.0",
- "expect-type": "^1.3.0",
- "magic-string": "^0.30.21",
- "obug": "^2.1.1",
- "pathe": "^2.0.3",
- "picomatch": "^4.0.3",
- "std-env": "^4.0.0-rc.1",
- "tinybench": "^2.9.0",
- "tinyexec": "^1.0.2",
- "tinyglobby": "^0.2.15",
- "tinyrainbow": "^3.1.0",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
- "why-is-node-running": "^2.3.0"
- },
- "bin": {
- "vitest": "vitest.mjs"
- },
- "engines": {
- "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "@edge-runtime/vm": "*",
- "@opentelemetry/api": "^1.9.0",
- "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.8",
- "@vitest/browser-preview": "4.1.8",
- "@vitest/browser-webdriverio": "4.1.8",
- "@vitest/coverage-istanbul": "4.1.8",
- "@vitest/coverage-v8": "4.1.8",
- "@vitest/ui": "4.1.8",
- "happy-dom": "*",
- "jsdom": "*",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "@edge-runtime/vm": {
- "optional": true
- },
- "@opentelemetry/api": {
- "optional": true
- },
- "@types/node": {
- "optional": true
- },
- "@vitest/browser-playwright": {
- "optional": true
- },
- "@vitest/browser-preview": {
- "optional": true
- },
- "@vitest/browser-webdriverio": {
- "optional": true
- },
- "@vitest/coverage-istanbul": {
- "optional": true
- },
- "@vitest/coverage-v8": {
- "optional": true
- },
- "@vitest/ui": {
- "optional": true
- },
- "happy-dom": {
- "optional": true
- },
- "jsdom": {
- "optional": true
- },
- "vite": {
- "optional": false
- }
- }
- },
- "node_modules/which": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
- "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "isexe": "^3.1.1"
- },
- "bin": {
- "node-which": "bin/which.js"
- },
- "engines": {
- "node": "^18.17.0 || >=20.5.0"
- }
- },
- "node_modules/why-is-node-running": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
- "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "siginfo": "^2.0.0",
- "stackback": "0.0.2"
- },
- "bin": {
- "why-is-node-running": "cli.js"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/wrap-ansi": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
- "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "node_modules/wrappy": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/xmlbuilder": {
- "version": "15.1.1",
- "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
- "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8.0"
- }
- },
- "node_modules/y18n": {
- "version": "5.0.8",
- "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
- "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/yallist": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
- "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/yargs": {
- "version": "17.7.2",
- "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
- "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "cliui": "^8.0.1",
- "escalade": "^3.1.1",
- "get-caller-file": "^2.0.5",
- "require-directory": "^2.1.1",
- "string-width": "^4.2.3",
- "y18n": "^5.0.5",
- "yargs-parser": "^21.1.1"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/yargs-parser": {
- "version": "21.1.1",
- "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
- "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/yauzl": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
- "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "buffer-crc32": "~0.2.3",
- "fd-slicer": "~1.1.0"
- }
- },
- "node_modules/yocto-queue": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
- "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
+ "license": "AGPL-3.0-or-later"
}
}
}
diff --git a/package.json b/package.json
index 334eb71..0581e03 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,8 @@
{
"name": "switchify-pc",
- "version": "0.1.20",
+ "version": "0.2.0",
"description": "Desktop companion app for Switchify Android.",
- "main": "out/main/index.js",
"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",
@@ -13,19 +10,9 @@
"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",
- "native:smoke-text": "node scripts/smoke-text-input-helper.cjs",
- "package:win": "npm run build && npm run native:build && electron-builder --win --x64 --publish never && node scripts/sign-win-artifacts.cjs --update-latest-yml",
- "package:win:verify-uiaccess": "node scripts/verify-win-uiaccess-package.cjs",
- "package:win:verify-updater-metadata": "node scripts/verify-latest-yml-admin-rights.cjs",
- "signing:create-dev-cert": "node scripts/create-dev-signing-cert.cjs",
- "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
- "test": "vitest run"
+ "package:win:verify-updater-metadata": "node scripts/verify-latest-yml-admin-rights.cjs"
},
"keywords": [
- "electron",
"switchify",
"accessibility",
"desktop-control"
@@ -40,24 +27,5 @@
},
"bugs": {
"url": "https://github.com/switchifyapp/switchify-pc/issues"
- },
- "devDependencies": {
- "@types/node": "^25.9.1",
- "@types/react": "^19.2.15",
- "@types/react-dom": "^19.2.3",
- "@vitejs/plugin-react": "^5.2.0",
- "electron": "^39.8.10",
- "electron-builder": "^26.8.1",
- "electron-vite": "^5.0.0",
- "react": "^19.2.6",
- "react-dom": "^19.2.6",
- "typescript": "^6.0.3",
- "vite": "^7.3.3",
- "vitest": "^4.1.8"
- },
- "dependencies": {
- "@nut-tree-fork/libnut-win32": "^2.7.5",
- "electron-updater": "^6.8.9",
- "lucide-react": "^1.21.0"
}
}
diff --git a/scripts/build-cursor-overlay-helper.cjs b/scripts/build-cursor-overlay-helper.cjs
deleted file mode 100644
index ee98c92..0000000
--- a/scripts/build-cursor-overlay-helper.cjs
+++ /dev/null
@@ -1,64 +0,0 @@
-const fs = require('node:fs');
-const path = require('node:path');
-const { spawnSync } = require('node:child_process');
-const { resolveProjectPath } = require('./win-signing-tools.cjs');
-
-const helpers = [
- {
- name: 'cursor overlay helper',
- projectPath: resolveProjectPath('native', 'cursor-overlay-helper', 'CursorOverlayHelper.csproj'),
- outputDir: resolveProjectPath('build', 'native', 'cursor-overlay-helper', 'win-x64'),
- outputExeName: 'SwitchifyCursorOverlay.exe'
- },
- {
- name: 'Bluetooth transport helper',
- projectPath: resolveProjectPath('native', 'bluetooth-transport-helper', 'SwitchifyBluetoothTransport.csproj'),
- outputDir: resolveProjectPath('build', 'native', 'bluetooth-transport-helper', 'win-x64'),
- outputExeName: 'SwitchifyBluetoothTransport.exe'
- },
- {
- name: 'text input helper',
- projectPath: resolveProjectPath('native', 'text-input-helper', 'TextInputHelper.csproj'),
- outputDir: resolveProjectPath('build', 'native', 'text-input-helper', 'win-x64'),
- outputExeName: 'SwitchifyTextInput.exe'
- }
-];
-
-for (const helper of helpers) {
- fs.mkdirSync(helper.outputDir, { recursive: true });
-
- const result = spawnSync(
- 'dotnet',
- [
- 'publish',
- helper.projectPath,
- '-c',
- 'Release',
- '-r',
- 'win-x64',
- '--self-contained',
- 'true',
- '-p:PublishSingleFile=true',
- '-p:PublishTrimmed=false',
- '-p:ReadyToRun=false',
- '-o',
- helper.outputDir
- ],
- {
- cwd: resolveProjectPath(),
- stdio: 'inherit',
- shell: process.platform === 'win32'
- }
- );
-
- if (result.status !== 0) {
- throw new Error(`dotnet publish failed for ${helper.name} with exit code ${result.status ?? 'unknown'}.`);
- }
-
- const outputExe = path.join(helper.outputDir, helper.outputExeName);
- if (!fs.existsSync(outputExe)) {
- throw new Error(`Expected ${helper.name} output was not created: ${outputExe}`);
- }
-
- console.log(`Built ${helper.name}: ${outputExe}`);
-}
diff --git a/scripts/dev.cjs b/scripts/dev.cjs
deleted file mode 100644
index b47fdf3..0000000
--- a/scripts/dev.cjs
+++ /dev/null
@@ -1,24 +0,0 @@
-const { spawn } = require('node:child_process');
-const { join } = require('node:path');
-
-const env = Object.fromEntries(
- Object.entries(process.env).filter(
- ([key, value]) => key !== 'ELECTRON_RUN_AS_NODE' && typeof value === 'string'
- )
-);
-
-const electronViteBin = join(__dirname, '..', 'node_modules', 'electron-vite', 'bin', 'electron-vite.js');
-const child = spawn(process.execPath, [electronViteBin, 'dev'], {
- env,
- stdio: 'inherit',
- shell: false
-});
-
-child.on('exit', (code, signal) => {
- if (signal) {
- process.kill(process.pid, signal);
- return;
- }
-
- process.exit(code ?? 0);
-});
diff --git a/scripts/package-dotnet-win.cjs b/scripts/package-dotnet-win.cjs
index 5c51e06..2ea5802 100644
--- a/scripts/package-dotnet-win.cjs
+++ b/scripts/package-dotnet-win.cjs
@@ -3,12 +3,12 @@ const path = require('node:path');
const crypto = require('node:crypto');
const { spawnSync } = require('node:child_process');
const {
+ createSigningArgs,
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');
diff --git a/scripts/package-win-after-pack.cjs b/scripts/package-win-after-pack.cjs
deleted file mode 100644
index 8d10e7e..0000000
--- a/scripts/package-win-after-pack.cjs
+++ /dev/null
@@ -1,247 +0,0 @@
-const fs = require('node:fs');
-const path = require('node:path');
-const ResEdit = require('resedit');
-const packageJson = require('../package.json');
-const {
- findWindowsSdkTool,
- isWindows,
- resolveProjectPath,
- runTool
-} = require('./win-signing-tools.cjs');
-
-module.exports = async function packageWindowsAfterPack(context) {
- if (context.electronPlatformName !== 'win32') {
- return;
- }
-
- const executableName = `${context.packager.appInfo.productFilename}.exe`;
- const executablePath = path.join(context.appOutDir, executableName);
-
- applyWindowsExecutableResources(executablePath);
- embedUiAccessManifest(executablePath);
- signWindowsExecutable(executablePath);
- signNativeHelpers(context.appOutDir);
-};
-
-function applyWindowsExecutableResources(executablePath) {
- const iconPath = resolveProjectPath('build', 'icon.ico');
- const executable = ResEdit.NtExecutable.from(fs.readFileSync(executablePath), { ignoreCert: true });
- const resources = ResEdit.NtExecutableResource.from(executable);
- const iconFile = ResEdit.Data.IconFile.from(fs.readFileSync(iconPath));
-
- ResEdit.Resource.IconGroupEntry.replaceIconsForResource(
- resources.entries,
- 1,
- 1033,
- iconFile.icons.map((item) => item.data)
- );
-
- applyWindowsVersionInfo(resources);
- resources.outputResource(executable);
- fs.writeFileSync(executablePath, Buffer.from(executable.generate()));
-}
-
-function applyWindowsVersionInfo(resources) {
- const versionInfo = ResEdit.Resource.VersionInfo.fromEntries(resources.entries)[0];
- if (!versionInfo) {
- throw new Error('Unable to find executable version info resource.');
- }
-
- const [major, minor, patch, build] = parseWindowsVersion(packageJson.version);
- versionInfo.setFileVersion(major, minor, patch, build, 1033);
- versionInfo.setProductVersion(major, minor, patch, build, 1033);
- versionInfo.setStringValues(
- { lang: 1033, codepage: 1200 },
- {
- CompanyName: 'Switchify',
- FileDescription: 'Switchify PC',
- FileVersion: packageJson.version,
- InternalName: 'Switchify PC',
- LegalCopyright: 'Copyright (C) 2026 Owen McGirr',
- OriginalFilename: 'Switchify PC.exe',
- ProductName: 'Switchify PC',
- ProductVersion: packageJson.version
- }
- );
- versionInfo.outputToResourceEntries(resources.entries);
-}
-
-function parseWindowsVersion(version) {
- const numericParts = String(version)
- .split(/[.-]/)
- .slice(0, 4)
- .map((part) => Number.parseInt(part, 10))
- .map((part) => (Number.isFinite(part) && part >= 0 ? part : 0));
-
- while (numericParts.length < 4) {
- numericParts.push(0);
- }
-
- return numericParts;
-}
-
-function embedUiAccessManifest(executablePath) {
- const mtExe = findWindowsSdkTool('mt.exe');
- const manifestPath = resolveProjectPath('build', 'win-uiaccess.manifest');
- runTool(mtExe, ['-nologo', '-manifest', manifestPath, `-outputresource:${executablePath};#1`]);
-}
-
-function signWindowsExecutable(filePath) {
- const signingArgs = createSigningArgs(filePath, { requireSigning: true });
- if (!signingArgs) return;
-
- const signtoolExe = findWindowsSdkTool('signtool.exe');
- runTool(signtoolExe, signingArgs);
-}
-
-const nativeHelperNames = [
- 'SwitchifyCursorOverlay.exe',
- 'SwitchifyBluetoothTransport.exe',
- 'SwitchifyTextInput.exe'
-];
-
-function signNativeHelpers(appOutDir) {
- for (const helperName of nativeHelperNames) {
- const helperPath = path.join(appOutDir, 'resources', 'native', helperName);
- if (!fs.existsSync(helperPath)) {
- throw new Error(`Native helper is missing from packaged resources: ${helperPath}`);
- }
-
- signWindowsExecutable(helperPath);
- }
-}
-
-function createSigningArgs(filePath, { requireSigning }) {
- if (!isWindows()) return null;
-
- if (process.env.SWITCHIFY_SIGNING_MODE === 'certum-simplysign') {
- return createCertumSimplySignArgs(filePath, requireSigning);
- }
-
- if (process.env.SWITCHIFY_SIGNING_MODE === 'azure') {
- return createAzureSigningArgs(filePath, requireSigning);
- }
-
- const devArgs = createDevPfxSigningArgs(filePath);
- if (devArgs) return devArgs;
-
- const devStoreArgs = createDevStoreSigningArgs(filePath);
- if (devStoreArgs) return devStoreArgs;
-
- const azureArgs = createAzureSigningArgs(filePath, false);
- if (azureArgs) return azureArgs;
-
- if (process.env.SWITCHIFY_ALLOW_UNSIGNED_UIACCESS_PACKAGE === '1' && !requireSigning) return null;
- if (process.env.SWITCHIFY_ALLOW_UNSIGNED_UIACCESS_PACKAGE === '1') {
- console.warn('WARNING: Producing an unsigned uiAccess package because SWITCHIFY_ALLOW_UNSIGNED_UIACCESS_PACKAGE=1.');
- return null;
- }
-
- throw new Error('Signing is required for uiAccess packaging. Set SWITCHIFY_DEV_CERT_PASSWORD with a dev PFX, run npm run signing:create-dev-cert, or configure Azure Artifact Signing.');
-}
-
-function createCertumSimplySignArgs(filePath, requireSigning) {
- const thumbprint = normalizeThumbprint(process.env.SWITCHIFY_CERTUM_CERT_THUMBPRINT || '');
-
- if (!thumbprint) {
- if (requireSigning && process.env.SWITCHIFY_SIGNING_MODE === 'certum-simplysign') {
- throw new Error('Certum SimplySign signing requires SWITCHIFY_CERTUM_CERT_THUMBPRINT.');
- }
- return null;
- }
-
- return [
- 'sign',
- '/v',
- '/fd',
- 'SHA256',
- '/sha1',
- thumbprint,
- '/tr',
- process.env.SWITCHIFY_CERTUM_TIMESTAMP_URL || 'http://time.certum.pl',
- '/td',
- 'SHA256',
- filePath
- ];
-}
-
-function createDevPfxSigningArgs(filePath) {
- const password = process.env.SWITCHIFY_DEV_CERT_PASSWORD;
- const pfxPath = process.env.SWITCHIFY_DEV_CERT_PFX || resolveProjectPath('.certs', 'switchify-dev-code-signing.pfx');
- const thumbprint = resolveDevCertificateThumbprint(pfxPath);
-
- if (!password || !fs.existsSync(pfxPath)) return null;
-
- const args = ['sign', '/fd', 'SHA256', '/f', pfxPath, '/p', password];
- if (thumbprint) {
- args.push('/sha1', thumbprint);
- }
- if (process.env.SWITCHIFY_SIGN_SKIP_TIMESTAMP !== '1') {
- args.push('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256');
- }
- args.push(filePath);
- return args;
-}
-
-function createDevStoreSigningArgs(filePath) {
- const thumbprint = resolveDevCertificateThumbprint(
- process.env.SWITCHIFY_DEV_CERT_PFX || resolveProjectPath('.certs', 'switchify-dev-code-signing.pfx')
- );
-
- if (!thumbprint) return null;
-
- const args = ['sign', '/fd', 'SHA256', '/sha1', thumbprint];
- if (process.env.SWITCHIFY_SIGN_SKIP_TIMESTAMP !== '1') {
- args.push('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256');
- }
- args.push(filePath);
- return args;
-}
-
-function resolveDevCertificateThumbprint(pfxPath) {
- if (process.env.SWITCHIFY_DEV_CERT_THUMBPRINT) {
- return normalizeThumbprint(process.env.SWITCHIFY_DEV_CERT_THUMBPRINT);
- }
-
- const thumbprintPath = path.join(path.dirname(pfxPath), 'switchify-dev-code-signing.thumbprint');
- if (!fs.existsSync(thumbprintPath)) return null;
-
- return normalizeThumbprint(fs.readFileSync(thumbprintPath, 'utf8'));
-}
-
-function normalizeThumbprint(value) {
- const thumbprint = value.replace(/[^a-fA-F0-9]/g, '').toUpperCase();
- return thumbprint.length > 0 ? thumbprint : null;
-}
-
-function createAzureSigningArgs(filePath, requireSigning) {
- const dlibPath = process.env.SWITCHIFY_AZURE_SIGNING_DLIB;
- const metadataPath = process.env.SWITCHIFY_AZURE_SIGNING_METADATA;
-
- if (!dlibPath || !metadataPath) {
- if (requireSigning && process.env.SWITCHIFY_SIGNING_MODE === 'azure') {
- throw new Error('Azure Artifact Signing requires SWITCHIFY_AZURE_SIGNING_DLIB and SWITCHIFY_AZURE_SIGNING_METADATA.');
- }
- return null;
- }
-
- return [
- 'sign',
- '/v',
- '/debug',
- '/fd',
- 'SHA256',
- '/tr',
- 'http://timestamp.acs.microsoft.com',
- '/td',
- 'SHA256',
- '/dlib',
- dlibPath,
- '/dmdf',
- metadataPath,
- filePath
- ];
-}
-
-module.exports.createSigningArgs = createSigningArgs;
-module.exports.nativeHelperNames = nativeHelperNames;
diff --git a/scripts/sign-win-artifacts.cjs b/scripts/sign-win-artifacts.cjs
deleted file mode 100644
index 4f07ca8..0000000
--- a/scripts/sign-win-artifacts.cjs
+++ /dev/null
@@ -1,201 +0,0 @@
-const fs = require('node:fs');
-const path = require('node:path');
-const crypto = require('node:crypto');
-const {
- findWindowsSdkTool,
- isWindows,
- resolveProjectPath,
- runTool
-} = require('./win-signing-tools.cjs');
-const { createSigningArgs } = require('./package-win-after-pack.cjs');
-
-module.exports = async function signWindowsArtifacts(buildResult) {
- if (!isWindows()) {
- return [];
- }
-
- const distDir = resolveProjectPath('dist');
- const artifacts = buildResult.artifactPaths && buildResult.artifactPaths.length > 0
- ? buildResult.artifactPaths
- : fs.readdirSync(distDir)
- .filter((entry) => entry.toLowerCase().endsWith('.exe'))
- .map((entry) => path.join(distDir, entry));
-
- const installerArtifacts = artifacts.filter((artifactPath) => {
- const normalized = path.normalize(artifactPath);
- const lower = normalized.toLowerCase();
- return lower.endsWith('.exe') && !lower.includes(`${path.sep}win-unpacked${path.sep}`) && !lower.endsWith('.__uninstaller.exe');
- });
-
- if (installerArtifacts.length === 0) {
- return artifacts;
- }
-
- const signtoolExe = findWindowsSdkTool('signtool.exe');
- for (const artifactPath of installerArtifacts) {
- const signingArgs = createSigningArgs(artifactPath, { requireSigning: process.env.SWITCHIFY_ALLOW_UNSIGNED_INSTALLER !== '1' });
- if (!signingArgs) {
- console.warn(`WARNING: Skipping installer signing for ${artifactPath}.`);
- continue;
- }
- runTool(signtoolExe, signingArgs);
- updateLatestYmlForSignedInstaller(artifactPath);
- }
-
- return artifacts;
-};
-
-if (require.main === module) {
- const command = process.argv[2];
- if (command !== '--update-latest-yml') {
- throw new Error('Unsupported command. Use --update-latest-yml.');
- }
-
- updateLatestYmlForReferencedInstaller(resolveProjectPath('dist', 'latest.yml'));
-}
-
-function updateLatestYmlForSignedInstaller(installerPath) {
- const latestPath = path.join(path.dirname(installerPath), 'latest.yml');
- if (!fs.existsSync(latestPath)) {
- return;
- }
-
- const installerName = path.basename(installerPath);
- const sha512 = createSha512Base64(installerPath);
- const content = fs.readFileSync(latestPath, 'utf8');
-
- if (!referencesInstaller(content, installerName)) {
- console.warn(`WARNING: Skipping latest.yml update because it does not reference ${installerName}.`);
- return;
- }
-
- if (!/^(\s*sha512:\s*).+$/gm.test(content)) {
- throw new Error('latest.yml did not contain any sha512 entries to update.');
- }
-
- const updated = ensureAdminRightsMetadata(content.replace(/^(\s*sha512:\s*).+$/gm, `$1${sha512}`));
-
- fs.writeFileSync(latestPath, updated);
- console.log(`Updated latest.yml sha512 for ${installerName}.`);
-}
-
-function updateLatestYmlForReferencedInstaller(latestPath) {
- if (!fs.existsSync(latestPath)) {
- return;
- }
-
- const content = fs.readFileSync(latestPath, 'utf8');
- const installerName = findInstallerNameInLatestYml(content);
- const installerPath = path.join(path.dirname(latestPath), installerName);
-
- if (!fs.existsSync(installerPath)) {
- throw new Error(`latest.yml references missing installer ${installerName}.`);
- }
-
- updateLatestYmlForSignedInstaller(installerPath);
-}
-
-function createSha512Base64(filePath) {
- return crypto.createHash('sha512').update(fs.readFileSync(filePath)).digest('base64');
-}
-
-function findInstallerNameInLatestYml(content) {
- const match = content.match(/^\s*(?:url|path):\s*['"]?([^'"\r\n]+\.exe)['"]?\s*$/im);
- if (!match) {
- throw new Error('latest.yml does not reference a Windows installer.');
- }
-
- return path.basename(decodeURI(match[1]));
-}
-
-function referencesInstaller(content, installerName) {
- const encodedInstallerName = encodeURI(installerName);
- return (
- content.includes(`url: ${installerName}`) ||
- content.includes(`path: ${installerName}`) ||
- content.includes(`url: ${encodedInstallerName}`) ||
- content.includes(`path: ${encodedInstallerName}`)
- );
-}
-
-function ensureAdminRightsMetadata(content) {
- let output = ensureTopLevelAdminRights(content);
- output = ensureFileEntryAdminRights(output);
- return output;
-}
-
-function ensureTopLevelAdminRights(content) {
- if (/^isAdminRightsRequired:\s*(?:true|false)\s*$/m.test(content)) {
- return content.replace(/^isAdminRightsRequired:\s*(?:true|false)\s*$/m, 'isAdminRightsRequired: true');
- }
-
- const releaseDateMatch = content.match(/^releaseDate:\s*.+$/m);
- if (releaseDateMatch?.index !== undefined) {
- const insertAt = releaseDateMatch.index;
- return `${content.slice(0, insertAt)}isAdminRightsRequired: true\n${content.slice(insertAt)}`;
- }
-
- return `${content.replace(/\s*$/, '')}\nisAdminRightsRequired: true\n`;
-}
-
-function ensureFileEntryAdminRights(content) {
- const lines = content.split(/\r?\n/);
- const result = [];
- let inFiles = false;
- let inFirstFile = false;
- let inserted = false;
- let sawAdminLine = false;
-
- for (const line of lines) {
- if (line === 'files:') {
- inFiles = true;
- result.push(line);
- continue;
- }
-
- if (inFiles && /^ -\s+url:/.test(line)) {
- if (inFirstFile && !sawAdminLine && !inserted) {
- result.push(' isAdminRightsRequired: true');
- inserted = true;
- }
- inFirstFile = !inserted;
- sawAdminLine = false;
- result.push(line);
- continue;
- }
-
- if (inFirstFile && /^ isAdminRightsRequired:\s*(?:true|false)\s*$/.test(line)) {
- result.push(' isAdminRightsRequired: true');
- sawAdminLine = true;
- inserted = true;
- inFirstFile = false;
- continue;
- }
-
- if (inFirstFile && /^ [^ ].*:/.test(line)) {
- result.push(line);
- continue;
- }
-
- if (inFirstFile && (/^[^ ]/.test(line) || line === '')) {
- if (!sawAdminLine && !inserted) {
- result.push(' isAdminRightsRequired: true');
- inserted = true;
- }
- inFirstFile = false;
- }
-
- result.push(line);
- }
-
- if (inFirstFile && !sawAdminLine && !inserted) {
- result.push(' isAdminRightsRequired: true');
- }
-
- return result.join('\n');
-}
-
-module.exports.updateLatestYmlForSignedInstaller = updateLatestYmlForSignedInstaller;
-module.exports.updateLatestYmlForReferencedInstaller = updateLatestYmlForReferencedInstaller;
-module.exports.createSha512Base64 = createSha512Base64;
-module.exports.ensureAdminRightsMetadata = ensureAdminRightsMetadata;
diff --git a/scripts/smoke-cursor-overlay-helper.cjs b/scripts/smoke-cursor-overlay-helper.cjs
deleted file mode 100644
index 2915867..0000000
--- a/scripts/smoke-cursor-overlay-helper.cjs
+++ /dev/null
@@ -1,136 +0,0 @@
-const { spawn } = require('node:child_process');
-const { existsSync } = require('node:fs');
-const { join, resolve } = require('node:path');
-
-const projectRoot = resolve(__dirname, '..');
-const helperPath = join(
- projectRoot,
- 'build',
- 'native',
- 'cursor-overlay-helper',
- 'win-x64',
- 'SwitchifyCursorOverlay.exe'
-);
-
-const timeoutMs = 5_000;
-let stdoutBuffer = '';
-let stderrBuffer = '';
-let settled = false;
-let sawReady = false;
-let shutdownSent = false;
-let timeout = null;
-let helper = null;
-
-if (!existsSync(helperPath)) {
- fail(`Cursor overlay helper was not found: ${helperPath}`);
-}
-
-helper = spawn(helperPath, [], {
- stdio: ['pipe', 'pipe', 'pipe'],
- windowsHide: true
-});
-
-timeout = setTimeout(() => {
- fail(`Cursor overlay helper smoke test timed out.${stderrBuffer ? ` stderr: ${stderrBuffer.trim()}` : ''}`);
-}, timeoutMs);
-
-helper.once('error', (error) => {
- fail(`Cursor overlay helper failed to start: ${error.message}`);
-});
-
-helper.once('exit', (code, signal) => {
- if (!settled && !shutdownSent) {
- fail(`Cursor overlay helper exited before shutdown: ${signal ?? code ?? 'unknown'}`);
- }
-});
-
-helper.stdout.on('data', (chunk) => {
- stdoutBuffer += String(chunk);
- readStdoutLines();
-});
-
-helper.stderr.on('data', (chunk) => {
- stderrBuffer += String(chunk);
-});
-
-function readStdoutLines() {
- while (stdoutBuffer.includes('\n')) {
- const newlineIndex = stdoutBuffer.indexOf('\n');
- const line = stdoutBuffer.slice(0, newlineIndex).trim();
- stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
- if (line) {
- handleMessage(line);
- }
- }
-}
-
-function handleMessage(line) {
- let message;
- try {
- message = JSON.parse(line);
- } catch (error) {
- fail(`Cursor overlay helper returned malformed output: ${line}`);
- return;
- }
-
- if (message.type === 'error') {
- fail(`Cursor overlay helper reported ${message.code ?? 'error'}: ${message.message ?? 'unknown error'}`);
- return;
- }
-
- if (message.type === 'ready' && !sawReady) {
- sawReady = true;
- smokeEvents();
- }
-}
-
-function smokeEvents() {
- for (const event of ['move', 'click', 'drag']) {
- writeCommand({
- type: 'show',
- event,
- x: 100,
- y: 100,
- size: 96,
- durationMs: 50,
- crosshairs: false,
- persistent: false,
- color: {
- red: 211,
- green: 47,
- blue: 47
- }
- });
- }
-
- setTimeout(() => {
- shutdownSent = true;
- writeCommand({ type: 'shutdown' });
- helper.stdin.end();
- clearTimeout(timeout);
- settled = true;
- console.log('Cursor overlay helper smoke test passed.');
- setTimeout(() => {
- if (helper && !helper.killed) {
- helper.kill();
- }
- }, 1_000).unref();
- }, 100);
-}
-
-function writeCommand(command) {
- helper.stdin.write(`${JSON.stringify(command)}\n`);
-}
-
-function fail(message) {
- if (settled) return;
- settled = true;
- if (timeout) {
- clearTimeout(timeout);
- }
- if (helper && !helper.killed) {
- helper.kill();
- }
- console.error(message);
- process.exitCode = 1;
-}
diff --git a/scripts/smoke-text-input-helper.cjs b/scripts/smoke-text-input-helper.cjs
deleted file mode 100644
index 8ebdce7..0000000
--- a/scripts/smoke-text-input-helper.cjs
+++ /dev/null
@@ -1,131 +0,0 @@
-const fs = require('node:fs');
-const path = require('node:path');
-const { spawn } = require('node:child_process');
-const { resolveProjectPath } = require('./win-signing-tools.cjs');
-
-const helperPath = resolveProjectPath('build', 'native', 'text-input-helper', 'win-x64', 'SwitchifyTextInput.exe');
-const TIMEOUT_MS = 5_000;
-
-if (!fs.existsSync(helperPath)) {
- console.error(`Text input helper was not found: ${helperPath}`);
- process.exit(1);
-}
-
-const helper = spawn(helperPath, [], {
- stdio: ['pipe', 'pipe', 'pipe'],
- windowsHide: true
-});
-
-let stdoutBuffer = '';
-let stderr = '';
-const pendingMessages = [];
-let finished = false;
-let expectingExit = false;
-
-const timeout = setTimeout(() => {
- fail(`Timed out after ${TIMEOUT_MS}ms waiting for text input helper.`);
-}, TIMEOUT_MS);
-
-helper.stdout.on('data', (chunk) => {
- stdoutBuffer += String(chunk);
- let newlineIndex;
- while ((newlineIndex = stdoutBuffer.search(/\r?\n/)) >= 0) {
- const line = stdoutBuffer.slice(0, newlineIndex).trim();
- stdoutBuffer = stdoutBuffer.slice(newlineIndex + (stdoutBuffer[newlineIndex] === '\r' ? 2 : 1));
- if (!line) continue;
- try {
- pendingMessages.push(JSON.parse(line));
- } catch (error) {
- fail(`Text input helper returned malformed JSON: ${line}`);
- }
- }
-});
-
-helper.stderr.on('data', (chunk) => {
- stderr += String(chunk);
-});
-
-helper.once('error', (error) => {
- fail(`Text input helper failed to start: ${error.message}`);
-});
-
-helper.once('exit', (code, signal) => {
- if (!finished && !expectingExit) {
- fail(`Text input helper exited early: ${signal ?? code ?? 'unknown'}`);
- }
-});
-
-main().catch((error) => fail(error instanceof Error ? error.message : String(error)));
-
-async function main() {
- const ready = await waitForMessage((message) => message.type === 'ready');
- if (!ready) throw new Error('Text input helper did not report ready.');
-
- send({ type: 'typeText', id: 'smoke-empty', text: '' });
- const emptyResult = await waitForMessage((message) => message.id === 'smoke-empty');
- if (!emptyResult?.ok || emptyResult.sentEvents !== 0) {
- throw new Error(`Empty text smoke command failed: ${JSON.stringify(emptyResult)}`);
- }
-
- send({ type: 'unsupported', id: 'smoke-invalid' });
- const invalidResult = await waitForMessage((message) => message.id === 'smoke-invalid');
- if (invalidResult?.type !== 'error' || invalidResult.code !== 'invalid_command') {
- throw new Error(`Invalid command did not return a structured error: ${JSON.stringify(invalidResult)}`);
- }
-
- send({ type: 'shutdown' });
- expectingExit = true;
- await waitForExit();
- finish();
-}
-
-function send(command) {
- helper.stdin.write(`${JSON.stringify(command)}\n`);
-}
-
-async function waitForMessage(predicate) {
- const startedAt = Date.now();
- while (Date.now() - startedAt < TIMEOUT_MS) {
- const index = pendingMessages.findIndex(predicate);
- if (index >= 0) {
- const [message] = pendingMessages.splice(index, 1);
- return message;
- }
- await delay(25);
- }
- return null;
-}
-
-async function waitForExit() {
- if (helper.exitCode !== null || helper.signalCode !== null) return;
- await new Promise((resolve) => helper.once('exit', resolve));
-}
-
-function finish() {
- finished = true;
- clearTimeout(timeout);
- if (stderr.trim()) {
- console.warn(`Text input helper stderr: ${stderr.trim()}`);
- }
- console.log(`Text input helper smoke check passed: ${path.relative(resolveProjectPath(), helperPath)}`);
-}
-
-function fail(message) {
- if (finished) return;
- finished = true;
- clearTimeout(timeout);
- try {
- helper.kill();
- } catch {
- // Ignore cleanup failures after a smoke failure.
- }
- console.error(message);
- if (stderr.trim()) {
- console.error(`stderr: ${stderr.trim()}`);
- }
- process.exit(1);
-}
-
-function delay(ms) {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
diff --git a/scripts/verify-win-uiaccess-package.cjs b/scripts/verify-win-uiaccess-package.cjs
deleted file mode 100644
index 971d050..0000000
--- a/scripts/verify-win-uiaccess-package.cjs
+++ /dev/null
@@ -1,103 +0,0 @@
-const fs = require('node:fs');
-const path = require('node:path');
-const { spawnSync } = require('node:child_process');
-const {
- findWindowsSdkTool,
- isWindows,
- resolveProjectPath,
- runTool
-} = require('./win-signing-tools.cjs');
-
-if (!isWindows()) {
- throw new Error('uiAccess package verification must run on Windows.');
-}
-
-const executablePath = resolveProjectPath('dist', 'win-unpacked', 'Switchify PC.exe');
-if (!fs.existsSync(executablePath)) {
- throw new Error(`Packaged executable not found: ${executablePath}`);
-}
-const nativeHelpers = [
- ['cursor overlay helper', resolveProjectPath('dist', 'win-unpacked', 'resources', 'native', 'SwitchifyCursorOverlay.exe')],
- [
- 'Bluetooth transport helper',
- resolveProjectPath('dist', 'win-unpacked', 'resources', 'native', 'SwitchifyBluetoothTransport.exe')
- ],
- ['text input helper', resolveProjectPath('dist', 'win-unpacked', 'resources', 'native', 'SwitchifyTextInput.exe')]
-];
-
-for (const [name, helperPath] of nativeHelpers) {
- if (!fs.existsSync(helperPath)) {
- throw new Error(`Native ${name} not found: ${helperPath}`);
- }
-}
-
-const mtExe = findWindowsSdkTool('mt.exe');
-const signtoolExe = findWindowsSdkTool('signtool.exe');
-const manifestOutPath = path.join(path.dirname(executablePath), 'Switchify PC.exe.manifest');
-
-try {
- runTool(mtExe, ['-nologo', `-inputresource:${executablePath};#1`, `-out:${manifestOutPath}`]);
- const manifest = fs.readFileSync(manifestOutPath, 'utf8');
- const hasUiAccess = /uiAccess\s*=\s*"true"/.test(manifest);
- const hasHighestAvailable = /level\s*=\s*"highestAvailable"/.test(manifest);
-
- const signatureResult = runTool(signtoolExe, ['verify', '/pa', '/v', executablePath], { stdio: 'pipe' });
- const signatureOutput = `${signatureResult.stdout || ''}${signatureResult.stderr || ''}`;
- console.log(`manifest embedded: yes`);
- console.log(`uiAccess=true: ${hasUiAccess ? 'yes' : 'no'}`);
- console.log(`highestAvailable: ${hasHighestAvailable ? 'yes' : 'no'}`);
- console.log(`signature status: ${signatureOutput.includes('Successfully verified') ? 'valid' : 'check output above'}`);
- for (const [name, helperPath] of nativeHelpers) {
- const helperSignatureResult = runTool(signtoolExe, ['verify', '/pa', '/v', helperPath], { stdio: 'pipe' });
- const helperSignatureOutput = `${helperSignatureResult.stdout || ''}${helperSignatureResult.stderr || ''}`;
- console.log(`${name}: present`);
- console.log(`${name} signature: ${helperSignatureOutput.includes('Successfully verified') ? 'valid' : 'check output above'}`);
- }
- console.log('secure install location required: install per-machine under Program Files for uiAccess to take effect.');
-
- if (!hasUiAccess || !hasHighestAvailable) {
- throw new Error('Packaged executable manifest does not contain the required uiAccess settings.');
- }
-
- if (process.env.SWITCHIFY_VERIFY_INSTALLED_LAUNCH === '1') {
- verifyInstalledLaunch();
- }
-} finally {
- fs.rmSync(manifestOutPath, { force: true });
-}
-
-function verifyInstalledLaunch() {
- const installedPath = path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Switchify PC', 'Switchify PC.exe');
- if (!fs.existsSync(installedPath)) {
- throw new Error(`Installed executable not found: ${installedPath}`);
- }
-
- const launchScript = `
-$path = '${escapePowerShellString(installedPath)}'
-$ErrorActionPreference = 'Stop'
-$process = Start-Process -FilePath $path -PassThru
-Start-Sleep -Seconds 5
-$launched = Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -eq $process.Id -and $_.ExecutablePath -eq $path }
-if ($null -eq $launched) {
- Write-Host 'installed launch: failed'
- exit 1
-}
-Write-Host ('installed launch: running (pid {0})' -f $process.Id)
-`.trim();
-
- const result = spawnSync('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', launchScript], {
- encoding: 'utf8',
- stdio: 'pipe'
- });
- const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
- if (output) {
- console.log(output);
- }
- if (result.status !== 0) {
- throw new Error('Installed uiAccess executable did not stay running.');
- }
-}
-
-function escapePowerShellString(value) {
- return value.replace(/'/g, "''");
-}
diff --git a/scripts/win-signing-tools.cjs b/scripts/win-signing-tools.cjs
index 5939ffe..c44b998 100644
--- a/scripts/win-signing-tools.cjs
+++ b/scripts/win-signing-tools.cjs
@@ -84,7 +84,142 @@ function redactSensitiveArgs(args) {
});
}
+function createSigningArgs(filePath, { requireSigning }) {
+ if (!isWindows()) return null;
+
+ if (process.env.SWITCHIFY_SIGNING_MODE === 'certum-simplysign') {
+ return createCertumSimplySignArgs(filePath, requireSigning);
+ }
+
+ if (process.env.SWITCHIFY_SIGNING_MODE === 'azure') {
+ return createAzureSigningArgs(filePath, requireSigning);
+ }
+
+ const devArgs = createDevPfxSigningArgs(filePath);
+ if (devArgs) return devArgs;
+
+ const devStoreArgs = createDevStoreSigningArgs(filePath);
+ if (devStoreArgs) return devStoreArgs;
+
+ const azureArgs = createAzureSigningArgs(filePath, false);
+ if (azureArgs) return azureArgs;
+
+ if (process.env.SWITCHIFY_ALLOW_UNSIGNED_UIACCESS_PACKAGE === '1' && !requireSigning) return null;
+ if (process.env.SWITCHIFY_ALLOW_UNSIGNED_UIACCESS_PACKAGE === '1') {
+ console.warn('WARNING: Producing an unsigned uiAccess package because SWITCHIFY_ALLOW_UNSIGNED_UIACCESS_PACKAGE=1.');
+ return null;
+ }
+
+ throw new Error(
+ 'Signing is required for uiAccess packaging. Set SWITCHIFY_DEV_CERT_PASSWORD with a dev PFX, run the dev certificate script, or configure Azure Artifact Signing.'
+ );
+}
+
+function createCertumSimplySignArgs(filePath, requireSigning) {
+ const thumbprint = normalizeThumbprint(process.env.SWITCHIFY_CERTUM_CERT_THUMBPRINT || '');
+
+ if (!thumbprint) {
+ if (requireSigning && process.env.SWITCHIFY_SIGNING_MODE === 'certum-simplysign') {
+ throw new Error('Certum SimplySign signing requires SWITCHIFY_CERTUM_CERT_THUMBPRINT.');
+ }
+ return null;
+ }
+
+ return [
+ 'sign',
+ '/v',
+ '/fd',
+ 'SHA256',
+ '/sha1',
+ thumbprint,
+ '/tr',
+ process.env.SWITCHIFY_CERTUM_TIMESTAMP_URL || 'http://time.certum.pl',
+ '/td',
+ 'SHA256',
+ filePath
+ ];
+}
+
+function createDevPfxSigningArgs(filePath) {
+ const password = process.env.SWITCHIFY_DEV_CERT_PASSWORD;
+ const pfxPath = process.env.SWITCHIFY_DEV_CERT_PFX || resolveProjectPath('.certs', 'switchify-dev-code-signing.pfx');
+ const thumbprint = resolveDevCertificateThumbprint(pfxPath);
+
+ if (!password || !fs.existsSync(pfxPath)) return null;
+
+ const args = ['sign', '/fd', 'SHA256', '/f', pfxPath, '/p', password];
+ if (thumbprint) {
+ args.push('/sha1', thumbprint);
+ }
+ if (process.env.SWITCHIFY_SIGN_SKIP_TIMESTAMP !== '1') {
+ args.push('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256');
+ }
+ args.push(filePath);
+ return args;
+}
+
+function createDevStoreSigningArgs(filePath) {
+ const thumbprint = resolveDevCertificateThumbprint(
+ process.env.SWITCHIFY_DEV_CERT_PFX || resolveProjectPath('.certs', 'switchify-dev-code-signing.pfx')
+ );
+
+ if (!thumbprint) return null;
+
+ const args = ['sign', '/fd', 'SHA256', '/sha1', thumbprint];
+ if (process.env.SWITCHIFY_SIGN_SKIP_TIMESTAMP !== '1') {
+ args.push('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256');
+ }
+ args.push(filePath);
+ return args;
+}
+
+function resolveDevCertificateThumbprint(pfxPath) {
+ if (process.env.SWITCHIFY_DEV_CERT_THUMBPRINT) {
+ return normalizeThumbprint(process.env.SWITCHIFY_DEV_CERT_THUMBPRINT);
+ }
+
+ const thumbprintPath = path.join(path.dirname(pfxPath), 'switchify-dev-code-signing.thumbprint');
+ if (!fs.existsSync(thumbprintPath)) return null;
+
+ return normalizeThumbprint(fs.readFileSync(thumbprintPath, 'utf8'));
+}
+
+function normalizeThumbprint(value) {
+ const thumbprint = value.replace(/[^a-fA-F0-9]/g, '').toUpperCase();
+ return thumbprint.length > 0 ? thumbprint : null;
+}
+
+function createAzureSigningArgs(filePath, requireSigning) {
+ const dlibPath = process.env.SWITCHIFY_AZURE_SIGNING_DLIB;
+ const metadataPath = process.env.SWITCHIFY_AZURE_SIGNING_METADATA;
+
+ if (!dlibPath || !metadataPath) {
+ if (requireSigning && process.env.SWITCHIFY_SIGNING_MODE === 'azure') {
+ throw new Error('Azure Artifact Signing requires SWITCHIFY_AZURE_SIGNING_DLIB and SWITCHIFY_AZURE_SIGNING_METADATA.');
+ }
+ return null;
+ }
+
+ return [
+ 'sign',
+ '/v',
+ '/debug',
+ '/fd',
+ 'SHA256',
+ '/tr',
+ 'http://timestamp.acs.microsoft.com',
+ '/td',
+ 'SHA256',
+ '/dlib',
+ dlibPath,
+ '/dmdf',
+ metadataPath,
+ filePath
+ ];
+}
+
module.exports = {
+ createSigningArgs,
findWindowsSdkTool,
isWindows,
resolveProjectPath,
diff --git a/src/main/app-window-ipc.ts b/src/main/app-window-ipc.ts
deleted file mode 100644
index 259f310..0000000
--- a/src/main/app-window-ipc.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { BrowserWindow, ipcMain } from 'electron';
-import { APP_WINDOW_CLOSE_CHANNEL, APP_WINDOW_MINIMIZE_CHANNEL } from '../shared/ipc-channels';
-
-function getSenderWindow(event: Electron.IpcMainInvokeEvent): BrowserWindow | null {
- return BrowserWindow.fromWebContents(event.sender);
-}
-
-export function registerAppWindowIpc(): void {
- ipcMain.handle(APP_WINDOW_MINIMIZE_CHANNEL, (event) => {
- getSenderWindow(event)?.minimize();
- });
-
- ipcMain.handle(APP_WINDOW_CLOSE_CHANNEL, (event) => {
- getSenderWindow(event)?.close();
- });
-}
diff --git a/src/main/bluetooth/bluetooth-helper-client.test.ts b/src/main/bluetooth/bluetooth-helper-client.test.ts
deleted file mode 100644
index 0c7f766..0000000
--- a/src/main/bluetooth/bluetooth-helper-client.test.ts
+++ /dev/null
@@ -1,246 +0,0 @@
-import { EventEmitter } from 'node:events';
-import { PassThrough } from 'node:stream';
-import { describe, expect, it, vi } from 'vitest';
-import { BLUETOOTH_FRAME_VERSION } from '../../shared/bluetooth-frame';
-import { BluetoothHelperClient } from './bluetooth-helper-client';
-import type { BluetoothHelperEvent } from './helper-protocol';
-
-describe('BluetoothHelperClient', () => {
- it('writes start commands and parses helper events', () => {
- const helper = createFakeProcess();
- const events: BluetoothHelperEvent[] = [];
- const client = new BluetoothHelperClient({
- helperPath: 'package.json',
- spawnProcess: () => helper.process,
- onEvent: (event) => events.push(event)
- });
-
- expect(
- client.start({
- type: 'start',
- serviceUuid: 'service',
- rxCharacteristicUuid: 'rx',
- txCharacteristicUuid: 'tx',
- statusCharacteristicUuid: 'status',
- displayName: 'Switchify PC',
- desktopId: 'desktop-1'
- })
- ).toBe(true);
- helper.stdout.write(`${JSON.stringify({ type: 'ready' })}\n`);
-
- expect(helper.stdinText()).toContain('"type":"start"');
- expect(events).toEqual([{ type: 'ready' }]);
- });
-
- it('writes framed send commands', () => {
- const helper = createFakeProcess();
- const client = new BluetoothHelperClient({
- helperPath: 'package.json',
- spawnProcess: () => helper.process,
- onEvent: vi.fn()
- });
-
- client.start({
- type: 'start',
- serviceUuid: 'service',
- rxCharacteristicUuid: 'rx',
- txCharacteristicUuid: 'tx',
- statusCharacteristicUuid: 'status',
- displayName: 'Switchify PC',
- desktopId: 'desktop-1'
- });
- client.send('ble', {
- version: BLUETOOTH_FRAME_VERSION,
- messageId: 'message-1',
- sequence: 0,
- isFinal: true,
- totalBytes: 2,
- payloadBase64: 'e30='
- });
-
- expect(helper.stdinText()).toContain('"type":"send"');
- expect(helper.stdinText()).toContain('"connectionId":"ble"');
- });
-
- it('parses diagnostic and disconnected events', () => {
- const helper = createFakeProcess();
- const events: BluetoothHelperEvent[] = [];
- const client = new BluetoothHelperClient({
- helperPath: 'package.json',
- spawnProcess: () => helper.process,
- onEvent: (event) => events.push(event)
- });
-
- client.start({
- type: 'start',
- serviceUuid: 'service',
- rxCharacteristicUuid: 'rx',
- txCharacteristicUuid: 'tx',
- statusCharacteristicUuid: 'status',
- displayName: 'Switchify PC',
- desktopId: 'desktop-1'
- });
- helper.stdout.write(`${JSON.stringify({ type: 'diagnostic', event: 'subscribed' })}\n`);
- helper.stdout.write(`${JSON.stringify({ type: 'diagnostic', event: 'system_radio_on' })}\n`);
- helper.stdout.write(`${JSON.stringify({ type: 'diagnostic', event: 'system_radio_off' })}\n`);
- helper.stdout.write(`${JSON.stringify({ type: 'diagnostic', event: 'advertising_restarted' })}\n`);
- helper.stdout.write(`${JSON.stringify({ type: 'diagnostic', event: 'unsubscribed' })}\n`);
- helper.stdout.write(`${JSON.stringify({ type: 'diagnostic', event: 'unsubscribe_grace_timed_out' })}\n`);
- helper.stdout.write(`${JSON.stringify({ type: 'disconnected', connectionId: 'ble', reason: 'client_requested' })}\n`);
- helper.stdout.write(`${JSON.stringify({ type: 'disconnected', connectionId: 'ble', reason: 'notification_unsubscribed' })}\n`);
- helper.stdout.write(`${JSON.stringify({ type: 'disconnected', connectionId: 'ble', reason: 'adapter_off' })}\n`);
-
- expect(events).toEqual([
- { type: 'diagnostic', event: 'subscribed' },
- { type: 'diagnostic', event: 'system_radio_on' },
- { type: 'diagnostic', event: 'system_radio_off' },
- { type: 'diagnostic', event: 'advertising_restarted' },
- { type: 'diagnostic', event: 'unsubscribed' },
- { type: 'diagnostic', event: 'unsubscribe_grace_timed_out' },
- { type: 'disconnected', connectionId: 'ble', reason: 'client_requested' },
- { type: 'disconnected', connectionId: 'ble', reason: 'notification_unsubscribed' },
- { type: 'disconnected', connectionId: 'ble', reason: 'adapter_off' }
- ]);
- });
-
- it('parses live system Bluetooth status events', () => {
- const helper = createFakeProcess();
- const events: BluetoothHelperEvent[] = [];
- const client = new BluetoothHelperClient({
- helperPath: 'package.json',
- spawnProcess: () => helper.process,
- onEvent: (event) => events.push(event)
- });
-
- client.start({
- type: 'start',
- serviceUuid: 'service',
- rxCharacteristicUuid: 'rx',
- txCharacteristicUuid: 'tx',
- statusCharacteristicUuid: 'status',
- displayName: 'Switchify PC',
- desktopId: 'desktop-1'
- });
- helper.stdout.write(
- `${JSON.stringify({
- type: 'systemStatus',
- adapterPresent: true,
- radioState: 'on',
- isLowEnergySupported: true,
- isPeripheralRoleSupported: true,
- deviceId: 'not-forwarded'
- })}\n`
- );
- helper.stdout.write(
- `${JSON.stringify({
- type: 'systemStatus',
- adapterPresent: false,
- radioState: 'unknown',
- isLowEnergySupported: null,
- isPeripheralRoleSupported: null
- })}\n`
- );
-
- expect(events).toEqual([
- {
- type: 'systemStatus',
- adapterPresent: true,
- radioState: 'on',
- isLowEnergySupported: true,
- isPeripheralRoleSupported: true
- },
- {
- type: 'systemStatus',
- adapterPresent: false,
- radioState: 'unknown',
- isLowEnergySupported: null,
- isPeripheralRoleSupported: null
- }
- ]);
- });
-
- it('treats malformed system Bluetooth status as safe helper failure', () => {
- const helper = createFakeProcess();
- const failures: string[] = [];
- const client = new BluetoothHelperClient({
- helperPath: 'package.json',
- spawnProcess: () => helper.process,
- onEvent: vi.fn(),
- onFailure: (message) => failures.push(message)
- });
-
- client.start({
- type: 'start',
- serviceUuid: 'service',
- rxCharacteristicUuid: 'rx',
- txCharacteristicUuid: 'tx',
- statusCharacteristicUuid: 'status',
- displayName: 'Switchify PC',
- desktopId: 'desktop-1'
- });
- helper.stdout.write(
- `${JSON.stringify({
- type: 'systemStatus',
- adapterPresent: true,
- radioState: 'pairing-token',
- isLowEnergySupported: true,
- isPeripheralRoleSupported: true
- })}\n`
- );
-
- expect(failures).toEqual(['Bluetooth helper returned malformed status output.']);
- expect(helper.kill).toHaveBeenCalledTimes(1);
- });
-
- it('treats malformed diagnostic events as safe helper failure', () => {
- const helper = createFakeProcess();
- const failures: string[] = [];
- const client = new BluetoothHelperClient({
- helperPath: 'package.json',
- spawnProcess: () => helper.process,
- onEvent: vi.fn(),
- onFailure: (message) => failures.push(message)
- });
-
- client.start({
- type: 'start',
- serviceUuid: 'service',
- rxCharacteristicUuid: 'rx',
- txCharacteristicUuid: 'tx',
- statusCharacteristicUuid: 'status',
- displayName: 'Switchify PC',
- desktopId: 'desktop-1'
- });
- helper.stdout.write(`${JSON.stringify({ type: 'diagnostic', event: 'payload:secret' })}\n`);
-
- expect(failures).toEqual(['Bluetooth helper returned malformed status output.']);
- expect(helper.kill).toHaveBeenCalledTimes(1);
- });
-});
-
-function createFakeProcess() {
- const stdin = new PassThrough();
- const stdout = new PassThrough();
- const stderr = new PassThrough();
- const chunks: string[] = [];
- const kill = vi.fn(() => {
- process.killed = true;
- return true;
- });
- stdin.on('data', (chunk) => chunks.push(String(chunk)));
-
- const process = Object.assign(new EventEmitter(), {
- stdin,
- stdout,
- stderr,
- killed: false,
- kill
- });
-
- return {
- process: process as never,
- stdout,
- kill,
- stdinText: () => chunks.join('')
- };
-}
diff --git a/src/main/bluetooth/bluetooth-helper-client.ts b/src/main/bluetooth/bluetooth-helper-client.ts
deleted file mode 100644
index c06c773..0000000
--- a/src/main/bluetooth/bluetooth-helper-client.ts
+++ /dev/null
@@ -1,256 +0,0 @@
-import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
-import { existsSync } from 'node:fs';
-import { validateBluetoothFrame, type BluetoothFrame } from '../../shared/bluetooth-frame';
-import type {
- BluetoothDiagnosticEvent,
- BluetoothDisconnectReason,
- BluetoothSystemRadioState,
- BluetoothUnavailableReason
-} from '../../shared/bluetooth-status';
-import type { BluetoothHelperCommand, BluetoothHelperEvent } from './helper-protocol';
-
-export type BluetoothHelperClientOptions = {
- helperPath: string;
- spawnProcess?: (helperPath: string) => ChildProcessWithoutNullStreams;
- onEvent: (event: BluetoothHelperEvent) => void;
- onFailure?: (message: string) => void;
- shutdownKillDelayMs?: number;
-};
-
-export class BluetoothHelperClient {
- private process: ChildProcessWithoutNullStreams | null = null;
- private unavailable = false;
- private destroyed = false;
- private stdoutBuffer = '';
-
- constructor(private readonly options: BluetoothHelperClientOptions) {}
-
- start(command: Extract): boolean {
- if (this.destroyed || this.unavailable) return false;
- const helper = this.ensureProcess();
- if (!helper) return false;
- return this.write(command);
- }
-
- stop(): void {
- this.write({ type: 'stop' });
- }
-
- send(connectionId: string, frame: BluetoothFrame): void {
- this.write({ type: 'send', connectionId, frame });
- }
-
- disconnect(connectionId: string): void {
- this.write({ type: 'disconnect', connectionId });
- }
-
- destroy(): void {
- this.destroyed = true;
- const helper = this.process;
- this.process = null;
- if (!helper) return;
-
- try {
- helper.stdin.write(`${JSON.stringify({ type: 'shutdown' } satisfies BluetoothHelperCommand)}\n`);
- helper.stdin.end();
- } catch {
- // Ignore shutdown failures; the process is being torn down.
- }
-
- const killDelayMs = this.options.shutdownKillDelayMs ?? 500;
- setTimeout(() => {
- if (!helper.killed) {
- helper.kill();
- }
- }, killDelayMs).unref();
- }
-
- private ensureProcess(): ChildProcessWithoutNullStreams | null {
- if (this.process) return this.process;
-
- if (!existsSync(this.options.helperPath)) {
- this.fail(`Bluetooth helper was not found: ${this.options.helperPath}`);
- return null;
- }
-
- try {
- const helper = (this.options.spawnProcess ?? spawnBluetoothHelper)(this.options.helperPath);
- this.process = helper;
-
- helper.once('error', (error) => {
- this.fail(`Bluetooth helper failed: ${error.message}`);
- });
- helper.once('exit', (code, signal) => {
- if (!this.destroyed) {
- this.fail(`Bluetooth helper exited unexpectedly: ${signal ?? code ?? 'unknown'}`);
- }
- });
- helper.stdout.on('data', (chunk) => this.handleStdout(String(chunk)));
- helper.stderr.on('data', (chunk) => {
- this.options.onFailure?.(`Bluetooth helper stderr: ${String(chunk).trim()}`);
- });
-
- return helper;
- } catch (error) {
- this.fail(error instanceof Error ? error.message : 'Bluetooth helper could not start.');
- return null;
- }
- }
-
- private write(command: BluetoothHelperCommand): boolean {
- const helper = this.process;
- if (!helper || helper.stdin.destroyed) {
- this.fail('Bluetooth helper stdin is unavailable.');
- return false;
- }
-
- try {
- return helper.stdin.write(`${JSON.stringify(command)}\n`);
- } catch (error) {
- this.fail(error instanceof Error ? error.message : 'Bluetooth helper write failed.');
- return false;
- }
- }
-
- private handleStdout(output: string): void {
- this.stdoutBuffer += output;
- const lines = this.stdoutBuffer.split(/\r?\n/);
- this.stdoutBuffer = lines.pop() ?? '';
-
- for (const line of lines.map((item) => item.trim()).filter(Boolean)) {
- try {
- const event = parseHelperEvent(JSON.parse(line));
- if (!event) {
- this.fail('Bluetooth helper returned malformed status output.');
- return;
- }
- this.options.onEvent(event);
- } catch {
- this.fail('Bluetooth helper returned malformed status output.');
- }
- }
- }
-
- private fail(message: string): void {
- if (this.unavailable) return;
-
- this.unavailable = true;
- this.options.onFailure?.(message);
-
- const helper = this.process;
- this.process = null;
- if (helper && !helper.killed) {
- helper.kill();
- }
- }
-}
-
-function parseHelperEvent(value: unknown): BluetoothHelperEvent | null {
- if (!isRecord(value) || typeof value.type !== 'string') return null;
-
- switch (value.type) {
- case 'ready':
- return { type: 'ready' };
- case 'unavailable':
- return isUnavailableReason(value.reason) ? { type: 'unavailable', reason: value.reason } : null;
- case 'connected':
- return typeof value.connectionId === 'string' && typeof value.label === 'string'
- ? { type: 'connected', connectionId: value.connectionId, label: value.label }
- : null;
- case 'message':
- return typeof value.connectionId === 'string' && isBluetoothFrame(value.frame)
- ? { type: 'message', connectionId: value.connectionId, frame: value.frame }
- : null;
- case 'disconnected':
- return typeof value.connectionId === 'string' && isDisconnectReason(value.reason)
- ? { type: 'disconnected', connectionId: value.connectionId, reason: value.reason }
- : null;
- case 'diagnostic':
- return isDiagnosticEvent(value.event) ? { type: 'diagnostic', event: value.event } : null;
- case 'systemStatus':
- return isSystemStatusEvent(value)
- ? {
- type: 'systemStatus',
- adapterPresent: value.adapterPresent,
- radioState: value.radioState,
- isLowEnergySupported: value.isLowEnergySupported,
- isPeripheralRoleSupported: value.isPeripheralRoleSupported
- }
- : null;
- case 'error':
- return typeof value.reason === 'string' ? { type: 'error', reason: value.reason } : null;
- default:
- return null;
- }
-}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null;
-}
-
-function isBluetoothFrame(value: unknown): value is BluetoothFrame {
- if (!isRecord(value)) return false;
- const result = validateBluetoothFrame(value as BluetoothFrame);
- return !result.ok && result.reason === 'incomplete';
-}
-
-function isUnavailableReason(value: unknown): value is BluetoothUnavailableReason {
- return value === 'unsupported' || value === 'permission_denied' || value === 'adapter_off' || value === 'startup_failed';
-}
-
-function isDisconnectReason(value: unknown): value is Exclude {
- return (
- value === 'notification_unsubscribed' ||
- value === 'client_requested' ||
- value === 'pc_requested' ||
- value === 'helper_stopped' ||
- value === 'helper_error' ||
- value === 'adapter_off'
- );
-}
-
-function isDiagnosticEvent(value: unknown): value is Exclude {
- return (
- value === 'advertising_started' ||
- value === 'advertising_restarted' ||
- value === 'system_radio_on' ||
- value === 'system_radio_off' ||
- value === 'subscribed' ||
- value === 'unsubscribed' ||
- value === 'unsubscribe_grace_started' ||
- value === 'unsubscribe_grace_cancelled' ||
- value === 'unsubscribe_grace_timed_out' ||
- value === 'write_received'
- );
-}
-
-function isSystemStatusEvent(value: Record): value is {
- type: 'systemStatus';
- adapterPresent: boolean;
- radioState: BluetoothSystemRadioState;
- isLowEnergySupported: boolean | null;
- isPeripheralRoleSupported: boolean | null;
-} {
- return (
- typeof value.adapterPresent === 'boolean' &&
- isBluetoothSystemRadioState(value.radioState) &&
- isNullableBoolean(value.isLowEnergySupported) &&
- isNullableBoolean(value.isPeripheralRoleSupported)
- );
-}
-
-function isBluetoothSystemRadioState(value: unknown): value is BluetoothSystemRadioState {
- return value === 'on' || value === 'off' || value === 'disabled' || value === 'unknown';
-}
-
-function isNullableBoolean(value: unknown): value is boolean | null {
- return typeof value === 'boolean' || value === null;
-}
-
-function spawnBluetoothHelper(helperPath: string): ChildProcessWithoutNullStreams {
- return spawn(helperPath, [], {
- stdio: ['pipe', 'pipe', 'pipe'],
- windowsHide: true
- });
-}
-
diff --git a/src/main/bluetooth/bluetooth-transport.test.ts b/src/main/bluetooth/bluetooth-transport.test.ts
deleted file mode 100644
index 850009b..0000000
--- a/src/main/bluetooth/bluetooth-transport.test.ts
+++ /dev/null
@@ -1,451 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { PROTOCOL_VERSION, validateProtocolResponse, type PingCommand } from '../../shared/protocol';
-import { createCommandAuthProof, CommandAuthValidator } from '../pairing/auth';
-import { PairingManager } from '../pairing/pairing-manager';
-import { MemoryPairingStore } from '../pairing/pairing-store';
-import { ControlService } from '../control/control-service';
-import { WindowsBluetoothTransport } from './bluetooth-transport';
-import type { BluetoothHelperClient } from './bluetooth-helper-client';
-import type { BluetoothHelperEvent } from './helper-protocol';
-
-const now = 1_724_000_000_000;
-const token = 'shared-token';
-
-beforeEach(() => {
- vi.spyOn(Date, 'now').mockReturnValue(now);
-});
-
-afterEach(() => {
- vi.restoreAllMocks();
-});
-
-describe('WindowsBluetoothTransport', () => {
- it('bridges Bluetooth frames into the shared remote session manager', async () => {
- const { controlService, fakeHelper, transport } = createTransport();
-
- await transport.start();
- fakeHelper.emit({ type: 'ready' });
- fakeHelper.emit({ type: 'connected', connectionId: 'ble', label: 'Bluetooth device' });
- fakeHelper.emitFrame(JSON.stringify(createPingCommand()));
- await waitFor(() => fakeHelper.sentMessages().length === 1);
-
- const sent = fakeHelper.sentMessages();
- expect(sent).toHaveLength(1);
- expect(validateProtocolResponse(JSON.parse(sent[0]))).toMatchObject({ ok: true });
- expect(JSON.parse(sent[0])).toMatchObject({ type: 'ack', id: 'request-1', ok: true });
- expect(controlService.getStatus().connectedClients[0]).toMatchObject({
- deviceId: 'android-1',
- transport: 'bluetooth'
- });
- });
-
- it('records diagnostic events in Bluetooth status', async () => {
- const { controlService, fakeHelper, transport } = createTransport();
-
- await transport.start();
- fakeHelper.emit({ type: 'diagnostic', event: 'subscribed' });
- fakeHelper.emit({ type: 'diagnostic', event: 'unsubscribed' });
-
- expect(controlService.getStatus().bluetooth).toMatchObject({
- lastEvent: 'unsubscribed',
- lastEventAt: now,
- recentEvents: [
- { event: 'subscribed', at: now },
- { event: 'unsubscribed', at: now }
- ]
- });
- });
-
- it('keeps only the most recent Bluetooth diagnostic events', async () => {
- const { controlService, fakeHelper, transport } = createTransport();
-
- await transport.start();
- fakeHelper.emit({ type: 'diagnostic', event: 'advertising_started' });
- fakeHelper.emit({ type: 'diagnostic', event: 'subscribed' });
- fakeHelper.emit({ type: 'diagnostic', event: 'unsubscribed' });
- fakeHelper.emit({ type: 'diagnostic', event: 'unsubscribe_grace_started' });
- fakeHelper.emit({ type: 'diagnostic', event: 'unsubscribe_grace_cancelled' });
- fakeHelper.emit({ type: 'diagnostic', event: 'write_received' });
-
- expect(controlService.getStatus().bluetooth.recentEvents.map((event) => event.event)).toEqual([
- 'subscribed',
- 'unsubscribed',
- 'unsubscribe_grace_started',
- 'unsubscribe_grace_cancelled',
- 'write_received'
- ]);
- });
-
- it('records disconnect reasons in Bluetooth status', async () => {
- const { controlService, fakeHelper, transport } = createTransport();
-
- await transport.start();
- fakeHelper.emit({ type: 'connected', connectionId: 'ble', label: 'Bluetooth device' });
- fakeHelper.emit({ type: 'disconnected', connectionId: 'ble', reason: 'notification_unsubscribed' });
-
- expect(controlService.getStatus().bluetooth).toMatchObject({
- status: 'ready',
- connectedClientCount: 0,
- lastDisconnectReason: 'notification_unsubscribed',
- lastDisconnectAt: now
- });
- });
-
- it('overrides subscription timeout when the client announced an intentional disconnect', async () => {
- const { controlService, fakeHelper, transport } = createTransport();
-
- await transport.start();
- fakeHelper.emit({ type: 'connected', connectionId: 'ble', label: 'Bluetooth device' });
- transport.markClientRequestedDisconnect('ble');
- fakeHelper.emit({ type: 'disconnected', connectionId: 'ble', reason: 'notification_unsubscribed' });
-
- expect(controlService.getStatus().bluetooth).toMatchObject({
- status: 'ready',
- connectedClientCount: 0,
- lastDisconnectReason: 'client_requested',
- lastDisconnectAt: now
- });
- });
-
- it('does not let client disconnect intent override helper failures', async () => {
- const { controlService, fakeHelper, transport } = createTransport();
-
- await transport.start();
- fakeHelper.emit({ type: 'connected', connectionId: 'ble', label: 'Bluetooth device' });
- transport.markClientRequestedDisconnect('ble');
- fakeHelper.fail('helper crashed');
-
- expect(controlService.getStatus().bluetooth).toMatchObject({
- status: 'error',
- connectedClientCount: 0,
- lastDisconnectReason: 'helper_error',
- lastDisconnectAt: now
- });
- });
-
- it('clears client disconnect intent when Bluetooth traffic resumes before timeout', async () => {
- const { controlService, fakeHelper, transport } = createTransport();
-
- await transport.start();
- fakeHelper.emit({ type: 'connected', connectionId: 'ble', label: 'Bluetooth device' });
- transport.markClientRequestedDisconnect('ble');
- fakeHelper.emitFrame(JSON.stringify(createPingCommand()));
- await waitFor(() => fakeHelper.sentMessages().length === 1);
- fakeHelper.emit({ type: 'disconnected', connectionId: 'ble', reason: 'notification_unsubscribed' });
-
- expect(controlService.getStatus().bluetooth).toMatchObject({
- status: 'ready',
- connectedClientCount: 0,
- lastDisconnectReason: 'notification_unsubscribed',
- lastDisconnectAt: now
- });
- });
-
- it('clears active connections when the helper fails', async () => {
- const { controlService, fakeHelper, transport } = createTransport();
-
- await transport.start();
- fakeHelper.emit({ type: 'connected', connectionId: 'ble', label: 'Bluetooth device' });
- fakeHelper.fail('helper crashed');
-
- expect(controlService.getStatus().bluetooth).toMatchObject({
- status: 'error',
- connectedClientCount: 0,
- lastDisconnectReason: 'helper_error',
- lastDisconnectAt: now,
- lastError: 'helper crashed'
- });
- });
-
- it('includes live system Bluetooth status with checked and changed timestamps', async () => {
- const { controlService, fakeHelper, transport } = createTransport();
-
- await transport.start();
- fakeHelper.emit({
- type: 'systemStatus',
- adapterPresent: true,
- radioState: 'on',
- isLowEnergySupported: true,
- isPeripheralRoleSupported: true
- });
-
- expect(controlService.getStatus().bluetooth.system).toEqual({
- adapterPresent: true,
- radioState: 'on',
- isLowEnergySupported: true,
- isPeripheralRoleSupported: true,
- lastCheckedAt: now,
- lastChangedAt: now
- });
- });
-
- it('updates checked timestamp without changing changed timestamp for identical system status', async () => {
- const { controlService, fakeHelper, transport } = createTransport();
-
- await transport.start();
- fakeHelper.emit({
- type: 'systemStatus',
- adapterPresent: true,
- radioState: 'on',
- isLowEnergySupported: true,
- isPeripheralRoleSupported: true
- });
- vi.mocked(Date.now).mockReturnValue(now + 5_000);
- fakeHelper.emit({
- type: 'systemStatus',
- adapterPresent: true,
- radioState: 'on',
- isLowEnergySupported: true,
- isPeripheralRoleSupported: true
- });
-
- expect(controlService.getStatus().bluetooth.system).toMatchObject({
- lastCheckedAt: now + 5_000,
- lastChangedAt: now
- });
- });
-
- it('updates changed timestamp when system radio state changes', async () => {
- const { controlService, fakeHelper, transport } = createTransport();
-
- await transport.start();
- fakeHelper.emit({
- type: 'systemStatus',
- adapterPresent: true,
- radioState: 'on',
- isLowEnergySupported: true,
- isPeripheralRoleSupported: true
- });
- vi.mocked(Date.now).mockReturnValue(now + 5_000);
- fakeHelper.emit({
- type: 'systemStatus',
- adapterPresent: true,
- radioState: 'off',
- isLowEnergySupported: true,
- isPeripheralRoleSupported: true
- });
-
- expect(controlService.getStatus().bluetooth.system).toMatchObject({
- radioState: 'off',
- lastCheckedAt: now + 5_000,
- lastChangedAt: now + 5_000
- });
- });
-
- it('removes active connections when the system radio turns off', async () => {
- const { controlService, fakeHelper, transport } = createTransport();
-
- await transport.start();
- fakeHelper.emit({ type: 'connected', connectionId: 'ble', label: 'Bluetooth device' });
- fakeHelper.emit({
- type: 'systemStatus',
- adapterPresent: true,
- radioState: 'off',
- isLowEnergySupported: true,
- isPeripheralRoleSupported: true
- });
-
- expect(controlService.getStatus().bluetooth).toMatchObject({
- status: 'unavailable',
- reason: 'adapter_off',
- connectedClientCount: 0,
- lastDisconnectReason: 'adapter_off',
- lastDisconnectAt: now
- });
- expect(controlService.getStatus().connectedClients).toEqual([]);
- });
-
- it('moves to starting when the system radio comes back on after adapter off', async () => {
- const { controlService, fakeHelper, transport } = createTransport();
-
- await transport.start();
- fakeHelper.emit({
- type: 'systemStatus',
- adapterPresent: true,
- radioState: 'off',
- isLowEnergySupported: true,
- isPeripheralRoleSupported: true
- });
- fakeHelper.emit({
- type: 'systemStatus',
- adapterPresent: true,
- radioState: 'on',
- isLowEnergySupported: true,
- isPeripheralRoleSupported: true
- });
-
- expect(controlService.getStatus().bluetooth).toMatchObject({
- status: 'starting',
- reason: null
- });
-
- fakeHelper.emit({ type: 'ready' });
-
- expect(controlService.getStatus().bluetooth).toMatchObject({
- status: 'ready',
- reason: null
- });
- });
-
- it('reports unsupported when the system adapter is missing', async () => {
- const { controlService, fakeHelper, transport } = createTransport();
-
- await transport.start();
- fakeHelper.emit({
- type: 'systemStatus',
- adapterPresent: false,
- radioState: 'unknown',
- isLowEnergySupported: null,
- isPeripheralRoleSupported: null
- });
-
- expect(controlService.getStatus().bluetooth).toMatchObject({
- status: 'unavailable',
- reason: 'unsupported',
- connectedClientCount: 0,
- system: {
- adapterPresent: false,
- radioState: 'unknown',
- isLowEnergySupported: null,
- isPeripheralRoleSupported: null,
- lastCheckedAt: now,
- lastChangedAt: now
- }
- });
- });
-
- it('notifies status listeners when system Bluetooth status changes', async () => {
- const statusChanges: unknown[] = [];
- const { fakeHelper, transport } = createTransport({
- onStatusChange: (status) => statusChanges.push(status)
- });
-
- await transport.start();
- fakeHelper.emit({
- type: 'systemStatus',
- adapterPresent: true,
- radioState: 'on',
- isLowEnergySupported: true,
- isPeripheralRoleSupported: true
- });
-
- expect(statusChanges).toEqual([
- expect.objectContaining({
- status: 'starting'
- }),
- expect.objectContaining({
- system: expect.objectContaining({ radioState: 'on' })
- })
- ]);
- });
-});
-
-function createTransport(options: Pick[0], 'onStatusChange'> = {}): {
- controlService: ControlService;
- fakeHelper: FakeBluetoothHelper;
- transport: WindowsBluetoothTransport;
-} {
- const store = new MemoryPairingStore({
- desktopId: 'desktop-1',
- pairedDevices: [
- {
- deviceId: 'android-1',
- deviceName: 'Android device',
- token,
- pairedAt: now - 1_000,
- lastSeenAt: null
- }
- ]
- });
- const controlService = new ControlService({
- pairingManager: new PairingManager(store),
- authValidator: new CommandAuthValidator(store, () => now)
- });
- const fakeHelper = new FakeBluetoothHelper();
- const transport = new WindowsBluetoothTransport({
- controlService,
- getDesktopId: () => Promise.resolve('desktop-1'),
- displayName: 'Switchify PC',
- helperPath: 'fake-helper.exe',
- ...options,
- createHelper: (options) => {
- fakeHelper.onEvent = options.onEvent;
- fakeHelper.onFailure = options.onFailure ?? (() => {});
- return fakeHelper as unknown as BluetoothHelperClient;
- }
- });
- return { controlService, fakeHelper, transport };
-}
-
-class FakeBluetoothHelper {
- onEvent: (event: BluetoothHelperEvent) => void = () => {};
- onFailure: (message: string) => void = () => {};
- private readonly sentFrames: string[] = [];
-
- start(): boolean {
- return true;
- }
-
- stop(): void {}
-
- destroy(): void {}
-
- disconnect(): void {}
-
- send(_connectionId: string, frame: { payloadBase64: string }): void {
- this.sentFrames.push(Buffer.from(frame.payloadBase64, 'base64').toString('utf8'));
- }
-
- emit(event: BluetoothHelperEvent): void {
- this.onEvent(event);
- }
-
- fail(message: string): void {
- this.onFailure(message);
- }
-
- emitFrame(message: string): void {
- this.emit({
- type: 'message',
- connectionId: 'ble',
- frame: {
- version: 1,
- messageId: 'inbound-1',
- sequence: 0,
- isFinal: true,
- totalBytes: Buffer.byteLength(message, 'utf8'),
- payloadBase64: Buffer.from(message, 'utf8').toString('base64')
- }
- });
- }
-
- sentMessages(): string[] {
- return this.sentFrames;
- }
-}
-
-async function waitFor(predicate: () => boolean): Promise {
- const deadline = Date.now() + 1_000;
- while (Date.now() < deadline) {
- if (predicate()) return;
- await new Promise((resolve) => setTimeout(resolve, 10));
- }
- throw new Error('Timed out waiting for condition.');
-}
-
-function createPingCommand(overrides: Partial = {}): PingCommand {
- const command = {
- version: PROTOCOL_VERSION,
- id: 'request-1',
- deviceId: 'android-1',
- timestamp: now,
- type: 'connection.ping',
- payload: {},
- auth: ''
- } satisfies PingCommand;
- const merged = { ...command, ...overrides } as PingCommand;
- return {
- ...merged,
- auth: overrides.auth ?? createCommandAuthProof(merged, token)
- };
-}
diff --git a/src/main/bluetooth/bluetooth-transport.ts b/src/main/bluetooth/bluetooth-transport.ts
deleted file mode 100644
index 6a6d82c..0000000
--- a/src/main/bluetooth/bluetooth-transport.ts
+++ /dev/null
@@ -1,270 +0,0 @@
-import { join } from 'node:path';
-import { app } from 'electron';
-import {
- BluetoothFrameReassembler,
- createBluetoothFrames,
- type BluetoothFrame
-} from '../../shared/bluetooth-frame';
-import {
- DEFAULT_BLUETOOTH_SYSTEM_STATUS,
- type BluetoothDiagnosticEvent,
- type BluetoothStatus,
- type BluetoothUnavailableReason
-} from '../../shared/bluetooth-status';
-import type { ControlService } from '../control/control-service';
-import type { RemoteConnection } from '../transport/remote-connection';
-import {
- SWITCHIFY_BLE_RX_CHARACTERISTIC_UUID,
- SWITCHIFY_BLE_SERVICE_UUID,
- SWITCHIFY_BLE_STATUS_CHARACTERISTIC_UUID,
- SWITCHIFY_BLE_TX_CHARACTERISTIC_UUID
-} from './constants';
-import { BluetoothHelperClient } from './bluetooth-helper-client';
-import type { BluetoothHelperEvent } from './helper-protocol';
-
-export type WindowsBluetoothTransportOptions = {
- controlService: ControlService;
- getDesktopId: () => Promise;
- displayName: string;
- helperPath?: string;
- createHelper?: (options: ConstructorParameters[0]) => BluetoothHelperClient;
- onStatusChange?: (status: BluetoothStatus) => void;
-};
-
-type BluetoothConnectionState = {
- connection: RemoteConnection;
- reassembler: BluetoothFrameReassembler;
-};
-
-export class WindowsBluetoothTransport {
- private readonly connections = new Map();
- private readonly pendingDisconnectReasons = new Map>();
- private helper: BluetoothHelperClient | null = null;
- private status: BluetoothStatus = {
- status: process.platform === 'win32' ? 'stopped' : 'disabled',
- reason: null,
- connectedClientCount: 0,
- lastError: null,
- lastEvent: null,
- lastEventAt: null,
- recentEvents: [],
- lastDisconnectReason: null,
- lastDisconnectAt: null,
- system: DEFAULT_BLUETOOTH_SYSTEM_STATUS
- };
-
- constructor(private readonly options: WindowsBluetoothTransportOptions) {}
-
- getStatus(): BluetoothStatus {
- return { ...this.status };
- }
-
- markClientRequestedDisconnect(connectionId: string): void {
- if (this.connections.has(connectionId)) {
- this.pendingDisconnectReasons.set(connectionId, 'client_requested');
- }
- }
-
- async start(): Promise {
- if (process.platform !== 'win32') {
- return this.setUnavailable('unsupported');
- }
- if (this.helper) return this.getStatus();
-
- this.setStatus({ status: 'starting', reason: null, lastError: null });
- this.helper = (this.options.createHelper ?? ((options) => new BluetoothHelperClient(options)))({
- helperPath: this.options.helperPath ?? defaultBluetoothHelperPath(),
- onEvent: (event) => {
- void this.handleEvent(event);
- },
- onFailure: (message) => {
- this.removeAllConnections('helper_error');
- this.setStatus({ status: 'error', lastError: safeBluetoothError(message) });
- }
- });
-
- const started = this.helper.start({
- type: 'start',
- serviceUuid: SWITCHIFY_BLE_SERVICE_UUID,
- rxCharacteristicUuid: SWITCHIFY_BLE_RX_CHARACTERISTIC_UUID,
- txCharacteristicUuid: SWITCHIFY_BLE_TX_CHARACTERISTIC_UUID,
- statusCharacteristicUuid: SWITCHIFY_BLE_STATUS_CHARACTERISTIC_UUID,
- displayName: this.options.displayName,
- desktopId: await this.options.getDesktopId()
- });
-
- if (!started) {
- return this.setUnavailable('startup_failed');
- }
- return this.getStatus();
- }
-
- stop(): void {
- this.removeAllConnections('helper_stopped');
- this.helper?.stop();
- this.helper?.destroy();
- this.helper = null;
- this.setStatus({ status: 'stopped', connectedClientCount: 0, reason: null, lastError: null });
- }
-
- private async handleEvent(event: BluetoothHelperEvent): Promise {
- switch (event.type) {
- case 'ready':
- this.setStatus({ status: 'ready', reason: null, lastError: null });
- return;
- case 'unavailable':
- this.setUnavailable(event.reason);
- return;
- case 'connected':
- this.addConnection(event.connectionId, event.label);
- return;
- case 'message':
- await this.handleFrame(event.connectionId, event.frame);
- return;
- case 'disconnected':
- this.removeConnection(event.connectionId, event.reason);
- return;
- case 'diagnostic':
- this.recordDiagnostic(event.event);
- return;
- case 'systemStatus':
- this.setSystemStatus(event);
- return;
- case 'error':
- this.setStatus({ status: 'error', lastError: safeBluetoothError(event.reason) });
- return;
- }
- }
-
- private addConnection(connectionId: string, label: string): void {
- if (this.connections.has(connectionId)) return;
- this.pendingDisconnectReasons.delete(connectionId);
-
- const connection: RemoteConnection = {
- id: connectionId,
- kind: 'bluetooth',
- label,
- remoteAddress: null,
- send: (message) => {
- for (const frame of createBluetoothFrames(message)) {
- this.helper?.send(connectionId, frame);
- }
- },
- close: () => {
- this.helper?.disconnect(connectionId);
- }
- };
-
- this.connections.set(connectionId, {
- connection,
- reassembler: new BluetoothFrameReassembler()
- });
- this.options.controlService.addRemoteConnection(connection);
- this.setStatus({ status: 'connected', connectedClientCount: this.connections.size, reason: null });
- }
-
- private removeConnection(connectionId: string, reason: BluetoothStatus['lastDisconnectReason'] = null): void {
- if (!this.connections.delete(connectionId)) return;
- const pendingReason = this.pendingDisconnectReasons.get(connectionId) ?? null;
- this.pendingDisconnectReasons.delete(connectionId);
- const disconnectReason = reason === 'notification_unsubscribed' ? pendingReason ?? reason : reason;
- this.options.controlService.removeRemoteConnection(connectionId);
- this.setStatus({
- status: this.connections.size > 0 ? 'connected' : 'ready',
- connectedClientCount: this.connections.size,
- lastDisconnectReason: disconnectReason,
- lastDisconnectAt: disconnectReason ? Date.now() : this.status.lastDisconnectAt
- });
- }
-
- private removeAllConnections(reason: Exclude): void {
- for (const connectionId of [...this.connections.keys()]) {
- this.removeConnection(connectionId, reason);
- }
- }
-
- private setSystemStatus(event: Extract): void {
- const at = Date.now();
- const previous = this.status.system;
- const changed =
- previous.lastCheckedAt === null ||
- previous.adapterPresent !== event.adapterPresent ||
- previous.radioState !== event.radioState ||
- previous.isLowEnergySupported !== event.isLowEnergySupported ||
- previous.isPeripheralRoleSupported !== event.isPeripheralRoleSupported;
-
- const system = {
- adapterPresent: event.adapterPresent,
- radioState: event.radioState,
- isLowEnergySupported: event.isLowEnergySupported,
- isPeripheralRoleSupported: event.isPeripheralRoleSupported,
- lastCheckedAt: at,
- lastChangedAt: changed ? at : previous.lastChangedAt
- };
-
- if (!event.adapterPresent) {
- this.removeAllConnections('adapter_off');
- this.setStatus({ system, status: 'unavailable', reason: 'unsupported', connectedClientCount: 0 });
- return;
- }
-
- if (event.radioState === 'off' || event.radioState === 'disabled') {
- this.removeAllConnections('adapter_off');
- this.setStatus({ system, status: 'unavailable', reason: 'adapter_off', connectedClientCount: 0 });
- return;
- }
-
- if (event.radioState === 'on' && this.status.status === 'unavailable' && this.status.reason === 'adapter_off') {
- this.setStatus({ system, status: 'starting', reason: null, lastError: null });
- return;
- }
-
- this.setStatus({ system });
- }
-
- private async handleFrame(connectionId: string, frame: BluetoothFrame): Promise {
- const state = this.connections.get(connectionId);
- if (!state) return;
- this.pendingDisconnectReasons.delete(connectionId);
-
- const result = state.reassembler.accept(frame);
- if (!result.ok) {
- if (result.reason !== 'incomplete') {
- this.setStatus({ status: 'error', lastError: `Bluetooth frame rejected: ${result.reason}.` });
- }
- return;
- }
-
- await this.options.controlService.handleRemoteMessage(connectionId, result.message);
- }
-
- private setUnavailable(reason: BluetoothUnavailableReason): BluetoothStatus {
- return this.setStatus({ status: 'unavailable', reason, connectedClientCount: 0 });
- }
-
- private setStatus(update: Partial): BluetoothStatus {
- this.status = { ...this.status, ...update };
- this.options.controlService.setBluetoothStatus(this.status);
- this.options.onStatusChange?.(this.getStatus());
- return this.getStatus();
- }
-
- private recordDiagnostic(event: Exclude): void {
- const at = Date.now();
- this.setStatus({
- lastEvent: event,
- lastEventAt: at,
- recentEvents: [...this.status.recentEvents, { event, at }].slice(-5)
- });
- }
-}
-
-function defaultBluetoothHelperPath(): string {
- return app.isPackaged
- ? join(process.resourcesPath, 'native', 'SwitchifyBluetoothTransport.exe')
- : join(process.cwd(), 'build', 'native', 'bluetooth-transport-helper', 'win-x64', 'SwitchifyBluetoothTransport.exe');
-}
-
-function safeBluetoothError(message: string): string {
- return message.length > 300 ? `${message.slice(0, 297)}...` : message;
-}
diff --git a/src/main/bluetooth/constants.ts b/src/main/bluetooth/constants.ts
deleted file mode 100644
index 9eacdc0..0000000
--- a/src/main/bluetooth/constants.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export const SWITCHIFY_BLE_SERVICE_UUID = '7a78f7e8-1d6d-4d92-9ef0-1f89d3db21f4';
-export const SWITCHIFY_BLE_RX_CHARACTERISTIC_UUID = '7a78f7e9-1d6d-4d92-9ef0-1f89d3db21f4';
-export const SWITCHIFY_BLE_TX_CHARACTERISTIC_UUID = '7a78f7ea-1d6d-4d92-9ef0-1f89d3db21f4';
-export const SWITCHIFY_BLE_STATUS_CHARACTERISTIC_UUID = '7a78f7eb-1d6d-4d92-9ef0-1f89d3db21f4';
-
diff --git a/src/main/bluetooth/helper-protocol.ts b/src/main/bluetooth/helper-protocol.ts
deleted file mode 100644
index c7ca39f..0000000
--- a/src/main/bluetooth/helper-protocol.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { BluetoothFrame } from '../../shared/bluetooth-frame';
-import type {
- BluetoothDiagnosticEvent,
- BluetoothDisconnectReason,
- BluetoothSystemRadioState,
- BluetoothUnavailableReason
-} from '../../shared/bluetooth-status';
-
-export type BluetoothHelperEvent =
- | { type: 'ready' }
- | { type: 'unavailable'; reason: BluetoothUnavailableReason }
- | { type: 'connected'; connectionId: string; label: string }
- | { type: 'message'; connectionId: string; frame: BluetoothFrame }
- | { type: 'disconnected'; connectionId: string; reason: Exclude }
- | { type: 'diagnostic'; event: Exclude }
- | {
- type: 'systemStatus';
- adapterPresent: boolean;
- radioState: BluetoothSystemRadioState;
- isLowEnergySupported: boolean | null;
- isPeripheralRoleSupported: boolean | null;
- }
- | { type: 'error'; reason: string };
-
-export type BluetoothHelperCommand =
- | {
- type: 'start';
- serviceUuid: string;
- rxCharacteristicUuid: string;
- txCharacteristicUuid: string;
- statusCharacteristicUuid: string;
- displayName: string;
- desktopId: string;
- }
- | { type: 'stop' }
- | { type: 'send'; connectionId: string; frame: BluetoothFrame }
- | { type: 'disconnect'; connectionId: string }
- | { type: 'shutdown' };
-
diff --git a/src/main/control/control-service.test.ts b/src/main/control/control-service.test.ts
deleted file mode 100644
index 6fd7223..0000000
--- a/src/main/control/control-service.test.ts
+++ /dev/null
@@ -1,253 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import { DEFAULT_BLUETOOTH_STATUS } from '../../shared/bluetooth-status';
-import {
- PROTOCOL_VERSION,
- validateProtocolResponse,
- type PairingCompleteResponse,
- type PingCommand
-} from '../../shared/protocol';
-import { createCommandAuthProof, CommandAuthValidator } from '../pairing/auth';
-import { PairingApprovalManager } from '../pairing/pairing-approval-manager';
-import { PairingManager } from '../pairing/pairing-manager';
-import { MemoryPairingStore } from '../pairing/pairing-store';
-import type { RemoteConnection } from '../transport/remote-connection';
-import { ControlService } from './control-service';
-
-const now = 1_724_000_000_000;
-const token = 'shared-token';
-
-describe('ControlService', () => {
- it('tracks authenticated Bluetooth connections in control status', async () => {
- const service = createControlService();
- const connection = createConnection();
- service.addRemoteConnection(connection);
-
- await service.handleRemoteMessage(connection.id, JSON.stringify(createPingCommand()));
-
- expect(sentResponses(connection)).toEqual([expect.objectContaining({ type: 'ack', id: 'request-1', ok: true })]);
- expect(service.getStatus()).toMatchObject({
- connectedClientCount: 1,
- connectedClients: [
- {
- id: connection.id,
- deviceId: 'android-1',
- transport: 'bluetooth'
- }
- ]
- });
- });
-
- it('disconnects all active remote connections', async () => {
- const service = createControlService();
- const connection = createConnection();
- service.addRemoteConnection(connection);
- await service.handleRemoteMessage(connection.id, JSON.stringify(createPingCommand()));
-
- const status = service.disconnectClients();
-
- expect(status.connectedClientCount).toBe(0);
- expect(status.connectedClients).toEqual([]);
- expect(connection.close).toHaveBeenCalledTimes(1);
- });
-
- it('disconnects only the requested device', async () => {
- const service = createControlService();
- const matching = createConnection({ id: 'matching' });
- const other = createConnection({ id: 'other' });
- service.addRemoteConnection(matching);
- service.addRemoteConnection(other);
- await service.handleRemoteMessage(matching.id, JSON.stringify(createPingCommand()));
- await service.handleRemoteMessage(other.id, JSON.stringify(createPingCommand({ id: 'request-2', deviceId: 'android-2' })));
-
- service.disconnectDevice('android-1');
-
- expect(matching.close).toHaveBeenCalledTimes(1);
- expect(other.close).not.toHaveBeenCalled();
- });
-
- it('keeps pairing requests pending until accepted', async () => {
- const store = new MemoryPairingStore({ desktopId: 'desktop-1', pairedDevices: [] });
- const approvalManager = new PairingApprovalManager(store, () => now);
- const service = createControlService({
- store,
- pairingApprovalManager: approvalManager,
- authValidator: new CommandAuthValidator(store, () => now)
- });
- const connection = createConnection();
- service.addRemoteConnection(connection);
-
- await service.handleRemoteMessage(connection.id, JSON.stringify(createPairingRequest()));
-
- expect(service.getPendingPairingRequests()).toEqual([
- expect.objectContaining({
- requestId: 'approval-1',
- deviceName: 'Android smoke'
- })
- ]);
-
- await expect(service.respondToPairingRequest('approval-1', 'accept')).resolves.toEqual({ ok: true });
- const [response] = sentResponses(connection) as PairingCompleteResponse[];
- expect(response).toMatchObject({
- type: 'pairing.complete',
- payload: {
- desktopId: 'desktop-1',
- deviceId: 'android-smoke-1'
- }
- });
- });
-
- it('rejects pairing requests with a structured error', async () => {
- const store = new MemoryPairingStore({ desktopId: 'desktop-1', pairedDevices: [] });
- const approvalManager = new PairingApprovalManager(store, () => now);
- const service = createControlService({
- store,
- pairingApprovalManager: approvalManager,
- authValidator: new CommandAuthValidator(store, () => now)
- });
- const connection = createConnection();
- service.addRemoteConnection(connection);
- await service.handleRemoteMessage(connection.id, JSON.stringify(createPairingRequest()));
-
- await expect(service.respondToPairingRequest('approval-1', 'reject')).resolves.toEqual({ ok: true });
-
- expect(sentResponses(connection)).toEqual([
- expect.objectContaining({
- type: 'error',
- error: expect.objectContaining({ code: 'invalid_auth', message: 'pairing_rejected' })
- })
- ]);
- expect(connection.close).toHaveBeenCalledTimes(1);
- });
-
- it('includes Bluetooth status updates in control status', () => {
- const service = createControlService();
-
- service.setBluetoothStatus({
- ...DEFAULT_BLUETOOTH_STATUS,
- status: 'ready',
- reason: null,
- connectedClientCount: 0,
- lastError: null
- });
-
- expect(service.getStatus().bluetooth).toEqual({
- ...DEFAULT_BLUETOOTH_STATUS,
- status: 'ready',
- reason: null,
- connectedClientCount: 0,
- lastError: null
- });
- expect(service.getStatus().state).toBe('ready');
- });
-
- it('preserves live Bluetooth system status in control status', () => {
- const service = createControlService();
-
- service.setBluetoothStatus({
- ...DEFAULT_BLUETOOTH_STATUS,
- status: 'unavailable',
- reason: 'adapter_off',
- system: {
- adapterPresent: true,
- radioState: 'off',
- isLowEnergySupported: true,
- isPeripheralRoleSupported: true,
- lastCheckedAt: now,
- lastChangedAt: now
- }
- });
-
- expect(service.getStatus().bluetooth.system).toEqual({
- adapterPresent: true,
- radioState: 'off',
- isLowEnergySupported: true,
- isPeripheralRoleSupported: true,
- lastCheckedAt: now,
- lastChangedAt: now
- });
- expect(service.getStatus().state).toBe('error');
- });
-});
-
-function createControlService(
- overrides: Partial[0]> & { store?: MemoryPairingStore } = {}
-): ControlService {
- const store =
- overrides.store ??
- new MemoryPairingStore({
- desktopId: 'desktop-1',
- pairedDevices: [
- {
- deviceId: 'android-1',
- deviceName: 'Android device',
- token,
- pairedAt: now - 1_000,
- lastSeenAt: null
- },
- {
- deviceId: 'android-2',
- deviceName: 'Second Android device',
- token,
- pairedAt: now - 1_000,
- lastSeenAt: null
- }
- ]
- });
-
- return new ControlService({
- pairingManager: new PairingManager(store),
- authValidator: new CommandAuthValidator(store, () => now),
- ...overrides
- });
-}
-
-function createConnection(overrides: Partial = {}): RemoteConnection {
- return {
- id: 'connection-1',
- kind: 'bluetooth',
- label: 'Bluetooth device',
- remoteAddress: null,
- send: vi.fn(),
- close: vi.fn(),
- ...overrides
- };
-}
-
-function sentResponses(connection: RemoteConnection): unknown[] {
- return vi.mocked(connection.send).mock.calls.map(([message]) => {
- const parsed = JSON.parse(message);
- expect(validateProtocolResponse(parsed)).toMatchObject({ ok: true });
- return parsed;
- });
-}
-
-function createPingCommand(overrides: Partial = {}): PingCommand {
- const command = {
- version: PROTOCOL_VERSION,
- id: 'request-1',
- deviceId: 'android-1',
- timestamp: now,
- type: 'connection.ping',
- payload: {},
- auth: ''
- } satisfies PingCommand;
- const merged = { ...command, ...overrides } as PingCommand;
- return {
- ...merged,
- auth: overrides.auth ?? createCommandAuthProof(merged, token)
- };
-}
-
-function createPairingRequest() {
- return {
- version: PROTOCOL_VERSION,
- id: 'approval-1',
- type: 'pairing.request',
- payload: {
- deviceId: 'android-smoke-1',
- deviceName: 'Android smoke',
- desktopId: 'desktop-1',
- requestNonce: 'nonce'
- }
- };
-}
diff --git a/src/main/control/control-service.ts b/src/main/control/control-service.ts
deleted file mode 100644
index 2180bdd..0000000
--- a/src/main/control/control-service.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import type { BluetoothStatus } from '../../shared/bluetooth-status';
-import type { PairingApprovalDecision, PendingPairingApprovalView } from '../../shared/pairing-approval';
-import type { CommandRequest, PointerMovementProfile } from '../../shared/protocol';
-import type { PcConnectedClient, PcControlStatus, PcControlStatusListener } from '../../shared/server-status';
-import type { CommandAuthValidator } from '../pairing/auth';
-import type { PairingApprovalManager } from '../pairing/pairing-approval-manager';
-import type { PairingManager } from '../pairing/pairing-manager';
-import type { RemoteConnection } from '../transport/remote-connection';
-import { RemoteSessionManager, type CommandHandlerResult } from '../transport/remote-session-manager';
-import { cloneControlStatus, createInitialControlStatus } from './control-status';
-
-export type ControlServiceOptions = {
- pairingManager: PairingManager;
- pairingApprovalManager?: PairingApprovalManager;
- authValidator: CommandAuthValidator;
- getPointerProfile?: () => PointerMovementProfile;
- onStatusChange?: PcControlStatusListener;
- onClientDisconnecting?: (connectionId: string, deviceId: string) => void;
- onCommand?: (command: CommandRequest) => Promise | CommandHandlerResult;
-};
-
-export class ControlService {
- private readonly sessions: RemoteSessionManager;
- private status: PcControlStatus;
-
- constructor(private readonly options: ControlServiceOptions) {
- this.status = createInitialControlStatus();
- this.sessions = new RemoteSessionManager({
- pairingManager: options.pairingManager,
- pairingApprovalManager: options.pairingApprovalManager,
- authValidator: options.authValidator,
- getPointerProfile: options.getPointerProfile,
- onCommand: options.onCommand,
- onClientDisconnecting: options.onClientDisconnecting,
- onClientStatusChange: () => this.updateClientStatus(),
- onLastSeen: (seenAt) => this.setStatus({ lastSeenAt: seenAt, lastError: null }),
- onError: (message) => this.setStatus({ lastError: message })
- });
- }
-
- getStatus(): PcControlStatus {
- return cloneControlStatus(this.status);
- }
-
- addRemoteConnection(connection: RemoteConnection): PcConnectedClient {
- return this.sessions.addConnection(connection);
- }
-
- async handleRemoteMessage(connectionId: string, rawMessage: string): Promise {
- await this.sessions.handleMessage(connectionId, rawMessage);
- }
-
- removeRemoteConnection(connectionId: string): void {
- this.sessions.removeConnection(connectionId);
- }
-
- async closeAllConnections(): Promise {
- await this.sessions.closeAllConnections();
- return this.getStatus();
- }
-
- disconnectClients(): PcControlStatus {
- void this.sessions.closeAllConnections();
- return this.getStatus();
- }
-
- disconnectDevice(deviceId: string): PcControlStatus {
- void this.sessions.disconnectDevice(deviceId);
- return this.getStatus();
- }
-
- getPendingPairingRequests(): PendingPairingApprovalView[] {
- return this.sessions.getPendingPairingRequests();
- }
-
- async respondToPairingRequest(
- requestId: string,
- decision: PairingApprovalDecision
- ): Promise<{ ok: boolean; reason?: string }> {
- return this.sessions.respondToPairingRequest(requestId, decision);
- }
-
- setBluetoothStatus(bluetooth: BluetoothStatus): PcControlStatus {
- this.setStatus({ bluetooth, state: controlStateFromBluetooth(bluetooth) });
- return this.getStatus();
- }
-
- setDesktopId(desktopId: string): PcControlStatus {
- this.setStatus({ desktopId });
- return this.getStatus();
- }
-
- private updateClientStatus(): void {
- this.setStatus({
- connectedClientCount: this.sessions.getAuthenticatedClientCount(),
- connectedClients: this.sessions.getAuthenticatedClients()
- });
- }
-
- private setStatus(update: Partial): void {
- this.status = { ...this.status, ...update };
- this.options.onStatusChange?.(this.getStatus());
- }
-}
-
-function controlStateFromBluetooth(bluetooth: BluetoothStatus): PcControlStatus['state'] {
- if (bluetooth.status === 'starting') return 'starting';
- if (bluetooth.status === 'ready' || bluetooth.status === 'connected') return 'ready';
- if (bluetooth.status === 'unavailable' || bluetooth.status === 'error') return 'error';
- return 'stopped';
-}
diff --git a/src/main/control/control-status.ts b/src/main/control/control-status.ts
deleted file mode 100644
index 0a535ad..0000000
--- a/src/main/control/control-status.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { DEFAULT_BLUETOOTH_STATUS } from '../../shared/bluetooth-status';
-import type { PcControlStatus } from '../../shared/server-status';
-
-export function createInitialControlStatus(): PcControlStatus {
- return {
- state: 'stopped',
- desktopId: null,
- connectedClientCount: 0,
- connectedClients: [],
- lastSeenAt: null,
- lastError: null,
- bluetooth: DEFAULT_BLUETOOTH_STATUS
- };
-}
-
-export function cloneControlStatus(status: PcControlStatus): PcControlStatus {
- return {
- ...status,
- connectedClients: status.connectedClients.map((client) => ({ ...client })),
- bluetooth: { ...status.bluetooth, system: { ...status.bluetooth.system } }
- };
-}
-
-
diff --git a/src/main/cursor-overlay-helper-client.test.ts b/src/main/cursor-overlay-helper-client.test.ts
deleted file mode 100644
index 3339057..0000000
--- a/src/main/cursor-overlay-helper-client.test.ts
+++ /dev/null
@@ -1,335 +0,0 @@
-import { EventEmitter } from 'node:events';
-import { describe, expect, it, vi } from 'vitest';
-import {
- NativeWindowsCursorOverlayBackend,
- nativeHelperCursorPosition,
- type CursorOverlayBackend,
- type CursorOverlayRenderOptions
-} from './cursor-overlay-helper-client';
-
-class FakeHelperProcess extends EventEmitter {
- readonly writes: string[] = [];
- killed = false;
- readonly stdin = {
- destroyed: false,
- write: vi.fn((chunk: string) => {
- this.writes.push(chunk);
- return true;
- }),
- end: vi.fn()
- };
- readonly stdout = new EventEmitter();
- readonly stderr = new EventEmitter();
-
- kill(): boolean {
- this.killed = true;
- this.emit('exit', null, 'SIGTERM');
- return true;
- }
-}
-
-class FakeOverlayBackend implements CursorOverlayBackend {
- readonly events: string[] = [];
-
- show(event: 'move' | 'click' | 'drag', _options: CursorOverlayRenderOptions): void {
- this.events.push(`show:${event}`);
- }
-
- hide(): void {
- this.events.push('hide');
- }
-
- destroy(): void {
- this.events.push('destroy');
- }
-}
-
-describe('NativeWindowsCursorOverlayBackend', () => {
- it('converts Electron cursor DIP coordinates to native screen pixels', () => {
- expect(
- nativeHelperCursorPosition({
- getCursorScreenPoint: () => ({ x: 100, y: 200 }),
- dipToScreenPoint: (point) => ({ x: point.x * 2, y: point.y * 2 })
- })
- ).toEqual({ x: 200, y: 400 });
- });
-
- it('writes show commands to the helper process', () => {
- const helper = new FakeHelperProcess();
- const fallback = new FakeOverlayBackend();
- const backend = new NativeWindowsCursorOverlayBackend({
- helperPath: __filename,
- fallback,
- getCursorPosition: () => ({ x: 100, y: 200 }),
- idleTimeoutMs: 900,
- getSettings: () => ({ enabled: true, size: 'medium', visibility: 'onInput', crosshairs: false, color: 'red' }),
- resolveSizePixels: () => 128,
- spawnProcess: () => helper as never
- });
-
- backend.show('move', {
- size: 128,
- idleTimeoutMs: 900,
- crosshairs: true,
- persistent: false,
- colorRgb: [211, 47, 47]
- });
-
- expect(fallback.events).toEqual([]);
- expect(JSON.parse(helper.writes[0])).toEqual({
- type: 'show',
- event: 'move',
- x: 100,
- y: 200,
- size: 128,
- durationMs: 900,
- crosshairs: true,
- persistent: false,
- color: {
- red: 211,
- green: 47,
- blue: 47
- }
- });
- });
-
- it('writes non-default color commands to the helper process', () => {
- const helper = new FakeHelperProcess();
- const fallback = new FakeOverlayBackend();
- const backend = new NativeWindowsCursorOverlayBackend({
- helperPath: __filename,
- fallback,
- getCursorPosition: () => ({ x: 10, y: 20 }),
- idleTimeoutMs: 900,
- getSettings: () => ({ enabled: true, size: 'medium', visibility: 'onInput', crosshairs: false, color: 'green' }),
- resolveSizePixels: () => 128,
- spawnProcess: () => helper as never
- });
-
- backend.show('move', {
- size: 128,
- idleTimeoutMs: 900,
- crosshairs: false,
- persistent: false,
- colorRgb: [132, 255, 145]
- });
-
- expect(JSON.parse(helper.writes[0])).toMatchObject({
- color: {
- red: 132,
- green: 255,
- blue: 145
- }
- });
- });
-
- it('writes drag commands to the helper process', () => {
- const helper = new FakeHelperProcess();
- const fallback = new FakeOverlayBackend();
- const backend = new NativeWindowsCursorOverlayBackend({
- helperPath: __filename,
- fallback,
- getCursorPosition: () => ({ x: 100, y: 200 }),
- idleTimeoutMs: 900,
- getSettings: () => ({ enabled: true, size: 'medium', visibility: 'onInput', crosshairs: false, color: 'red' }),
- resolveSizePixels: () => 128,
- spawnProcess: () => helper as never
- });
-
- backend.show('drag', {
- size: 128,
- idleTimeoutMs: 900,
- crosshairs: false,
- persistent: true,
- colorRgb: [211, 47, 47]
- });
-
- expect(JSON.parse(helper.writes[0])).toMatchObject({
- type: 'show',
- event: 'drag',
- persistent: true,
- durationMs: 0
- });
- });
-
- it('writes persistent show commands without a hide duration', () => {
- const helper = new FakeHelperProcess();
- const fallback = new FakeOverlayBackend();
- const backend = new NativeWindowsCursorOverlayBackend({
- helperPath: __filename,
- fallback,
- getCursorPosition: () => ({ x: 100, y: 200 }),
- idleTimeoutMs: 900,
- getSettings: () => ({ enabled: true, size: 'large', visibility: 'whileControlling', crosshairs: true, color: 'red' }),
- resolveSizePixels: () => 176,
- spawnProcess: () => helper as never
- });
-
- backend.show('move', {
- size: 176,
- idleTimeoutMs: 900,
- crosshairs: true,
- persistent: true,
- colorRgb: [211, 47, 47]
- });
-
- expect(JSON.parse(helper.writes[0])).toEqual({
- type: 'show',
- event: 'move',
- x: 100,
- y: 200,
- size: 176,
- durationMs: 0,
- crosshairs: true,
- persistent: true,
- color: {
- red: 211,
- green: 47,
- blue: 47
- }
- });
- });
-
- it('writes hide and shutdown commands', async () => {
- const helper = new FakeHelperProcess();
- const fallback = new FakeOverlayBackend();
- const backend = new NativeWindowsCursorOverlayBackend({
- helperPath: __filename,
- fallback,
- getCursorPosition: () => ({ x: 0, y: 0 }),
- idleTimeoutMs: 900,
- getSettings: () => ({ enabled: true, size: 'medium', visibility: 'onInput', crosshairs: false, color: 'red' }),
- resolveSizePixels: () => 128,
- shutdownKillDelayMs: 1,
- spawnProcess: () => helper as never
- });
-
- backend.show('click', {
- size: 128,
- idleTimeoutMs: 900,
- crosshairs: false,
- persistent: false,
- colorRgb: [211, 47, 47]
- });
- backend.hide();
- backend.destroy();
- await new Promise((resolve) => setTimeout(resolve, 5));
-
- expect(JSON.parse(helper.writes[1])).toEqual({ type: 'hide' });
- expect(JSON.parse(helper.writes[2])).toEqual({ type: 'shutdown' });
- expect(helper.stdin.end).toHaveBeenCalled();
- expect(fallback.events).toEqual(['destroy']);
- });
-
- it('falls back when the helper is missing', () => {
- const fallback = new FakeOverlayBackend();
- const failures: string[] = [];
- const backend = new NativeWindowsCursorOverlayBackend({
- helperPath: 'C:\\missing\\SwitchifyCursorOverlay.exe',
- fallback,
- getCursorPosition: () => ({ x: 100, y: 200 }),
- idleTimeoutMs: 900,
- getSettings: () => ({ enabled: true, size: 'medium', visibility: 'onInput', crosshairs: false, color: 'red' }),
- resolveSizePixels: () => 128,
- onFailure: (message) => failures.push(message)
- });
-
- backend.show('move', {
- size: 128,
- idleTimeoutMs: 900,
- crosshairs: false,
- persistent: false,
- colorRgb: [211, 47, 47]
- });
-
- expect(fallback.events).toEqual(['show:move']);
- expect(failures[0]).toContain('Cursor overlay helper was not found');
- });
-
- it('falls back with the drag event when the helper is missing during drag', () => {
- const fallback = new FakeOverlayBackend();
- const backend = new NativeWindowsCursorOverlayBackend({
- helperPath: 'C:\\missing\\SwitchifyCursorOverlay.exe',
- fallback,
- getCursorPosition: () => ({ x: 100, y: 200 }),
- idleTimeoutMs: 900,
- getSettings: () => ({ enabled: true, size: 'medium', visibility: 'onInput', crosshairs: false, color: 'red' }),
- resolveSizePixels: () => 128
- });
-
- backend.show('drag', {
- size: 128,
- idleTimeoutMs: 900,
- crosshairs: false,
- persistent: true,
- colorRgb: [211, 47, 47]
- });
-
- expect(fallback.events).toEqual(['show:drag']);
- });
-
- it('falls back after helper errors', () => {
- const helper = new FakeHelperProcess();
- const fallback = new FakeOverlayBackend();
- const backend = new NativeWindowsCursorOverlayBackend({
- helperPath: __filename,
- fallback,
- getCursorPosition: () => ({ x: 0, y: 0 }),
- idleTimeoutMs: 900,
- getSettings: () => ({ enabled: true, size: 'medium', visibility: 'onInput', crosshairs: false, color: 'red' }),
- resolveSizePixels: () => 128,
- spawnProcess: () => helper as never
- });
-
- backend.show('move', {
- size: 128,
- idleTimeoutMs: 900,
- crosshairs: false,
- persistent: false,
- colorRgb: [211, 47, 47]
- });
- helper.emit('error', new Error('boom'));
- backend.show('click', {
- size: 128,
- idleTimeoutMs: 900,
- crosshairs: false,
- persistent: false,
- colorRgb: [211, 47, 47]
- });
-
- expect(helper.killed).toBe(true);
- expect(fallback.events).toEqual(['show:click']);
- });
-
- it('falls back when helper reports an error status', () => {
- const helper = new FakeHelperProcess();
- const fallback = new FakeOverlayBackend();
- const backend = new NativeWindowsCursorOverlayBackend({
- helperPath: __filename,
- fallback,
- getCursorPosition: () => ({ x: 0, y: 0 }),
- idleTimeoutMs: 900,
- getSettings: () => ({ enabled: true, size: 'medium', visibility: 'onInput', crosshairs: false, color: 'red' }),
- resolveSizePixels: () => 128,
- spawnProcess: () => helper as never
- });
-
- backend.show('move', {
- size: 128,
- idleTimeoutMs: 900,
- crosshairs: false,
- persistent: false,
- colorRgb: [211, 47, 47]
- });
- helper.stdout.emit('data', '{"type":"error","message":"bad command"}\n');
- backend.show('click', {
- size: 128,
- idleTimeoutMs: 900,
- crosshairs: false,
- persistent: false,
- colorRgb: [211, 47, 47]
- });
-
- expect(fallback.events).toEqual(['show:click']);
- });
-});
diff --git a/src/main/cursor-overlay-helper-client.ts b/src/main/cursor-overlay-helper-client.ts
deleted file mode 100644
index ebe4974..0000000
--- a/src/main/cursor-overlay-helper-client.ts
+++ /dev/null
@@ -1,252 +0,0 @@
-import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
-import { existsSync } from 'node:fs';
-import { resolveCursorOverlayColorRgb, type CursorOverlaySettings } from '../shared/cursor-overlay-settings';
-
-export type CursorOverlayEvent = 'move' | 'click' | 'drag';
-
-export type CursorOverlayPoint = {
- x: number;
- y: number;
-};
-
-export type CursorOverlayBackend = {
- show(event: CursorOverlayEvent, options: CursorOverlayRenderOptions): void;
- hide(): void;
- destroy(): void;
-};
-
-export type CursorOverlayRenderOptions = {
- size: number;
- idleTimeoutMs: number;
- crosshairs: boolean;
- persistent: boolean;
- colorRgb: [number, number, number];
-};
-
-export type NativeWindowsCursorOverlayBackendOptions = {
- helperPath: string;
- fallback: CursorOverlayBackend;
- getCursorPosition: () => CursorOverlayPoint;
- getSettings: () => CursorOverlaySettings;
- resolveSizePixels: () => number;
- idleTimeoutMs: number;
- spawnProcess?: (helperPath: string) => ChildProcessWithoutNullStreams;
- onFailure?: (message: string) => void;
- shutdownKillDelayMs?: number;
-};
-
-export type CursorPositionProvider = {
- getCursorScreenPoint: () => CursorOverlayPoint;
- dipToScreenPoint: (point: CursorOverlayPoint) => CursorOverlayPoint;
-};
-
-type OverlayHelperCommand =
- | {
- type: 'show';
- event: CursorOverlayEvent;
- x: number;
- y: number;
- size: number;
- durationMs: number;
- crosshairs: boolean;
- persistent: boolean;
- color: {
- red: number;
- green: number;
- blue: number;
- };
- }
- | { type: 'hide' }
- | { type: 'shutdown' };
-
-export class NativeWindowsCursorOverlayBackend implements CursorOverlayBackend {
- private process: ChildProcessWithoutNullStreams | null = null;
- private unavailable = false;
- private destroyed = false;
-
- constructor(private readonly options: NativeWindowsCursorOverlayBackendOptions) {}
-
- show(event: CursorOverlayEvent, options: CursorOverlayRenderOptions): void {
- if (this.destroyed || this.unavailable) {
- this.options.fallback.show(event, options);
- return;
- }
-
- const helper = this.ensureProcess();
- if (!helper) {
- this.options.fallback.show(event, options);
- return;
- }
-
- const cursor = this.options.getCursorPosition();
- const size = options.size || this.options.resolveSizePixels();
- this.writeCommand(
- {
- type: 'show',
- event,
- x: cursor.x,
- y: cursor.y,
- size,
- durationMs: options.persistent ? 0 : options.idleTimeoutMs,
- crosshairs: options.crosshairs,
- persistent: options.persistent,
- color: {
- red: options.colorRgb[0],
- green: options.colorRgb[1],
- blue: options.colorRgb[2]
- }
- },
- { fallbackEvent: event, fallbackOptions: options, fallbackOnBackpressure: false }
- );
- }
-
- hide(): void {
- if (this.unavailable || this.destroyed) {
- this.options.fallback.hide();
- return;
- }
-
- if (!this.process) {
- this.options.fallback.hide();
- return;
- }
-
- this.writeCommand({ type: 'hide' });
- }
-
- destroy(): void {
- this.destroyed = true;
- this.options.fallback.destroy();
-
- const helper = this.process;
- this.process = null;
- if (!helper) return;
-
- try {
- helper.stdin.write(`${JSON.stringify({ type: 'shutdown' } satisfies OverlayHelperCommand)}\n`);
- helper.stdin.end();
- } catch {
- // Ignore shutdown failures; the process is about to be killed if it remains alive.
- }
-
- const killDelayMs = this.options.shutdownKillDelayMs ?? 500;
- setTimeout(() => {
- if (!helper.killed) {
- helper.kill();
- }
- }, killDelayMs).unref();
- }
-
- private ensureProcess(): ChildProcessWithoutNullStreams | null {
- if (this.process) return this.process;
-
- if (!existsSync(this.options.helperPath)) {
- this.fail(`Cursor overlay helper was not found: ${this.options.helperPath}`);
- return null;
- }
-
- try {
- const helper = (this.options.spawnProcess ?? spawnOverlayHelper)(this.options.helperPath);
- this.process = helper;
-
- helper.once('error', (error) => {
- this.fail(`Cursor overlay helper failed: ${error.message}`);
- });
- helper.once('exit', (code, signal) => {
- if (!this.destroyed) {
- this.fail(`Cursor overlay helper exited unexpectedly: ${signal ?? code ?? 'unknown'}`);
- }
- });
- helper.stdout.on('data', (chunk) => this.handleStdout(String(chunk)));
- helper.stderr.on('data', (chunk) => {
- this.options.onFailure?.(`Cursor overlay helper stderr: ${String(chunk).trim()}`);
- });
-
- return helper;
- } catch (error) {
- this.fail(error instanceof Error ? error.message : 'Cursor overlay helper could not start.');
- return null;
- }
- }
-
- private writeCommand(
- command: OverlayHelperCommand,
- options: {
- fallbackEvent?: CursorOverlayEvent;
- fallbackOptions?: CursorOverlayRenderOptions;
- fallbackOnBackpressure?: boolean;
- } = {}
- ): void {
- const helper = this.process;
- if (!helper || helper.stdin.destroyed) {
- this.fail('Cursor overlay helper stdin is unavailable.');
- if (options.fallbackEvent) {
- this.options.fallback.show(options.fallbackEvent, options.fallbackOptions ?? fallbackRenderOptions(this.options));
- }
- return;
- }
-
- try {
- const accepted = helper.stdin.write(`${JSON.stringify(command)}\n`);
- if (!accepted && options.fallbackOnBackpressure) {
- this.options.fallback.show(
- options.fallbackEvent ?? 'move',
- options.fallbackOptions ?? fallbackRenderOptions(this.options)
- );
- }
- } catch (error) {
- this.fail(error instanceof Error ? error.message : 'Cursor overlay helper write failed.');
- if (options.fallbackEvent) {
- this.options.fallback.show(options.fallbackEvent, options.fallbackOptions ?? fallbackRenderOptions(this.options));
- }
- }
- }
-
- private handleStdout(output: string): void {
- for (const line of output.split(/\r?\n/).map((item) => item.trim()).filter(Boolean)) {
- try {
- const message = JSON.parse(line) as { type?: string; message?: string };
- if (message.type === 'error') {
- this.fail(message.message ?? 'Cursor overlay helper reported an error.');
- }
- } catch {
- this.fail('Cursor overlay helper returned malformed status output.');
- }
- }
- }
-
- private fail(message: string): void {
- if (this.unavailable) return;
-
- this.unavailable = true;
- this.options.onFailure?.(message);
-
- const helper = this.process;
- this.process = null;
- if (helper && !helper.killed) {
- helper.kill();
- }
- }
-}
-
-function fallbackRenderOptions(options: NativeWindowsCursorOverlayBackendOptions): CursorOverlayRenderOptions {
- const settings = options.getSettings();
- return {
- size: options.resolveSizePixels(),
- idleTimeoutMs: options.idleTimeoutMs,
- crosshairs: settings.crosshairs,
- persistent: settings.visibility === 'whileControlling',
- colorRgb: resolveCursorOverlayColorRgb(settings.color)
- };
-}
-
-function spawnOverlayHelper(helperPath: string): ChildProcessWithoutNullStreams {
- return spawn(helperPath, [], {
- stdio: ['pipe', 'pipe', 'pipe'],
- windowsHide: true
- });
-}
-
-export function nativeHelperCursorPosition(provider: CursorPositionProvider): CursorOverlayPoint {
- return provider.dipToScreenPoint(provider.getCursorScreenPoint());
-}
diff --git a/src/main/cursor-overlay-ipc.ts b/src/main/cursor-overlay-ipc.ts
deleted file mode 100644
index 0314218..0000000
--- a/src/main/cursor-overlay-ipc.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { ipcMain } from 'electron';
-import { normalizeCursorOverlaySettings, type CursorOverlaySettings } from '../shared/cursor-overlay-settings';
-import {
- GET_CURSOR_OVERLAY_ENABLED_CHANNEL,
- GET_CURSOR_OVERLAY_SETTINGS_CHANNEL,
- SET_CURSOR_OVERLAY_SETTINGS_CHANNEL,
- SET_CURSOR_OVERLAY_ENABLED_CHANNEL
-} from '../shared/ipc-channels';
-import type { CursorOverlay } from './cursor-overlay';
-import type { JsonCursorOverlaySettingsStore } from './cursor-overlay-settings-store';
-
-export function registerCursorOverlayIpc(
- cursorOverlay: CursorOverlay,
- settingsStore: JsonCursorOverlaySettingsStore
-): void {
- const saveSettings = (settings: CursorOverlaySettings): CursorOverlaySettings => {
- const normalized = settingsStore.save(settings);
- cursorOverlay.setSettings(normalized);
- return cursorOverlay.getSettings();
- };
-
- ipcMain.handle(GET_CURSOR_OVERLAY_SETTINGS_CHANNEL, () => cursorOverlay.getSettings());
- ipcMain.handle(SET_CURSOR_OVERLAY_SETTINGS_CHANNEL, (_event, settings: CursorOverlaySettings) =>
- saveSettings(normalizeCursorOverlaySettings(settings))
- );
- ipcMain.handle(GET_CURSOR_OVERLAY_ENABLED_CHANNEL, () => cursorOverlay.isEnabled());
- ipcMain.handle(SET_CURSOR_OVERLAY_ENABLED_CHANNEL, (_event, enabled: boolean) => {
- saveSettings({ ...cursorOverlay.getSettings(), enabled: Boolean(enabled) });
- return cursorOverlay.isEnabled();
- });
-}
diff --git a/src/main/cursor-overlay-settings-store.test.ts b/src/main/cursor-overlay-settings-store.test.ts
deleted file mode 100644
index 17e5347..0000000
--- a/src/main/cursor-overlay-settings-store.test.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import { DEFAULT_CURSOR_OVERLAY_SETTINGS } from '../shared/cursor-overlay-settings';
-import { JsonCursorOverlaySettingsStore } from './cursor-overlay-settings-store';
-
-describe('JsonCursorOverlaySettingsStore', () => {
- let tempDir: string | null = null;
-
- afterEach(() => {
- vi.restoreAllMocks();
- if (tempDir) {
- rmSync(tempDir, { recursive: true, force: true });
- tempDir = null;
- }
- });
-
- function store(): JsonCursorOverlaySettingsStore {
- tempDir = mkdtempSync(join(tmpdir(), 'switchify-cursor-overlay-'));
- return new JsonCursorOverlaySettingsStore(join(tempDir, 'cursor-overlay-settings.json'));
- }
-
- it('returns defaults when the file is missing', () => {
- expect(store().load()).toEqual(DEFAULT_CURSOR_OVERLAY_SETTINGS);
- });
-
- it('loads partial files with defaults', () => {
- const settingsStore = store();
- writeFileSync(join(tempDir!, 'cursor-overlay-settings.json'), JSON.stringify({ size: 'large' }), 'utf8');
-
- expect(settingsStore.load()).toEqual({
- ...DEFAULT_CURSOR_OVERLAY_SETTINGS,
- size: 'large'
- });
- });
-
- it('uses defaults for corrupt JSON without throwing', () => {
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
- const settingsStore = store();
- writeFileSync(join(tempDir!, 'cursor-overlay-settings.json'), '{', 'utf8');
-
- expect(settingsStore.load()).toEqual(DEFAULT_CURSOR_OVERLAY_SETTINGS);
- expect(warn).toHaveBeenCalledWith('Switchify cursor overlay settings could not be loaded. Defaults will be used.');
- });
-
- it('saves normalized settings', () => {
- const settingsStore = store();
- expect(
- settingsStore.save({
- enabled: false,
- size: 'large',
- visibility: 'whileControlling',
- crosshairs: true,
- color: 'blue'
- })
- ).toEqual({
- enabled: false,
- size: 'large',
- visibility: 'whileControlling',
- crosshairs: true,
- color: 'blue'
- });
-
- expect(settingsStore.load()).toEqual({
- enabled: false,
- size: 'large',
- visibility: 'whileControlling',
- crosshairs: true,
- color: 'blue'
- });
- });
-
- it('creates parent directories when saving', () => {
- tempDir = mkdtempSync(join(tmpdir(), 'switchify-cursor-overlay-'));
- const settingsFile = join(tempDir, 'nested', 'cursor-overlay-settings.json');
-
- new JsonCursorOverlaySettingsStore(settingsFile).save(DEFAULT_CURSOR_OVERLAY_SETTINGS);
-
- expect(JSON.parse(readFileSync(settingsFile, 'utf8'))).toEqual(DEFAULT_CURSOR_OVERLAY_SETTINGS);
- });
-
- it('leaves no temp files after saving', () => {
- const settingsStore = store();
-
- settingsStore.save(DEFAULT_CURSOR_OVERLAY_SETTINGS);
-
- expect(readdirSync(tempDir!).filter((name) => name.endsWith('.tmp'))).toEqual([]);
- });
-});
diff --git a/src/main/cursor-overlay-settings-store.ts b/src/main/cursor-overlay-settings-store.ts
deleted file mode 100644
index 39a780d..0000000
--- a/src/main/cursor-overlay-settings-store.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { readFileSync } from 'node:fs';
-import {
- DEFAULT_CURSOR_OVERLAY_SETTINGS,
- normalizeCursorOverlaySettings,
- type CursorOverlaySettings
-} from '../shared/cursor-overlay-settings';
-import { writeJsonFileAtomicSync } from './json-file-store';
-
-export class JsonCursorOverlaySettingsStore {
- constructor(private readonly filePath: string) {}
-
- load(): CursorOverlaySettings {
- try {
- const raw = readFileSync(this.filePath, 'utf8');
- return normalizeCursorOverlaySettings(JSON.parse(raw));
- } catch (error) {
- if (isMissingFileError(error)) {
- return { ...DEFAULT_CURSOR_OVERLAY_SETTINGS };
- }
-
- console.warn('Switchify cursor overlay settings could not be loaded. Defaults will be used.');
- return { ...DEFAULT_CURSOR_OVERLAY_SETTINGS };
- }
- }
-
- save(settings: CursorOverlaySettings): CursorOverlaySettings {
- const normalized = normalizeCursorOverlaySettings(settings);
- writeJsonFileAtomicSync(this.filePath, `${JSON.stringify(normalized, null, 2)}\n`);
- return normalized;
- }
-}
-
-function isMissingFileError(error: unknown): boolean {
- return (
- error !== null &&
- typeof error === 'object' &&
- 'code' in error &&
- (error as { code?: unknown }).code === 'ENOENT'
- );
-}
diff --git a/src/main/cursor-overlay-state.test.ts b/src/main/cursor-overlay-state.test.ts
deleted file mode 100644
index 02baf88..0000000
--- a/src/main/cursor-overlay-state.test.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { cursorOverlayBounds, nativeCursorToElectronPoint } from './cursor-overlay-state';
-
-describe('cursorOverlayBounds', () => {
- it('centers the overlay window on the cursor', () => {
- expect(cursorOverlayBounds({ x: 100, y: 200 }, 96)).toEqual({
- x: 52,
- y: 152,
- width: 96,
- height: 96
- });
- });
-
- it('supports negative monitor coordinates', () => {
- expect(cursorOverlayBounds({ x: -300, y: 100 }, 96)).toEqual({
- x: -348,
- y: 52,
- width: 96,
- height: 96
- });
- });
-});
-
-describe('nativeCursorToElectronPoint', () => {
- it('converts native cursor coordinates on a scaled display', () => {
- expect(
- nativeCursorToElectronPoint(
- { x: 900, y: 600 },
- [{ bounds: { x: 0, y: 0, width: 640, height: 360 }, scaleFactor: 3 }]
- )
- ).toEqual({ x: 300, y: 200 });
- });
-
- it('converts native cursor coordinates on a negative scaled display', () => {
- expect(
- nativeCursorToElectronPoint(
- { x: -450, y: 300 },
- [{ bounds: { x: -640, y: 0, width: 640, height: 360 }, scaleFactor: 1.5 }]
- )
- ).toEqual({ x: -300, y: 200 });
- });
-
- it('returns the original cursor point when no display contains it', () => {
- expect(
- nativeCursorToElectronPoint(
- { x: 2500, y: 1200 },
- [{ bounds: { x: 0, y: 0, width: 640, height: 360 }, scaleFactor: 3 }]
- )
- ).toEqual({ x: 2500, y: 1200 });
- });
-});
diff --git a/src/main/cursor-overlay-state.ts b/src/main/cursor-overlay-state.ts
deleted file mode 100644
index ddf2a06..0000000
--- a/src/main/cursor-overlay-state.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-export type CursorPoint = {
- x: number;
- y: number;
-};
-
-export type CursorOverlayBounds = {
- x: number;
- y: number;
- width: number;
- height: number;
-};
-
-export type CursorDisplay = {
- bounds: CursorOverlayBounds;
- scaleFactor: number;
-};
-
-export function cursorOverlayBounds(cursor: CursorPoint, windowSize: number): CursorOverlayBounds {
- const offset = Math.round(windowSize / 2);
- return {
- x: Math.round(cursor.x) - offset,
- y: Math.round(cursor.y) - offset,
- width: windowSize,
- height: windowSize
- };
-}
-
-export function nativeCursorToElectronPoint(cursor: CursorPoint, displays: CursorDisplay[]): CursorPoint {
- const display = findDisplayForNativeCursor(cursor, displays);
- if (!display) return cursor;
-
- const scale = display.scaleFactor > 0 && Number.isFinite(display.scaleFactor) ? display.scaleFactor : 1;
- const nativeBounds = toNativeBounds(display);
- return {
- x: display.bounds.x + (cursor.x - nativeBounds.x) / scale,
- y: display.bounds.y + (cursor.y - nativeBounds.y) / scale
- };
-}
-
-function findDisplayForNativeCursor(cursor: CursorPoint, displays: CursorDisplay[]): CursorDisplay | null {
- return displays.find((display) => pointInBounds(cursor, toNativeBounds(display))) ?? null;
-}
-
-function toNativeBounds(display: CursorDisplay): CursorOverlayBounds {
- const scale = display.scaleFactor > 0 && Number.isFinite(display.scaleFactor) ? display.scaleFactor : 1;
- return {
- x: display.bounds.x * scale,
- y: display.bounds.y * scale,
- width: display.bounds.width * scale,
- height: display.bounds.height * scale
- };
-}
-
-function pointInBounds(point: CursorPoint, bounds: CursorOverlayBounds): boolean {
- return (
- point.x >= bounds.x &&
- point.x < bounds.x + bounds.width &&
- point.y >= bounds.y &&
- point.y < bounds.y + bounds.height
- );
-}
diff --git a/src/main/cursor-overlay.ts b/src/main/cursor-overlay.ts
deleted file mode 100644
index 3740003..0000000
--- a/src/main/cursor-overlay.ts
+++ /dev/null
@@ -1,485 +0,0 @@
-import { app, BrowserWindow, screen } from 'electron';
-import { join } from 'node:path';
-import {
- NativeWindowsCursorOverlayBackend,
- nativeHelperCursorPosition,
- type CursorOverlayBackend,
- type CursorOverlayEvent,
- type CursorOverlayRenderOptions
-} from './cursor-overlay-helper-client';
-import { cursorOverlayBounds } from './cursor-overlay-state';
-import {
- DEFAULT_CURSOR_OVERLAY_SETTINGS,
- normalizeCursorOverlaySettings,
- resolveCursorOverlayColorRgb,
- resolveCursorOverlaySizePixels,
- type CursorOverlaySettings
-} from '../shared/cursor-overlay-settings';
-
-export type { CursorOverlayEvent };
-
-export type CursorOverlayOptions = {
- idleTimeoutMs?: number;
- settings?: CursorOverlaySettings;
- followIntervalMs?: number;
-};
-
-const DEFAULT_IDLE_TIMEOUT_MS = 900;
-const DEFAULT_FOLLOW_INTERVAL_MS = 75;
-
-export class CursorOverlay {
- private readonly backend: CursorOverlayBackend;
- private readonly idleTimeoutMs: number;
- private readonly followIntervalMs: number;
- private settings: CursorOverlaySettings;
- private followTimer: NodeJS.Timeout | null = null;
- private dragActive = false;
-
- constructor(private readonly options: CursorOverlayOptions) {
- this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
- this.followIntervalMs = options.followIntervalMs ?? DEFAULT_FOLLOW_INTERVAL_MS;
- this.settings = normalizeCursorOverlaySettings(options.settings ?? DEFAULT_CURSOR_OVERLAY_SETTINGS);
- const electronBackend = new ElectronCursorOverlayBackend({ idleTimeoutMs: this.idleTimeoutMs });
- this.backend =
- process.platform === 'win32' && app.isPackaged
- ? new NativeWindowsCursorOverlayBackend({
- helperPath: resolveNativeOverlayHelperPath(),
- fallback: electronBackend,
- getCursorPosition: () => nativeHelperCursorPosition(screen),
- getSettings: () => this.getSettings(),
- resolveSizePixels: () => this.resolveSizePixels(),
- idleTimeoutMs: this.idleTimeoutMs,
- onFailure: (message) => console.error('Switchify cursor overlay helper failed.', message)
- })
- : electronBackend;
- }
-
- setEnabled(enabled: boolean): void {
- this.setSettings({ ...this.settings, enabled });
- }
-
- isEnabled(): boolean {
- return this.settings.enabled;
- }
-
- getSettings(): CursorOverlaySettings {
- return { ...this.settings };
- }
-
- setSettings(settings: CursorOverlaySettings): void {
- const previous = this.settings;
- this.settings = normalizeCursorOverlaySettings(settings);
- if (!this.settings.enabled || !this.shouldFollowCursor()) {
- this.stopFollowing();
- }
- if (!this.settings.enabled) {
- this.hide();
- return;
- }
- if (
- previous.size !== this.settings.size ||
- previous.crosshairs !== this.settings.crosshairs ||
- previous.color !== this.settings.color
- ) {
- this.refreshPersistentOverlay();
- }
- }
-
- markControlActive(): void {
- if (!this.settings.enabled || !this.shouldFollowCursor()) return;
- this.startFollowing();
- }
-
- setDragActive(active: boolean): void {
- if (this.dragActive === active) return;
-
- this.dragActive = active;
- if (!this.settings.enabled) return;
-
- if (this.dragActive) {
- this.startFollowing();
- return;
- }
-
- if (this.shouldFollowCursor()) {
- this.refreshPersistentOverlay();
- } else {
- this.stopFollowing();
- }
- }
-
- show(event: CursorOverlayEvent): void {
- if (!this.settings.enabled) return;
- if (this.shouldFollowCursor()) {
- this.startFollowing();
- }
- this.backend.show(event, this.renderOptions());
- }
-
- hide(): void {
- this.stopFollowing();
- this.backend.hide();
- }
-
- endControlSession(): void {
- this.dragActive = false;
- this.hide();
- }
-
- destroy(): void {
- this.stopFollowing();
- this.backend.destroy();
- }
-
- private startFollowing(): void {
- if (this.followTimer) return;
- this.refreshPersistentOverlay();
- this.followTimer = setInterval(() => {
- if (!this.settings.enabled || !this.shouldFollowCursor()) {
- this.hide();
- return;
- }
- this.backend.show(this.currentPersistentEvent(), this.renderOptions());
- }, this.followIntervalMs);
- this.followTimer.unref?.();
- }
-
- private stopFollowing(): void {
- if (this.followTimer) {
- clearInterval(this.followTimer);
- this.followTimer = null;
- }
- }
-
- private refreshPersistentOverlay(): void {
- if (!this.settings.enabled || !this.shouldFollowCursor()) return;
- this.backend.show(this.currentPersistentEvent(), this.renderOptions());
- }
-
- private renderOptions(): CursorOverlayRenderOptions {
- return {
- size: this.resolveSizePixels(),
- idleTimeoutMs: this.idleTimeoutMs,
- crosshairs: this.settings.crosshairs,
- persistent: this.shouldFollowCursor(),
- colorRgb: resolveCursorOverlayColorRgb(this.settings.color)
- };
- }
-
- private resolveSizePixels(): number {
- return resolveCursorOverlaySizePixels(this.settings.size);
- }
-
- private shouldFollowCursor(): boolean {
- return this.settings.visibility === 'whileControlling' || this.dragActive;
- }
-
- private currentPersistentEvent(): CursorOverlayEvent {
- return this.dragActive ? 'drag' : 'move';
- }
-}
-
-type ElectronCursorOverlayBackendOptions = {
- idleTimeoutMs: number;
-};
-
-class ElectronCursorOverlayBackend implements CursorOverlayBackend {
- private window: BrowserWindow | null = null;
- private windowReady: Promise | null = null;
- private hideTimer: NodeJS.Timeout | null = null;
- private failedToCreate = false;
- private readonly idleTimeoutMs: number;
-
- constructor(options: ElectronCursorOverlayBackendOptions) {
- this.idleTimeoutMs = options.idleTimeoutMs;
- }
-
- show(event: CursorOverlayEvent, options: CursorOverlayRenderOptions): void {
- if (this.failedToCreate) return;
-
- void this.showWhenReady(event, options);
- }
-
- private async showWhenReady(event: CursorOverlayEvent, options: CursorOverlayRenderOptions): Promise {
- try {
- const cursor = screen.getCursorScreenPoint();
- const display = screen.getDisplayNearestPoint(cursor);
- const bounds = options.crosshairs ? display.bounds : cursorOverlayBounds(cursor, options.size);
- const window = this.ensureWindow();
- if (!window) return;
-
- await this.windowReady;
- if (window.isDestroyed()) return;
-
- window.setBounds(bounds, false);
- await window.webContents.executeJavaScript(
- createOverlayEventScript(event, {
- centerX: cursor.x - bounds.x,
- centerY: cursor.y - bounds.y,
- crosshairs: options.crosshairs,
- colorRgb: options.colorRgb,
- size: options.size
- })
- );
- this.showOverlayWindow(window);
- if (options.persistent) {
- this.clearHideTimer();
- } else {
- this.resetHideTimer(options.idleTimeoutMs);
- }
- } catch (error) {
- if (!this.window || this.window.isDestroyed()) {
- this.failedToCreate = true;
- }
- this.hide();
- }
- }
-
- hide(): void {
- this.clearHideTimer();
- if (this.window && !this.window.isDestroyed()) {
- this.window.hide();
- }
- }
-
- destroy(): void {
- this.clearHideTimer();
- if (this.window && !this.window.isDestroyed()) {
- this.window.destroy();
- }
- this.window = null;
- this.windowReady = null;
- }
-
- private ensureWindow(): BrowserWindow | null {
- if (this.window && !this.window.isDestroyed()) {
- return this.window;
- }
-
- const window = new BrowserWindow({
- width: 128,
- height: 128,
- frame: false,
- transparent: true,
- resizable: false,
- movable: false,
- show: false,
- paintWhenInitiallyHidden: true,
- skipTaskbar: true,
- focusable: false,
- alwaysOnTop: true,
- hasShadow: false,
- backgroundColor: '#00000000',
- webPreferences: {
- contextIsolation: true,
- nodeIntegration: false,
- sandbox: false
- }
- });
-
- window.setIgnoreMouseEvents(true);
- window.setAlwaysOnTop(true, 'screen-saver');
- try {
- window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
- } catch {
- // Best-effort only; Windows utility behavior still works with always-on-top.
- }
- this.windowReady = window.loadURL(createOverlayDataUrl()).then(() => undefined);
- window.on('closed', () => {
- if (this.window === window) {
- this.window = null;
- this.windowReady = null;
- }
- });
- this.window = window;
- return window;
- }
-
- private showOverlayWindow(window: BrowserWindow): void {
- window.setAlwaysOnTop(true, 'screen-saver');
- if (process.platform === 'win32') {
- window.show();
- } else {
- window.showInactive();
- }
- window.moveTop();
- }
-
- private resetHideTimer(idleTimeoutMs = this.idleTimeoutMs): void {
- this.clearHideTimer();
- this.hideTimer = setTimeout(() => {
- this.hide();
- }, idleTimeoutMs);
- }
-
- private clearHideTimer(): void {
- if (this.hideTimer) {
- clearTimeout(this.hideTimer);
- this.hideTimer = null;
- }
- }
-}
-
-function resolveNativeOverlayHelperPath(): string {
- return app.isPackaged
- ? join(process.resourcesPath, 'native', 'SwitchifyCursorOverlay.exe')
- : join(process.cwd(), 'build', 'native', 'cursor-overlay-helper', 'win-x64', 'SwitchifyCursorOverlay.exe');
-}
-
-function createOverlayDataUrl(): string {
- return `data:text/html;charset=utf-8,${encodeURIComponent(createOverlayHtml())}`;
-}
-
-function createOverlayEventScript(
- event: CursorOverlayEvent,
- options: {
- centerX: number;
- centerY: number;
- crosshairs: boolean;
- colorRgb: [number, number, number];
- size: number;
- }
-): string {
- return `
- document.body.className = ${JSON.stringify(event)};
- document.body.classList.toggle('crosshairs-enabled', ${JSON.stringify(options.crosshairs)});
- document.documentElement.style.setProperty('--overlay-rgb', '${options.colorRgb.join(', ')}');
- document.documentElement.style.setProperty('--center-x', '${Math.round(options.centerX)}px');
- document.documentElement.style.setProperty('--center-y', '${Math.round(options.centerY)}px');
- document.documentElement.style.setProperty('--ring-size', '${Math.round(options.size * 0.5625)}px');
- document.documentElement.style.setProperty('--ring-stroke', '${Math.max(4, Math.round(options.size * 0.039))}px');
- document.documentElement.style.setProperty('--glow-stroke', '${Math.max(18, Math.round(options.size * 0.1875))}px');
- if (${JSON.stringify(event)} === 'click') {
- window.setTimeout(() => {
- if (document.body.classList.contains('click')) {
- document.body.classList.remove('click');
- document.body.classList.add('move');
- }
- }, 190);
- }
- `;
-}
-
-function createOverlayHtml(): string {
- return `
-
-
-
-
-
-
-
-
-
-
-
-`;
-}
diff --git a/src/main/external-url-ipc.ts b/src/main/external-url-ipc.ts
deleted file mode 100644
index c7d870c..0000000
--- a/src/main/external-url-ipc.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { ipcMain, shell } from 'electron';
-import { OPEN_EXTERNAL_URL_CHANNEL } from '../shared/ipc-channels';
-
-export function registerExternalUrlIpc(): void {
- ipcMain.handle(OPEN_EXTERNAL_URL_CHANNEL, async (_event, rawUrl: unknown): Promise<{ ok: boolean; reason?: string }> => {
- if (typeof rawUrl !== 'string') return { ok: false, reason: 'invalid_url' };
-
- let url: URL;
- try {
- url = new URL(rawUrl);
- } catch {
- return { ok: false, reason: 'invalid_url' };
- }
-
- if (url.protocol !== 'https:' && url.protocol !== 'http:') {
- return { ok: false, reason: 'unsupported_protocol' };
- }
-
- try {
- await shell.openExternal(url.toString());
- return { ok: true };
- } catch {
- return { ok: false, reason: 'open_failed' };
- }
- });
-}
diff --git a/src/main/index.ts b/src/main/index.ts
deleted file mode 100644
index 9e06f2e..0000000
--- a/src/main/index.ts
+++ /dev/null
@@ -1,439 +0,0 @@
-import { app, BrowserWindow, nativeTheme, screen } from 'electron';
-import { autoUpdater } from 'electron-updater';
-import { existsSync } from 'node:fs';
-import { join } from 'node:path';
-import { DEFAULT_BLUETOOTH_STATUS } from '../shared/bluetooth-status';
-import { SHOW_SETTINGS_SECTION_CHANNEL, UPDATE_STATE_CHANGED_CHANNEL } from '../shared/ipc-channels';
-import type { SettingsSectionId } from '../shared/settings';
-import type { UpdateState } from '../shared/update';
-import { WindowsBluetoothTransport } from './bluetooth/bluetooth-transport';
-import { ControlService } from './control/control-service';
-import { CursorOverlay } from './cursor-overlay';
-import { registerCursorOverlayIpc } from './cursor-overlay-ipc';
-import { JsonCursorOverlaySettingsStore } from './cursor-overlay-settings-store';
-import { registerAppWindowIpc } from './app-window-ipc';
-import { registerExternalUrlIpc } from './external-url-ipc';
-import { DesktopCommandExecutor } from './input/command-executor';
-import { LibnutWin32InputAdapter } from './input/libnut-win32-adapter';
-import { createPointerMovementProfile } from './input/pointer-profile';
-import { CommandAuthValidator } from './pairing/auth';
-import { PairingApprovalManager } from './pairing/pairing-approval-manager';
-import { registerPairingApprovalIpc } from './pairing/pairing-approval-ipc';
-import { JsonPairingStore } from './pairing/pairing-store';
-import { PairingManager } from './pairing/pairing-manager';
-import { registerPointerMovementSettingsIpc } from './pointer-movement-settings-ipc';
-import { JsonPointerMovementSettingsStore } from './pointer-movement-settings-store';
-import { registerServerIpc } from './server-ipc';
-import { registerSettingsWindowIpc } from './settings-window-ipc';
-import { secondInstanceAction } from './single-instance';
-import { appendStartupDiagnostics } from './startup-diagnostics';
-import { registerSystemStartupIpc } from './system-startup-ipc';
-import { shouldStartHidden, SystemStartupService } from './system-startup';
-import { createSwitchifyTray, type SwitchifyTray } from './tray';
-import { registerUpdateIpc } from './updates/update-ipc';
-import { UpdateService } from './updates/update-service';
-import { WINDOWS_APP_USER_MODEL_ID } from './windows-app-user-model-id';
-import { createWindowsStartupRegistry } from './windows-startup-registry';
-
-const isDev = Boolean(process.env.ELECTRON_RENDERER_URL);
-let controlService: ControlService | null = null;
-let mainWindow: BrowserWindow | null = null;
-let settingsWindow: BrowserWindow | null = null;
-let tray: SwitchifyTray | null = null;
-let cursorOverlay: CursorOverlay | null = null;
-let bluetoothTransport: WindowsBluetoothTransport | null = null;
-let releaseHeldMouseButtons: (() => Promise) | null = null;
-let updateService: UpdateService | null = null;
-let isQuitting = false;
-
-if (process.platform === 'win32' && app.isPackaged) {
- // Chromium GPU sandboxed child processes can fail to start under the
- // uiAccess packaged executable on Windows.
- app.commandLine.appendSwitch('disable-gpu-sandbox');
-}
-
-const gotSingleInstanceLock = app.requestSingleInstanceLock();
-
-// Matches --color-bg in src/renderer/styles.css so the window frame paints
-// the correct base before the renderer loads.
-function shellBackgroundColor(): string {
- return nativeTheme.shouldUseDarkColors ? '#161518' : '#f5f4f7';
-}
-
-function appIconPath(): string | undefined {
- if (process.platform === 'darwin') return undefined;
-
- const iconPath = app.isPackaged
- ? join(process.resourcesPath, 'icon.png')
- : join(process.cwd(), 'build', 'icon.png');
-
- return existsSync(iconPath) ? iconPath : undefined;
-}
-
-type MainWindowOptions = {
- showOnReady?: boolean;
-};
-
-function createMainWindow(options: MainWindowOptions = {}): BrowserWindow {
- const showOnReady = options.showOnReady ?? true;
- const iconPath = appIconPath();
- const window = new BrowserWindow({
- width: 920,
- height: 640,
- minWidth: 720,
- minHeight: 520,
- title: 'Switchify PC',
- backgroundColor: shellBackgroundColor(),
- show: false,
- titleBarStyle: 'hidden',
- maximizable: false,
- ...(iconPath ? { icon: iconPath } : {}),
- webPreferences: {
- preload: join(__dirname, '../preload/index.js'),
- contextIsolation: true,
- nodeIntegration: false,
- sandbox: false
- }
- });
-
- window.once('ready-to-show', () => {
- if (showOnReady) {
- window.show();
- }
- });
-
- window.on('close', (event) => {
- if (isQuitting) return;
- event.preventDefault();
- window.hide();
- });
-
- window.on('closed', () => {
- if (mainWindow === window) {
- mainWindow = null;
- }
- });
-
- const applyThemeBackground = (): void => {
- if (!window.isDestroyed()) {
- window.setBackgroundColor(shellBackgroundColor());
- }
- };
- nativeTheme.on('updated', applyThemeBackground);
- window.on('closed', () => {
- nativeTheme.off('updated', applyThemeBackground);
- });
-
- if (isDev && process.env.ELECTRON_RENDERER_URL) {
- void window.loadURL(process.env.ELECTRON_RENDERER_URL);
- } else {
- void window.loadFile(join(__dirname, '../renderer/index.html'));
- }
-
- return window;
-}
-
-function settingsHashForSection(section: SettingsSectionId): string {
- return section === 'general' ? '/settings' : `/settings/${section}`;
-}
-
-function createSettingsWindow(section: SettingsSectionId = 'general'): BrowserWindow {
- const iconPath = appIconPath();
- const window = new BrowserWindow({
- width: 820,
- height: 620,
- minWidth: 680,
- minHeight: 460,
- title: 'Settings',
- backgroundColor: shellBackgroundColor(),
- show: false,
- titleBarStyle: 'hidden',
- minimizable: false,
- maximizable: false,
- ...(iconPath ? { icon: iconPath } : {}),
- webPreferences: {
- preload: join(__dirname, '../preload/index.js'),
- contextIsolation: true,
- nodeIntegration: false,
- sandbox: false
- }
- });
-
- window.once('ready-to-show', () => {
- window.show();
- });
-
- window.on('close', (event) => {
- if (isQuitting) return;
- event.preventDefault();
- window.hide();
- });
-
- window.on('closed', () => {
- if (settingsWindow === window) {
- settingsWindow = null;
- }
- });
-
- const applyThemeBackground = (): void => {
- if (!window.isDestroyed()) {
- window.setBackgroundColor(shellBackgroundColor());
- }
- };
- nativeTheme.on('updated', applyThemeBackground);
- window.on('closed', () => {
- nativeTheme.off('updated', applyThemeBackground);
- });
-
- if (isDev && process.env.ELECTRON_RENDERER_URL) {
- const url = new URL(process.env.ELECTRON_RENDERER_URL);
- url.hash = settingsHashForSection(section);
- void window.loadURL(url.toString());
- } else {
- void window.loadFile(join(__dirname, '../renderer/index.html'), { hash: settingsHashForSection(section) });
- }
-
- return window;
-}
-
-function showMainWindow(): void {
- if (!mainWindow || mainWindow.isDestroyed()) {
- mainWindow = createMainWindow();
- return;
- }
-
- if (mainWindow.isMinimized()) {
- mainWindow.restore();
- }
- mainWindow.show();
- mainWindow.focus();
-}
-
-function sendSettingsSection(window: BrowserWindow, section: SettingsSectionId): void {
- const send = (): void => {
- if (!window.isDestroyed()) {
- window.webContents.send(SHOW_SETTINGS_SECTION_CHANNEL, section);
- }
- };
-
- if (window.webContents.isLoading()) {
- window.webContents.once('did-finish-load', send);
- return;
- }
-
- send();
-}
-
-function showSettingsWindow(section: SettingsSectionId = 'general'): void {
- if (!settingsWindow || settingsWindow.isDestroyed()) {
- settingsWindow = createSettingsWindow(section);
- return;
- }
-
- if (settingsWindow.isMinimized()) {
- settingsWindow.restore();
- }
- settingsWindow.show();
- settingsWindow.focus();
- sendSettingsSection(settingsWindow, section);
-}
-
-function quitApp(): void {
- app.quit();
-}
-
-function broadcastUpdateState(state: UpdateState): void {
- for (const window of BrowserWindow.getAllWindows()) {
- if (!window.isDestroyed()) {
- window.webContents.send(UPDATE_STATE_CHANGED_CHANNEL, state);
- }
- }
-}
-
-if (!gotSingleInstanceLock) {
- app.quit();
-} else {
- app.on('second-instance', (_event, argv) => {
- if (secondInstanceAction(argv, process.platform) === 'ignore') {
- return;
- }
-
- showMainWindow();
- });
-
- app.whenReady().then(() => {
- if (process.platform === 'win32') {
- app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID);
- }
-
- const startHidden = shouldStartHidden(process.argv, process.platform);
- const systemStartup = new SystemStartupService({
- platform: process.platform,
- isPackaged: app.isPackaged,
- executablePath: process.execPath,
- startupRegistry: createWindowsStartupRegistry()
- });
- void systemStartup
- .getSettings()
- .then((settings) => {
- appendStartupDiagnostics(join(app.getPath('userData'), 'startup-diagnostics.jsonl'), {
- startedAt: new Date().toISOString(),
- version: app.getVersion(),
- isPackaged: app.isPackaged,
- platform: process.platform,
- executablePath: process.execPath,
- argv: process.argv,
- startHidden,
- startupRegistration: settings.registration
- ? {
- startWithSystem: settings.startWithSystem,
- registeredCommand: settings.registration.registeredCommand,
- startupApproved: settings.registration.startupApproved
- }
- : undefined
- });
- })
- .catch((error) => {
- console.warn(error instanceof Error ? error.message : 'Could not write startup diagnostics.');
- });
- const pairingStore = new JsonPairingStore(join(app.getPath('userData'), 'pairing-state.json'));
- const cursorOverlaySettingsStore = new JsonCursorOverlaySettingsStore(
- join(app.getPath('userData'), 'cursor-overlay-settings.json')
- );
- const pointerMovementSettingsStore = new JsonPointerMovementSettingsStore(
- join(app.getPath('userData'), 'pointer-movement-settings.json')
- );
- let pointerMovementSettings = pointerMovementSettingsStore.load();
- const pairingManager = new PairingManager(pairingStore);
- const pairingApprovalManager = new PairingApprovalManager(pairingStore);
- const inputAdapter = new LibnutWin32InputAdapter((position) => {
- const display = screen.getDisplayNearestPoint(position);
- return {
- bounds: display.bounds,
- scaleFactor: display.scaleFactor
- };
- }, undefined, pointerMovementSettings);
- cursorOverlay = new CursorOverlay({ settings: cursorOverlaySettingsStore.load() });
- const commandExecutor = new DesktopCommandExecutor(inputAdapter, cursorOverlay);
- releaseHeldMouseButtons = () => commandExecutor.releaseHeldMouseButtons();
- const releaseHeldMouseButtonsSafely = (): void => {
- void commandExecutor.releaseHeldMouseButtons().catch((error) => {
- console.warn(error instanceof Error ? error.message : 'Could not release held mouse buttons.');
- });
- };
- controlService = new ControlService({
- pairingManager,
- pairingApprovalManager,
- authValidator: new CommandAuthValidator(pairingStore),
- getPointerProfile: () => {
- const cursor = inputAdapter.getMousePosition();
- const display = screen.getDisplayNearestPoint(cursor);
- return createPointerMovementProfile({
- cursor,
- display: {
- bounds: display.bounds,
- scaleFactor: display.scaleFactor
- }
- });
- },
- onStatusChange: (status) => {
- if (status.connectedClientCount === 0) {
- releaseHeldMouseButtonsSafely();
- cursorOverlay?.endControlSession();
- }
- tray?.update();
- },
- onClientDisconnecting: (connectionId) => {
- bluetoothTransport?.markClientRequestedDisconnect(connectionId);
- },
- onCommand: (command) => commandExecutor.execute(command)
- });
- void pairingManager.getDesktopId().then((desktopId) => {
- controlService?.setDesktopId(desktopId);
- });
- bluetoothTransport = new WindowsBluetoothTransport({
- controlService,
- getDesktopId: () => pairingManager.getDesktopId(),
- displayName: 'Switchify PC',
- onStatusChange: () => {
- tray?.update();
- }
- });
- registerServerIpc(controlService, pairingStore);
- registerCursorOverlayIpc(cursorOverlay, cursorOverlaySettingsStore);
- registerPointerMovementSettingsIpc(pointerMovementSettingsStore, (settings) => {
- pointerMovementSettings = settings;
- inputAdapter.setPointerMovementSettings(settings);
- });
- registerPairingApprovalIpc(controlService);
- registerSettingsWindowIpc(showSettingsWindow);
- registerAppWindowIpc();
- registerExternalUrlIpc();
- registerSystemStartupIpc(systemStartup);
- updateService = new UpdateService({
- currentVersion: app.getVersion(),
- isPackaged: app.isPackaged,
- platform: process.platform,
- diagnosticsFilePath: join(app.getPath('userData'), 'update-install-diagnostics.jsonl'),
- autoUpdater,
- onStateChanged: broadcastUpdateState
- });
- registerUpdateIpc(updateService);
- updateService.startAutomaticUpdateChecks();
- void bluetoothTransport.start();
-
- mainWindow = createMainWindow({ showOnReady: !startHidden });
- tray = createSwitchifyTray({
- getStatus: () =>
- controlService?.getStatus() ?? {
- state: 'stopped',
- desktopId: null,
- connectedClientCount: 0,
- connectedClients: [],
- lastSeenAt: null,
- lastError: null,
- bluetooth: DEFAULT_BLUETOOTH_STATUS
- },
- showWindow: showMainWindow,
- openSettings: showSettingsWindow,
- disconnectClients: () => {
- releaseHeldMouseButtonsSafely();
- controlService?.disconnectClients();
- tray?.update();
- },
- quit: quitApp
- });
-
- app.on('activate', () => {
- showMainWindow();
- });
- });
-
- app.on('window-all-closed', () => {});
-
- app.on('before-quit', (event) => {
- if (isQuitting) return;
-
- event.preventDefault();
- isQuitting = true;
- updateService?.stopAutomaticUpdateChecks();
- void (releaseHeldMouseButtons?.() ?? Promise.resolve())
- .catch((error) => {
- console.warn(error instanceof Error ? error.message : 'Could not release held mouse buttons.');
- })
- .then(() => {
- tray?.destroy();
- tray = null;
- cursorOverlay?.destroy();
- cursorOverlay = null;
- bluetoothTransport?.stop();
- bluetoothTransport = null;
- releaseHeldMouseButtons = null;
- updateService = null;
- controlService = null;
- })
- .finally(() => {
- app.quit();
- });
- });
-}
diff --git a/src/main/input/command-executor.test.ts b/src/main/input/command-executor.test.ts
deleted file mode 100644
index 781c432..0000000
--- a/src/main/input/command-executor.test.ts
+++ /dev/null
@@ -1,641 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import type { CommandRequest, WindowControlAction } from '../../shared/protocol';
-import { PROTOCOL_VERSION } from '../../shared/protocol';
-import type { DesktopInputAdapter } from './desktop-input-adapter';
-import { DesktopInputError } from './desktop-input-adapter';
-import { DesktopCommandExecutor } from './command-executor';
-
-type RecordedCall = { method: keyof DesktopInputAdapter; args: unknown[] };
-
-class FakeCursorOverlay {
- readonly events: Array<'move' | 'click' | 'drag'> = [];
- readonly dragActiveChanges: boolean[] = [];
- activeCount = 0;
- hideCount = 0;
-
- show(event: 'move' | 'click' | 'drag'): void {
- this.events.push(event);
- }
-
- setDragActive(active: boolean): void {
- this.dragActiveChanges.push(active);
- }
-
- markControlActive(): void {
- this.activeCount += 1;
- }
-
- hide(): void {
- this.hideCount += 1;
- }
-}
-
-class FakeInputAdapter implements DesktopInputAdapter {
- readonly calls: RecordedCall[] = [];
- failNext: Error | null = null;
- activeMouseMoves = 0;
- maxActiveMouseMoves = 0;
- mouseMoveDelayMs = 0;
-
- getMousePosition(): { x: number; y: number } {
- return { x: 100, y: 200 };
- }
-
- async moveMouseBy(delta: { dx: number; dy: number }): Promise {
- this.activeMouseMoves += 1;
- this.maxActiveMouseMoves = Math.max(this.maxActiveMouseMoves, this.activeMouseMoves);
- try {
- if (this.mouseMoveDelayMs > 0) {
- await new Promise((resolve) => setTimeout(resolve, this.mouseMoveDelayMs));
- }
- this.record('moveMouseBy', [delta]);
- } finally {
- this.activeMouseMoves -= 1;
- }
- }
-
- async setMouseButtonDown(button: 'left' | 'right' | 'middle', down: boolean): Promise {
- this.record('setMouseButtonDown', [button, down]);
- }
-
- async clickMouse(button: 'left' | 'right' | 'middle'): Promise {
- this.record('clickMouse', [button]);
- }
-
- async doubleClickMouse(button: 'left' | 'right' | 'middle'): Promise {
- this.record('doubleClickMouse', [button]);
- }
-
- async scrollMouse(delta: { dx: number; dy: number }): Promise {
- this.record('scrollMouse', [delta]);
- }
-
- async pressKey(key: string): Promise {
- this.record('pressKey', [key]);
- }
-
- async pressShortcut(keys: string[]): Promise {
- this.record('pressShortcut', [keys]);
- }
-
- async typeText(text: string): Promise {
- this.record('typeText', [text]);
- }
-
- async typeCharacter(text: string): Promise {
- this.record('typeCharacter', [text]);
- }
-
- async mediaControl(action: string): Promise {
- this.record('mediaControl', [action]);
- }
-
- async controlWindow(action: WindowControlAction): Promise {
- this.record('controlWindow', [action]);
- }
-
- private record(method: keyof DesktopInputAdapter, args: unknown[]): void {
- if (this.failNext) {
- const error = this.failNext;
- this.failNext = null;
- throw error;
- }
- this.calls.push({ method, args });
- }
-}
-
-describe('DesktopCommandExecutor', () => {
- it('maps mouse commands to the adapter', async () => {
- const { adapter, executor, overlay } = createExecutor();
-
- await executor.execute(command('mouse.move', { dx: 10, dy: -4 }));
- await executor.execute(command('mouse.click', { button: 'left' }));
- await executor.execute(command('mouse.doubleClick', { button: 'middle' }));
- await executor.execute(command('mouse.rightClick', {}));
- await executor.execute(command('mouse.scroll', { dx: 0, dy: -3 }));
-
- expect(adapter.calls).toEqual([
- { method: 'moveMouseBy', args: [{ dx: 10, dy: -4 }] },
- { method: 'clickMouse', args: ['left'] },
- { method: 'doubleClickMouse', args: ['middle'] },
- { method: 'clickMouse', args: ['right'] },
- { method: 'scrollMouse', args: [{ dx: 0, dy: -3 }] }
- ]);
- expect(overlay.events).toEqual(['move', 'click', 'click', 'click']);
- expect(overlay.activeCount).toBe(5);
- expect(overlay.hideCount).toBe(0);
- });
-
- it('maps keyboard, text, media, and ping commands', async () => {
- const { adapter, executor, overlay } = createExecutor();
-
- await executor.execute(command('keyboard.key', { key: 'Enter' }));
- await executor.execute(command('keyboard.key', { key: 'F12' }));
- await executor.execute(command('keyboard.key', { key: 'Meta' }));
- await executor.execute(command('keyboard.shortcut', { keys: ['Meta'] }));
- await executor.execute(command('keyboard.shortcut', { keys: ['Ctrl', 'C'] }));
- await executor.execute(command('keyboard.shortcut', { keys: ['Ctrl', 'F5'] }));
- await executor.execute(command('keyboard.typeText', { text: 'Hello' }));
- await executor.execute(command('media.control', { action: 'playPause' }));
- await executor.execute(command('window.control', { action: 'switchNext' }));
- const pingResult = await executor.execute(command('connection.ping', {}));
-
- expect(pingResult).toEqual({ ok: true });
- expect(adapter.calls).toEqual([
- { method: 'pressKey', args: ['Enter'] },
- { method: 'pressKey', args: ['F12'] },
- { method: 'pressKey', args: ['Meta'] },
- { method: 'pressShortcut', args: [['Meta']] },
- { method: 'pressShortcut', args: [['Ctrl', 'C']] },
- { method: 'pressShortcut', args: [['Ctrl', 'F5']] },
- { method: 'typeText', args: ['Hello'] },
- { method: 'mediaControl', args: ['playPause'] },
- { method: 'controlWindow', args: ['switchNext'] }
- ]);
- expect(overlay.events).toHaveLength(0);
- expect(overlay.activeCount).toBe(0);
- expect(overlay.hideCount).toBe(10);
- });
-
- it('executes non-movement no-response commands without coalescing', async () => {
- const { adapter, executor, overlay } = createExecutor();
-
- await executor.execute(command('mouse.click', { button: 'left' }, { responseMode: 'none' }));
- await executor.execute(command('mouse.scroll', { dx: 0, dy: -3 }, { responseMode: 'none' }));
- await executor.execute(command('keyboard.key', { key: 'F12' }, { responseMode: 'none' }));
- await executor.execute(command('window.control', { action: 'switchNext' }, { responseMode: 'none' }));
-
- expect(adapter.calls).toEqual([
- { method: 'clickMouse', args: ['left'] },
- { method: 'scrollMouse', args: [{ dx: 0, dy: -3 }] },
- { method: 'pressKey', args: ['F12'] },
- { method: 'controlWindow', args: ['switchNext'] }
- ]);
- expect(overlay.events).toEqual(['click']);
- expect(overlay.activeCount).toBe(2);
- expect(overlay.hideCount).toBe(2);
- });
-
- it('passes empty committed text to the adapter', async () => {
- const { adapter, executor } = createExecutor();
-
- await expect(executor.execute(command('keyboard.typeText', { text: '' }))).resolves.toEqual({ ok: true });
-
- expect(adapter.calls).toEqual([{ method: 'typeText', args: [''] }]);
- });
-
- it('streams text characters and keys before closing successfully', async () => {
- const { adapter, executor } = createExecutor();
-
- await expect(executor.execute(command('keyboard.textStream.open', { streamId: 'stream-1' }))).resolves.toEqual({
- ok: true
- });
- await expect(
- executor.execute(command('keyboard.textStream.char', { streamId: 'stream-1', seq: 0, text: 'H' }, { responseMode: 'none' }))
- ).resolves.toEqual({ ok: true });
- await expect(
- executor.execute(command('keyboard.textStream.key', { streamId: 'stream-1', seq: 1, key: 'Enter' }, { responseMode: 'none' }))
- ).resolves.toEqual({ ok: true });
- await expect(executor.execute(command('keyboard.textStream.close', { streamId: 'stream-1', expectedCount: 2 }))).resolves.toEqual({
- ok: true
- });
-
- expect(adapter.calls).toEqual([
- { method: 'typeCharacter', args: ['H'] },
- { method: 'pressKey', args: ['Enter'] }
- ]);
- });
-
- it('streams Meta as a key before closing successfully', async () => {
- const { adapter, executor } = createExecutor();
-
- await expect(executor.execute(command('keyboard.textStream.open', { streamId: 'stream-1' }))).resolves.toEqual({
- ok: true
- });
- await expect(
- executor.execute(command('keyboard.textStream.key', { streamId: 'stream-1', seq: 0, key: 'Meta' }, { responseMode: 'none' }))
- ).resolves.toEqual({ ok: true });
- await expect(executor.execute(command('keyboard.textStream.close', { streamId: 'stream-1', expectedCount: 1 }))).resolves.toEqual({
- ok: true
- });
-
- expect(adapter.calls).toEqual([{ method: 'pressKey', args: ['Meta'] }]);
- });
-
- it('streams ACKed text chunks before closing successfully', async () => {
- const { adapter, executor } = createExecutor();
-
- await expect(executor.execute(command('keyboard.textStream.open', { streamId: 'stream-1' }))).resolves.toEqual({
- ok: true
- });
- await expect(
- executor.execute(command('keyboard.textStream.chunk', { streamId: 'stream-1', seq: 0, text: 'Hello ' }))
- ).resolves.toEqual({ ok: true });
- await expect(
- executor.execute(command('keyboard.textStream.chunk', { streamId: 'stream-1', seq: 1, text: 'world' }))
- ).resolves.toEqual({ ok: true });
- await expect(executor.execute(command('keyboard.textStream.close', { streamId: 'stream-1', expectedCount: 2 }))).resolves.toEqual({
- ok: true
- });
-
- expect(adapter.calls).toEqual([
- { method: 'typeCharacter', args: ['H'] },
- { method: 'typeCharacter', args: ['e'] },
- { method: 'typeCharacter', args: ['l'] },
- { method: 'typeCharacter', args: ['l'] },
- { method: 'typeCharacter', args: ['o'] },
- { method: 'typeCharacter', args: [' '] },
- { method: 'typeCharacter', args: ['w'] },
- { method: 'typeCharacter', args: ['o'] },
- { method: 'typeCharacter', args: ['r'] },
- { method: 'typeCharacter', args: ['l'] },
- { method: 'typeCharacter', args: ['d'] }
- ]);
- });
-
- it('fails text stream close when an expected item is missing', async () => {
- const { executor } = createExecutor();
-
- await executor.execute(command('keyboard.textStream.open', { streamId: 'stream-1' }));
- await executor.execute(command('keyboard.textStream.char', { streamId: 'stream-1', seq: 0, text: 'H' }, { responseMode: 'none' }));
-
- await expect(executor.execute(command('keyboard.textStream.close', { streamId: 'stream-1', expectedCount: 2 }))).resolves.toEqual({
- ok: false,
- code: 'adapter_failure',
- message: 'Text stream did not receive every item.'
- });
- });
-
- it('accepts duplicate text stream item retries without reinserting input', async () => {
- const { adapter, executor } = createExecutor();
-
- await executor.execute(command('keyboard.textStream.open', { streamId: 'stream-1' }));
- await expect(
- executor.execute(command('keyboard.textStream.char', { streamId: 'stream-1', seq: 0, text: 'H' }, { responseMode: 'none' }))
- ).resolves.toEqual({ ok: true });
- await expect(
- executor.execute(command('keyboard.textStream.char', { streamId: 'stream-1', seq: 0, text: 'H' }, { responseMode: 'none' }))
- ).resolves.toEqual({ ok: true });
- await expect(
- executor.execute(command('keyboard.textStream.key', { streamId: 'stream-1', seq: 1, key: 'Enter' }, { responseMode: 'none' }))
- ).resolves.toEqual({ ok: true });
- await expect(
- executor.execute(command('keyboard.textStream.key', { streamId: 'stream-1', seq: 1, key: 'Enter' }, { responseMode: 'none' }))
- ).resolves.toEqual({ ok: true });
- await expect(executor.execute(command('keyboard.textStream.close', { streamId: 'stream-1', expectedCount: 2 }))).resolves.toEqual({
- ok: true
- });
-
- expect(adapter.calls).toEqual([
- { method: 'typeCharacter', args: ['H'] },
- { method: 'pressKey', args: ['Enter'] }
- ]);
- });
-
- it('accepts duplicate text stream chunk retries without reinserting input', async () => {
- const { adapter, executor } = createExecutor();
-
- await executor.execute(command('keyboard.textStream.open', { streamId: 'stream-1' }));
- await expect(
- executor.execute(command('keyboard.textStream.chunk', { streamId: 'stream-1', seq: 0, text: 'Hello' }))
- ).resolves.toEqual({ ok: true });
- await expect(
- executor.execute(command('keyboard.textStream.chunk', { streamId: 'stream-1', seq: 0, text: 'Hello' }))
- ).resolves.toEqual({ ok: true });
- await expect(executor.execute(command('keyboard.textStream.close', { streamId: 'stream-1', expectedCount: 1 }))).resolves.toEqual({
- ok: true
- });
-
- expect(adapter.calls).toEqual([
- { method: 'typeCharacter', args: ['H'] },
- { method: 'typeCharacter', args: ['e'] },
- { method: 'typeCharacter', args: ['l'] },
- { method: 'typeCharacter', args: ['l'] },
- { method: 'typeCharacter', args: ['o'] }
- ]);
- });
-
- it('marks a text stream failed after a sequence mismatch', async () => {
- const { adapter, executor } = createExecutor();
-
- await executor.execute(command('keyboard.textStream.open', { streamId: 'stream-1' }));
- await expect(
- executor.execute(command('keyboard.textStream.char', { streamId: 'stream-1', seq: 1, text: 'H' }, { responseMode: 'none' }))
- ).resolves.toEqual({
- ok: false,
- code: 'adapter_failure',
- message: 'Text stream sequence mismatch.'
- });
- await expect(executor.execute(command('keyboard.textStream.close', { streamId: 'stream-1', expectedCount: 2 }))).resolves.toEqual({
- ok: false,
- code: 'adapter_failure',
- message: 'Text stream sequence mismatch.'
- });
-
- expect(adapter.calls).toHaveLength(0);
- });
-
- it('marks a text stream failed after character insertion fails', async () => {
- const { adapter, executor } = createExecutor();
-
- await executor.execute(command('keyboard.textStream.open', { streamId: 'stream-1' }));
- adapter.failNext = new DesktopInputError('adapter_failure', 'Native character failed.');
- await expect(
- executor.execute(command('keyboard.textStream.char', { streamId: 'stream-1', seq: 0, text: 'H' }, { responseMode: 'none' }))
- ).resolves.toEqual({
- ok: false,
- code: 'adapter_failure',
- message: 'Text stream character insertion failed.'
- });
- await expect(executor.execute(command('keyboard.textStream.close', { streamId: 'stream-1', expectedCount: 1 }))).resolves.toEqual({
- ok: false,
- code: 'adapter_failure',
- message: 'Text stream character insertion failed.'
- });
-
- expect(adapter.calls).toHaveLength(0);
- });
-
- it('resets a duplicate text stream open', async () => {
- const { adapter, executor } = createExecutor();
-
- await executor.execute(command('keyboard.textStream.open', { streamId: 'stream-1' }));
- await executor.execute(command('keyboard.textStream.char', { streamId: 'stream-1', seq: 0, text: 'A' }, { responseMode: 'none' }));
- await executor.execute(command('keyboard.textStream.open', { streamId: 'stream-1' }));
- await executor.execute(command('keyboard.textStream.char', { streamId: 'stream-1', seq: 0, text: 'B' }, { responseMode: 'none' }));
-
- await expect(executor.execute(command('keyboard.textStream.close', { streamId: 'stream-1', expectedCount: 1 }))).resolves.toEqual({
- ok: true
- });
- expect(adapter.calls).toEqual([
- { method: 'typeCharacter', args: ['A'] },
- { method: 'typeCharacter', args: ['B'] }
- ]);
- });
-
- it('fails text stream items and close when the stream is not open', async () => {
- const { executor } = createExecutor();
-
- await expect(
- executor.execute(command('keyboard.textStream.char', { streamId: 'stream-1', seq: 0, text: 'H' }, { responseMode: 'none' }))
- ).resolves.toEqual({
- ok: false,
- code: 'adapter_failure',
- message: 'Text stream is not open.'
- });
- await expect(executor.execute(command('keyboard.textStream.close', { streamId: 'stream-1', expectedCount: 0 }))).resolves.toEqual({
- ok: false,
- code: 'adapter_failure',
- message: 'Text stream is not open.'
- });
- });
-
- it('hides the overlay for unsupported server-level non-mouse commands', async () => {
- const { executor, overlay } = createExecutor();
-
- await expect(executor.execute(command('connection.disconnecting', {}))).resolves.toMatchObject({
- ok: false,
- code: 'unsupported_command'
- });
- await expect(executor.execute(command('pointer.profile', {}))).resolves.toMatchObject({
- ok: false,
- code: 'unsupported_command'
- });
-
- expect(overlay.activeCount).toBe(0);
- expect(overlay.hideCount).toBe(2);
- });
-
- it('rejects unsafe movement, scroll, shortcut, and text values', async () => {
- const { adapter, executor, overlay } = createExecutor();
-
- await expect(executor.execute(command('mouse.move', { dx: 501, dy: 0 }))).resolves.toMatchObject({
- ok: false,
- code: 'unsafe_payload'
- });
- await expect(executor.execute(command('mouse.scroll', { dx: 0, dy: 51 }))).resolves.toMatchObject({
- ok: false,
- code: 'unsafe_payload'
- });
- await expect(executor.execute(command('keyboard.shortcut', { keys: [] }))).resolves.toMatchObject({
- ok: false,
- code: 'unsafe_payload'
- });
- await expect(executor.execute(command('keyboard.typeText', { text: 'x'.repeat(2_001) }))).resolves.toMatchObject({
- ok: false,
- code: 'unsafe_payload'
- });
- expect(adapter.calls).toHaveLength(0);
- expect(overlay.events).toHaveLength(0);
- expect(overlay.activeCount).toBe(2);
- expect(overlay.hideCount).toBe(2);
- });
-
- it('converts mouse adapter errors into structured failures without hiding the overlay', async () => {
- const { adapter, executor, overlay } = createExecutor();
- adapter.failNext = new DesktopInputError('adapter_failure', 'Native input failed.');
-
- await expect(executor.execute(command('mouse.click', { button: 'left' }))).resolves.toEqual({
- ok: false,
- code: 'adapter_failure',
- message: 'Native input failed.'
- });
- expect(overlay.events).toHaveLength(0);
- expect(overlay.activeCount).toBe(1);
- expect(overlay.hideCount).toBe(0);
- });
-
- it('converts non-mouse adapter errors into structured failures after hiding the overlay', async () => {
- const { adapter, executor, overlay } = createExecutor();
- adapter.failNext = new DesktopInputError('adapter_failure', 'Native input failed.');
-
- await expect(executor.execute(command('keyboard.key', { key: 'Enter' }))).resolves.toEqual({
- ok: false,
- code: 'adapter_failure',
- message: 'Native input failed.'
- });
- expect(overlay.events).toHaveLength(0);
- expect(overlay.activeCount).toBe(0);
- expect(overlay.hideCount).toBe(1);
- });
-
- it('serializes repeated pointer movement commands', async () => {
- const { adapter, executor } = createExecutor();
- adapter.mouseMoveDelayMs = 10;
-
- await Promise.all([
- executor.execute(command('mouse.move', { dx: 1, dy: 0 })),
- executor.execute(command('mouse.move', { dx: 2, dy: 0 })),
- executor.execute(command('mouse.move', { dx: 3, dy: 0 }))
- ]);
-
- expect(adapter.maxActiveMouseMoves).toBe(1);
- expect(adapter.calls).toEqual([
- { method: 'moveMouseBy', args: [{ dx: 1, dy: 0 }] },
- { method: 'moveMouseBy', args: [{ dx: 2, dy: 0 }] },
- { method: 'moveMouseBy', args: [{ dx: 3, dy: 0 }] }
- ]);
- });
-
- it('coalesces no-response pointer movement commands', async () => {
- const { adapter, executor } = createExecutor();
- adapter.mouseMoveDelayMs = 10;
-
- await Promise.all([
- executor.execute(command('mouse.move', { dx: 1, dy: 0 }, { responseMode: 'none' })),
- executor.execute(command('mouse.move', { dx: 2, dy: 0 }, { responseMode: 'none' })),
- executor.execute(command('mouse.move', { dx: 3, dy: 0 }, { responseMode: 'none' }))
- ]);
-
- expect(adapter.maxActiveMouseMoves).toBe(1);
- expect(adapter.calls.length).toBeLessThan(3);
- expect(adapter.calls).toEqual([
- { method: 'moveMouseBy', args: [{ dx: 6, dy: 0 }] }
- ]);
- });
-
- it('keeps drag actions ordered around no-response pointer movement', async () => {
- const { adapter, executor } = createExecutor();
- adapter.mouseMoveDelayMs = 10;
-
- await Promise.all([
- executor.execute(command('mouse.dragStart', { button: 'left' })),
- executor.execute(command('mouse.move', { dx: 1, dy: 0 }, { responseMode: 'none' })),
- executor.execute(command('mouse.move', { dx: 2, dy: 0 }, { responseMode: 'none' })),
- executor.execute(command('mouse.dragEnd', { button: 'left' }))
- ]);
-
- expect(adapter.maxActiveMouseMoves).toBe(1);
- expect(adapter.calls).toEqual([
- { method: 'setMouseButtonDown', args: ['left', true] },
- { method: 'moveMouseBy', args: [{ dx: 3, dy: 0 }] },
- { method: 'setMouseButtonDown', args: ['left', false] }
- ]);
- });
-
- it('maps drag start and end to held mouse button actions', async () => {
- const { adapter, executor, overlay } = createExecutor();
-
- await executor.execute(command('mouse.dragStart', { button: 'left' }));
- await executor.execute(command('mouse.move', { dx: 10, dy: 0 }));
- await executor.execute(command('mouse.dragEnd', { button: 'left' }));
-
- expect(adapter.calls).toEqual([
- { method: 'setMouseButtonDown', args: ['left', true] },
- { method: 'moveMouseBy', args: [{ dx: 10, dy: 0 }] },
- { method: 'setMouseButtonDown', args: ['left', false] }
- ]);
- expect(overlay.events).toEqual(['drag', 'drag', 'move']);
- expect(overlay.dragActiveChanges).toEqual([true, false]);
- expect(overlay.activeCount).toBe(3);
- expect(overlay.hideCount).toBe(0);
- });
-
- it('uses the drag overlay while moving with a held mouse button', async () => {
- const { executor, overlay } = createExecutor();
-
- await executor.execute(command('mouse.dragStart', { button: 'left' }));
- await executor.execute(command('mouse.move', { dx: 5, dy: 0 }));
- await executor.execute(command('mouse.dragEnd', { button: 'left' }));
- await executor.execute(command('mouse.move', { dx: 5, dy: 0 }));
-
- expect(overlay.events).toEqual(['drag', 'drag', 'move', 'move']);
- expect(overlay.dragActiveChanges).toEqual([true, false]);
- });
-
- it('serializes drag start, movement, and drag end pointer actions', async () => {
- const { adapter, executor } = createExecutor();
- adapter.mouseMoveDelayMs = 10;
-
- await Promise.all([
- executor.execute(command('mouse.dragStart', { button: 'left' })),
- executor.execute(command('mouse.move', { dx: 1, dy: 0 })),
- executor.execute(command('mouse.dragEnd', { button: 'left' }))
- ]);
-
- expect(adapter.maxActiveMouseMoves).toBe(1);
- expect(adapter.calls).toEqual([
- { method: 'setMouseButtonDown', args: ['left', true] },
- { method: 'moveMouseBy', args: [{ dx: 1, dy: 0 }] },
- { method: 'setMouseButtonDown', args: ['left', false] }
- ]);
- });
-
- it('treats repeated drag start and drag end without active drag as no-ops', async () => {
- const { adapter, executor } = createExecutor();
-
- await executor.execute(command('mouse.dragStart', { button: 'left' }));
- await executor.execute(command('mouse.dragStart', { button: 'left' }));
- await executor.execute(command('mouse.dragEnd', { button: 'left' }));
- await executor.execute(command('mouse.dragEnd', { button: 'left' }));
-
- expect(adapter.calls).toEqual([
- { method: 'setMouseButtonDown', args: ['left', true] },
- { method: 'setMouseButtonDown', args: ['left', false] }
- ]);
- });
-
- it('releases an active drag button before starting a different drag button', async () => {
- const { adapter, executor } = createExecutor();
-
- await executor.execute(command('mouse.dragStart', { button: 'left' }));
- await executor.execute(command('mouse.dragStart', { button: 'right' }));
-
- expect(adapter.calls).toEqual([
- { method: 'setMouseButtonDown', args: ['left', true] },
- { method: 'setMouseButtonDown', args: ['left', false] },
- { method: 'setMouseButtonDown', args: ['right', true] }
- ]);
- });
-
- it('releases the active drag button and clears the drag overlay during cleanup', async () => {
- const { adapter, executor, overlay } = createExecutor();
-
- await executor.execute(command('mouse.dragStart', { button: 'left' }));
- await executor.releaseHeldMouseButtons();
- await executor.releaseHeldMouseButtons();
-
- expect(adapter.calls).toEqual([
- { method: 'setMouseButtonDown', args: ['left', true] },
- { method: 'setMouseButtonDown', args: ['left', false] }
- ]);
- expect(overlay.dragActiveChanges).toEqual([true, false]);
- expect(overlay.hideCount).toBe(1);
- });
-
- it('converts drag adapter errors into structured failures', async () => {
- const { adapter, executor } = createExecutor();
- adapter.failNext = new DesktopInputError('adapter_failure', 'Native drag failed.');
-
- await expect(executor.execute(command('mouse.dragStart', { button: 'left' }))).resolves.toEqual({
- ok: false,
- code: 'adapter_failure',
- message: 'Native drag failed.'
- });
-
- expect(adapter.calls).toHaveLength(0);
- });
-});
-
-function createExecutor(): { adapter: FakeInputAdapter; overlay: FakeCursorOverlay; executor: DesktopCommandExecutor } {
- const adapter = new FakeInputAdapter();
- const overlay = new FakeCursorOverlay();
- return { adapter, overlay, executor: new DesktopCommandExecutor(adapter, overlay) };
-}
-
-function command(
- type: TType,
- payload: Extract['payload'],
- overrides: Partial> = {}
-): Extract {
- return {
- version: PROTOCOL_VERSION,
- id: 'request-1',
- deviceId: 'android-1',
- timestamp: 1,
- type,
- payload,
- auth: 'proof',
- ...overrides
- } as Extract;
-}
diff --git a/src/main/input/command-executor.ts b/src/main/input/command-executor.ts
deleted file mode 100644
index 8847d1d..0000000
--- a/src/main/input/command-executor.ts
+++ /dev/null
@@ -1,414 +0,0 @@
-import type { CommandRequest, MouseButton } from '../../shared/protocol';
-import {
- MAX_POINTER_DELTA,
- MAX_SCROLL_DELTA,
- MAX_SHORTCUT_KEYS,
- MAX_TEXT_LENGTH,
- PROTOCOL_VERSION
-} from '../../shared/protocol';
-import type { DesktopInputAdapter } from './desktop-input-adapter';
-import { DesktopInputError } from './desktop-input-adapter';
-
-export type CommandExecutionResult =
- | { ok: true }
- | { ok: false; code: 'unsupported_command' | 'unsafe_payload' | 'adapter_failure'; message: string };
-
-export type CursorOverlayNotifier = {
- show(event: 'move' | 'click' | 'drag'): void;
- hide?(): void;
- markControlActive?(): void;
- setDragActive?(active: boolean): void;
-};
-
-type TextInputStreamState = {
- deviceId: string;
- streamId: string;
- nextSeq: number;
- failed: boolean;
- errorMessage?: string;
- openedAtMs: number;
- updatedAtMs: number;
-};
-
-const TEXT_STREAM_TTL_MS = 60_000;
-const TEXT_STREAM_CHUNK_CHARACTER_DELAY_MS = 35;
-
-export class DesktopCommandExecutor {
- private pointerActionQueue: Promise = Promise.resolve();
- private activeDragButton: MouseButton | null = null;
- private pendingRealtimeMove: { dx: number; dy: number } | null = null;
- private realtimeMoveFlush: Promise | null = null;
- private readonly textInputStreams = new Map();
-
- constructor(
- private readonly adapter: DesktopInputAdapter,
- private readonly cursorOverlay?: CursorOverlayNotifier
- ) {}
-
- async execute(command: CommandRequest): Promise {
- if (command.type === 'mouse.move' && command.responseMode === 'none') {
- return this.enqueueCoalescedMouseMove(command);
- }
-
- if (isPointerAction(command)) {
- return this.enqueuePointerAction(command);
- }
-
- return this.executeNow(command);
- }
-
- async releaseHeldMouseButtons(): Promise {
- const release = this.pointerActionQueue.then(async () => {
- if (!this.activeDragButton) return;
- const button = this.activeDragButton;
- await this.adapter.setMouseButtonDown(button, false);
- this.activeDragButton = null;
- this.cursorOverlay?.setDragActive?.(false);
- this.cursorOverlay?.hide?.();
- });
- this.pointerActionQueue = release.then(
- () => undefined,
- () => undefined
- );
- await release;
- }
-
- private async enqueuePointerAction(
- command: CommandRequest & { type: 'mouse.move' | 'mouse.dragStart' | 'mouse.dragEnd' }
- ): Promise {
- const result = this.pointerActionQueue.then(() => this.executeNow(command));
- this.pointerActionQueue = result.then(
- () => undefined,
- () => undefined
- );
- return result;
- }
-
- private enqueueCoalescedMouseMove(command: CommandRequest & { type: 'mouse.move' }): Promise {
- this.pendingRealtimeMove = addMouseDeltas(this.pendingRealtimeMove, command.payload);
-
- if (this.realtimeMoveFlush) {
- return Promise.resolve({ ok: true });
- }
-
- const result = this.pointerActionQueue.then(() => this.flushRealtimeMouseMoves());
- this.realtimeMoveFlush = result;
- this.pointerActionQueue = result.then(
- () => undefined,
- () => undefined
- );
-
- return result;
- }
-
- private async flushRealtimeMouseMoves(): Promise {
- try {
- while (this.pendingRealtimeMove) {
- const payload = this.pendingRealtimeMove;
- this.pendingRealtimeMove = null;
- const result = await this.executeNow({
- version: PROTOCOL_VERSION,
- id: 'realtime-move',
- deviceId: 'realtime',
- timestamp: Date.now(),
- type: 'mouse.move',
- payload,
- auth: '',
- responseMode: 'none'
- });
- if (!result.ok) {
- return result;
- }
- }
- return { ok: true };
- } finally {
- this.realtimeMoveFlush = null;
- if (this.pendingRealtimeMove) {
- const result = this.pointerActionQueue.then(() => this.flushRealtimeMouseMoves());
- this.realtimeMoveFlush = result;
- this.pointerActionQueue = result.then(
- () => undefined,
- () => undefined
- );
- }
- }
- }
-
- private async executeNow(command: CommandRequest): Promise {
- try {
- if (isMouseCommand(command)) {
- this.cursorOverlay?.markControlActive?.();
- } else {
- this.cursorOverlay?.hide?.();
- }
- switch (command.type) {
- case 'mouse.move':
- assertBoundedNumber(command.payload.dx, MAX_POINTER_DELTA, 'dx');
- assertBoundedNumber(command.payload.dy, MAX_POINTER_DELTA, 'dy');
- await this.adapter.moveMouseBy(command.payload);
- this.cursorOverlay?.show(this.activeDragButton ? 'drag' : 'move');
- return { ok: true };
- case 'mouse.dragStart':
- await this.startDrag(command.payload.button);
- this.cursorOverlay?.setDragActive?.(true);
- this.cursorOverlay?.show('drag');
- return { ok: true };
- case 'mouse.dragEnd':
- await this.endDrag(command.payload.button);
- this.cursorOverlay?.setDragActive?.(false);
- this.cursorOverlay?.show('move');
- return { ok: true };
- case 'mouse.click':
- await this.adapter.clickMouse(command.payload.button);
- this.cursorOverlay?.show('click');
- return { ok: true };
- case 'mouse.doubleClick':
- await this.adapter.doubleClickMouse(command.payload.button);
- this.cursorOverlay?.show('click');
- return { ok: true };
- case 'mouse.rightClick':
- await this.adapter.clickMouse('right');
- this.cursorOverlay?.show('click');
- return { ok: true };
- case 'mouse.scroll':
- assertBoundedNumber(command.payload.dx, MAX_SCROLL_DELTA, 'dx');
- assertBoundedNumber(command.payload.dy, MAX_SCROLL_DELTA, 'dy');
- await this.adapter.scrollMouse(command.payload);
- return { ok: true };
- case 'keyboard.key':
- await this.adapter.pressKey(command.payload.key);
- return { ok: true };
- case 'keyboard.shortcut':
- if (command.payload.keys.length === 0 || command.payload.keys.length > MAX_SHORTCUT_KEYS) {
- return unsafe('Shortcut key count is invalid.');
- }
- await this.adapter.pressShortcut(command.payload.keys);
- return { ok: true };
- case 'keyboard.typeText':
- if (command.payload.text.length > MAX_TEXT_LENGTH) {
- return unsafe('Text payload is too long.');
- }
- await this.adapter.typeText(command.payload.text);
- return { ok: true };
- case 'keyboard.textStream.open':
- this.openTextInputStream(command.deviceId, command.payload.streamId);
- return { ok: true };
- case 'keyboard.textStream.char':
- return await this.executeTextStreamItem(
- command.deviceId,
- command.payload.streamId,
- command.payload.seq,
- () => this.adapter.typeCharacter(command.payload.text),
- 'Text stream character insertion failed.'
- );
- case 'keyboard.textStream.chunk':
- return await this.executeTextStreamItem(
- command.deviceId,
- command.payload.streamId,
- command.payload.seq,
- () => this.typeTextStreamChunk(command.payload.text),
- 'Text stream chunk insertion failed.'
- );
- case 'keyboard.textStream.key':
- return await this.executeTextStreamItem(
- command.deviceId,
- command.payload.streamId,
- command.payload.seq,
- () => this.adapter.pressKey(command.payload.key),
- 'Text stream key insertion failed.'
- );
- case 'keyboard.textStream.close':
- return this.closeTextInputStream(command.deviceId, command.payload.streamId, command.payload.expectedCount);
- case 'media.control':
- await this.adapter.mediaControl(command.payload.action);
- return { ok: true };
- case 'window.control':
- await this.adapter.controlWindow(command.payload.action);
- return { ok: true };
- case 'connection.ping':
- return { ok: true };
- case 'connection.disconnecting':
- return { ok: false, code: 'unsupported_command', message: 'Disconnect intent must be handled by the server.' };
- case 'pointer.profile':
- return { ok: false, code: 'unsupported_command', message: 'Pointer profile must be handled by the server.' };
- }
- } catch (error) {
- if (error instanceof DesktopInputError) {
- return { ok: false, code: error.code, message: error.message };
- }
- return {
- ok: false,
- code: 'adapter_failure',
- message: error instanceof Error ? error.message : 'Desktop input failed.'
- };
- }
- }
-
- private async startDrag(button: MouseButton): Promise {
- if (this.activeDragButton === button) return;
-
- if (this.activeDragButton) {
- const previousButton = this.activeDragButton;
- await this.adapter.setMouseButtonDown(previousButton, false);
- this.activeDragButton = null;
- }
-
- await this.adapter.setMouseButtonDown(button, true);
- this.activeDragButton = button;
- }
-
- private async endDrag(_button: MouseButton): Promise {
- if (!this.activeDragButton) return;
-
- const buttonToRelease = this.activeDragButton;
- await this.adapter.setMouseButtonDown(buttonToRelease, false);
- this.activeDragButton = null;
- }
-
- private openTextInputStream(deviceId: string, streamId: string): void {
- this.expireTextInputStreams();
- const now = Date.now();
- this.textInputStreams.set(textInputStreamKey(deviceId, streamId), {
- deviceId,
- streamId,
- nextSeq: 0,
- failed: false,
- openedAtMs: now,
- updatedAtMs: now
- });
- }
-
- private async executeTextStreamItem(
- deviceId: string,
- streamId: string,
- seq: number,
- execute: () => Promise,
- failureMessage: string
- ): Promise {
- const stream = this.textInputStreams.get(textInputStreamKey(deviceId, streamId));
- if (!stream) {
- return { ok: false, code: 'adapter_failure', message: 'Text stream is not open.' };
- }
-
- stream.updatedAtMs = Date.now();
- if (seq < stream.nextSeq) {
- return stream.failed
- ? { ok: false, code: 'adapter_failure', message: stream.errorMessage ?? 'Text stream failed.' }
- : { ok: true };
- }
-
- if (seq > stream.nextSeq) {
- stream.failed = true;
- stream.errorMessage = 'Text stream sequence mismatch.';
- stream.nextSeq = Math.max(stream.nextSeq, seq + 1);
- return { ok: false, code: 'adapter_failure', message: stream.errorMessage };
- }
-
- stream.nextSeq = seq + 1;
- if (stream.failed) {
- return { ok: false, code: 'adapter_failure', message: stream.errorMessage ?? 'Text stream failed.' };
- }
-
- try {
- await execute();
- return { ok: true };
- } catch {
- stream.failed = true;
- stream.errorMessage = failureMessage;
- return { ok: false, code: 'adapter_failure', message: failureMessage };
- }
- }
-
- private closeTextInputStream(
- deviceId: string,
- streamId: string,
- expectedCount: number
- ): CommandExecutionResult {
- this.expireTextInputStreams();
- const key = textInputStreamKey(deviceId, streamId);
- const stream = this.textInputStreams.get(key);
- if (!stream) {
- return { ok: false, code: 'adapter_failure', message: 'Text stream is not open.' };
- }
-
- this.textInputStreams.delete(key);
- if (expectedCount !== stream.nextSeq) {
- return { ok: false, code: 'adapter_failure', message: 'Text stream did not receive every item.' };
- }
-
- if (stream.failed) {
- return { ok: false, code: 'adapter_failure', message: stream.errorMessage ?? 'Text stream failed.' };
- }
-
- return { ok: true };
- }
-
- private async typeTextStreamChunk(text: string): Promise {
- const characters = Array.from(text);
- for (const [index, character] of characters.entries()) {
- await this.adapter.typeCharacter(character);
- if (index < characters.length - 1) {
- await delay(TEXT_STREAM_CHUNK_CHARACTER_DELAY_MS);
- }
- }
- }
-
- private expireTextInputStreams(): void {
- const expiresBefore = Date.now() - TEXT_STREAM_TTL_MS;
- for (const [key, stream] of this.textInputStreams) {
- if (stream.updatedAtMs < expiresBefore) {
- this.textInputStreams.delete(key);
- }
- }
- }
-}
-
-function assertBoundedNumber(value: number, maxAbsValue: number, label: string): void {
- if (!Number.isFinite(value) || Math.abs(value) > maxAbsValue) {
- throw new DesktopInputError('unsafe_payload', `${label} is outside allowed bounds.`);
- }
-}
-
-function unsafe(message: string): CommandExecutionResult {
- return { ok: false, code: 'unsafe_payload', message };
-}
-
-function textInputStreamKey(deviceId: string, streamId: string): string {
- return `${deviceId}:${streamId}`;
-}
-
-function delay(delayMs: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, delayMs));
-}
-
-function addMouseDeltas(
- current: { dx: number; dy: number } | null,
- next: { dx: number; dy: number }
-): { dx: number; dy: number } {
- return {
- dx: clampDelta((current?.dx ?? 0) + next.dx),
- dy: clampDelta((current?.dy ?? 0) + next.dy)
- };
-}
-
-function clampDelta(value: number): number {
- return Math.max(-MAX_POINTER_DELTA, Math.min(MAX_POINTER_DELTA, value));
-}
-
-function isPointerAction(
- command: CommandRequest
-): command is CommandRequest & { type: 'mouse.move' | 'mouse.dragStart' | 'mouse.dragEnd' } {
- return command.type === 'mouse.move' || command.type === 'mouse.dragStart' || command.type === 'mouse.dragEnd';
-}
-
-function isMouseCommand(command: CommandRequest): boolean {
- return (
- command.type === 'mouse.move' ||
- command.type === 'mouse.click' ||
- command.type === 'mouse.doubleClick' ||
- command.type === 'mouse.rightClick' ||
- command.type === 'mouse.scroll' ||
- command.type === 'mouse.dragStart' ||
- command.type === 'mouse.dragEnd'
- );
-}
diff --git a/src/main/input/desktop-input-adapter.ts b/src/main/input/desktop-input-adapter.ts
deleted file mode 100644
index ca6c50d..0000000
--- a/src/main/input/desktop-input-adapter.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import type { KeyboardKey, MediaAction, MouseButton, ShortcutKey, WindowControlAction } from '../../shared/protocol';
-
-export type DesktopInputAdapter = {
- getMousePosition(): { x: number; y: number };
- moveMouseBy(delta: { dx: number; dy: number }): Promise;
- setMouseButtonDown(button: MouseButton, down: boolean): Promise;
- clickMouse(button: MouseButton): Promise;
- doubleClickMouse(button: MouseButton): Promise;
- scrollMouse(delta: { dx: number; dy: number }): Promise;
- pressKey(key: KeyboardKey): Promise;
- pressShortcut(keys: ShortcutKey[]): Promise;
- typeText(text: string): Promise;
- typeCharacter(text: string): Promise;
- mediaControl(action: MediaAction): Promise;
- controlWindow(action: WindowControlAction): Promise;
-};
-
-export type DesktopInputErrorCode = 'unsupported_command' | 'unsafe_payload' | 'adapter_failure';
-
-export class DesktopInputError extends Error {
- constructor(
- public readonly code: DesktopInputErrorCode,
- message: string,
- options?: ErrorOptions
- ) {
- super(message, options);
- this.name = 'DesktopInputError';
- }
-}
diff --git a/src/main/input/libnut-win32-adapter.test.ts b/src/main/input/libnut-win32-adapter.test.ts
deleted file mode 100644
index 9d1cf8a..0000000
--- a/src/main/input/libnut-win32-adapter.test.ts
+++ /dev/null
@@ -1,427 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import {
- calculateNativeScrollDelta,
- calculateDisplayNormalizedMouseTarget,
- calculateScaledMouseTarget,
- inferPointerMovementSize,
- toLibnutKeyboardKey,
- toLibnutMouseToggle
-} from './libnut-win32-adapter';
-import {
- createWindowControlScript,
- toWindowsWindowControlStrategy
-} from './windows-window-control';
-import { createPointerMovementProfile } from './pointer-profile';
-import type { PointerMovementSettings } from '../../shared/pointer-movement-settings';
-
-describe('calculateScaledMouseTarget', () => {
- it('applies the display scale factor to relative movement', () => {
- expect(calculateScaledMouseTarget({ x: 100, y: 200 }, { dx: 30, dy: -12 }, 2.5)).toEqual({
- x: 175,
- y: 170
- });
- });
-
- it('handles displays with negative coordinates', () => {
- expect(calculateScaledMouseTarget({ x: -300, y: 100 }, { dx: 80, dy: 40 }, 1.5)).toEqual({
- x: -180,
- y: 160
- });
- });
-
- it('falls back to unscaled movement for invalid scale values', () => {
- expect(calculateScaledMouseTarget({ x: 10, y: 20 }, { dx: 5, dy: 6 }, 0)).toEqual({
- x: 15,
- y: 26
- });
- });
-});
-
-describe('calculateDisplayNormalizedMouseTarget', () => {
- it('uses 1080p as the reference display size', () => {
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: 100, y: 200 },
- { dx: 128, dy: -64 },
- { bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 }
- )
- ).toEqual({
- x: 228,
- y: 136
- });
- });
-
- it('applies larger native movement on a 4K display at 1x scale', () => {
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: 100, y: 200 },
- { dx: 128, dy: 0 },
- { bounds: { x: 0, y: 0, width: 3840, height: 2160 }, scaleFactor: 1 }
- )
- ).toEqual({
- x: 356,
- y: 200
- });
- });
-
- it('combines high-DPI logical deltas with display resolution normalization', () => {
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: 100, y: 200 },
- { dx: 64, dy: 0 },
- { bounds: { x: 0, y: 0, width: 3840, height: 2160 }, scaleFactor: 2 }
- )
- ).toEqual({
- x: 356,
- y: 200
- });
- });
-
- it('uses the display short edge for ultrawide displays', () => {
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: 100, y: 200 },
- { dx: 128, dy: 0 },
- { bounds: { x: 0, y: 0, width: 3440, height: 1440 }, scaleFactor: 1 }
- )
- ).toEqual({
- x: 271,
- y: 200
- });
- });
-
- it('accounts for fractional scale factors', () => {
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: 100, y: 200 },
- { dx: 102, dy: 0 },
- { bounds: { x: 0, y: 0, width: 2560, height: 1440 }, scaleFactor: 1.25 }
- )
- ).toEqual({
- x: 270,
- y: 200
- });
- });
-
- it('handles displays with negative coordinates', () => {
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: -300, y: 100 },
- { dx: 128, dy: 64 },
- { bounds: { x: -3840, y: 0, width: 3840, height: 2160 }, scaleFactor: 1 }
- )
- ).toEqual({
- x: -44,
- y: 228
- });
- });
-
- it('falls back to the reference size for invalid bounds', () => {
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: 10, y: 20 },
- { dx: 5, dy: 6 },
- { bounds: { x: 0, y: 0, width: 0, height: 2160 }, scaleFactor: 1 }
- )
- ).toEqual({
- x: 15,
- y: 26
- });
- });
-
- it('falls back to scale 1 for invalid scale values', () => {
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: 10, y: 20 },
- { dx: 5, dy: 6 },
- { bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 0 }
- )
- ).toEqual({
- x: 15,
- y: 26
- });
- });
-
- it('applies the movement scale to small movement', () => {
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: 100, y: 200 },
- { dx: 48, dy: 0 },
- { bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 },
- { scalePercent: 200 }
- )
- ).toEqual({
- x: 196,
- y: 200
- });
- });
-
- it('applies the movement scale to medium movement', () => {
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: 100, y: 200 },
- { dx: 128, dy: 0 },
- { bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 },
- { scalePercent: 50 }
- )
- ).toEqual({
- x: 164,
- y: 200
- });
- });
-
- it('combines display normalization with customized movement scale', () => {
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: 100, y: 200 },
- { dx: 128, dy: 0 },
- { bounds: { x: 0, y: 0, width: 3840, height: 2160 }, scaleFactor: 1 },
- { scalePercent: 150 }
- )
- ).toEqual({
- x: 484,
- y: 200
- });
- });
-
- it('falls back for invalid display data while applying movement scale', () => {
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: 10, y: 20 },
- { dx: 48, dy: 0 },
- { bounds: { x: 0, y: 0, width: 0, height: 2160 }, scaleFactor: 0 },
- { scalePercent: 200 }
- )
- ).toEqual({
- x: 106,
- y: 20
- });
- });
-
- it('migrates legacy percentage settings before applying movement scale', () => {
- const legacySettings = {
- percentages: { small: 9, medium: 24, large: 50 }
- } as unknown as PointerMovementSettings;
-
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: 100, y: 200 },
- { dx: 48, dy: 0 },
- { bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 },
- legacySettings
- )
- ).toEqual({
- x: 196,
- y: 200
- });
- });
-
- it('normalizes a 4K profile delta once at execution time', () => {
- const profile = createPointerMovementProfile({
- cursor: { x: 100, y: 200 },
- display: {
- bounds: { x: 0, y: 0, width: 3840, height: 2160 },
- scaleFactor: 1
- }
- });
-
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: 100, y: 200 },
- { dx: profile.recommendedDeltas.medium, dy: 0 },
- { bounds: { x: 0, y: 0, width: 3840, height: 2160 }, scaleFactor: 1 }
- )
- ).toEqual({
- x: 360,
- y: 200
- });
- });
-
- it('normalizes a high-DPI profile delta once at execution time', () => {
- const profile = createPointerMovementProfile({
- cursor: { x: 100, y: 200 },
- display: {
- bounds: { x: 0, y: 0, width: 3840, height: 2160 },
- scaleFactor: 2
- }
- });
-
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: 100, y: 200 },
- { dx: profile.recommendedDeltas.medium, dy: 0 },
- { bounds: { x: 0, y: 0, width: 3840, height: 2160 }, scaleFactor: 2 }
- )
- ).toEqual({
- x: 360,
- y: 200
- });
- });
-
- it('applies configured movement scale during profile delta execution', () => {
- const profile = createPointerMovementProfile({
- cursor: { x: 100, y: 200 },
- display: {
- bounds: { x: 0, y: 0, width: 3840, height: 2160 },
- scaleFactor: 1
- }
- });
-
- expect(
- calculateDisplayNormalizedMouseTarget(
- { x: 100, y: 200 },
- { dx: profile.recommendedDeltas.medium, dy: 0 },
- { bounds: { x: 0, y: 0, width: 3840, height: 2160 }, scaleFactor: 1 },
- { scalePercent: 150 }
- )
- ).toEqual({
- x: 490,
- y: 200
- });
- });
-});
-
-describe('inferPointerMovementSize', () => {
- it('classifies movement deltas by dominant axis', () => {
- expect(inferPointerMovementSize({ dx: 48, dy: 0 })).toBe('small');
- expect(inferPointerMovementSize({ dx: 128, dy: 0 })).toBe('medium');
- expect(inferPointerMovementSize({ dx: 280, dy: 0 })).toBe('large');
- expect(inferPointerMovementSize({ dx: 0, dy: 128 })).toBe('medium');
- });
-});
-
-describe('calculateNativeScrollDelta', () => {
- it('scales vertical scroll by the native multiplier', () => {
- expect(calculateNativeScrollDelta({ dx: 0, dy: 5 })).toEqual({
- dx: 0,
- dy: 40
- });
- });
-
- it('scales horizontal scroll by the native multiplier', () => {
- expect(calculateNativeScrollDelta({ dx: 1, dy: 0 })).toEqual({
- dx: 8,
- dy: 0
- });
- });
-
- it('preserves negative scroll direction', () => {
- expect(calculateNativeScrollDelta({ dx: -2, dy: -5 })).toEqual({
- dx: -16,
- dy: -40
- });
- });
-
- it('preserves zero axes exactly', () => {
- expect(calculateNativeScrollDelta({ dx: 0, dy: 0 })).toEqual({
- dx: 0,
- dy: 0
- });
- });
-
- it('rounds fractional scaled values', () => {
- expect(calculateNativeScrollDelta({ dx: 1.4, dy: -1.6 })).toEqual({
- dx: 11,
- dy: -13
- });
- });
-
- it('keeps tiny nonzero scroll values at least one native unit', () => {
- expect(calculateNativeScrollDelta({ dx: 0.1, dy: -0.1 })).toEqual({
- dx: 1,
- dy: -1
- });
- });
-
- it('falls back to unscaled output for invalid multiplier values', () => {
- expect(calculateNativeScrollDelta({ dx: 2, dy: -3 }, 0)).toEqual({
- dx: 2,
- dy: -3
- });
- expect(calculateNativeScrollDelta({ dx: 2, dy: -3 }, Number.NaN)).toEqual({
- dx: 2,
- dy: -3
- });
- });
-});
-
-describe('toLibnutMouseToggle', () => {
- it('maps held-button state to libnut toggle values', () => {
- expect(toLibnutMouseToggle(true)).toBe('down');
- expect(toLibnutMouseToggle(false)).toBe('up');
- });
-});
-
-describe('toLibnutKeyboardKey', () => {
- it('maps navigation keys to libnut key values', () => {
- expect(toLibnutKeyboardKey('PageUp')).toBe('pageup');
- });
-
- it('maps function keys to libnut key values', () => {
- expect(toLibnutKeyboardKey('F1')).toBe('f1');
- expect(toLibnutKeyboardKey('F12')).toBe('f12');
- });
-
- it('maps Meta to the libnut Windows key', () => {
- expect(toLibnutKeyboardKey('Meta')).toBe('win');
- });
-});
-
-describe('toWindowsWindowControlStrategy', () => {
- it('leaves app switching on the known-good libnut path', () => {
- expect(toWindowsWindowControlStrategy('switchNext')).toBeNull();
- expect(toWindowsWindowControlStrategy('switchPrevious')).toBeNull();
- });
-
- it('maps task view to the shell task view strategy', () => {
- expect(toWindowsWindowControlStrategy('taskView')).toEqual({ kind: 'taskView' });
- });
-
- it('maps show desktop to the shell desktop strategy', () => {
- expect(toWindowsWindowControlStrategy('showDesktop')).toEqual({ kind: 'showDesktop' });
- });
-
- it('maps close focused to a foreground window operation', () => {
- expect(toWindowsWindowControlStrategy('closeFocused')).toEqual({ kind: 'foregroundWindow', operation: 'close' });
- });
-
- it('maps minimize focused to a foreground window operation', () => {
- expect(toWindowsWindowControlStrategy('minimizeFocused')).toEqual({
- kind: 'foregroundWindow',
- operation: 'minimize'
- });
- });
-
- it('maps maximize focused to a foreground window operation', () => {
- expect(toWindowsWindowControlStrategy('maximizeFocused')).toEqual({
- kind: 'foregroundWindow',
- operation: 'maximizeOrRestore'
- });
- });
-});
-
-describe('createWindowControlScript', () => {
- it('uses shell integration for task view', () => {
- expect(createWindowControlScript({ kind: 'taskView' })).toBe(
- "Start-Process explorer.exe 'shell:::{3080F90E-D7AD-11D9-BD98-0000947B0257}'"
- );
- });
-
- it('uses shell integration for show desktop', () => {
- expect(createWindowControlScript({ kind: 'showDesktop' })).toBe(
- '(New-Object -ComObject Shell.Application).MinimizeAll()'
- );
- });
-
- it('uses direct foreground window APIs for close, minimize, and maximize', () => {
- const closeScript = createWindowControlScript({ kind: 'foregroundWindow', operation: 'close' });
- const minimizeScript = createWindowControlScript({ kind: 'foregroundWindow', operation: 'minimize' });
- const maximizeScript = createWindowControlScript({ kind: 'foregroundWindow', operation: 'maximizeOrRestore' });
-
- expect(closeScript).toContain('PostMessage($handle, 0x0010');
- expect(minimizeScript).toContain('ShowWindow($handle, 6)');
- expect(maximizeScript).toContain('IsZoomed($handle)');
- expect(maximizeScript).toContain('ShowWindow($handle, 9)');
- expect(maximizeScript).toContain('ShowWindow($handle, 3)');
- });
-});
diff --git a/src/main/input/libnut-win32-adapter.ts b/src/main/input/libnut-win32-adapter.ts
deleted file mode 100644
index b2c9dab..0000000
--- a/src/main/input/libnut-win32-adapter.ts
+++ /dev/null
@@ -1,311 +0,0 @@
-import {
- getMousePos,
- keyTap,
- mouseClick,
- mouseToggle,
- moveMouse,
- scrollMouse as nativeScrollMouse,
- typeString
-} from '@nut-tree-fork/libnut-win32';
-import { clipboard } from 'electron';
-import type { KeyboardKey, MediaAction, MouseButton, ShortcutKey, WindowControlAction } from '../../shared/protocol';
-import {
- DEFAULT_POINTER_MOVEMENT_SETTINGS,
- normalizePointerMovementSettings,
- pointerMovementFractionFor,
- type PointerMovementSettings,
- type PointerMovementSizeKey
-} from '../../shared/pointer-movement-settings';
-import type { DesktopInputAdapter } from './desktop-input-adapter';
-import { DesktopInputError } from './desktop-input-adapter';
-import { insertText } from './text-inserter';
-import { createTextInputBackend, type TextInputBackend } from './text-input-helper-client';
-import { runWindowsWindowControlAction } from './windows-window-control';
-
-type Point = { x: number; y: number };
-type PointerDisplay = {
- bounds: { x: number; y: number; width: number; height: number };
- scaleFactor: number;
-};
-type PointerDisplayProvider = (position: Point) => PointerDisplay;
-
-export const NATIVE_SCROLL_DELTA_MULTIPLIER = 8;
-export const REFERENCE_POINTER_SHORT_EDGE = 1080;
-const BASELINE_POINTER_DELTAS = {
- small: 48,
- medium: 128,
- large: 280
-};
-const SMALL_MEDIUM_POINTER_BOUNDARY = (BASELINE_POINTER_DELTAS.small + BASELINE_POINTER_DELTAS.medium) / 2;
-const MEDIUM_LARGE_POINTER_BOUNDARY = (BASELINE_POINTER_DELTAS.medium + BASELINE_POINTER_DELTAS.large) / 2;
-
-function defaultPointerDisplayProvider(): PointerDisplay {
- return {
- bounds: { x: 0, y: 0, width: REFERENCE_POINTER_SHORT_EDGE, height: REFERENCE_POINTER_SHORT_EDGE },
- scaleFactor: 1
- };
-}
-
-export class LibnutWin32InputAdapter implements DesktopInputAdapter {
- private pointerMovementSettings: PointerMovementSettings;
-
- constructor(
- private readonly getPointerDisplay: PointerDisplayProvider = defaultPointerDisplayProvider,
- private readonly textInputBackend: TextInputBackend = createTextInputBackend(),
- pointerMovementSettings: PointerMovementSettings = DEFAULT_POINTER_MOVEMENT_SETTINGS
- ) {
- this.pointerMovementSettings = normalizePointerMovementSettings(pointerMovementSettings);
- }
-
- setPointerMovementSettings(settings: PointerMovementSettings): void {
- this.pointerMovementSettings = normalizePointerMovementSettings(settings);
- }
-
- getMousePosition(): { x: number; y: number } {
- const current = getMousePos();
- return { x: current.x, y: current.y };
- }
-
- async moveMouseBy(delta: { dx: number; dy: number }): Promise {
- const current = this.getMousePosition();
- const display = this.getPointerDisplay(current);
- const target = calculateDisplayNormalizedMouseTarget(current, delta, display, this.pointerMovementSettings);
- moveMouse(target.x, target.y);
- }
-
- async setMouseButtonDown(button: MouseButton, down: boolean): Promise {
- const toggle = toLibnutMouseToggle(down);
- mouseToggle(toggle, toLibnutMouseButton(button));
- }
-
- async clickMouse(button: MouseButton): Promise {
- mouseClick(toLibnutMouseButton(button), false);
- }
-
- async doubleClickMouse(button: MouseButton): Promise {
- mouseClick(toLibnutMouseButton(button), true);
- }
-
- async scrollMouse(delta: { dx: number; dy: number }): Promise {
- const nativeDelta = calculateNativeScrollDelta(delta);
- nativeScrollMouse(nativeDelta.dx, nativeDelta.dy);
- }
-
- async pressKey(key: KeyboardKey): Promise {
- keyTap(toLibnutKeyboardKey(key));
- }
-
- async pressShortcut(keys: ShortcutKey[]): Promise {
- if (keys.length === 1) {
- keyTap(toLibnutKeyboardKey(keys[0]));
- return;
- }
-
- const [key, ...modifiers] = [...keys].reverse();
- keyTap(toLibnutKeyboardKey(key), modifiers.map(toLibnutKeyboardKey));
- }
-
- async typeText(text: string): Promise {
- try {
- await insertText(text, {
- clipboard,
- typeString,
- typeUnicodeText: (value) => this.textInputBackend.typeText(value),
- pasteFromClipboard: () => keyTap('v', ['control']),
- scheduleRestore: (callback, delayMs) => {
- setTimeout(callback, delayMs);
- },
- wait: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))
- });
- } catch (error) {
- throw new DesktopInputError('adapter_failure', 'Text insertion failed.', { cause: error });
- }
- }
-
- async typeCharacter(text: string): Promise {
- try {
- await this.textInputBackend.typeText(text);
- } catch (error) {
- throw new DesktopInputError('adapter_failure', 'Character insertion failed.', { cause: error });
- }
- }
-
- async mediaControl(action: MediaAction): Promise {
- keyTap(toLibnutMediaKey(action));
- }
-
- async controlWindow(action: WindowControlAction): Promise {
- try {
- if (action === 'switchNext') {
- keyTap('tab', ['alt']);
- return;
- }
-
- if (action === 'switchPrevious') {
- keyTap('tab', ['alt', 'shift']);
- return;
- }
-
- await runWindowsWindowControlAction(action);
- } catch (error) {
- throw new DesktopInputError('adapter_failure', 'Window control failed.', { cause: error });
- }
- }
-}
-
-export function calculateScaledMouseTarget(
- current: Point,
- delta: { dx: number; dy: number },
- scale: number
-): Point {
- const effectiveScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
- return {
- x: Math.round(current.x + delta.dx * effectiveScale),
- y: Math.round(current.y + delta.dy * effectiveScale)
- };
-}
-
-export function calculateDisplayNormalizedMouseTarget(
- current: Point,
- delta: { dx: number; dy: number },
- display: PointerDisplay,
- movementSettings: PointerMovementSettings = DEFAULT_POINTER_MOVEMENT_SETTINGS
-): Point {
- const scaleFactor = Number.isFinite(display.scaleFactor) && display.scaleFactor > 0 ? display.scaleFactor : 1;
- const shortEdge =
- Number.isFinite(display.bounds.width) && display.bounds.width > 0 &&
- Number.isFinite(display.bounds.height) && display.bounds.height > 0
- ? Math.min(display.bounds.width, display.bounds.height)
- : REFERENCE_POINTER_SHORT_EDGE;
- const settings = normalizePointerMovementSettings(movementSettings);
- const size = inferPointerMovementSize(delta);
- const baselineFraction = pointerMovementFractionFor(DEFAULT_POINTER_MOVEMENT_SETTINGS, size);
- const movementScale = pointerMovementFractionFor(settings, size) / baselineFraction;
- const multiplier = scaleFactor * (shortEdge / REFERENCE_POINTER_SHORT_EDGE) * movementScale;
-
- return {
- x: Math.round(current.x + delta.dx * multiplier),
- y: Math.round(current.y + delta.dy * multiplier)
- };
-}
-
-export function inferPointerMovementSize(delta: { dx: number; dy: number }): PointerMovementSizeKey {
- const magnitude = Math.max(Math.abs(delta.dx), Math.abs(delta.dy));
- if (magnitude <= SMALL_MEDIUM_POINTER_BOUNDARY) return 'small';
- if (magnitude <= MEDIUM_LARGE_POINTER_BOUNDARY) return 'medium';
- return 'large';
-}
-
-export function calculateNativeScrollDelta(
- delta: { dx: number; dy: number },
- multiplier = NATIVE_SCROLL_DELTA_MULTIPLIER
-): { dx: number; dy: number } {
- const effectiveMultiplier = Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1;
- return {
- dx: scaleScrollAxis(delta.dx, effectiveMultiplier),
- dy: scaleScrollAxis(delta.dy, effectiveMultiplier)
- };
-}
-
-export function toLibnutMouseToggle(down: boolean): 'down' | 'up' {
- return down ? 'down' : 'up';
-}
-
-function scaleScrollAxis(value: number, multiplier: number): number {
- if (value === 0) return 0;
-
- const scaledValue = value * multiplier;
- const roundedValue = Math.round(Math.abs(scaledValue));
- return Math.sign(scaledValue) * Math.max(1, roundedValue);
-}
-
-function toLibnutMouseButton(button: MouseButton): string {
- switch (button) {
- case 'left':
- return 'left';
- case 'right':
- return 'right';
- case 'middle':
- return 'middle';
- }
-}
-
-export function toLibnutKeyboardKey(key: KeyboardKey | ShortcutKey): string {
- switch (key) {
- case 'Backspace':
- return 'backspace';
- case 'Delete':
- return 'delete';
- case 'Enter':
- return 'enter';
- case 'Escape':
- return 'escape';
- case 'Space':
- return 'space';
- case 'Tab':
- return 'tab';
- case 'ArrowUp':
- return 'up';
- case 'ArrowDown':
- return 'down';
- case 'ArrowLeft':
- return 'left';
- case 'ArrowRight':
- return 'right';
- case 'Home':
- return 'home';
- case 'End':
- return 'end';
- case 'PageUp':
- return 'pageup';
- case 'PageDown':
- return 'pagedown';
- case 'F1':
- case 'F2':
- case 'F3':
- case 'F4':
- case 'F5':
- case 'F6':
- case 'F7':
- case 'F8':
- case 'F9':
- case 'F10':
- case 'F11':
- case 'F12':
- return key.toLowerCase();
- case 'Ctrl':
- return 'control';
- case 'Alt':
- return 'alt';
- case 'Shift':
- return 'shift';
- case 'Meta':
- return 'win';
- case 'A':
- case 'C':
- case 'V':
- case 'X':
- case 'Y':
- case 'Z':
- return key.toLowerCase();
- }
-}
-
-function toLibnutMediaKey(action: MediaAction): string {
- switch (action) {
- case 'playPause':
- return 'audio_play';
- case 'nextTrack':
- return 'audio_next';
- case 'previousTrack':
- return 'audio_prev';
- case 'volumeUp':
- return 'audio_vol_up';
- case 'volumeDown':
- return 'audio_vol_down';
- case 'mute':
- return 'audio_mute';
- default:
- throw new DesktopInputError('unsupported_command', `Unsupported media action: ${action}`);
- }
-}
diff --git a/src/main/input/pointer-profile.test.ts b/src/main/input/pointer-profile.test.ts
deleted file mode 100644
index 38fb3a8..0000000
--- a/src/main/input/pointer-profile.test.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { MAX_POINTER_DELTA } from '../../shared/protocol';
-import { createPointerMovementProfile } from './pointer-profile';
-
-describe('createPointerMovementProfile', () => {
- it('creates stable baseline deltas for a 1280x720 display at 1.5 scale', () => {
- const profile = createPointerMovementProfile({
- cursor: { x: 100, y: 100 },
- display: {
- bounds: { x: 0, y: 0, width: 1280, height: 720 },
- scaleFactor: 1.5
- }
- });
-
- expect(profile).toMatchObject({
- displayId: '0:0:1280:720:1.5',
- scaleFactor: 1.5,
- bounds: { x: 0, y: 0, width: 1280, height: 720 },
- maxDelta: MAX_POINTER_DELTA,
- recommendedDeltas: {
- small: 32,
- medium: 86,
- large: 187
- },
- capabilities: {
- noAckMouseMove: true
- }
- });
- expect(profile.capabilities.noAckCommands).toContain('keyboard.textStream.char');
- expect(profile.capabilities.noAckCommands).toContain('keyboard.textStream.key');
- expect(profile.capabilities.supportedCommands).toEqual(
- expect.arrayContaining([
- 'keyboard.textStream.open',
- 'keyboard.textStream.chunk',
- 'keyboard.textStream.char',
- 'keyboard.textStream.key',
- 'keyboard.textStream.close'
- ])
- );
- });
-
- it('preserves current feel on a 1920x1080 display at 1x scale', () => {
- expect(
- createPointerMovementProfile({
- cursor: { x: 100, y: 100 },
- display: {
- bounds: { x: 0, y: 0, width: 1920, height: 1080 },
- scaleFactor: 1
- }
- }).recommendedDeltas
- ).toEqual({
- small: 49,
- medium: 130,
- large: 281
- });
- });
-
- it('keeps profile deltas stable when pointer movement scale changes', () => {
- expect(
- createPointerMovementProfile({
- cursor: { x: 100, y: 100 },
- display: {
- bounds: { x: 0, y: 0, width: 1920, height: 1080 },
- scaleFactor: 1
- }
- }).recommendedDeltas
- ).toEqual({
- small: 49,
- medium: 130,
- large: 281
- });
- });
-
- it('divides stable baseline deltas by scale factor on high-DPI displays', () => {
- expect(
- createPointerMovementProfile({
- cursor: { x: 100, y: 100 },
- display: {
- bounds: { x: 0, y: 0, width: 3840, height: 2160 },
- scaleFactor: 2
- }
- }).recommendedDeltas.medium
- ).toBe(65);
- });
-
- it('does not apply movement settings in the pointer profile', () => {
- expect(
- createPointerMovementProfile({
- cursor: { x: 100, y: 100 },
- display: {
- bounds: { x: 0, y: 0, width: 1920, height: 1080 },
- scaleFactor: 1
- }
- }).recommendedDeltas
- ).toEqual({
- small: 49,
- medium: 130,
- large: 281
- });
- });
-
- it('returns stable baseline deltas on a 4K display at 1x scale', () => {
- expect(
- createPointerMovementProfile({
- cursor: { x: 100, y: 100 },
- display: {
- bounds: { x: 0, y: 0, width: 3840, height: 2160 },
- scaleFactor: 1
- }
- }).recommendedDeltas
- ).toEqual({
- small: 49,
- medium: 130,
- large: 281
- });
- });
-
- it('returns stable baseline deltas on ultrawide displays', () => {
- expect(
- createPointerMovementProfile({
- cursor: { x: 100, y: 100 },
- display: {
- bounds: { x: 0, y: 0, width: 3440, height: 1440 },
- scaleFactor: 1
- }
- }).recommendedDeltas
- ).toEqual({
- small: 49,
- medium: 130,
- large: 281
- });
- });
-
- it('still divides by scale factor on high-DPI displays', () => {
- expect(
- createPointerMovementProfile({
- cursor: { x: 100, y: 100 },
- display: {
- bounds: { x: 0, y: 0, width: 3840, height: 2160 },
- scaleFactor: 2
- }
- }).recommendedDeltas
- ).toEqual({
- small: 24,
- medium: 65,
- large: 140
- });
- });
-
- it('keeps all recommended deltas below the configured max', () => {
- const profile = createPointerMovementProfile({
- cursor: { x: 100, y: 100 },
- display: {
- bounds: { x: 0, y: 0, width: 3840, height: 2160 },
- scaleFactor: 0.25
- },
- maxDelta: 500
- });
-
- expect(profile.recommendedDeltas.small).toBeLessThanOrEqual(500);
- expect(profile.recommendedDeltas.medium).toBeLessThanOrEqual(500);
- expect(profile.recommendedDeltas.large).toBeLessThanOrEqual(500);
- });
-
- it('falls back to scale factor 1 for invalid scale values', () => {
- const profile = createPointerMovementProfile({
- cursor: { x: 100, y: 100 },
- display: {
- bounds: { x: 0, y: 0, width: 1280, height: 720 },
- scaleFactor: 0
- }
- });
-
- expect(profile.scaleFactor).toBe(1);
- expect(profile.displayId).toBe('0:0:1280:720:1');
- });
-
- it('uses negative display coordinates in the stable display id', () => {
- const profile = createPointerMovementProfile({
- cursor: { x: -100, y: 100 },
- display: {
- bounds: { x: -1920, y: 0, width: 1920, height: 1080 },
- scaleFactor: 1.25
- }
- });
-
- expect(profile.displayId).toBe('-1920:0:1920:1080:1.25');
- });
-});
diff --git a/src/main/input/pointer-profile.ts b/src/main/input/pointer-profile.ts
deleted file mode 100644
index 464d015..0000000
--- a/src/main/input/pointer-profile.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { MAX_POINTER_DELTA, NO_ACK_CONTROL_COMMAND_TYPES, type PointerMovementProfile } from '../../shared/protocol';
-import {
- pointerMovementFractionFor,
- DEFAULT_POINTER_MOVEMENT_SETTINGS,
- type PointerMovementSizeKey
-} from '../../shared/pointer-movement-settings';
-
-type Point = { x: number; y: number };
-type Bounds = { x: number; y: number; width: number; height: number };
-
-const REFERENCE_POINTER_SHORT_EDGE = 1080;
-const pointerMovementSizeKeys: PointerMovementSizeKey[] = ['small', 'medium', 'large'];
-const TARGET_REFERENCE_NATIVE_DELTAS = Object.fromEntries(
- pointerMovementSizeKeys.map((size) => [
- size,
- REFERENCE_POINTER_SHORT_EDGE * pointerMovementFractionFor(DEFAULT_POINTER_MOVEMENT_SETTINGS, size)
- ])
-) as Record;
-
-export function createPointerMovementProfile(input: {
- cursor: Point;
- display: {
- bounds: Bounds;
- scaleFactor: number;
- };
- maxDelta?: number;
-}): PointerMovementProfile {
- const bounds = normalizeBounds(input.display.bounds);
- const scaleFactor =
- Number.isFinite(input.display.scaleFactor) && input.display.scaleFactor > 0 ? input.display.scaleFactor : 1;
- const maxDelta = input.maxDelta ?? MAX_POINTER_DELTA;
-
- return {
- displayId: `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}:${scaleFactor}`,
- scaleFactor,
- bounds,
- maxDelta,
- recommendedDeltas: {
- small: toLogicalDelta(TARGET_REFERENCE_NATIVE_DELTAS.small, scaleFactor, maxDelta),
- medium: toLogicalDelta(TARGET_REFERENCE_NATIVE_DELTAS.medium, scaleFactor, maxDelta),
- large: toLogicalDelta(TARGET_REFERENCE_NATIVE_DELTAS.large, scaleFactor, maxDelta)
- },
- capabilities: {
- noAckMouseMove: true,
- noAckCommands: [...NO_ACK_CONTROL_COMMAND_TYPES],
- supportedCommands: [
- ...NO_ACK_CONTROL_COMMAND_TYPES,
- 'keyboard.textStream.open',
- 'keyboard.textStream.chunk',
- 'keyboard.textStream.close',
- 'connection.ping',
- 'pointer.profile'
- ]
- }
- };
-}
-
-function normalizeBounds(bounds: Bounds): Bounds {
- return {
- x: finiteOr(bounds.x, 0),
- y: finiteOr(bounds.y, 0),
- width: positiveFiniteOr(bounds.width, 1),
- height: positiveFiniteOr(bounds.height, 1)
- };
-}
-
-function finiteOr(value: number, fallback: number): number {
- return Number.isFinite(value) ? value : fallback;
-}
-
-function positiveFiniteOr(value: number, fallback: number): number {
- return Number.isFinite(value) && value > 0 ? value : fallback;
-}
-
-function clamp(value: number, min: number, max: number): number {
- return Math.min(max, Math.max(min, value));
-}
-
-function toLogicalDelta(nativePixels: number, scaleFactor: number, maxDelta: number): number {
- return clamp(Math.round(nativePixels / scaleFactor), 1, maxDelta);
-}
diff --git a/src/main/input/text-input-helper-client.test.ts b/src/main/input/text-input-helper-client.test.ts
deleted file mode 100644
index 6a7a5fd..0000000
--- a/src/main/input/text-input-helper-client.test.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-import { EventEmitter } from 'node:events';
-import { describe, expect, it, vi } from 'vitest';
-import { createTextInputBackend } from './text-input-helper-client';
-
-class FakeTextInputHelperProcess extends EventEmitter {
- readonly writes: string[] = [];
- killed = false;
- readonly stdin = {
- destroyed: false,
- write: vi.fn((chunk: string) => {
- this.writes.push(chunk);
- return true;
- }),
- end: vi.fn()
- };
- readonly stdout = new EventEmitter();
- readonly stderr = new EventEmitter();
-
- kill(): boolean {
- this.killed = true;
- this.emit('exit', null, 'SIGTERM');
- return true;
- }
-}
-
-function emitJson(helper: FakeTextInputHelperProcess, message: unknown): void {
- helper.stdout.emit('data', `${JSON.stringify(message)}\n`);
-}
-
-async function waitForWrites(helper: FakeTextInputHelperProcess, count: number): Promise {
- const startedAt = Date.now();
- while (Date.now() - startedAt < 1_000) {
- if (helper.writes.length >= count) return;
- await new Promise((resolve) => setTimeout(resolve, 5));
- }
- throw new Error(`Expected ${count} helper writes, saw ${helper.writes.length}.`);
-}
-
-describe('createTextInputBackend', () => {
- it('waits for ready and sends typeText commands', async () => {
- const helper = new FakeTextInputHelperProcess();
- const backend = createTextInputBackend({
- helperPath: 'C:\\SwitchifyTextInput.exe',
- exists: () => true,
- spawnProcess: () => helper as never,
- createRequestId: () => 'request-1'
- });
-
- const result = backend.typeText('Secret text');
- emitJson(helper, { type: 'ready' });
- await waitForWrites(helper, 1);
- expect(JSON.parse(helper.writes[0])).toEqual({
- type: 'typeText',
- id: 'request-1',
- text: 'Secret text'
- });
- emitJson(helper, { type: 'result', id: 'request-1', ok: true, sentEvents: 22 });
-
- await expect(result).resolves.toBeUndefined();
- });
-
- it('rejects if the helper executable is missing', async () => {
- const backend = createTextInputBackend({
- helperPath: 'C:\\missing\\SwitchifyTextInput.exe',
- exists: () => false
- });
-
- await expect(backend.typeText('Secret text')).rejects.toThrow('Text input helper was not found');
- });
-
- it('rejects helper error responses without including typed text', async () => {
- const helper = new FakeTextInputHelperProcess();
- const backend = createTextInputBackend({
- helperPath: 'C:\\SwitchifyTextInput.exe',
- exists: () => true,
- spawnProcess: () => helper as never,
- createRequestId: () => 'request-1'
- });
-
- const result = backend.typeText('Secret text');
- emitJson(helper, { type: 'ready' });
- await waitForWrites(helper, 1);
- emitJson(helper, {
- type: 'error',
- id: 'request-1',
- ok: false,
- code: 'send_input_failed',
- message: 'Secret text should not appear in the client error.'
- });
-
- const error = await result.catch((item: unknown) => item);
- expect(error).toBeInstanceOf(Error);
- expect((error as Error).message).toContain('Text input helper failed (send_input_failed).');
- expect((error as Error).message).not.toContain('Secret text');
- });
-
- it('rejects when the helper does not become ready before timeout', async () => {
- vi.useFakeTimers();
- try {
- const helper = new FakeTextInputHelperProcess();
- const backend = createTextInputBackend({
- helperPath: 'C:\\SwitchifyTextInput.exe',
- exists: () => true,
- spawnProcess: () => helper as never,
- timeoutMs: 50
- });
-
- const result = backend.typeText('Secret text');
- const expectation = expect(result).rejects.toThrow('Text input helper timed out.');
- await vi.advanceTimersByTimeAsync(50);
-
- await expectation;
- } finally {
- vi.useRealTimers();
- }
- });
-
- it('rejects when the helper exits before responding', async () => {
- const helper = new FakeTextInputHelperProcess();
- const backend = createTextInputBackend({
- helperPath: 'C:\\SwitchifyTextInput.exe',
- exists: () => true,
- spawnProcess: () => helper as never,
- createRequestId: () => 'request-1'
- });
-
- const result = backend.typeText('Secret text');
- emitJson(helper, { type: 'ready' });
- await waitForWrites(helper, 1);
- helper.emit('exit', 1, null);
-
- await expect(result).rejects.toThrow('Text input helper exited unexpectedly');
- });
-
- it('sends shutdown on dispose', async () => {
- const helper = new FakeTextInputHelperProcess();
- const backend = createTextInputBackend({
- helperPath: 'C:\\SwitchifyTextInput.exe',
- exists: () => true,
- spawnProcess: () => helper as never,
- createRequestId: () => 'request-1',
- shutdownKillDelayMs: 1
- });
-
- const result = backend.typeText('Secret text');
- emitJson(helper, { type: 'ready' });
- await waitForWrites(helper, 1);
- emitJson(helper, { type: 'result', id: 'request-1', ok: true, sentEvents: 22 });
- await result;
-
- backend.dispose();
-
- expect(JSON.parse(helper.writes[1])).toEqual({ type: 'shutdown' });
- expect(helper.stdin.end).toHaveBeenCalled();
- });
-});
diff --git a/src/main/input/text-input-helper-client.ts b/src/main/input/text-input-helper-client.ts
deleted file mode 100644
index 1ec79f4..0000000
--- a/src/main/input/text-input-helper-client.ts
+++ /dev/null
@@ -1,268 +0,0 @@
-import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
-import { randomUUID } from 'node:crypto';
-import { existsSync } from 'node:fs';
-import { join } from 'node:path';
-import { app } from 'electron';
-
-export type TextInputBackend = {
- typeText(text: string): Promise;
- dispose(): void;
-};
-
-export type TextInputBackendOptions = {
- helperPath?: string;
- timeoutMs?: number;
- spawnProcess?: (helperPath: string) => ChildProcessWithoutNullStreams;
- exists?: (helperPath: string) => boolean;
- createRequestId?: () => string;
- shutdownKillDelayMs?: number;
-};
-
-type TextInputHelperCommand =
- | {
- type: 'typeText';
- id: string;
- text: string;
- }
- | { type: 'shutdown' };
-
-type PendingRequest = {
- resolve: () => void;
- reject: (error: Error) => void;
- timeout: NodeJS.Timeout;
-};
-
-type HelperMessage = {
- type?: string;
- id?: string | null;
- ok?: boolean;
- code?: string;
- message?: string;
-};
-
-const DEFAULT_TIMEOUT_MS = 2_000;
-
-export function createTextInputBackend(options: TextInputBackendOptions = {}): TextInputBackend {
- return new NativeTextInputBackend(options);
-}
-
-class NativeTextInputBackend implements TextInputBackend {
- private process: ChildProcessWithoutNullStreams | null = null;
- private ready: Promise | null = null;
- private readyResolve: (() => void) | null = null;
- private readyReject: ((error: Error) => void) | null = null;
- private stdoutBuffer = '';
- private disposed = false;
- private readonly pending = new Map();
-
- constructor(private readonly options: TextInputBackendOptions) {}
-
- async typeText(text: string): Promise {
- if (this.disposed) {
- throw new Error('Text input helper is disposed.');
- }
-
- const helper = this.ensureProcess();
- const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
- await this.waitUntilReady(timeoutMs);
-
- if (!helper.stdin || helper.stdin.destroyed) {
- throw new Error('Text input helper stdin is unavailable.');
- }
-
- const id = this.options.createRequestId?.() ?? `text-input-${randomUUID()}`;
-
- await new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- const error = new Error('Text input helper timed out.');
- this.pending.delete(id);
- this.fail(error);
- reject(error);
- }, timeoutMs);
- timeout.unref?.();
- this.pending.set(id, { resolve, reject, timeout });
-
- try {
- helper.stdin.write(`${JSON.stringify({ type: 'typeText', id, text } satisfies TextInputHelperCommand)}\n`);
- } catch (error) {
- this.pending.delete(id);
- clearTimeout(timeout);
- reject(error instanceof Error ? error : new Error('Text input helper write failed.'));
- }
- });
- }
-
- dispose(): void {
- this.disposed = true;
- this.rejectAll(new Error('Text input helper was disposed.'));
-
- const helper = this.process;
- this.process = null;
- this.ready = null;
- this.readyResolve = null;
- this.readyReject = null;
- if (!helper) return;
-
- try {
- helper.stdin.write(`${JSON.stringify({ type: 'shutdown' } satisfies TextInputHelperCommand)}\n`);
- helper.stdin.end();
- } catch {
- // Ignore shutdown failures; the process is about to be killed if it remains alive.
- }
-
- const killDelayMs = this.options.shutdownKillDelayMs ?? 500;
- setTimeout(() => {
- if (!helper.killed) {
- helper.kill();
- }
- }, killDelayMs).unref();
- }
-
- private ensureProcess(): ChildProcessWithoutNullStreams {
- if (this.process) return this.process;
-
- const helperPath = this.options.helperPath ?? resolveTextInputHelperPath();
- const exists = this.options.exists ?? existsSync;
- if (!exists(helperPath)) {
- throw new Error(`Text input helper was not found: ${helperPath}`);
- }
-
- const helper = (this.options.spawnProcess ?? spawnTextInputHelper)(helperPath);
- this.process = helper;
- this.ready = new Promise((resolve, reject) => {
- this.readyResolve = resolve;
- this.readyReject = reject;
- });
-
- helper.once('error', (error) => {
- this.fail(new Error(`Text input helper failed: ${error.message}`));
- });
- helper.once('exit', (code, signal) => {
- if (!this.disposed) {
- this.fail(new Error(`Text input helper exited unexpectedly: ${signal ?? code ?? 'unknown'}`));
- }
- });
- helper.stdout.on('data', (chunk) => this.handleStdout(String(chunk)));
- helper.stderr.on('data', () => {
- // Stderr is intentionally ignored to avoid leaking target text from unexpected helper output.
- });
-
- return helper;
- }
-
- private async waitUntilReady(timeoutMs: number): Promise {
- if (!this.ready) {
- throw new Error('Text input helper is unavailable.');
- }
-
- await new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- const error = new Error('Text input helper timed out.');
- this.fail(error);
- reject(error);
- }, timeoutMs);
- timeout.unref?.();
- this.ready?.then(
- () => {
- clearTimeout(timeout);
- resolve();
- },
- (error) => {
- clearTimeout(timeout);
- reject(error);
- }
- );
- });
- }
-
- private handleStdout(output: string): void {
- this.stdoutBuffer += output;
-
- let newlineIndex = this.stdoutBuffer.search(/\r?\n/);
- while (newlineIndex >= 0) {
- const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
- this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + (this.stdoutBuffer[newlineIndex] === '\r' ? 2 : 1));
- if (line) {
- this.handleLine(line);
- }
- newlineIndex = this.stdoutBuffer.search(/\r?\n/);
- }
- }
-
- private handleLine(line: string): void {
- let message: HelperMessage;
- try {
- message = JSON.parse(line) as HelperMessage;
- } catch {
- this.fail(new Error('Text input helper returned malformed status output.'));
- return;
- }
-
- if (message.type === 'ready') {
- this.readyResolve?.();
- this.readyResolve = null;
- this.readyReject = null;
- return;
- }
-
- const id = typeof message.id === 'string' ? message.id : null;
- if (!id) {
- if (message.type === 'error') {
- this.fail(new Error(helperErrorMessage(message)));
- }
- return;
- }
-
- const pending = this.pending.get(id);
- if (!pending) return;
-
- this.pending.delete(id);
- clearTimeout(pending.timeout);
-
- if (message.type === 'result' && message.ok === true) {
- pending.resolve();
- return;
- }
-
- pending.reject(new Error(helperErrorMessage(message)));
- }
-
- private fail(error: Error): void {
- this.readyReject?.(error);
- this.readyResolve = null;
- this.readyReject = null;
- this.rejectAll(error);
-
- const helper = this.process;
- this.process = null;
- if (helper && !helper.killed) {
- helper.kill();
- }
- }
-
- private rejectAll(error: Error): void {
- for (const [id, pending] of this.pending) {
- this.pending.delete(id);
- clearTimeout(pending.timeout);
- pending.reject(error);
- }
- }
-}
-
-function helperErrorMessage(message: HelperMessage): string {
- const code = typeof message.code === 'string' ? message.code : 'helper_error';
- return `Text input helper failed (${code}).`;
-}
-
-function spawnTextInputHelper(helperPath: string): ChildProcessWithoutNullStreams {
- return spawn(helperPath, [], {
- stdio: ['pipe', 'pipe', 'pipe'],
- windowsHide: true
- });
-}
-
-function resolveTextInputHelperPath(): string {
- return app.isPackaged
- ? join(process.resourcesPath, 'native', 'SwitchifyTextInput.exe')
- : join(process.cwd(), 'build', 'native', 'text-input-helper', 'win-x64', 'SwitchifyTextInput.exe');
-}
diff --git a/src/main/input/text-inserter.test.ts b/src/main/input/text-inserter.test.ts
deleted file mode 100644
index c51ed2e..0000000
--- a/src/main/input/text-inserter.test.ts
+++ /dev/null
@@ -1,248 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import {
- CLIPBOARD_RESTORE_DELAY_MS,
- CLIPBOARD_WRITE_VERIFY_DELAY_MS,
- insertText,
- shouldUseClipboardPaste,
- type TextInsertionDeps
-} from './text-inserter';
-
-type ScheduledRestore = {
- callback: () => void;
- delayMs: number;
-};
-
-function createDeps(initialClipboard = 'previous'): {
- deps: TextInsertionDeps;
- calls: string[];
- scheduled: ScheduledRestore[];
- getClipboardText: () => string;
- setClipboardReadFailure: () => void;
- setClipboardWriteFailure: () => void;
- setUnicodeFailure: () => void;
- setReadAfterWriteOverrides: (values: string[]) => void;
-} {
- const calls: string[] = [];
- const scheduled: ScheduledRestore[] = [];
- let clipboardText = initialClipboard;
- let failRead = false;
- let failWrite = false;
- let failUnicode = false;
- let justWrote = false;
- let readAfterWriteOverrides: string[] = [];
-
- return {
- deps: {
- clipboard: {
- readText: () => {
- calls.push('readText');
- if (failRead) {
- throw new Error('Clipboard read failed.');
- }
- if (justWrote && readAfterWriteOverrides.length > 0) {
- justWrote = false;
- return readAfterWriteOverrides.shift() ?? clipboardText;
- }
- justWrote = false;
- return clipboardText;
- },
- writeText: (text) => {
- calls.push(`writeText:${text}`);
- if (failWrite) {
- throw new Error('Clipboard write failed.');
- }
- clipboardText = text;
- justWrote = true;
- }
- },
- typeString: (text) => {
- calls.push(`typeString:${text}`);
- },
- typeUnicodeText: async (text) => {
- calls.push(`typeUnicodeText:${text}`);
- if (failUnicode) {
- throw new Error('Unicode input failed.');
- }
- },
- pasteFromClipboard: () => {
- calls.push('pasteFromClipboard');
- },
- scheduleRestore: (callback, delayMs) => {
- calls.push(`scheduleRestore:${delayMs}`);
- scheduled.push({ callback, delayMs });
- },
- wait: async (delayMs) => {
- calls.push(`wait:${delayMs}`);
- }
- },
- calls,
- scheduled,
- getClipboardText: () => clipboardText,
- setClipboardReadFailure: () => {
- failRead = true;
- },
- setClipboardWriteFailure: () => {
- failWrite = true;
- },
- setUnicodeFailure: () => {
- failUnicode = true;
- },
- setReadAfterWriteOverrides: (values) => {
- readAfterWriteOverrides = [...values];
- }
- };
-}
-
-describe('insertText', () => {
- it('uses native typing for simple lowercase text', async () => {
- const { deps, calls, scheduled, getClipboardText } = createDeps();
-
- await expect(insertText('hello', deps)).resolves.toBe('native');
- expect(calls).toEqual(['typeString:hello']);
- expect(scheduled).toEqual([]);
- expect(getClipboardText()).toBe('previous');
- });
-
- it('uses native typing for simple lowercase letters, digits, and spaces', async () => {
- const { deps, calls } = createDeps();
-
- await expect(insertText('abc 123', deps)).resolves.toBe('native');
- expect(calls).toEqual(['typeString:abc 123']);
- });
-
- it('returns none for empty text without side effects', async () => {
- const { deps, calls, scheduled, getClipboardText } = createDeps();
-
- await expect(insertText('', deps)).resolves.toBe('none');
- expect(calls).toEqual([]);
- expect(scheduled).toEqual([]);
- expect(getClipboardText()).toBe('previous');
- });
-
- it.each(['Hello', 'Hello, world!', 'cafe!', 'wave :)', 'line one\nline two'])(
- 'uses Unicode text input for %j',
- async (text) => {
- const { deps, calls, scheduled, getClipboardText } = createDeps();
-
- await expect(insertText(text, deps)).resolves.toBe('unicode');
- expect(calls).toEqual([`typeUnicodeText:${text}`]);
- expect(scheduled).toHaveLength(0);
- expect(getClipboardText()).toBe('previous');
- }
- );
-
- it('uses verified clipboard fallback when Unicode text input fails', async () => {
- const { deps, calls, scheduled, getClipboardText, setUnicodeFailure } = createDeps();
- setUnicodeFailure();
-
- await expect(insertText('Hello', deps)).resolves.toBe('clipboard');
-
- expect(calls).toEqual([
- 'typeUnicodeText:Hello',
- 'readText',
- 'writeText:Hello',
- 'readText',
- 'pasteFromClipboard',
- `scheduleRestore:${CLIPBOARD_RESTORE_DELAY_MS}`
- ]);
- expect(scheduled).toHaveLength(1);
- expect(scheduled[0].delayMs).toBe(CLIPBOARD_RESTORE_DELAY_MS);
- expect(getClipboardText()).toBe('Hello');
- });
-
- it('retries clipboard verification before pasting fallback text', async () => {
- const { deps, calls, setUnicodeFailure, setReadAfterWriteOverrides } = createDeps('saved text');
- setUnicodeFailure();
- setReadAfterWriteOverrides(['saved text']);
-
- await expect(insertText('Hello', deps)).resolves.toBe('clipboard');
-
- expect(calls).toEqual([
- 'typeUnicodeText:Hello',
- 'readText',
- 'writeText:Hello',
- 'readText',
- `wait:${CLIPBOARD_WRITE_VERIFY_DELAY_MS}`,
- 'writeText:Hello',
- 'readText',
- 'pasteFromClipboard',
- `scheduleRestore:${CLIPBOARD_RESTORE_DELAY_MS}`
- ]);
- });
-
- it('throws without pasting if clipboard fallback cannot verify the write', async () => {
- const { deps, calls, getClipboardText, setUnicodeFailure, setReadAfterWriteOverrides } = createDeps('saved text');
- setUnicodeFailure();
- setReadAfterWriteOverrides(['saved text', 'saved text', 'saved text']);
-
- await expect(insertText('Hello', deps)).rejects.toThrow('Clipboard did not contain requested text after write.');
-
- expect(calls).not.toContain('pasteFromClipboard');
- expect(getClipboardText()).toBe('saved text');
- });
-
- it('restores previous text if clipboard still contains inserted fallback text', async () => {
- const { deps, scheduled, getClipboardText, setUnicodeFailure } = createDeps('saved text');
- setUnicodeFailure();
-
- await insertText('Hello', deps);
- scheduled[0].callback();
-
- expect(getClipboardText()).toBe('saved text');
- });
-
- it('does not restore if clipboard changed after fallback paste', async () => {
- const { deps, scheduled, getClipboardText, setUnicodeFailure } = createDeps('saved text');
- setUnicodeFailure();
-
- await insertText('Hello', deps);
- deps.clipboard.writeText('user changed clipboard');
- scheduled[0].callback();
-
- expect(getClipboardText()).toBe('user changed clipboard');
- });
-
- it('restores an empty previous clipboard value after fallback paste', async () => {
- const { deps, scheduled, getClipboardText, setUnicodeFailure } = createDeps('');
- setUnicodeFailure();
-
- await insertText('Hello', deps);
- scheduled[0].callback();
-
- expect(getClipboardText()).toBe('');
- });
-
- it('swallows restore errors', async () => {
- const { deps, scheduled, setClipboardReadFailure, setUnicodeFailure } = createDeps('saved text');
- setUnicodeFailure();
-
- await insertText('Hello', deps);
- setClipboardReadFailure();
-
- expect(() => scheduled[0].callback()).not.toThrow();
- });
-
- it('throws fallback clipboard write failures', async () => {
- const { deps, setClipboardWriteFailure, setUnicodeFailure } = createDeps();
- setUnicodeFailure();
- setClipboardWriteFailure();
-
- await expect(insertText('Hello', deps)).rejects.toThrow('Clipboard write failed.');
- });
-});
-
-describe('shouldUseClipboardPaste', () => {
- it('keeps lowercase ASCII letters, digits, and spaces on native typing', () => {
- expect(shouldUseClipboardPaste('abc 123')).toBe(false);
- });
-
- it.each([
- ['uppercase', 'Hello'],
- ['punctuation', 'hello!'],
- ['unicode', 'cafe!'],
- ['newline', 'hello\nworld'],
- ['long text', 'a'.repeat(81)]
- ])('uses non-simple path for %s', (_label, text) => {
- expect(shouldUseClipboardPaste(text)).toBe(true);
- });
-});
diff --git a/src/main/input/text-inserter.ts b/src/main/input/text-inserter.ts
deleted file mode 100644
index af640e6..0000000
--- a/src/main/input/text-inserter.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-export const CLIPBOARD_RESTORE_DELAY_MS = 250;
-export const CLIPBOARD_WRITE_VERIFY_ATTEMPTS = 3;
-export const CLIPBOARD_WRITE_VERIFY_DELAY_MS = 25;
-
-export type TextClipboard = {
- readText(): string;
- writeText(text: string): void;
-};
-
-export type TextInsertionDeps = {
- clipboard: TextClipboard;
- typeString(text: string): void;
- typeUnicodeText(text: string): Promise;
- pasteFromClipboard(): void;
- scheduleRestore(callback: () => void, delayMs: number): void;
- wait(delayMs: number): Promise;
-};
-
-export type TextInsertionMethod = 'none' | 'native' | 'unicode' | 'clipboard';
-
-const SIMPLE_TEXT_PATTERN = /^[a-z0-9 ]{1,80}$/;
-
-export function shouldUseClipboardPaste(text: string): boolean {
- return !SIMPLE_TEXT_PATTERN.test(text);
-}
-
-export async function insertText(text: string, deps: TextInsertionDeps): Promise {
- if (text.length === 0) {
- return 'none';
- }
-
- if (!shouldUseClipboardPaste(text)) {
- deps.typeString(text);
- return 'native';
- }
-
- try {
- await deps.typeUnicodeText(text);
- return 'unicode';
- } catch {
- // Fall back to verified clipboard paste for targets where Unicode input is unavailable.
- }
-
- await pasteTextWithVerifiedClipboard(text, deps);
- return 'clipboard';
-}
-
-async function pasteTextWithVerifiedClipboard(text: string, deps: TextInsertionDeps): Promise {
- const previousText = deps.clipboard.readText();
- const verified = await writeAndVerifyClipboardText(text, deps);
- if (!verified) {
- deps.clipboard.writeText(previousText);
- throw new Error('Clipboard did not contain requested text after write.');
- }
-
- deps.pasteFromClipboard();
- deps.scheduleRestore(() => {
- try {
- if (deps.clipboard.readText() === text) {
- deps.clipboard.writeText(previousText);
- }
- } catch {
- // Best-effort restoration should not affect an already completed paste.
- }
- }, CLIPBOARD_RESTORE_DELAY_MS);
-
-}
-
-async function writeAndVerifyClipboardText(text: string, deps: TextInsertionDeps): Promise {
- for (let attempt = 0; attempt < CLIPBOARD_WRITE_VERIFY_ATTEMPTS; attempt += 1) {
- deps.clipboard.writeText(text);
- if (deps.clipboard.readText() === text) {
- return true;
- }
- if (attempt < CLIPBOARD_WRITE_VERIFY_ATTEMPTS - 1) {
- await deps.wait(CLIPBOARD_WRITE_VERIFY_DELAY_MS);
- }
- }
-
- return false;
-}
diff --git a/src/main/input/windows-window-control.ts b/src/main/input/windows-window-control.ts
deleted file mode 100644
index 4426442..0000000
--- a/src/main/input/windows-window-control.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { execFile } from 'node:child_process';
-import { promisify } from 'node:util';
-import type { WindowControlAction } from '../../shared/protocol';
-
-const execFileAsync = promisify(execFile);
-
-export type WindowsWindowControlStrategy =
- | { kind: 'taskView' }
- | { kind: 'showDesktop' }
- | { kind: 'foregroundWindow'; operation: 'close' | 'minimize' | 'maximizeOrRestore' };
-
-export function toWindowsWindowControlStrategy(action: WindowControlAction): WindowsWindowControlStrategy | null {
- switch (action) {
- case 'switchNext':
- case 'switchPrevious':
- return null;
- case 'taskView':
- return { kind: 'taskView' };
- case 'showDesktop':
- return { kind: 'showDesktop' };
- case 'closeFocused':
- return { kind: 'foregroundWindow', operation: 'close' };
- case 'minimizeFocused':
- return { kind: 'foregroundWindow', operation: 'minimize' };
- case 'maximizeFocused':
- return { kind: 'foregroundWindow', operation: 'maximizeOrRestore' };
- }
-}
-
-export async function runWindowsWindowControlAction(action: WindowControlAction): Promise {
- const strategy = toWindowsWindowControlStrategy(action);
- if (strategy === null) return;
-
- const script = createWindowControlScript(strategy);
- await execFileAsync('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], {
- windowsHide: true,
- timeout: 2_000
- });
-}
-
-export function createWindowControlScript(strategy: WindowsWindowControlStrategy): string {
- switch (strategy.kind) {
- case 'taskView':
- return `Start-Process explorer.exe 'shell:::{3080F90E-D7AD-11D9-BD98-0000947B0257}'`;
- case 'showDesktop':
- return `(New-Object -ComObject Shell.Application).MinimizeAll()`;
- case 'foregroundWindow':
- return createForegroundWindowScript(strategy.operation);
- }
-}
-
-function createForegroundWindowScript(operation: 'close' | 'minimize' | 'maximizeOrRestore'): string {
- return `
-$signature = @"
-using System;
-using System.Runtime.InteropServices;
-
-public static class SwitchifyWindowControl {
- [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
- [DllImport("user32.dll")] public static extern bool IsZoomed(IntPtr hWnd);
- [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
- [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
-}
-"@
-Add-Type -TypeDefinition $signature -ErrorAction SilentlyContinue
-$handle = [SwitchifyWindowControl]::GetForegroundWindow()
-if ($handle -eq [IntPtr]::Zero) {
- exit 0
-}
-${foregroundWindowOperationScript(operation)}
-`.trim();
-}
-
-function foregroundWindowOperationScript(operation: 'close' | 'minimize' | 'maximizeOrRestore'): string {
- switch (operation) {
- case 'close':
- return '[SwitchifyWindowControl]::PostMessage($handle, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null';
- case 'minimize':
- return '[SwitchifyWindowControl]::ShowWindow($handle, 6) | Out-Null';
- case 'maximizeOrRestore':
- return `
-if ([SwitchifyWindowControl]::IsZoomed($handle)) {
- [SwitchifyWindowControl]::ShowWindow($handle, 9) | Out-Null
-} else {
- [SwitchifyWindowControl]::ShowWindow($handle, 3) | Out-Null
-}`.trim();
- }
-}
diff --git a/src/main/json-file-store.test.ts b/src/main/json-file-store.test.ts
deleted file mode 100644
index daa0f4f..0000000
--- a/src/main/json-file-store.test.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { basename, join } from 'node:path';
-import { afterEach, describe, expect, it } from 'vitest';
-import { backupCorruptJsonFile, writeJsonFileAtomic, writeJsonFileAtomicSync } from './json-file-store';
-
-describe('json file store helpers', () => {
- let tempDir: string | null = null;
-
- afterEach(() => {
- if (tempDir) {
- rmSync(tempDir, { recursive: true, force: true });
- tempDir = null;
- }
- });
-
- it('writes async JSON content to the target path', async () => {
- const filePath = path('state.json');
-
- await writeJsonFileAtomic(filePath, '{ "ok": true }\n');
-
- expect(readFileSync(filePath, 'utf8')).toBe('{ "ok": true }\n');
- expect(readdirSync(tempDir!).filter((name) => name.endsWith('.tmp'))).toEqual([]);
- });
-
- it('creates parent directories for async writes', async () => {
- const filePath = path('nested', 'state.json');
-
- await writeJsonFileAtomic(filePath, '{}\n');
-
- expect(readFileSync(filePath, 'utf8')).toBe('{}\n');
- });
-
- it('removes async temp files on failure before rename', async () => {
- const parentFile = path('not-a-directory');
- writeFileSync(parentFile, 'file', 'utf8');
-
- await expect(writeJsonFileAtomic(join(parentFile, 'state.json'), '{}\n')).rejects.toThrow();
- expect(readdirSync(tempDir!).filter((name) => name.endsWith('.tmp'))).toEqual([]);
- });
-
- it('writes sync JSON content to the target path', () => {
- const filePath = path('sync-state.json');
-
- writeJsonFileAtomicSync(filePath, '{ "ok": true }\n');
-
- expect(readFileSync(filePath, 'utf8')).toBe('{ "ok": true }\n');
- expect(readdirSync(tempDir!).filter((name) => name.endsWith('.tmp'))).toEqual([]);
- });
-
- it('creates parent directories for sync writes', () => {
- const filePath = path('sync-nested', 'state.json');
-
- writeJsonFileAtomicSync(filePath, '{}\n');
-
- expect(readFileSync(filePath, 'utf8')).toBe('{}\n');
- });
-
- it('backs up corrupt JSON files with a safe filename', async () => {
- const filePath = path('pairing-state.json');
- writeFileSync(filePath, '\0\0', 'utf8');
-
- const result = await backupCorruptJsonFile(filePath);
-
- expect(result.backupPath).toMatch(/pairing-state\.corrupt-\d{8}T\d{9}Z\.json$/);
- expect(basename(result.backupPath!)).not.toContain(':');
- expect(readFileSync(result.backupPath!, 'utf8')).toBe('\0\0');
- });
-
- it('returns null backup path for a missing corrupt file', async () => {
- await expect(backupCorruptJsonFile(path('missing.json'))).resolves.toEqual({ backupPath: null });
- });
-
- function path(...parts: string[]): string {
- if (!tempDir) {
- tempDir = mkdtempSync(join(tmpdir(), 'switchify-json-store-'));
- }
-
- return join(tempDir, ...parts);
- }
-});
diff --git a/src/main/json-file-store.ts b/src/main/json-file-store.ts
deleted file mode 100644
index a41b5d5..0000000
--- a/src/main/json-file-store.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import { constants } from 'node:fs';
-import { mkdir, open, rename, unlink } from 'node:fs/promises';
-import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
-import { dirname, extname, join, basename } from 'node:path';
-
-export type CorruptJsonBackupResult = {
- backupPath: string | null;
-};
-
-export async function writeJsonFileAtomic(filePath: string, content: string): Promise {
- await mkdir(dirname(filePath), { recursive: true });
- const tempPath = tempPathFor(filePath);
- let handle: Awaited> | null = null;
- let renamed = false;
-
- try {
- handle = await open(tempPath, 'w', 0o600);
- await handle.writeFile(content, 'utf8');
- await handle.sync();
- await handle.close();
- handle = null;
- await rename(tempPath, filePath);
- renamed = true;
- } finally {
- if (handle) {
- await handle.close().catch(() => undefined);
- }
-
- if (!renamed) {
- await unlink(tempPath).catch(() => undefined);
- }
- }
-}
-
-export function writeJsonFileAtomicSync(filePath: string, content: string): void {
- mkdirSync(dirname(filePath), { recursive: true });
- const tempPath = tempPathFor(filePath);
- let fd: number | null = null;
- let renamed = false;
-
- try {
- fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC, 0o600);
- writeFileSync(fd, content, 'utf8');
- fsyncSync(fd);
- closeSync(fd);
- fd = null;
- renameSync(tempPath, filePath);
- renamed = true;
- } finally {
- if (fd !== null) {
- try {
- closeSync(fd);
- } catch {
- // Best effort cleanup.
- }
- }
-
- if (!renamed) {
- try {
- unlinkSync(tempPath);
- } catch {
- // Best effort cleanup.
- }
- }
- }
-}
-
-export async function backupCorruptJsonFile(filePath: string): Promise {
- if (!existsSync(filePath)) {
- return { backupPath: null };
- }
-
- const backupPath = corruptBackupPathFor(filePath);
-
- try {
- await rename(filePath, backupPath);
- return { backupPath };
- } catch (error) {
- if (isMissingFileError(error)) {
- return { backupPath: null };
- }
-
- throw error;
- }
-}
-
-function tempPathFor(filePath: string): string {
- return `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
-}
-
-function corruptBackupPathFor(filePath: string, now = new Date()): string {
- const extension = extname(filePath);
- const name = basename(filePath, extension);
- return join(dirname(filePath), `${name}.corrupt-${sanitizeTimestamp(now.toISOString())}${extension}`);
-}
-
-function sanitizeTimestamp(value: string): string {
- return value.replace(/[-:.]/g, '');
-}
-
-function isMissingFileError(error: unknown): boolean {
- return (
- error !== null &&
- typeof error === 'object' &&
- 'code' in error &&
- (error as { code?: unknown }).code === 'ENOENT'
- );
-}
diff --git a/src/main/package-win-after-pack.test.ts b/src/main/package-win-after-pack.test.ts
deleted file mode 100644
index 259d2be..0000000
--- a/src/main/package-win-after-pack.test.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { createRequire } from 'node:module';
-import { afterEach, describe, expect, it } from 'vitest';
-
-const require = createRequire(import.meta.url);
-const { createSigningArgs, nativeHelperNames } = require('../../scripts/package-win-after-pack.cjs') as {
- createSigningArgs: (filePath: string, options: { requireSigning: boolean }) => string[] | null;
- nativeHelperNames: string[];
-};
-
-const originalEnv = { ...process.env };
-
-describe('createSigningArgs', () => {
- afterEach(() => {
- process.env = { ...originalEnv };
- });
-
- it('uses an installed certificate thumbprint when no PFX password is available', () => {
- clearSigningEnv();
- delete process.env.SWITCHIFY_DEV_CERT_PASSWORD;
- process.env.SWITCHIFY_DEV_CERT_THUMBPRINT = '24 05 4d 36';
-
- const args = createSigningArgs('C:\\build\\Switchify PC.exe', { requireSigning: true });
-
- if (process.platform !== 'win32') {
- expect(args).toBeNull();
- return;
- }
-
- expect(args).toEqual([
- 'sign',
- '/fd',
- 'SHA256',
- '/sha1',
- '24054D36',
- '/tr',
- 'http://timestamp.digicert.com',
- '/td',
- 'SHA256',
- 'C:\\build\\Switchify PC.exe'
- ]);
- });
-
- it('uses Certum SimplySign args when explicitly configured', () => {
- clearSigningEnv();
- process.env.SWITCHIFY_SIGNING_MODE = 'certum-simplysign';
- process.env.SWITCHIFY_CERTUM_CERT_THUMBPRINT = '9A 27 06 A2 E8 6F 13 97 95 DA 2E 45 2B 31 2A 8C 23 AA 0C 48';
-
- const args = createSigningArgs('C:\\build\\Switchify PC.exe', { requireSigning: true });
-
- if (process.platform !== 'win32') {
- expect(args).toBeNull();
- return;
- }
-
- expect(args).toEqual([
- 'sign',
- '/v',
- '/fd',
- 'SHA256',
- '/sha1',
- '9A2706A2E86F139795DA2E452B312A8C23AA0C48',
- '/tr',
- 'http://time.certum.pl',
- '/td',
- 'SHA256',
- 'C:\\build\\Switchify PC.exe'
- ]);
- });
-
- it('requires a Certum thumbprint when Certum mode is explicit', () => {
- clearSigningEnv();
- process.env.SWITCHIFY_SIGNING_MODE = 'certum-simplysign';
- delete process.env.SWITCHIFY_CERTUM_CERT_THUMBPRINT;
-
- if (process.platform !== 'win32') {
- expect(createSigningArgs('C:\\build\\Switchify PC.exe', { requireSigning: true })).toBeNull();
- return;
- }
-
- expect(() => createSigningArgs('C:\\build\\Switchify PC.exe', { requireSigning: true })).toThrow(
- 'Certum SimplySign signing requires SWITCHIFY_CERTUM_CERT_THUMBPRINT.'
- );
- });
-});
-
-describe('native helper packaging', () => {
- it('includes the packaged native helpers in the signed helper list', () => {
- expect(nativeHelperNames).toEqual([
- 'SwitchifyCursorOverlay.exe',
- 'SwitchifyBluetoothTransport.exe',
- 'SwitchifyTextInput.exe'
- ]);
- });
-});
-
-function clearSigningEnv(): void {
- delete process.env.SWITCHIFY_SIGNING_MODE;
- delete process.env.SWITCHIFY_CERTUM_CERT_THUMBPRINT;
- delete process.env.SWITCHIFY_CERTUM_TIMESTAMP_URL;
- delete process.env.SWITCHIFY_DEV_CERT_PASSWORD;
- delete process.env.SWITCHIFY_DEV_CERT_PFX;
- delete process.env.SWITCHIFY_DEV_CERT_THUMBPRINT;
- delete process.env.SWITCHIFY_AZURE_SIGNING_DLIB;
- delete process.env.SWITCHIFY_AZURE_SIGNING_METADATA;
- delete process.env.SWITCHIFY_SIGN_SKIP_TIMESTAMP;
- delete process.env.SWITCHIFY_ALLOW_UNSIGNED_UIACCESS_PACKAGE;
-}
diff --git a/src/main/pairing/auth.test.ts b/src/main/pairing/auth.test.ts
deleted file mode 100644
index f8844e3..0000000
--- a/src/main/pairing/auth.test.ts
+++ /dev/null
@@ -1,181 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import type { MouseClickCommand, MouseMoveCommand, PingCommand } from '../../shared/protocol';
-import { PROTOCOL_VERSION } from '../../shared/protocol';
-import { createCommandAuthProof, CommandAuthValidator, COMMAND_TIMESTAMP_TOLERANCE_MS } from './auth';
-import { MemoryPairingStore } from './pairing-store';
-
-const token = 'shared-token';
-const now = 1_724_000_000_000;
-
-function createStore(): MemoryPairingStore {
- return new MemoryPairingStore({
- desktopId: 'desktop-1',
- pairedDevices: [
- {
- deviceId: 'android-1',
- deviceName: 'Android device',
- token,
- pairedAt: now - 1_000,
- lastSeenAt: null
- }
- ]
- });
-}
-
-function createCommand(overrides: Partial = {}): PingCommand {
- const command = {
- version: PROTOCOL_VERSION,
- id: 'request-1',
- deviceId: 'android-1',
- timestamp: now,
- type: 'connection.ping',
- payload: {},
- auth: ''
- } satisfies PingCommand;
-
- const merged = { ...command, ...overrides } as PingCommand;
- return {
- ...merged,
- auth: overrides.auth ?? createCommandAuthProof(merged, token)
- };
-}
-
-describe('CommandAuthValidator', () => {
- it('accepts commands from paired devices with a valid auth proof', async () => {
- const store = createStore();
- const validator = new CommandAuthValidator(store, () => now);
- const command = createCommand();
-
- await expect(validator.validate(command)).resolves.toMatchObject({ ok: true, command });
- expect((await store.load()).pairedDevices[0].lastSeenAt).toBe(now);
- });
-
- it('rejects unknown devices before auth succeeds', async () => {
- const validator = new CommandAuthValidator(createStore(), () => now);
-
- await expect(validator.validate(createCommand({ deviceId: 'android-unknown' }))).resolves.toEqual({
- ok: false,
- reason: 'unknown_device'
- });
- });
-
- it('rejects invalid auth proofs', async () => {
- const store = createStore();
- const validator = new CommandAuthValidator(store, () => now);
-
- await expect(validator.validate(createCommand({ auth: 'bad-proof' }))).resolves.toEqual({
- ok: false,
- reason: 'invalid_auth'
- });
- expect((await store.load()).pairedDevices[0].lastSeenAt).toBeNull();
- });
-
- it('canonicalizes missing response mode as ack', () => {
- const implicitAck = createCommand();
- const explicitAck = createCommand({ responseMode: 'ack' });
-
- expect(createCommandAuthProof(implicitAck, token)).toBe(createCommandAuthProof(explicitAck, token));
- });
-
- it('includes no-response mode in command auth proofs', async () => {
- const ackMove = createMouseMoveCommand();
- const noAckMove = createMouseMoveCommand({ responseMode: 'none' });
- const ackClick = createMouseClickCommand();
- const noAckClick = createMouseClickCommand({ responseMode: 'none' });
-
- expect(createCommandAuthProof(noAckMove, token)).not.toBe(createCommandAuthProof(ackMove, token));
- expect(createCommandAuthProof(noAckClick, token)).not.toBe(createCommandAuthProof(ackClick, token));
- });
-
- it('rejects response mode tampering after signing', async () => {
- const store = createStore();
- const validator = new CommandAuthValidator(store, () => now);
- const command = createMouseMoveCommand();
-
- await expect(validator.validate({ ...command, responseMode: 'none' })).resolves.toEqual({
- ok: false,
- reason: 'invalid_auth'
- });
-
- const clickCommand = createMouseClickCommand();
- await expect(validator.validate({ ...clickCommand, responseMode: 'none' })).resolves.toEqual({
- ok: false,
- reason: 'invalid_auth'
- });
- });
-
- it('rejects expired timestamps', async () => {
- const store = createStore();
- const validator = new CommandAuthValidator(store, () => now);
-
- await expect(
- validator.validate(createCommand({ timestamp: now - COMMAND_TIMESTAMP_TOLERANCE_MS - 1 }))
- ).resolves.toEqual({
- ok: false,
- reason: 'expired_timestamp'
- });
- expect((await store.load()).pairedDevices[0].lastSeenAt).toBeNull();
- });
-
- it('rejects duplicate request ids inside the replay cache window', async () => {
- const validator = new CommandAuthValidator(createStore(), () => now);
- const command = createCommand();
-
- await expect(validator.validate(command)).resolves.toMatchObject({ ok: true });
- await expect(validator.validate(command)).resolves.toEqual({
- ok: false,
- reason: 'duplicate_request'
- });
- });
-
- it('rejects oversized payloads through protocol validation', async () => {
- const validator = new CommandAuthValidator(createStore(), () => now);
-
- await expect(
- validator.validate({
- ...createCommand(),
- type: 'keyboard.typeText',
- payload: { text: 'x'.repeat(2_001) }
- })
- ).resolves.toEqual({
- ok: false,
- reason: 'invalid_payload'
- });
- });
-});
-
-function createMouseMoveCommand(overrides: Partial = {}): MouseMoveCommand {
- const command = {
- version: PROTOCOL_VERSION,
- id: 'move-1',
- deviceId: 'android-1',
- timestamp: now,
- type: 'mouse.move',
- payload: { dx: 12, dy: -6 },
- auth: ''
- } satisfies MouseMoveCommand;
-
- const merged = { ...command, ...overrides } as MouseMoveCommand;
- return {
- ...merged,
- auth: overrides.auth ?? createCommandAuthProof(merged, token)
- };
-}
-
-function createMouseClickCommand(overrides: Partial = {}): MouseClickCommand {
- const command = {
- version: PROTOCOL_VERSION,
- id: 'click-1',
- deviceId: 'android-1',
- timestamp: now,
- type: 'mouse.click',
- payload: { button: 'left' },
- auth: ''
- } satisfies MouseClickCommand;
-
- const merged = { ...command, ...overrides } as MouseClickCommand;
- return {
- ...merged,
- auth: overrides.auth ?? createCommandAuthProof(merged, token)
- };
-}
diff --git a/src/main/pairing/auth.ts b/src/main/pairing/auth.ts
deleted file mode 100644
index 32d66a9..0000000
--- a/src/main/pairing/auth.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import { createHmac, timingSafeEqual } from 'node:crypto';
-import type { CommandRequest, CommandResponseMode } from '../../shared/protocol';
-import { validateProtocolRequest } from '../../shared/protocol';
-import type { PairingStore } from './pairing-store';
-import { findPairedDevice } from './pairing-store';
-
-export const COMMAND_TIMESTAMP_TOLERANCE_MS = 2 * 60 * 1000;
-export const REPLAY_CACHE_TTL_MS = COMMAND_TIMESTAMP_TOLERANCE_MS;
-
-export type AuthValidationResult =
- | { ok: true; command: CommandRequest }
- | { ok: false; reason: 'invalid_payload' | 'unknown_device' | 'expired_timestamp' | 'duplicate_request' | 'invalid_auth' };
-
-export class CommandAuthValidator {
- private readonly seenRequestIds = new Map();
-
- constructor(
- private readonly store: PairingStore,
- private readonly now: () => number = Date.now
- ) {}
-
- async validate(value: unknown): Promise {
- const parsed = validateProtocolRequest(value);
- if (!parsed.ok || !isCommandRequest(parsed.value)) {
- return { ok: false, reason: 'invalid_payload' };
- }
-
- const command = parsed.value;
- const state = await this.store.load();
- const pairedDevice = findPairedDevice(state, command.deviceId);
- if (!pairedDevice) return { ok: false, reason: 'unknown_device' };
- if (Math.abs(this.now() - command.timestamp) > COMMAND_TIMESTAMP_TOLERANCE_MS) {
- return { ok: false, reason: 'expired_timestamp' };
- }
- this.pruneReplayCache();
- if (this.seenRequestIds.has(replayKey(command))) {
- return { ok: false, reason: 'duplicate_request' };
- }
-
- const expectedAuth = createCommandAuthProof(command, pairedDevice.token);
- if (!safeEquals(command.auth, expectedAuth)) {
- return { ok: false, reason: 'invalid_auth' };
- }
-
- this.seenRequestIds.set(replayKey(command), this.now() + REPLAY_CACHE_TTL_MS);
- pairedDevice.lastSeenAt = this.now();
- await this.store.save(state);
-
- return { ok: true, command };
- }
-
- private pruneReplayCache(): void {
- const now = this.now();
- for (const [key, expiresAt] of this.seenRequestIds.entries()) {
- if (expiresAt <= now) {
- this.seenRequestIds.delete(key);
- }
- }
- }
-}
-
-export function createCommandAuthProof(command: CommandRequest, token: string): string {
- return createHmac('sha256', token).update(canonicalCommandString(command)).digest('base64url');
-}
-
-function canonicalCommandString(command: CommandRequest): string {
- return [
- command.version,
- command.id,
- command.deviceId,
- command.timestamp,
- command.type,
- stableStringify(command.payload),
- commandResponseMode(command)
- ].join('\n');
-}
-
-function commandResponseMode(command: CommandRequest): CommandResponseMode {
- return command.responseMode ?? 'ack';
-}
-
-function stableStringify(value: unknown): string {
- if (Array.isArray(value)) {
- return `[${value.map(stableStringify).join(',')}]`;
- }
- if (value && typeof value === 'object') {
- const record = value as Record;
- return `{${Object.keys(record)
- .sort()
- .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
- .join(',')}}`;
- }
- return JSON.stringify(value);
-}
-
-function isCommandRequest(value: unknown): value is CommandRequest {
- return (
- typeof value === 'object' &&
- value !== null &&
- 'auth' in value &&
- 'deviceId' in value &&
- 'timestamp' in value
- );
-}
-
-function safeEquals(actual: string, expected: string): boolean {
- const actualBuffer = Buffer.from(actual);
- const expectedBuffer = Buffer.from(expected);
- return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
-}
-
-function replayKey(command: CommandRequest): string {
- return `${command.deviceId}:${command.id}`;
-}
diff --git a/src/main/pairing/pairing-approval-ipc.ts b/src/main/pairing/pairing-approval-ipc.ts
deleted file mode 100644
index 200d53b..0000000
--- a/src/main/pairing/pairing-approval-ipc.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { ipcMain } from 'electron';
-import {
- GET_PENDING_PAIRING_REQUESTS_CHANNEL,
- RESPOND_TO_PAIRING_REQUEST_CHANNEL
-} from '../../shared/ipc-channels';
-import type { PairingApprovalDecision } from '../../shared/pairing-approval';
-import type { ControlService } from '../control/control-service';
-
-export function registerPairingApprovalIpc(controlService: ControlService): void {
- ipcMain.handle(GET_PENDING_PAIRING_REQUESTS_CHANNEL, () => controlService.getPendingPairingRequests());
- ipcMain.handle(RESPOND_TO_PAIRING_REQUEST_CHANNEL, (_event, requestId: string, decision: PairingApprovalDecision) =>
- controlService.respondToPairingRequest(requestId, decision)
- );
-}
diff --git a/src/main/pairing/pairing-approval-manager.test.ts b/src/main/pairing/pairing-approval-manager.test.ts
deleted file mode 100644
index c0f9fd0..0000000
--- a/src/main/pairing/pairing-approval-manager.test.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { createPairingVerificationCode } from '../../shared/pairing-verification-code';
-import { PairingApprovalManager, PAIRING_APPROVAL_REQUEST_TTL_MS } from './pairing-approval-manager';
-import { MemoryPairingStore } from './pairing-store';
-
-const now = 1_724_000_000_000;
-
-describe('PairingApprovalManager', () => {
- it('creates pending requests with expiry', () => {
- const manager = createManager();
-
- const result = manager.createRequest(createRequestInput());
-
- expect(result.replacedRequestId).toBeNull();
- expect(result.request).toMatchObject({
- requestId: 'approval-1',
- deviceId: 'android-1',
- deviceName: 'Android smoke',
- requestedAt: now,
- expiresAt: now + PAIRING_APPROVAL_REQUEST_TTL_MS
- });
- });
-
- it('lists renderer-safe pending request views without secrets', () => {
- const manager = createManager();
- manager.createRequest(createRequestInput({ requestNonce: 'secret-nonce' }));
-
- expect(manager.listPendingRequestViews()).toEqual([
- {
- requestId: 'approval-1',
- deviceName: 'Android smoke',
- verificationCode: createPairingVerificationCode({
- desktopId: 'desktop-1',
- deviceId: 'android-1',
- requestNonce: 'secret-nonce'
- }),
- requestedAt: now,
- expiresAt: now + PAIRING_APPROVAL_REQUEST_TTL_MS,
- remoteAddress: '192.168.1.50'
- }
- ]);
- });
-
- it('creates deterministic verification codes for pending request views', () => {
- const manager = createManager();
- manager.createRequest(createRequestInput({ requestNonce: 'nonce-1' }));
-
- const [view] = manager.listPendingRequestViews();
-
- expect(view.verificationCode).toBe(
- createPairingVerificationCode({
- desktopId: 'desktop-1',
- deviceId: 'android-1',
- requestNonce: 'nonce-1'
- })
- );
- });
-
- it('accepts a request, stores the paired device, and returns a token', async () => {
- const store = createStore();
- const manager = createManager(store);
- manager.createRequest(createRequestInput());
-
- const result = await manager.accept('approval-1');
-
- expect(result).toMatchObject({
- ok: true,
- desktopId: 'desktop-1',
- deviceId: 'android-1'
- });
- if (result.ok) {
- expect(result.token).toEqual(expect.any(String));
- }
- expect((await store.load()).pairedDevices[0]).toMatchObject({
- deviceId: 'android-1',
- deviceName: 'Android smoke',
- pairedAt: now,
- lastSeenAt: null
- });
- expect(manager.listPendingRequests()).toHaveLength(0);
- });
-
- it('rejects a request and removes it', () => {
- const manager = createManager();
- manager.createRequest(createRequestInput());
-
- expect(manager.reject('approval-1')).toEqual({ ok: true });
-
- expect(manager.listPendingRequests()).toHaveLength(0);
- });
-
- it('expires pending requests', () => {
- let currentTime = now;
- const manager = createManager(createStore(), () => currentTime);
- manager.createRequest(createRequestInput());
- currentTime += PAIRING_APPROVAL_REQUEST_TTL_MS + 1;
-
- const expired = manager.expirePendingRequests();
-
- expect(expired).toHaveLength(1);
- expect(expired[0].requestId).toBe('approval-1');
- expect(manager.listPendingRequests()).toHaveLength(0);
- });
-
- it('replaces older pending requests for the same device', () => {
- const manager = createManager();
- manager.createRequest(createRequestInput({ requestId: 'approval-1' }));
-
- const result = manager.createRequest(createRequestInput({ requestId: 'approval-2' }));
-
- expect(result.replacedRequestId).toBe('approval-1');
- expect(manager.listPendingRequests()).toHaveLength(1);
- expect(manager.listPendingRequests()[0].requestId).toBe('approval-2');
- });
-});
-
-function createManager(store = createStore(), nowFn: () => number = () => now): PairingApprovalManager {
- return new PairingApprovalManager(store, nowFn);
-}
-
-function createStore(): MemoryPairingStore {
- return new MemoryPairingStore({
- desktopId: 'desktop-1',
- pairedDevices: []
- });
-}
-
-function createRequestInput(overrides: Partial[0]> = {}) {
- return {
- requestId: 'approval-1',
- deviceId: 'android-1',
- deviceName: 'Android smoke',
- desktopId: 'desktop-1',
- requestNonce: 'nonce',
- remoteAddress: '192.168.1.50',
- ...overrides
- };
-}
diff --git a/src/main/pairing/pairing-approval-manager.ts b/src/main/pairing/pairing-approval-manager.ts
deleted file mode 100644
index 8465301..0000000
--- a/src/main/pairing/pairing-approval-manager.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import type { PendingPairingApprovalView } from '../../shared/pairing-approval';
-import { createPairingVerificationCode } from '../../shared/pairing-verification-code';
-import { createToken, TOKEN_BYTE_LENGTH } from './pairing-manager';
-import type { PairingStore } from './pairing-store';
-import { upsertPairedDevice } from './pairing-store';
-
-export const PAIRING_APPROVAL_REQUEST_TTL_MS = 2 * 60 * 1000;
-
-export type PendingPairingApproval = {
- requestId: string;
- deviceId: string;
- deviceName: string;
- desktopId: string;
- requestNonce: string;
- requestedAt: number;
- expiresAt: number;
- remoteAddress: string | null;
-};
-
-export type CreatePairingApprovalRequestResult = {
- request: PendingPairingApproval;
- replacedRequestId: string | null;
-};
-
-export class PairingApprovalManager {
- private readonly pendingRequests = new Map();
-
- constructor(
- private readonly pairingStore: PairingStore,
- private readonly now: () => number = Date.now
- ) {}
-
- createRequest(input: {
- requestId: string;
- deviceId: string;
- deviceName: string;
- desktopId: string;
- requestNonce: string;
- remoteAddress: string | null;
- }): CreatePairingApprovalRequestResult {
- this.expirePendingRequests();
-
- const replacedRequestId = this.findRequestIdByDeviceId(input.deviceId);
- if (replacedRequestId) {
- this.pendingRequests.delete(replacedRequestId);
- }
-
- const requestedAt = this.now();
- const request = {
- ...input,
- requestedAt,
- expiresAt: requestedAt + PAIRING_APPROVAL_REQUEST_TTL_MS
- };
- this.pendingRequests.set(request.requestId, request);
-
- return {
- request: { ...request },
- replacedRequestId
- };
- }
-
- listPendingRequests(): PendingPairingApproval[] {
- this.expirePendingRequests();
- return [...this.pendingRequests.values()].sort(sortNewestFirst).map((request) => ({ ...request }));
- }
-
- listPendingRequestViews(): PendingPairingApprovalView[] {
- return this.listPendingRequests().map((request) => ({
- requestId: request.requestId,
- deviceName: request.deviceName,
- verificationCode: createPairingVerificationCode({
- desktopId: request.desktopId,
- deviceId: request.deviceId,
- requestNonce: request.requestNonce
- }),
- requestedAt: request.requestedAt,
- expiresAt: request.expiresAt,
- remoteAddress: request.remoteAddress
- }));
- }
-
- getRequest(requestId: string): PendingPairingApproval | null {
- this.expirePendingRequests();
- const request = this.pendingRequests.get(requestId);
- return request ? { ...request } : null;
- }
-
- async accept(
- requestId: string
- ): Promise<{ ok: true; desktopId: string; deviceId: string; token: string } | { ok: false; reason: string }> {
- const request = this.getRequest(requestId);
- if (!request) return { ok: false, reason: 'pairing_request_not_found' };
-
- const state = await this.pairingStore.load();
- const token = createToken(TOKEN_BYTE_LENGTH);
- await this.pairingStore.save(
- upsertPairedDevice(state, {
- deviceId: request.deviceId,
- deviceName: request.deviceName,
- token,
- pairedAt: this.now(),
- lastSeenAt: null
- })
- );
- this.pendingRequests.delete(requestId);
-
- return {
- ok: true,
- desktopId: state.desktopId,
- deviceId: request.deviceId,
- token
- };
- }
-
- reject(requestId: string): { ok: true } | { ok: false; reason: string } {
- this.expirePendingRequests();
- if (!this.pendingRequests.delete(requestId)) {
- return { ok: false, reason: 'pairing_request_not_found' };
- }
- return { ok: true };
- }
-
- expirePendingRequests(): PendingPairingApproval[] {
- const now = this.now();
- const expired: PendingPairingApproval[] = [];
- for (const [requestId, request] of this.pendingRequests.entries()) {
- if (request.expiresAt <= now) {
- this.pendingRequests.delete(requestId);
- expired.push({ ...request });
- }
- }
- return expired;
- }
-
- private findRequestIdByDeviceId(deviceId: string): string | null {
- for (const [requestId, request] of this.pendingRequests.entries()) {
- if (request.deviceId === deviceId) {
- return requestId;
- }
- }
- return null;
- }
-}
-
-function sortNewestFirst(a: PendingPairingApproval, b: PendingPairingApproval): number {
- return b.requestedAt - a.requestedAt;
-}
diff --git a/src/main/pairing/pairing-manager.test.ts b/src/main/pairing/pairing-manager.test.ts
deleted file mode 100644
index c9198d5..0000000
--- a/src/main/pairing/pairing-manager.test.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { createToken, PairingManager } from './pairing-manager';
-import { MemoryPairingStore } from './pairing-store';
-
-describe('PairingManager', () => {
- it('creates a persistent desktop id on first load', async () => {
- const store = new MemoryPairingStore();
- const manager = new PairingManager(store);
-
- const first = await manager.getDesktopId();
- const second = await manager.getDesktopId();
-
- expect(first).toHaveLength(36);
- expect(second).toBe(first);
- });
-
- it('creates shared tokens', () => {
- expect(createToken().length).toBeGreaterThan(20);
- expect(createToken()).not.toBe(createToken());
- });
-});
diff --git a/src/main/pairing/pairing-manager.ts b/src/main/pairing/pairing-manager.ts
deleted file mode 100644
index 4baf686..0000000
--- a/src/main/pairing/pairing-manager.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { randomBytes } from 'node:crypto';
-import type { PairingStore } from './pairing-store';
-
-export const TOKEN_BYTE_LENGTH = 32;
-
-export class PairingManager {
- constructor(private readonly store: PairingStore) {}
-
- async getDesktopId(): Promise {
- return (await this.store.load()).desktopId;
- }
-}
-
-export function createToken(byteLength: number = TOKEN_BYTE_LENGTH): string {
- return randomBytes(byteLength).toString('base64url');
-}
diff --git a/src/main/pairing/pairing-store.test.ts b/src/main/pairing/pairing-store.test.ts
deleted file mode 100644
index acb3036..0000000
--- a/src/main/pairing/pairing-store.test.ts
+++ /dev/null
@@ -1,222 +0,0 @@
-import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import { JsonPairingStore, removePairedDevice, toPairedDeviceViews, type PairingState } from './pairing-store';
-
-describe('toPairedDeviceViews', () => {
- it('removes shared tokens from paired device metadata', () => {
- const state = {
- desktopId: 'desktop-1',
- pairedDevices: [
- {
- deviceId: 'android-1',
- deviceName: 'Android device',
- token: 'secret-token',
- pairedAt: 1_000,
- lastSeenAt: 2_000
- }
- ]
- } satisfies PairingState;
-
- const views = toPairedDeviceViews(state);
-
- expect(views).toEqual([
- {
- deviceId: 'android-1',
- deviceName: 'Android device',
- pairedAt: 1_000,
- lastSeenAt: 2_000
- }
- ]);
- expect(JSON.stringify(views)).not.toContain('secret-token');
- });
-});
-
-describe('removePairedDevice', () => {
- it('removes only the matching paired device and preserves desktop id', () => {
- const state = createState();
-
- const nextState = removePairedDevice(state, 'android-1');
-
- expect(nextState.desktopId).toBe('desktop-1');
- expect(nextState.pairedDevices).toEqual([
- {
- deviceId: 'android-2',
- deviceName: 'Tablet',
- token: 'secret-token-2',
- pairedAt: 3_000,
- lastSeenAt: null
- }
- ]);
- expect(state.pairedDevices).toHaveLength(2);
- });
-
- it('leaves paired devices unchanged when the device id is missing', () => {
- const state = createState();
-
- const nextState = removePairedDevice(state, 'missing');
-
- expect(nextState).toEqual(state);
- expect(nextState).not.toBe(state);
- expect(nextState.pairedDevices).not.toBe(state.pairedDevices);
- });
-});
-
-describe('JsonPairingStore', () => {
- let tempDir: string | null = null;
-
- afterEach(() => {
- vi.restoreAllMocks();
- if (tempDir) {
- rmSync(tempDir, { recursive: true, force: true });
- tempDir = null;
- }
- });
-
- it('creates and saves fresh pairing state when the file is missing', async () => {
- const filePath = pairingPath();
-
- const state = await new JsonPairingStore(filePath).load();
-
- expect(state.desktopId).toMatch(/[0-9a-f-]{36}/);
- expect(state.pairedDevices).toEqual([]);
- expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual(state);
- });
-
- it('loads valid pairing state', async () => {
- const filePath = pairingPath();
- writeFileSync(filePath, JSON.stringify(createState()), 'utf8');
-
- await expect(new JsonPairingStore(filePath).load()).resolves.toEqual(createState());
- });
-
- it('saves formatted JSON and creates parent directories', async () => {
- const filePath = nestedPairingPath();
-
- await new JsonPairingStore(filePath).save(createState());
-
- expect(readFileSync(filePath, 'utf8')).toBe(`${JSON.stringify(createState(), null, 2)}\n`);
- });
-
- it('leaves no temp files after a successful save', async () => {
- const filePath = pairingPath();
-
- await new JsonPairingStore(filePath).save(createState());
-
- expect(readdirSync(tempDir!).filter((name) => name.endsWith('.tmp'))).toEqual([]);
- });
-
- it('backs up invalid JSON and replaces it with fresh valid state', async () => {
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
- const filePath = pairingPath();
- writeFileSync(filePath, '{', 'utf8');
-
- const state = await new JsonPairingStore(filePath).load();
-
- expect(state.pairedDevices).toEqual([]);
- expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual(state);
- expect(corruptBackups()).toHaveLength(1);
- expect(warn.mock.calls.flat().join('\n')).not.toContain('{');
- });
-
- it('backs up NUL-byte files and replaces them with fresh valid state', async () => {
- const filePath = pairingPath();
- writeFileSync(filePath, '\0'.repeat(562), 'utf8');
-
- const state = await new JsonPairingStore(filePath).load();
-
- expect(state.desktopId).toMatch(/[0-9a-f-]{36}/);
- expect(state.pairedDevices).toEqual([]);
- expect(readFileSync(filePath, 'utf8')).toContain('"pairedDevices": []');
- expect(corruptBackups()).toHaveLength(1);
- });
-
- it('backs up invalid pairing state schema and replaces it with fresh valid state', async () => {
- const filePath = pairingPath();
- writeFileSync(filePath, JSON.stringify({ desktopId: 1, pairedDevices: [] }), 'utf8');
-
- const state = await new JsonPairingStore(filePath).load();
-
- expect(state.pairedDevices).toEqual([]);
- expect(corruptBackups()).toHaveLength(1);
- });
-
- it('backs up invalid paired-device entries and replaces them with fresh valid state', async () => {
- const filePath = pairingPath();
- writeFileSync(
- filePath,
- JSON.stringify({
- desktopId: 'desktop-1',
- pairedDevices: [{ deviceId: 'android-1', token: 'secret-token' }]
- }),
- 'utf8'
- );
-
- const state = await new JsonPairingStore(filePath).load();
-
- expect(state.pairedDevices).toEqual([]);
- expect(corruptBackups()).toHaveLength(1);
- });
-
- it('does not log tokens or corrupt pairing contents during recovery', async () => {
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
- const filePath = pairingPath();
- writeFileSync(
- filePath,
- JSON.stringify({
- desktopId: 'desktop-1',
- pairedDevices: [{ deviceId: 'android-1', token: 'secret-token' }]
- }),
- 'utf8'
- );
-
- await new JsonPairingStore(filePath).load();
-
- const warningText = warn.mock.calls.flat().join('\n');
- expect(warningText).not.toContain('secret-token');
- expect(warningText).not.toContain('android-1');
- });
-
- function pairingPath(): string {
- if (!tempDir) {
- tempDir = mkdtempSync(join(tmpdir(), 'switchify-pairing-store-'));
- }
-
- return join(tempDir, 'pairing-state.json');
- }
-
- function nestedPairingPath(): string {
- if (!tempDir) {
- tempDir = mkdtempSync(join(tmpdir(), 'switchify-pairing-store-'));
- }
-
- return join(tempDir, 'nested', 'pairing-state.json');
- }
-
- function corruptBackups(): string[] {
- return readdirSync(tempDir!).filter((name) => name.startsWith('pairing-state.corrupt-'));
- }
-});
-
-function createState(): PairingState {
- return {
- desktopId: 'desktop-1',
- pairedDevices: [
- {
- deviceId: 'android-1',
- deviceName: 'Phone',
- token: 'secret-token-1',
- pairedAt: 1_000,
- lastSeenAt: 2_000
- },
- {
- deviceId: 'android-2',
- deviceName: 'Tablet',
- token: 'secret-token-2',
- pairedAt: 3_000,
- lastSeenAt: null
- }
- ]
- };
-}
diff --git a/src/main/pairing/pairing-store.ts b/src/main/pairing/pairing-store.ts
deleted file mode 100644
index b68f3a6..0000000
--- a/src/main/pairing/pairing-store.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-import { readFile } from 'node:fs/promises';
-import { randomUUID } from 'node:crypto';
-import type { PairedDeviceView } from '../../shared/server-status';
-import { backupCorruptJsonFile, writeJsonFileAtomic } from '../json-file-store';
-
-export type PairedDevice = {
- deviceId: string;
- deviceName: string;
- token: string;
- pairedAt: number;
- lastSeenAt: number | null;
-};
-
-export type PairingState = {
- desktopId: string;
- pairedDevices: PairedDevice[];
-};
-
-export interface PairingStore {
- load(): Promise;
- save(state: PairingState): Promise;
-}
-
-export class JsonPairingStore implements PairingStore {
- constructor(private readonly filePath: string) {}
-
- async load(): Promise {
- try {
- const raw = await readFile(this.filePath, 'utf8');
- return parsePairingState(JSON.parse(raw));
- } catch (error) {
- if (isMissingFileError(error)) {
- const state = createEmptyPairingState();
- await this.save(state);
- return state;
- }
-
- if (isCorruptPairingStateError(error)) {
- const backup = await backupCorruptJsonFile(this.filePath);
- console.warn(
- backup.backupPath
- ? 'Switchify pairing state could not be loaded. The corrupt file was backed up and a fresh pairing state will be used.'
- : 'Switchify pairing state could not be loaded. A fresh pairing state will be used.'
- );
- const state = createEmptyPairingState();
- await this.save(state);
- return state;
- }
-
- throw error;
- }
- }
-
- async save(state: PairingState): Promise {
- await writeJsonFileAtomic(this.filePath, `${JSON.stringify(state, null, 2)}\n`);
- }
-}
-
-export class MemoryPairingStore implements PairingStore {
- private state: PairingState;
-
- constructor(initialState: PairingState = createEmptyPairingState()) {
- this.state = cloneState(initialState);
- }
-
- async load(): Promise {
- return cloneState(this.state);
- }
-
- async save(state: PairingState): Promise {
- this.state = cloneState(state);
- }
-}
-
-export function createEmptyPairingState(): PairingState {
- return {
- desktopId: randomUUID(),
- pairedDevices: []
- };
-}
-
-export function findPairedDevice(state: PairingState, deviceId: string): PairedDevice | null {
- return state.pairedDevices.find((device) => device.deviceId === deviceId) ?? null;
-}
-
-export function toPairedDeviceViews(state: PairingState): PairedDeviceView[] {
- return state.pairedDevices.map((device) => ({
- deviceId: device.deviceId,
- deviceName: device.deviceName,
- pairedAt: device.pairedAt,
- lastSeenAt: device.lastSeenAt
- }));
-}
-
-export function upsertPairedDevice(state: PairingState, device: PairedDevice): PairingState {
- const pairedDevices = state.pairedDevices.filter((existing) => existing.deviceId !== device.deviceId);
- pairedDevices.push(device);
-
- return {
- ...state,
- pairedDevices
- };
-}
-
-export function removePairedDevice(state: PairingState, deviceId: string): PairingState {
- return {
- ...state,
- pairedDevices: state.pairedDevices.filter((device) => device.deviceId !== deviceId)
- };
-}
-
-function parsePairingState(value: unknown): PairingState {
- if (!isRecord(value) || typeof value.desktopId !== 'string' || !Array.isArray(value.pairedDevices)) {
- throw new Error('Invalid pairing state.');
- }
-
- return {
- desktopId: value.desktopId,
- pairedDevices: value.pairedDevices.map(parsePairedDevice)
- };
-}
-
-function parsePairedDevice(value: unknown): PairedDevice {
- if (
- !isRecord(value) ||
- typeof value.deviceId !== 'string' ||
- typeof value.deviceName !== 'string' ||
- typeof value.token !== 'string' ||
- typeof value.pairedAt !== 'number' ||
- !(typeof value.lastSeenAt === 'number' || value.lastSeenAt === null)
- ) {
- throw new Error('Invalid paired device.');
- }
-
- return {
- deviceId: value.deviceId,
- deviceName: value.deviceName,
- token: value.token,
- pairedAt: value.pairedAt,
- lastSeenAt: value.lastSeenAt
- };
-}
-
-function cloneState(state: PairingState): PairingState {
- return {
- desktopId: state.desktopId,
- pairedDevices: state.pairedDevices.map((device) => ({ ...device }))
- };
-}
-
-function isMissingFileError(error: unknown): boolean {
- return isRecord(error) && error.code === 'ENOENT';
-}
-
-function isCorruptPairingStateError(error: unknown): boolean {
- return (
- error instanceof SyntaxError ||
- (error instanceof Error && (error.message === 'Invalid pairing state.' || error.message === 'Invalid paired device.'))
- );
-}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null;
-}
diff --git a/src/main/pointer-movement-settings-ipc.test.ts b/src/main/pointer-movement-settings-ipc.test.ts
deleted file mode 100644
index 5d3873c..0000000
--- a/src/main/pointer-movement-settings-ipc.test.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import {
- GET_POINTER_MOVEMENT_SETTINGS_CHANNEL,
- SET_POINTER_MOVEMENT_SETTINGS_CHANNEL
-} from '../shared/ipc-channels';
-import type { PointerMovementSettings } from '../shared/pointer-movement-settings';
-import { registerPointerMovementSettingsIpc } from './pointer-movement-settings-ipc';
-import type { JsonPointerMovementSettingsStore } from './pointer-movement-settings-store';
-
-type IpcHandler = (event: Electron.IpcMainInvokeEvent, ...args: unknown[]) => unknown;
-
-const ipcHandlers = new Map();
-
-vi.mock('electron', () => ({
- ipcMain: {
- handle: vi.fn((channel: string, handler: IpcHandler) => {
- ipcHandlers.set(channel, handler);
- })
- }
-}));
-
-describe('registerPointerMovementSettingsIpc', () => {
- beforeEach(() => {
- ipcHandlers.clear();
- });
-
- it('returns stored settings', async () => {
- const settings = { scalePercent: 125 };
- const store = createStore(settings);
-
- registerPointerMovementSettingsIpc(store, vi.fn());
-
- await expect(invoke(GET_POINTER_MOVEMENT_SETTINGS_CHANNEL)).resolves.toEqual(settings);
- });
-
- it('normalizes and saves settings', async () => {
- const store = createStore();
-
- registerPointerMovementSettingsIpc(store, vi.fn());
-
- await expect(invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, { scalePercent: 123 })).resolves.toEqual({
- scalePercent: 125
- });
- expect(store.save).toHaveBeenCalledWith({ scalePercent: 125 });
- });
-
- it('notifies when settings change', async () => {
- const store = createStore();
- const onSettingsChanged = vi.fn();
-
- registerPointerMovementSettingsIpc(store, onSettingsChanged);
- await invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, { scalePercent: 75 });
-
- expect(onSettingsChanged).toHaveBeenCalledWith({ scalePercent: 75 });
- });
-
- it('normalizes migrated percentage settings before saving and notifying', async () => {
- const store = createStore();
- const onSettingsChanged = vi.fn();
-
- registerPointerMovementSettingsIpc(store, onSettingsChanged);
-
- await expect(
- invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, {
- percentages: { small: 9, medium: 24, large: 50 }
- })
- ).resolves.toEqual({ scalePercent: 195 });
- expect(store.save).toHaveBeenCalledWith({ scalePercent: 195 });
- expect(onSettingsChanged).toHaveBeenCalledWith({ scalePercent: 195 });
- });
-});
-
-function createStore(settings: PointerMovementSettings = { scalePercent: 100 }): JsonPointerMovementSettingsStore {
- return {
- load: vi.fn(() => settings),
- save: vi.fn((nextSettings: PointerMovementSettings) => nextSettings)
- } as unknown as JsonPointerMovementSettingsStore;
-}
-
-function invoke(channel: string, ...args: unknown[]): Promise {
- const handler = ipcHandlers.get(channel);
- if (!handler) throw new Error(`Handler was not registered: ${channel}`);
- return Promise.resolve(handler({ sender: {} } as Electron.IpcMainInvokeEvent, ...args));
-}
diff --git a/src/main/pointer-movement-settings-ipc.ts b/src/main/pointer-movement-settings-ipc.ts
deleted file mode 100644
index 4dc5dfc..0000000
--- a/src/main/pointer-movement-settings-ipc.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { ipcMain } from 'electron';
-import { normalizePointerMovementSettings, type PointerMovementSettings } from '../shared/pointer-movement-settings';
-import {
- GET_POINTER_MOVEMENT_SETTINGS_CHANNEL,
- SET_POINTER_MOVEMENT_SETTINGS_CHANNEL
-} from '../shared/ipc-channels';
-import type { JsonPointerMovementSettingsStore } from './pointer-movement-settings-store';
-
-export function registerPointerMovementSettingsIpc(
- settingsStore: JsonPointerMovementSettingsStore,
- onSettingsChanged: (settings: PointerMovementSettings) => void
-): void {
- ipcMain.handle(GET_POINTER_MOVEMENT_SETTINGS_CHANNEL, () => settingsStore.load());
- ipcMain.handle(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, (_event, settings: PointerMovementSettings) => {
- const normalized = settingsStore.save(normalizePointerMovementSettings(settings));
- onSettingsChanged(normalized);
- return normalized;
- });
-}
diff --git a/src/main/pointer-movement-settings-store.test.ts b/src/main/pointer-movement-settings-store.test.ts
deleted file mode 100644
index ce9afd7..0000000
--- a/src/main/pointer-movement-settings-store.test.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { DEFAULT_POINTER_MOVEMENT_SETTINGS } from '../shared/pointer-movement-settings';
-import { JsonPointerMovementSettingsStore } from './pointer-movement-settings-store';
-
-describe('JsonPointerMovementSettingsStore', () => {
- let tempDir: string | null = null;
- let warn: ReturnType;
-
- beforeEach(() => {
- warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
- });
-
- afterEach(() => {
- warn.mockRestore();
- if (tempDir) {
- rmSync(tempDir, { recursive: true, force: true });
- tempDir = null;
- }
- });
-
- it('loads defaults when the settings file is missing', () => {
- expect(store().load()).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS);
- });
-
- it('loads and normalizes valid settings', () => {
- const settingsFile = settingsPath();
- writeFileSync(settingsFile, JSON.stringify({ scalePercent: 123 }), 'utf8');
-
- expect(new JsonPointerMovementSettingsStore(settingsFile).load()).toEqual({ scalePercent: 125 });
- });
-
- it('loads and migrates percentage settings', () => {
- const settingsFile = settingsPath();
- writeFileSync(settingsFile, JSON.stringify({ percentages: { small: 9, medium: 24, large: 50 } }), 'utf8');
-
- expect(new JsonPointerMovementSettingsStore(settingsFile).load()).toEqual({ scalePercent: 195 });
- });
-
- it('loads and migrates legacy multiplier settings', () => {
- const settingsFile = settingsPath();
- writeFileSync(settingsFile, JSON.stringify({ multipliers: { small: 200, medium: 50, large: 100 } }), 'utf8');
-
- expect(new JsonPointerMovementSettingsStore(settingsFile).load()).toEqual({ scalePercent: 115 });
- });
-
- it('loads defaults and warns when JSON is invalid', () => {
- const settingsFile = settingsPath();
- writeFileSync(settingsFile, '{', 'utf8');
-
- expect(new JsonPointerMovementSettingsStore(settingsFile).load()).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS);
- expect(warn).toHaveBeenCalledWith('Switchify pointer movement settings could not be loaded. Defaults will be used.');
- });
-
- it('saves normalized JSON', () => {
- const settingsFile = settingsPath();
- const saved = new JsonPointerMovementSettingsStore(settingsFile).save({ scalePercent: 123 });
-
- expect(saved).toEqual({ scalePercent: 125 });
- expect(JSON.parse(readFileSync(settingsFile, 'utf8'))).toEqual(saved);
- });
-
- it('creates the parent directory when saving', () => {
- tempDir = mkdtempSync(join(tmpdir(), 'switchify-pointer-movement-'));
- const settingsFile = join(tempDir, 'nested', 'pointer-movement-settings.json');
-
- new JsonPointerMovementSettingsStore(settingsFile).save(DEFAULT_POINTER_MOVEMENT_SETTINGS);
-
- expect(JSON.parse(readFileSync(settingsFile, 'utf8'))).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS);
- });
-
- it('leaves no temp files after saving', () => {
- const settingsFile = settingsPath();
-
- new JsonPointerMovementSettingsStore(settingsFile).save(DEFAULT_POINTER_MOVEMENT_SETTINGS);
-
- expect(readdirSync(tempDir!).filter((name) => name.endsWith('.tmp'))).toEqual([]);
- });
-
- function store(): JsonPointerMovementSettingsStore {
- return new JsonPointerMovementSettingsStore(settingsPath());
- }
-
- function settingsPath(): string {
- if (!tempDir) {
- tempDir = mkdtempSync(join(tmpdir(), 'switchify-pointer-movement-'));
- }
- return join(tempDir, 'pointer-movement-settings.json');
- }
-});
diff --git a/src/main/pointer-movement-settings-store.ts b/src/main/pointer-movement-settings-store.ts
deleted file mode 100644
index eba5bd8..0000000
--- a/src/main/pointer-movement-settings-store.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { readFileSync } from 'node:fs';
-import {
- DEFAULT_POINTER_MOVEMENT_SETTINGS,
- normalizePointerMovementSettings,
- type PointerMovementSettings
-} from '../shared/pointer-movement-settings';
-import { writeJsonFileAtomicSync } from './json-file-store';
-
-export class JsonPointerMovementSettingsStore {
- constructor(private readonly filePath: string) {}
-
- load(): PointerMovementSettings {
- try {
- const raw = readFileSync(this.filePath, 'utf8');
- return normalizePointerMovementSettings(JSON.parse(raw));
- } catch (error) {
- if (isMissingFileError(error)) {
- return DEFAULT_POINTER_MOVEMENT_SETTINGS;
- }
-
- console.warn('Switchify pointer movement settings could not be loaded. Defaults will be used.');
- return DEFAULT_POINTER_MOVEMENT_SETTINGS;
- }
- }
-
- save(settings: PointerMovementSettings): PointerMovementSettings {
- const normalized = normalizePointerMovementSettings(settings);
- writeJsonFileAtomicSync(this.filePath, `${JSON.stringify(normalized, null, 2)}\n`);
- return normalized;
- }
-}
-
-function isMissingFileError(error: unknown): boolean {
- return (
- error !== null &&
- typeof error === 'object' &&
- 'code' in error &&
- (error as { code?: unknown }).code === 'ENOENT'
- );
-}
diff --git a/src/main/server-ipc.ts b/src/main/server-ipc.ts
deleted file mode 100644
index c60f6a1..0000000
--- a/src/main/server-ipc.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { ipcMain } from 'electron';
-import {
- DISCONNECT_CLIENTS_CHANNEL,
- FORGET_PAIRED_DEVICE_CHANNEL,
- GET_PAIRED_DEVICES_CHANNEL,
- SERVER_STATUS_CHANNEL
-} from '../shared/ipc-channels';
-import type { ControlService } from './control/control-service';
-import type { PairingStore } from './pairing/pairing-store';
-import { removePairedDevice, toPairedDeviceViews } from './pairing/pairing-store';
-
-export function registerServerIpc(controlService: ControlService, pairingStore: PairingStore): void {
- ipcMain.handle(SERVER_STATUS_CHANNEL, () => controlService.getStatus());
- ipcMain.handle(GET_PAIRED_DEVICES_CHANNEL, async () => toPairedDeviceViews(await pairingStore.load()));
- ipcMain.handle(DISCONNECT_CLIENTS_CHANNEL, () => controlService.disconnectClients());
- ipcMain.handle(FORGET_PAIRED_DEVICE_CHANNEL, async (_event, deviceId: unknown) => {
- if (typeof deviceId !== 'string' || deviceId.length === 0) {
- return { ok: false, reason: 'invalid_device_id' };
- }
-
- const state = await pairingStore.load();
- const exists = state.pairedDevices.some((device) => device.deviceId === deviceId);
- if (!exists) {
- return { ok: false, reason: 'device_not_found' };
- }
-
- const nextState = removePairedDevice(state, deviceId);
- await pairingStore.save(nextState);
-
- return {
- ok: true,
- pairedDevices: toPairedDeviceViews(nextState),
- status: controlService.disconnectDevice(deviceId)
- };
- });
-}
diff --git a/src/main/settings-window-ipc.ts b/src/main/settings-window-ipc.ts
deleted file mode 100644
index 974b29a..0000000
--- a/src/main/settings-window-ipc.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { ipcMain } from 'electron';
-import { OPEN_SETTINGS_WINDOW_CHANNEL } from '../shared/ipc-channels';
-import { isSettingsSectionId, type SettingsSectionId } from '../shared/settings';
-
-export function registerSettingsWindowIpc(openSettingsWindow: (section?: SettingsSectionId) => void): void {
- ipcMain.handle(OPEN_SETTINGS_WINDOW_CHANNEL, (_event, section) => {
- openSettingsWindow(isSettingsSectionId(section) ? section : undefined);
- });
-}
diff --git a/src/main/sign-win-artifacts.test.ts b/src/main/sign-win-artifacts.test.ts
deleted file mode 100644
index 81c2b5d..0000000
--- a/src/main/sign-win-artifacts.test.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import { createRequire } from 'node:module';
-import { afterEach, describe, expect, it } from 'vitest';
-
-const require = createRequire(import.meta.url);
-const {
- createSha512Base64,
- ensureAdminRightsMetadata,
- updateLatestYmlForReferencedInstaller,
- updateLatestYmlForSignedInstaller
-} = require('../../scripts/sign-win-artifacts.cjs') as {
- createSha512Base64(filePath: string): string;
- ensureAdminRightsMetadata(content: string): string;
- updateLatestYmlForReferencedInstaller(latestPath: string): void;
- updateLatestYmlForSignedInstaller(installerPath: string): void;
-};
-
-const tempDirs: string[] = [];
-
-afterEach(() => {
- for (const tempDir of tempDirs.splice(0)) {
- fs.rmSync(tempDir, { recursive: true, force: true });
- }
-});
-
-describe('sign-win-artifacts updater metadata', () => {
- it('updates latest.yml checksums after installer bytes change', () => {
- const distDir = createTempDir();
- const installerPath = path.join(distDir, 'Switchify-PC-Setup-0.1.1-x64.exe');
- const latestPath = path.join(distDir, 'latest.yml');
- fs.writeFileSync(installerPath, 'unsigned installer bytes');
- fs.writeFileSync(
- latestPath,
- [
- 'version: 0.1.1',
- 'files:',
- ' - url: Switchify-PC-Setup-0.1.1-x64.exe',
- ' sha512: old-file-checksum',
- 'path: Switchify-PC-Setup-0.1.1-x64.exe',
- 'sha512: old-path-checksum'
- ].join('\n')
- );
-
- fs.writeFileSync(installerPath, 'signed installer bytes');
- updateLatestYmlForSignedInstaller(installerPath);
-
- const sha512 = createSha512Base64(installerPath);
- const latest = fs.readFileSync(latestPath, 'utf8');
- expect(latest.match(/^sha512: .+$/gm)).toEqual([`sha512: ${sha512}`]);
- expect(latest.match(/^\s+sha512: .+$/gm)).toEqual([` sha512: ${sha512}`]);
- expect(latest).toContain('isAdminRightsRequired: true');
- expect(latest).toContain(' isAdminRightsRequired: true');
- });
-
- it('ignores latest.yml when it does not reference the signed installer', () => {
- const distDir = createTempDir();
- const installerPath = path.join(distDir, 'Switchify-PC-Setup-0.1.1-x64.exe');
- fs.writeFileSync(installerPath, 'signed installer bytes');
- fs.writeFileSync(
- path.join(distDir, 'latest.yml'),
- [
- 'version: 0.1.1',
- 'files:',
- ' - url: Other-Setup.exe',
- ' sha512: old-file-checksum',
- 'path: Other-Setup.exe',
- 'sha512: old-path-checksum'
- ].join('\n')
- );
-
- expect(() => updateLatestYmlForSignedInstaller(installerPath)).not.toThrow();
- expect(fs.readFileSync(path.join(distDir, 'latest.yml'), 'utf8')).toContain('old-file-checksum');
- });
-
- it('updates latest.yml when the installer name is URL-encoded', () => {
- const distDir = createTempDir();
- const installerPath = path.join(distDir, 'Switchify PC Setup 0.1.1 x64.exe');
- const latestPath = path.join(distDir, 'latest.yml');
- fs.writeFileSync(installerPath, 'signed installer bytes');
- fs.writeFileSync(
- latestPath,
- [
- 'version: 0.1.1',
- 'files:',
- ' - url: Switchify%20PC%20Setup%200.1.1%20x64.exe',
- ' sha512: old-file-checksum',
- 'path: Switchify%20PC%20Setup%200.1.1%20x64.exe',
- 'sha512: old-path-checksum'
- ].join('\n')
- );
-
- updateLatestYmlForSignedInstaller(installerPath);
-
- const sha512 = createSha512Base64(installerPath);
- const latest = fs.readFileSync(latestPath, 'utf8');
- expect(latest.match(/^sha512: .+$/gm)).toEqual([`sha512: ${sha512}`]);
- expect(latest.match(/^\s+sha512: .+$/gm)).toEqual([` sha512: ${sha512}`]);
- expect(latest).toContain('isAdminRightsRequired: true');
- expect(latest).toContain(' isAdminRightsRequired: true');
- });
-
- it('does nothing when latest.yml is missing', () => {
- const distDir = createTempDir();
- const installerPath = path.join(distDir, 'Switchify-PC-Setup-0.1.1-x64.exe');
- fs.writeFileSync(installerPath, 'signed installer bytes');
-
- expect(() => updateLatestYmlForSignedInstaller(installerPath)).not.toThrow();
- });
-
- it('updates the installer referenced by latest.yml', () => {
- const distDir = createTempDir();
- const installerPath = path.join(distDir, 'Switchify-PC-Setup-0.1.1-x64.exe');
- const latestPath = path.join(distDir, 'latest.yml');
- fs.writeFileSync(installerPath, 'signed installer bytes');
- fs.writeFileSync(
- latestPath,
- [
- 'version: 0.1.1',
- 'files:',
- ' - url: Switchify-PC-Setup-0.1.1-x64.exe',
- ' sha512: old-file-checksum',
- 'path: Switchify-PC-Setup-0.1.1-x64.exe',
- 'sha512: old-path-checksum'
- ].join('\n')
- );
-
- updateLatestYmlForReferencedInstaller(latestPath);
-
- const sha512 = createSha512Base64(installerPath);
- const latest = fs.readFileSync(latestPath, 'utf8');
- expect(latest.match(/^sha512: .+$/gm)).toEqual([`sha512: ${sha512}`]);
- expect(latest.match(/^\s+sha512: .+$/gm)).toEqual([` sha512: ${sha512}`]);
- });
-
- it('adds admin-rights metadata to latest.yml', () => {
- const latest = ensureAdminRightsMetadata(
- [
- 'version: 0.1.1',
- 'files:',
- ' - url: Switchify-PC-Setup-0.1.1-x64.exe',
- ' sha512: file-checksum',
- 'path: Switchify-PC-Setup-0.1.1-x64.exe',
- 'sha512: path-checksum',
- "releaseDate: '2026-06-27T12:00:00.000Z'"
- ].join('\n')
- );
-
- expect(latest).toContain('isAdminRightsRequired: true');
- expect(latest).toContain(' isAdminRightsRequired: true');
- expect(latest.indexOf('isAdminRightsRequired: true')).toBeLessThan(latest.indexOf('releaseDate:'));
- });
-
- it('normalizes false admin-rights metadata to true without duplicating keys', () => {
- const latest = ensureAdminRightsMetadata(
- [
- 'version: 0.1.1',
- 'files:',
- ' - url: Switchify-PC-Setup-0.1.1-x64.exe',
- ' sha512: file-checksum',
- ' isAdminRightsRequired: false',
- 'path: Switchify-PC-Setup-0.1.1-x64.exe',
- 'sha512: path-checksum',
- 'isAdminRightsRequired: false'
- ].join('\n')
- );
-
- expect(latest.match(/^isAdminRightsRequired:/gm)).toEqual(['isAdminRightsRequired:']);
- expect(latest.match(/^ isAdminRightsRequired:/gm)).toEqual([' isAdminRightsRequired:']);
- expect(latest).not.toContain('isAdminRightsRequired: false');
- });
-});
-
-function createTempDir(): string {
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchify-sign-test-'));
- tempDirs.push(tempDir);
- return tempDir;
-}
diff --git a/src/main/single-instance.test.ts b/src/main/single-instance.test.ts
deleted file mode 100644
index 162ba1a..0000000
--- a/src/main/single-instance.test.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { secondInstanceAction } from './single-instance';
-
-describe('secondInstanceAction', () => {
- it('shows the main window for a normal Windows second launch', () => {
- expect(secondInstanceAction(['Switchify PC.exe'], 'win32')).toBe('showMainWindow');
- });
-
- it('ignores Windows startup launches so they stay hidden', () => {
- expect(secondInstanceAction(['Switchify PC.exe', '--start-hidden'], 'win32')).toBe('ignore');
- });
-
- it('does not suppress non-Windows launches that include the Windows startup flag', () => {
- expect(secondInstanceAction(['Switchify PC', '--start-hidden'], 'darwin')).toBe('showMainWindow');
- });
-});
diff --git a/src/main/single-instance.ts b/src/main/single-instance.ts
deleted file mode 100644
index 07c1340..0000000
--- a/src/main/single-instance.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { shouldStartHidden } from './system-startup';
-
-export type SecondInstanceAction = 'showMainWindow' | 'ignore';
-
-export function secondInstanceAction(argv: string[], platform: NodeJS.Platform): SecondInstanceAction {
- return shouldStartHidden(argv, platform) ? 'ignore' : 'showMainWindow';
-}
diff --git a/src/main/startup-diagnostics.test.ts b/src/main/startup-diagnostics.test.ts
deleted file mode 100644
index e1d6ddf..0000000
--- a/src/main/startup-diagnostics.test.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { describe, expect, it } from 'vitest';
-import { appendStartupDiagnostics, type StartupDiagnosticsEntry } from './startup-diagnostics';
-
-describe('appendStartupDiagnostics', () => {
- it('appends a JSONL diagnostics entry', () => {
- const filePath = diagnosticsPath();
-
- appendStartupDiagnostics(filePath, entry({ startedAt: '2026-06-24T10:00:00.000Z' }));
-
- const lines = readLines(filePath);
- expect(lines).toHaveLength(1);
- expect(JSON.parse(lines[0])).toMatchObject({
- startedAt: '2026-06-24T10:00:00.000Z',
- version: '0.1.15',
- startHidden: true
- });
- });
-
- it('keeps only the newest 50 lines', () => {
- const filePath = diagnosticsPath();
-
- for (let index = 0; index < 55; index += 1) {
- appendStartupDiagnostics(filePath, entry({ startedAt: `2026-06-24T10:${String(index).padStart(2, '0')}:00.000Z` }));
- }
-
- const lines = readLines(filePath);
- expect(lines).toHaveLength(50);
- expect(JSON.parse(lines[0]).startedAt).toBe('2026-06-24T10:05:00.000Z');
- expect(JSON.parse(lines[49]).startedAt).toBe('2026-06-24T10:54:00.000Z');
- });
-
- it('creates the parent directory if it is missing', () => {
- const filePath = join(mkdtempSync(join(tmpdir(), 'switchify-startup-')), 'nested', 'startup.jsonl');
-
- appendStartupDiagnostics(filePath, entry());
-
- expect(readLines(filePath)).toHaveLength(1);
- });
-
- it('drops malformed existing lines without throwing', () => {
- const filePath = diagnosticsPath();
- writeFileSync(filePath, 'not json\n{"startedAt":"old"}\n', 'utf8');
-
- appendStartupDiagnostics(filePath, entry());
-
- const lines = readLines(filePath);
- expect(lines).toHaveLength(2);
- expect(JSON.parse(lines[0])).toEqual({ startedAt: 'old' });
- });
-});
-
-function diagnosticsPath(): string {
- return join(mkdtempSync(join(tmpdir(), 'switchify-startup-')), 'startup-diagnostics.jsonl');
-}
-
-function readLines(filePath: string): string[] {
- return readFileSync(filePath, 'utf8')
- .split(/\r?\n/)
- .filter(Boolean);
-}
-
-function entry(overrides: Partial = {}): StartupDiagnosticsEntry {
- return {
- startedAt: '2026-06-24T10:00:00.000Z',
- version: '0.1.15',
- isPackaged: true,
- platform: 'win32',
- executablePath: 'C:\\Program Files\\Switchify PC\\Switchify PC.exe',
- argv: ['Switchify PC.exe', '--start-hidden'],
- startHidden: true,
- startupRegistration: {
- startWithSystem: true,
- registeredCommand: '"C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden',
- startupApproved: 'enabled'
- },
- ...overrides
- };
-}
diff --git a/src/main/startup-diagnostics.ts b/src/main/startup-diagnostics.ts
deleted file mode 100644
index 84c6d6b..0000000
--- a/src/main/startup-diagnostics.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
-import { dirname } from 'node:path';
-import type { StartupApprovedState } from '../shared/system-startup';
-
-export type StartupDiagnosticsEntry = {
- startedAt: string;
- version: string;
- isPackaged: boolean;
- platform: NodeJS.Platform;
- executablePath: string;
- argv: string[];
- startHidden: boolean;
- startupRegistration?: {
- startWithSystem: boolean;
- registeredCommand: string | null;
- startupApproved: StartupApprovedState;
- };
-};
-
-const MAX_STARTUP_DIAGNOSTICS_LINES = 50;
-
-export function appendStartupDiagnostics(filePath: string, entry: StartupDiagnosticsEntry): void {
- try {
- mkdirSync(dirname(filePath), { recursive: true });
- const existingLines = readExistingLines(filePath);
- const nextLines = [...existingLines, JSON.stringify(entry)].slice(-MAX_STARTUP_DIAGNOSTICS_LINES);
- writeFileSync(filePath, `${nextLines.join('\n')}\n`, 'utf8');
- } catch (error) {
- console.warn(error instanceof Error ? error.message : 'Could not write startup diagnostics.');
- }
-}
-
-function readExistingLines(filePath: string): string[] {
- try {
- return readFileSync(filePath, 'utf8')
- .split(/\r?\n/)
- .map((line) => line.trim())
- .filter(Boolean)
- .filter((line) => {
- try {
- JSON.parse(line);
- return true;
- } catch {
- return false;
- }
- });
- } catch {
- return [];
- }
-}
diff --git a/src/main/system-startup-ipc.ts b/src/main/system-startup-ipc.ts
deleted file mode 100644
index 275dd60..0000000
--- a/src/main/system-startup-ipc.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { ipcMain } from 'electron';
-import {
- GET_SYSTEM_STARTUP_SETTINGS_CHANNEL,
- SET_START_WITH_SYSTEM_CHANNEL
-} from '../shared/ipc-channels';
-import type { SystemStartupService } from './system-startup';
-
-export function registerSystemStartupIpc(systemStartup: SystemStartupService): void {
- ipcMain.handle(GET_SYSTEM_STARTUP_SETTINGS_CHANNEL, () => systemStartup.getSettings());
- ipcMain.handle(SET_START_WITH_SYSTEM_CHANNEL, (_event, enabled: unknown) =>
- systemStartup.setStartWithSystem(Boolean(enabled))
- );
-}
diff --git a/src/main/system-startup.test.ts b/src/main/system-startup.test.ts
deleted file mode 100644
index 9cdd66e..0000000
--- a/src/main/system-startup.test.ts
+++ /dev/null
@@ -1,212 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import { START_HIDDEN_ARG, STARTUP_VALUE_NAME, shouldStartHidden, SystemStartupService } from './system-startup';
-import type { StartupRegistryEntry, WindowsStartupRegistry } from './windows-startup-registry';
-
-const expectedCommand = '"C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden';
-
-describe('shouldStartHidden', () => {
- it('detects the Windows hidden startup argument', () => {
- expect(shouldStartHidden(['Switchify PC.exe', START_HIDDEN_ARG], 'win32')).toBe(true);
- });
-
- it('ignores the hidden startup argument on non-Windows platforms', () => {
- expect(shouldStartHidden(['Switchify PC', START_HIDDEN_ARG], 'darwin')).toBe(false);
- });
-
- it('returns false when the hidden startup argument is missing', () => {
- expect(shouldStartHidden(['Switchify PC.exe'], 'win32')).toBe(false);
- });
-});
-
-describe('SystemStartupService', () => {
- it('returns unsupported settings on non-Windows platforms without reading the registry', async () => {
- const service = createService({ platform: 'darwin', isPackaged: true });
-
- await expect(service.getSettings()).resolves.toEqual({
- supported: false,
- startWithSystem: false,
- startsHidden: true,
- reason: 'unsupported_platform'
- });
-
- await service.setStartWithSystem(true);
- expect(service.startupRegistry.getEntry).not.toHaveBeenCalled();
- expect(service.startupRegistry.setEntry).not.toHaveBeenCalled();
- });
-
- it('returns unsupported settings for unpackaged Windows builds without writing the registry', async () => {
- const service = createService({ platform: 'win32', isPackaged: false });
-
- await expect(service.getSettings()).resolves.toEqual({
- supported: false,
- startWithSystem: false,
- startsHidden: true,
- reason: 'unpackaged'
- });
-
- await service.setStartWithSystem(true);
- expect(service.startupRegistry.setEntry).not.toHaveBeenCalled();
- });
-
- it('reports enabled startup when the expected command is registered and approved', async () => {
- const service = createService({
- entry: {
- command: expectedCommand,
- startupApproved: 'enabled'
- }
- });
-
- await expect(service.getSettings()).resolves.toEqual({
- supported: true,
- startWithSystem: true,
- startsHidden: true,
- reason: null,
- registration: {
- expectedCommand,
- registeredCommand: expectedCommand,
- startupApproved: 'enabled'
- }
- });
- expect(service.startupRegistry.getEntry).toHaveBeenCalledWith(STARTUP_VALUE_NAME);
- });
-
- it('reports enabled startup when StartupApproved is missing but the command matches', async () => {
- const service = createService({
- entry: {
- command: expectedCommand,
- startupApproved: 'missing'
- }
- });
-
- await expect(service.getSettings()).resolves.toMatchObject({
- supported: true,
- startWithSystem: true,
- registration: {
- startupApproved: 'missing'
- }
- });
- });
-
- it('reports disabled startup when StartupApproved disables the matching command', async () => {
- const service = createService({
- entry: {
- command: expectedCommand,
- startupApproved: 'disabled'
- }
- });
-
- await expect(service.getSettings()).resolves.toMatchObject({
- supported: true,
- startWithSystem: false,
- registration: {
- registeredCommand: expectedCommand,
- startupApproved: 'disabled'
- }
- });
- });
-
- it('reports disabled startup when the Run command is missing', async () => {
- const service = createService({
- entry: {
- command: null,
- startupApproved: 'missing'
- }
- });
-
- await expect(service.getSettings()).resolves.toMatchObject({
- supported: true,
- startWithSystem: false,
- registration: {
- expectedCommand,
- registeredCommand: null,
- startupApproved: 'missing'
- }
- });
- });
-
- it('reports disabled startup when the Run command points to an older path', async () => {
- const oldCommand = '"C:\\Old\\Switchify PC.exe" --start-hidden';
- const service = createService({
- entry: {
- command: oldCommand,
- startupApproved: 'enabled'
- }
- });
-
- await expect(service.getSettings()).resolves.toMatchObject({
- supported: true,
- startWithSystem: false,
- registration: {
- expectedCommand,
- registeredCommand: oldCommand,
- startupApproved: 'enabled'
- }
- });
- });
-
- it('enables startup by writing the expected command', async () => {
- const service = createService();
-
- await service.setStartWithSystem(true);
-
- expect(service.startupRegistry.setEntry).toHaveBeenCalledWith(STARTUP_VALUE_NAME, expectedCommand);
- });
-
- it('disables startup by deleting the registry entry', async () => {
- const service = createService();
-
- await service.setStartWithSystem(false);
-
- expect(service.startupRegistry.deleteEntry).toHaveBeenCalledWith(STARTUP_VALUE_NAME);
- });
-
- it('returns disabled diagnostics when registry reads fail', async () => {
- const service = createService({ getEntryError: new Error('registry unavailable') });
-
- await expect(service.getSettings()).resolves.toMatchObject({
- supported: true,
- startWithSystem: false,
- registration: {
- expectedCommand,
- registeredCommand: null,
- startupApproved: 'unknown'
- }
- });
- });
-});
-
-function createService(
- options: Partial<{
- platform: NodeJS.Platform;
- isPackaged: boolean;
- entry: StartupRegistryEntry;
- getEntryError: Error;
- }> = {}
-): SystemStartupService & {
- startupRegistry: {
- getEntry: ReturnType;
- setEntry: ReturnType;
- deleteEntry: ReturnType;
- };
-} {
- const startupRegistry: WindowsStartupRegistry & {
- getEntry: ReturnType;
- setEntry: ReturnType;
- deleteEntry: ReturnType;
- } = {
- getEntry: vi.fn(async () => {
- if (options.getEntryError) throw options.getEntryError;
- return options.entry ?? ({ command: null, startupApproved: 'missing' } satisfies StartupRegistryEntry);
- }),
- setEntry: vi.fn(async () => undefined),
- deleteEntry: vi.fn(async () => undefined)
- };
- const service = new SystemStartupService({
- platform: options.platform ?? 'win32',
- isPackaged: options.isPackaged ?? true,
- executablePath: 'C:\\Program Files\\Switchify PC\\Switchify PC.exe',
- startupRegistry
- });
-
- return Object.assign(service, { startupRegistry });
-}
diff --git a/src/main/system-startup.ts b/src/main/system-startup.ts
deleted file mode 100644
index 8ddeac4..0000000
--- a/src/main/system-startup.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import type { SystemStartupSettings } from '../shared/system-startup';
-import { startupCommandFor, type WindowsStartupRegistry } from './windows-startup-registry';
-
-export const START_HIDDEN_ARG = '--start-hidden';
-export const STARTUP_VALUE_NAME = 'app.switchify.pc';
-
-export type SystemStartupServiceOptions = {
- platform: NodeJS.Platform;
- isPackaged: boolean;
- executablePath: string;
- startupRegistry: WindowsStartupRegistry;
-};
-
-export function shouldStartHidden(argv: string[], platform: NodeJS.Platform): boolean {
- return platform === 'win32' && argv.includes(START_HIDDEN_ARG);
-}
-
-export class SystemStartupService {
- constructor(private readonly options: SystemStartupServiceOptions) {}
-
- async getSettings(): Promise {
- if (!this.isSupported()) {
- return this.unsupportedSettings();
- }
-
- const expectedCommand = this.expectedCommand();
- const entry = await this.getRegistryEntrySafely();
-
- return {
- supported: true,
- startWithSystem: entry.command === expectedCommand && entry.startupApproved !== 'disabled',
- startsHidden: true,
- reason: null,
- registration: {
- expectedCommand,
- registeredCommand: entry.command,
- startupApproved: entry.startupApproved
- }
- };
- }
-
- async setStartWithSystem(enabled: boolean): Promise {
- if (!this.isSupported()) {
- return this.unsupportedSettings();
- }
-
- if (enabled) {
- await this.options.startupRegistry.setEntry(STARTUP_VALUE_NAME, this.expectedCommand());
- } else {
- await this.options.startupRegistry.deleteEntry(STARTUP_VALUE_NAME);
- }
-
- return this.getSettings();
- }
-
- private isSupported(): boolean {
- return this.options.platform === 'win32' && this.options.isPackaged;
- }
-
- private expectedCommand(): string {
- return startupCommandFor(this.options.executablePath, [START_HIDDEN_ARG]);
- }
-
- private async getRegistryEntrySafely(): Promise<{
- command: string | null;
- startupApproved: 'enabled' | 'disabled' | 'missing' | 'unknown';
- }> {
- try {
- return await this.options.startupRegistry.getEntry(STARTUP_VALUE_NAME);
- } catch (error) {
- console.warn(error instanceof Error ? error.message : 'Could not read startup registry settings.');
- return { command: null, startupApproved: 'unknown' };
- }
- }
-
- private unsupportedSettings(): SystemStartupSettings {
- return {
- supported: false,
- startWithSystem: false,
- startsHidden: true,
- reason: this.options.platform === 'win32' ? 'unpackaged' : 'unsupported_platform'
- };
- }
-}
diff --git a/src/main/transport/pending-approval-connections.ts b/src/main/transport/pending-approval-connections.ts
deleted file mode 100644
index e3fcdeb..0000000
--- a/src/main/transport/pending-approval-connections.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-export class PendingPairingApprovalConnections {
- private readonly connectionIdsByRequestId = new Map();
- private readonly timersByRequestId = new Map>();
-
- set(requestId: string, connectionId: string, expiresAt: number, onExpire: () => void): void {
- this.clear(requestId);
- this.connectionIdsByRequestId.set(requestId, connectionId);
- this.timersByRequestId.set(
- requestId,
- setTimeout(() => {
- onExpire();
- }, Math.max(0, expiresAt - Date.now()))
- );
- }
-
- get(requestId: string): string | null {
- return this.connectionIdsByRequestId.get(requestId) ?? null;
- }
-
- clear(requestId: string): void {
- const timer = this.timersByRequestId.get(requestId);
- if (timer) {
- clearTimeout(timer);
- }
- this.timersByRequestId.delete(requestId);
- this.connectionIdsByRequestId.delete(requestId);
- }
-
- clearForConnection(connectionId: string): string[] {
- const requestIds: string[] = [];
- for (const [requestId, pendingConnectionId] of this.connectionIdsByRequestId.entries()) {
- if (pendingConnectionId === connectionId) {
- requestIds.push(requestId);
- }
- }
- for (const requestId of requestIds) {
- this.clear(requestId);
- }
- return requestIds;
- }
-
- clearAll(): void {
- for (const timer of this.timersByRequestId.values()) {
- clearTimeout(timer);
- }
- this.timersByRequestId.clear();
- this.connectionIdsByRequestId.clear();
- }
-}
-
diff --git a/src/main/transport/protocol-response.ts b/src/main/transport/protocol-response.ts
deleted file mode 100644
index 7396004..0000000
--- a/src/main/transport/protocol-response.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import type { ProtocolErrorCode, ProtocolResponse } from '../../shared/protocol';
-import type { RemoteConnection } from './remote-connection';
-
-export function toProtocolCommandErrorCode(
- code: 'unsupported_command' | 'unsafe_payload' | 'adapter_failure'
-): ProtocolErrorCode {
- if (code === 'unsafe_payload') return 'invalid_payload';
- if (code === 'unsupported_command') return 'invalid_type';
- return 'command_failed';
-}
-
-export async function sendResponse(connection: RemoteConnection, response: ProtocolResponse): Promise {
- await connection.send(JSON.stringify(response));
-}
-
diff --git a/src/main/transport/remote-client-registry.test.ts b/src/main/transport/remote-client-registry.test.ts
deleted file mode 100644
index 83bcef4..0000000
--- a/src/main/transport/remote-client-registry.test.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import type { RemoteConnection } from './remote-connection';
-import { RemoteClientRegistry } from './remote-client-registry';
-
-const now = 1_724_000_000_000;
-
-beforeEach(() => {
- vi.useFakeTimers();
- vi.setSystemTime(now);
-});
-
-afterEach(() => {
- vi.useRealTimers();
-});
-
-describe('RemoteClientRegistry', () => {
- it('adds connected clients with transport metadata', () => {
- const registry = new RemoteClientRegistry();
- const connection = createConnection();
-
- const connectedClient = registry.add(connection);
-
- expect(connectedClient).toEqual({
- id: 'connection-1',
- deviceId: null,
- remoteAddress: null,
- connectedAt: now,
- lastSeenAt: null,
- transport: 'bluetooth'
- });
- expect(registry.count()).toBe(1);
- });
-
- it('returns authenticated snapshot clones without unauthenticated clients', () => {
- const registry = new RemoteClientRegistry();
- registry.add(createConnection({ id: 'authenticated' }));
- registry.add(createConnection({ id: 'unauthenticated' }));
- registry.markSeen('authenticated', 'android-1');
-
- const snapshot = registry.authenticatedSnapshot();
- snapshot[0].deviceId = 'mutated';
-
- expect(snapshot).toHaveLength(1);
- expect(registry.get('authenticated')?.deviceId).toBe('android-1');
- expect(registry.get('unauthenticated')?.deviceId).toBeNull();
- });
-
- it('closes and removes clients by device id before awaiting transport close', async () => {
- const registry = new RemoteClientRegistry();
- const matching = createConnection({ id: 'matching' });
- const other = createConnection({ id: 'other' });
- registry.add(matching);
- registry.add(other);
- registry.markSeen('matching', 'android-1');
- registry.markSeen('other', 'android-2');
-
- const closed = await registry.closeByDeviceId('android-1');
-
- expect(closed).toBe(1);
- expect(matching.close).toHaveBeenCalledTimes(1);
- expect(other.close).not.toHaveBeenCalled();
- expect(registry.get('matching')).toBeNull();
- expect(registry.get('other')?.deviceId).toBe('android-2');
- });
-});
-
-function createConnection(overrides: Partial = {}): RemoteConnection {
- return {
- id: 'connection-1',
- kind: 'bluetooth',
- label: 'Test Bluetooth connection',
- remoteAddress: null,
- send: vi.fn(),
- close: vi.fn(),
- ...overrides
- };
-}
-
diff --git a/src/main/transport/remote-client-registry.ts b/src/main/transport/remote-client-registry.ts
deleted file mode 100644
index c3ed646..0000000
--- a/src/main/transport/remote-client-registry.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import type { PcConnectedClient } from '../../shared/server-status';
-import type { RemoteConnection, TransportKind } from './remote-connection';
-
-export class RemoteClientRegistry {
- private readonly clients = new Map();
- private readonly connections = new Map();
-
- add(connection: RemoteConnection): PcConnectedClient {
- const connectedClient = {
- id: connection.id,
- deviceId: null,
- remoteAddress: connection.remoteAddress,
- connectedAt: Date.now(),
- lastSeenAt: null,
- transport: connection.kind
- } satisfies PcConnectedClient;
- this.connections.set(connection.id, connection);
- this.clients.set(connection.id, connectedClient);
- return { ...connectedClient };
- }
-
- remove(connectionId: string): void {
- this.connections.delete(connectionId);
- this.clients.delete(connectionId);
- }
-
- async closeAll(): Promise {
- await Promise.all([...this.connections.values()].map((connection) => connection.close()));
- }
-
- async closeByDeviceId(deviceId: string): Promise {
- let closedCount = 0;
- const closeResults: Array> = [];
- for (const [connectionId, connectedClient] of this.clients) {
- if (connectedClient.deviceId !== deviceId) continue;
-
- const connection = this.connections.get(connectionId);
- if (connection) {
- closeResults.push(connection.close());
- }
- this.remove(connectionId);
- closedCount += 1;
- }
- await Promise.all(closeResults);
- return closedCount;
- }
-
- clear(): void {
- this.connections.clear();
- this.clients.clear();
- }
-
- get(connectionId: string): PcConnectedClient | null {
- const connectedClient = this.clients.get(connectionId);
- return connectedClient ? { ...connectedClient } : null;
- }
-
- getConnection(connectionId: string): RemoteConnection | null {
- return this.connections.get(connectionId) ?? null;
- }
-
- getRemoteAddress(connectionId: string): string | null {
- return this.clients.get(connectionId)?.remoteAddress ?? null;
- }
-
- getTransport(connectionId: string): TransportKind | null {
- return this.clients.get(connectionId)?.transport ?? null;
- }
-
- markSeen(connectionId: string, deviceId: string, seenAt: number = Date.now()): void {
- const existing = this.clients.get(connectionId);
- if (!existing) return;
-
- this.clients.set(connectionId, {
- ...existing,
- deviceId,
- lastSeenAt: seenAt
- });
- }
-
- snapshot(): PcConnectedClient[] {
- return [...this.clients.values()].map((client) => ({ ...client }));
- }
-
- authenticatedSnapshot(): PcConnectedClient[] {
- return this.snapshot().filter((client) => client.deviceId !== null);
- }
-
- count(): number {
- return this.clients.size;
- }
-
- authenticatedCount(): number {
- let count = 0;
- for (const client of this.clients.values()) {
- if (client.deviceId !== null) count += 1;
- }
- return count;
- }
-}
diff --git a/src/main/transport/remote-connection.ts b/src/main/transport/remote-connection.ts
deleted file mode 100644
index 72ce10e..0000000
--- a/src/main/transport/remote-connection.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export type TransportKind = 'bluetooth';
-
-export type RemoteConnection = {
- id: string;
- kind: TransportKind;
- label: string;
- remoteAddress: string | null;
- send: (message: string) => void | Promise;
- close: () => void | Promise;
-};
-
diff --git a/src/main/transport/remote-session-manager.test.ts b/src/main/transport/remote-session-manager.test.ts
deleted file mode 100644
index 8dd5372..0000000
--- a/src/main/transport/remote-session-manager.test.ts
+++ /dev/null
@@ -1,444 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import {
- PROTOCOL_VERSION,
- validateProtocolResponse,
- type CommandRequest,
- type KeyboardKeyCommand,
- type MouseClickCommand,
- type MouseMoveCommand,
- type PairingCompleteResponse,
- type DisconnectingCommand,
- type PingCommand,
- type WindowControlCommand
-} from '../../shared/protocol';
-import { createCommandAuthProof, CommandAuthValidator } from '../pairing/auth';
-import { PairingApprovalManager } from '../pairing/pairing-approval-manager';
-import { PairingManager } from '../pairing/pairing-manager';
-import { MemoryPairingStore } from '../pairing/pairing-store';
-import type { RemoteConnection } from './remote-connection';
-import { RemoteSessionManager } from './remote-session-manager';
-
-const now = 1_724_000_000_000;
-const token = 'shared-token';
-
-describe('RemoteSessionManager', () => {
- it('executes authenticated desktop commands through a fake transport connection', async () => {
- const handled: MouseMoveCommand[] = [];
- const manager = createManager({
- onCommand: (command) => {
- handled.push(command as MouseMoveCommand);
- return { ok: true };
- }
- });
- const connection = createConnection();
- manager.addConnection(connection);
- const command = createMouseMoveCommand();
-
- await manager.handleMessage(connection.id, JSON.stringify(command));
-
- expect(sentResponses(connection)).toEqual([expect.objectContaining({ type: 'ack', id: command.id, ok: true })]);
- expect(handled).toEqual([command]);
- expect(manager.getAuthenticatedClients()).toEqual([
- expect.objectContaining({
- id: connection.id,
- deviceId: 'android-1',
- transport: 'bluetooth'
- })
- ]);
- });
-
- it('executes authenticated no-response mouse movement without sending an ack', async () => {
- const handled: MouseMoveCommand[] = [];
- const manager = createManager({
- onCommand: (command) => {
- handled.push(command as MouseMoveCommand);
- return { ok: true };
- }
- });
- const connection = createConnection();
- manager.addConnection(connection);
- const command = createMouseMoveCommand({ responseMode: 'none' });
-
- await manager.handleMessage(connection.id, JSON.stringify(command));
-
- expect(sentResponses(connection)).toEqual([]);
- expect(handled).toEqual([command]);
- expect(manager.getAuthenticatedClients()).toEqual([
- expect.objectContaining({
- id: connection.id,
- deviceId: 'android-1',
- transport: 'bluetooth'
- })
- ]);
- });
-
- it('suppresses adapter failure responses for no-response mouse movement', async () => {
- const onError = vi.fn();
- const manager = createManager({
- onError,
- onCommand: () => ({ ok: false, code: 'adapter_failure', message: 'Move failed.' })
- });
- const connection = createConnection();
- manager.addConnection(connection);
- const command = createMouseMoveCommand({ responseMode: 'none' });
-
- await manager.handleMessage(connection.id, JSON.stringify(command));
-
- expect(sentResponses(connection)).toEqual([]);
- expect(onError).toHaveBeenCalledWith('Move failed.');
- });
-
- it('executes authenticated no-response control commands without sending an ack', async () => {
- const handled: CommandRequest[] = [];
- const manager = createManager({
- onCommand: (command) => {
- handled.push(command);
- return { ok: true };
- }
- });
- const connection = createConnection();
- manager.addConnection(connection);
- const commands = [
- createMouseClickCommand({ responseMode: 'none' }),
- createKeyboardKeyCommand({ responseMode: 'none' }),
- createWindowControlCommand({ responseMode: 'none' })
- ];
-
- for (const command of commands) {
- await manager.handleMessage(connection.id, JSON.stringify(command));
- }
-
- expect(sentResponses(connection)).toEqual([]);
- expect(handled).toEqual(commands);
- });
-
- it('suppresses adapter failure responses for no-response control commands', async () => {
- const onError = vi.fn();
- const manager = createManager({
- onError,
- onCommand: () => ({ ok: false, code: 'adapter_failure', message: 'Click failed.' })
- });
- const connection = createConnection();
- manager.addConnection(connection);
- const command = createMouseClickCommand({ responseMode: 'none' });
-
- await manager.handleMessage(connection.id, JSON.stringify(command));
-
- expect(sentResponses(connection)).toEqual([]);
- expect(onError).toHaveBeenCalledWith('Click failed.');
- });
-
- it('acks authenticated heartbeat pings without executing desktop commands', async () => {
- const onCommand = vi.fn();
- const manager = createManager({ onCommand });
- const connection = createConnection();
- manager.addConnection(connection);
- const command = createPingCommand();
-
- await manager.handleMessage(connection.id, JSON.stringify(command));
-
- expect(sentResponses(connection)).toEqual([expect.objectContaining({ type: 'ack', id: command.id, ok: true })]);
- expect(onCommand).not.toHaveBeenCalled();
- expect(manager.getAuthenticatedClients()).toEqual([
- expect.objectContaining({
- id: connection.id,
- deviceId: 'android-1',
- transport: 'bluetooth'
- })
- ]);
- });
-
- it('rejects unauthenticated heartbeat pings with a structured auth error', async () => {
- const onCommand = vi.fn();
- const manager = createManager({ onCommand });
- const connection = createConnection();
- manager.addConnection(connection);
-
- await manager.handleMessage(connection.id, JSON.stringify(createPingCommand({ auth: 'invalid' })));
-
- expect(sentResponses(connection)).toEqual([
- expect.objectContaining({
- type: 'error',
- ok: false,
- error: expect.objectContaining({ code: 'invalid_auth' })
- })
- ]);
- expect(onCommand).not.toHaveBeenCalled();
- });
-
- it('rejects unauthenticated no-response movement with a structured auth error', async () => {
- const onCommand = vi.fn();
- const manager = createManager({ onCommand });
- const connection = createConnection();
- manager.addConnection(connection);
-
- await manager.handleMessage(
- connection.id,
- JSON.stringify(createMouseMoveCommand({ auth: 'invalid', responseMode: 'none' }))
- );
-
- expect(sentResponses(connection)).toEqual([
- expect.objectContaining({
- type: 'error',
- ok: false,
- error: expect.objectContaining({ code: 'invalid_auth' })
- })
- ]);
- expect(onCommand).not.toHaveBeenCalled();
- });
-
- it('rejects unsupported no-response command types during validation', async () => {
- const onCommand = vi.fn();
- const manager = createManager({ onCommand });
- const connection = createConnection();
- manager.addConnection(connection);
- const command = createPingCommand({ responseMode: 'none' } as Partial);
-
- await manager.handleMessage(connection.id, JSON.stringify(command));
-
- expect(sentResponses(connection)).toEqual([
- expect.objectContaining({
- type: 'error',
- ok: false,
- error: expect.objectContaining({ code: 'invalid_payload' })
- })
- ]);
- expect(onCommand).not.toHaveBeenCalled();
- });
-
- it('keeps pairing requests pending until they are accepted', async () => {
- const store = new MemoryPairingStore({ desktopId: 'desktop-1', pairedDevices: [] });
- const approvalManager = new PairingApprovalManager(store, () => now);
- const manager = createManager({
- store,
- pairingApprovalManager: approvalManager,
- authValidator: new CommandAuthValidator(store, () => now)
- });
- const connection = createConnection();
- manager.addConnection(connection);
-
- await manager.handleMessage(connection.id, JSON.stringify(createPairingRequest()));
-
- expect(sentResponses(connection)).toEqual([]);
- expect(manager.getPendingPairingRequests()).toEqual([
- expect.objectContaining({
- requestId: 'approval-1',
- deviceName: 'Android smoke',
- verificationCode: expect.stringMatching(/^\d{6}$/)
- })
- ]);
-
- await expect(manager.respondToPairingRequest('approval-1', 'accept')).resolves.toEqual({ ok: true });
- const [response] = sentResponses(connection) as PairingCompleteResponse[];
-
- expect(response).toMatchObject({
- type: 'pairing.complete',
- ok: true,
- payload: {
- desktopId: 'desktop-1',
- deviceId: 'android-smoke-1'
- }
- });
- expect(response.payload.token).not.toBe('');
- expect(manager.getAuthenticatedClientCount()).toBe(1);
- });
-
- it('returns structured errors for malformed JSON without crashing', async () => {
- const manager = createManager();
- const connection = createConnection();
- manager.addConnection(connection);
-
- await manager.handleMessage(connection.id, '{');
-
- expect(sentResponses(connection)).toEqual([
- expect.objectContaining({
- type: 'error',
- ok: false,
- error: expect.objectContaining({ code: 'invalid_json' })
- })
- ]);
- });
-
- it('acks authenticated client disconnect intent without executing desktop commands', async () => {
- const onClientDisconnecting = vi.fn();
- const onCommand = vi.fn();
- const manager = createManager({ onClientDisconnecting, onCommand });
- const connection = createConnection();
- manager.addConnection(connection);
- const command = createDisconnectingCommand();
-
- await manager.handleMessage(connection.id, JSON.stringify(command));
-
- expect(sentResponses(connection)).toEqual([expect.objectContaining({ type: 'ack', id: command.id, ok: true })]);
- expect(onClientDisconnecting).toHaveBeenCalledWith(connection.id, 'android-1');
- expect(onCommand).not.toHaveBeenCalled();
- expect(manager.getAuthenticatedClients()).toEqual([
- expect.objectContaining({
- id: connection.id,
- deviceId: 'android-1',
- transport: 'bluetooth'
- })
- ]);
- });
-});
-
-function createManager(
- overrides: Partial[0]> & { store?: MemoryPairingStore } = {}
-): RemoteSessionManager {
- const store =
- overrides.store ??
- new MemoryPairingStore({
- desktopId: 'desktop-1',
- pairedDevices: [
- {
- deviceId: 'android-1',
- deviceName: 'Android device',
- token,
- pairedAt: now - 1_000,
- lastSeenAt: null
- }
- ]
- });
-
- return new RemoteSessionManager({
- pairingManager: new PairingManager(store),
- authValidator: new CommandAuthValidator(store, () => now),
- ...overrides
- });
-}
-
-function createConnection(): RemoteConnection {
- return {
- id: 'connection-1',
- kind: 'bluetooth',
- label: 'Bluetooth test connection',
- remoteAddress: null,
- send: vi.fn(),
- close: vi.fn()
- };
-}
-
-function sentResponses(connection: RemoteConnection): unknown[] {
- return vi.mocked(connection.send).mock.calls.map(([message]) => {
- const parsed = JSON.parse(message);
- expect(validateProtocolResponse(parsed)).toMatchObject({ ok: true });
- return parsed;
- });
-}
-
-function createPingCommand(overrides: Partial = {}): PingCommand {
- const command = {
- version: PROTOCOL_VERSION,
- id: 'request-1',
- deviceId: 'android-1',
- timestamp: now,
- type: 'connection.ping',
- payload: {},
- auth: ''
- } satisfies PingCommand;
- const merged = { ...command, ...overrides } as PingCommand;
- return {
- ...merged,
- auth: overrides.auth ?? createCommandAuthProof(merged, token)
- };
-}
-
-function createMouseMoveCommand(overrides: Partial = {}): MouseMoveCommand {
- const command = {
- version: PROTOCOL_VERSION,
- id: 'move-1',
- deviceId: 'android-1',
- timestamp: now,
- type: 'mouse.move',
- payload: { dx: 5, dy: -3 },
- auth: ''
- } satisfies MouseMoveCommand;
- const merged = { ...command, ...overrides } as MouseMoveCommand;
- return {
- ...merged,
- auth: overrides.auth ?? createCommandAuthProof(merged, token)
- };
-}
-
-function createMouseClickCommand(overrides: Partial = {}): MouseClickCommand {
- const command = {
- version: PROTOCOL_VERSION,
- id: 'click-1',
- deviceId: 'android-1',
- timestamp: now,
- type: 'mouse.click',
- payload: { button: 'left' },
- auth: ''
- } satisfies MouseClickCommand;
- const merged = { ...command, ...overrides } as MouseClickCommand;
- return {
- ...merged,
- auth: overrides.auth ?? createCommandAuthProof(merged, token)
- };
-}
-
-function createKeyboardKeyCommand(overrides: Partial = {}): KeyboardKeyCommand {
- const command = {
- version: PROTOCOL_VERSION,
- id: 'key-1',
- deviceId: 'android-1',
- timestamp: now,
- type: 'keyboard.key',
- payload: { key: 'Enter' },
- auth: ''
- } satisfies KeyboardKeyCommand;
- const merged = { ...command, ...overrides } as KeyboardKeyCommand;
- return {
- ...merged,
- auth: overrides.auth ?? createCommandAuthProof(merged, token)
- };
-}
-
-function createWindowControlCommand(overrides: Partial = {}): WindowControlCommand {
- const command = {
- version: PROTOCOL_VERSION,
- id: 'window-1',
- deviceId: 'android-1',
- timestamp: now,
- type: 'window.control',
- payload: { action: 'switchNext' },
- auth: ''
- } satisfies WindowControlCommand;
- const merged = { ...command, ...overrides } as WindowControlCommand;
- return {
- ...merged,
- auth: overrides.auth ?? createCommandAuthProof(merged, token)
- };
-}
-
-function createDisconnectingCommand(overrides: Partial = {}): DisconnectingCommand {
- const command = {
- version: PROTOCOL_VERSION,
- id: 'disconnecting-1',
- deviceId: 'android-1',
- timestamp: now,
- type: 'connection.disconnecting',
- payload: {},
- auth: ''
- } satisfies DisconnectingCommand;
- const merged = { ...command, ...overrides } as DisconnectingCommand;
- return {
- ...merged,
- auth: overrides.auth ?? createCommandAuthProof(merged, token)
- };
-}
-
-function createPairingRequest() {
- return {
- version: PROTOCOL_VERSION,
- id: 'approval-1',
- type: 'pairing.request',
- payload: {
- deviceId: 'android-smoke-1',
- deviceName: 'Android smoke',
- desktopId: 'desktop-1',
- requestNonce: 'nonce'
- }
- };
-}
-
diff --git a/src/main/transport/remote-session-manager.ts b/src/main/transport/remote-session-manager.ts
deleted file mode 100644
index c39671c..0000000
--- a/src/main/transport/remote-session-manager.ts
+++ /dev/null
@@ -1,312 +0,0 @@
-import {
- createAckResponse,
- createErrorResponse,
- createPairingCompleteResponse,
- createPointerProfileResponse,
- NO_ACK_CONTROL_COMMAND_TYPES,
- parseProtocolRequest,
- type CommandResponseMode,
- type CommandRequest,
- type PairingApprovalRequest,
- type PointerMovementProfile
-} from '../../shared/protocol';
-import type { PcConnectedClient } from '../../shared/server-status';
-import type { PairingApprovalDecision, PendingPairingApprovalView } from '../../shared/pairing-approval';
-import type { CommandAuthValidator } from '../pairing/auth';
-import type { PairingApprovalManager } from '../pairing/pairing-approval-manager';
-import type { PairingManager } from '../pairing/pairing-manager';
-import { PendingPairingApprovalConnections } from './pending-approval-connections';
-import type { RemoteConnection } from './remote-connection';
-import { RemoteClientRegistry } from './remote-client-registry';
-import { sendResponse, toProtocolCommandErrorCode } from './protocol-response';
-
-export type CommandHandlerResult =
- | { ok: true }
- | { ok: false; code: 'unsupported_command' | 'unsafe_payload' | 'adapter_failure'; message: string };
-
-export type RemoteSessionManagerOptions = {
- pairingManager: PairingManager;
- pairingApprovalManager?: PairingApprovalManager;
- authValidator: CommandAuthValidator;
- getPointerProfile?: () => PointerMovementProfile;
- onClientStatusChange?: () => void;
- onLastSeen?: (seenAt: number) => void;
- onError?: (message: string) => void;
- onClientDisconnecting?: (connectionId: string, deviceId: string) => void;
- onCommand?: (command: CommandRequest) => Promise | CommandHandlerResult;
-};
-
-export class RemoteSessionManager {
- private readonly clientRegistry = new RemoteClientRegistry();
- private readonly pendingApprovalConnections = new PendingPairingApprovalConnections();
-
- constructor(private readonly options: RemoteSessionManagerOptions) {}
-
- addConnection(connection: RemoteConnection): PcConnectedClient {
- const client = this.clientRegistry.add(connection);
- this.notifyClientStatusChanged();
- return client;
- }
-
- removeConnection(connectionId: string): void {
- this.removePendingApprovalsForConnection(connectionId);
- this.clientRegistry.remove(connectionId);
- this.notifyClientStatusChanged();
- }
-
- async closeAllConnections(): Promise {
- const closePromise = this.clientRegistry.closeAll();
- this.clientRegistry.clear();
- this.pendingApprovalConnections.clearAll();
- this.notifyClientStatusChanged();
- await closePromise;
- }
-
- clearConnections(): void {
- this.clientRegistry.clear();
- this.pendingApprovalConnections.clearAll();
- this.notifyClientStatusChanged();
- }
-
- async disconnectDevice(deviceId: string): Promise {
- const closePromise = this.clientRegistry.closeByDeviceId(deviceId);
- this.notifyClientStatusChanged();
- return await closePromise;
- }
-
- getAuthenticatedClientCount(): number {
- return this.clientRegistry.authenticatedCount();
- }
-
- getAuthenticatedClients(): PcConnectedClient[] {
- return this.clientRegistry.authenticatedSnapshot();
- }
-
- getPendingPairingRequests(): PendingPairingApprovalView[] {
- this.expirePendingPairingRequests();
- return this.options.pairingApprovalManager?.listPendingRequestViews() ?? [];
- }
-
- async respondToPairingRequest(
- requestId: string,
- decision: PairingApprovalDecision
- ): Promise<{ ok: boolean; reason?: string }> {
- const manager = this.options.pairingApprovalManager;
- if (!manager) return { ok: false, reason: 'pairing_approval_unavailable' };
-
- if (decision === 'accept') {
- const result = await manager.accept(requestId);
- if (!result.ok) {
- this.pendingApprovalConnections.clear(requestId);
- return result;
- }
-
- const connection = this.getPendingConnection(requestId);
- if (connection) {
- await sendResponse(
- connection,
- createPairingCompleteResponse(requestId, {
- desktopId: result.desktopId,
- deviceId: result.deviceId,
- token: result.token
- })
- );
- this.markConnectionSeen(connection.id, result.deviceId);
- }
- this.pendingApprovalConnections.clear(requestId);
- return { ok: true };
- }
-
- const result = manager.reject(requestId);
- if (!result.ok) {
- this.pendingApprovalConnections.clear(requestId);
- return result;
- }
-
- const connection = this.getPendingConnection(requestId);
- if (connection) {
- await sendResponse(connection, createErrorResponse(requestId, 'invalid_auth', 'pairing_rejected'));
- await connection.close();
- }
- this.pendingApprovalConnections.clear(requestId);
- return { ok: true };
- }
-
- async handleMessage(connectionId: string, rawMessage: string): Promise {
- const connection = this.clientRegistry.getConnection(connectionId);
- if (!connection) return;
-
- const parsed = parseProtocolRequest(rawMessage);
- if (!parsed.ok) {
- await sendResponse(connection, createErrorResponse(null, parsed.error, parsed.message));
- return;
- }
-
- const message = parsed.value;
- if (message.type === 'pairing.request') {
- await this.handlePairingApprovalRequest(connection, message);
- return;
- }
-
- const authResult = await this.options.authValidator.validate(message);
- if (!authResult.ok) {
- await sendResponse(connection, createErrorResponse(message.id, 'invalid_auth', authResult.reason));
- return;
- }
-
- if (authResult.command.type === 'connection.ping') {
- this.markConnectionSeen(connection.id, authResult.command.deviceId);
- this.markLastSeen(Date.now());
- await sendResponse(connection, createAckResponse(message.id));
- return;
- }
-
- if (authResult.command.type === 'pointer.profile') {
- const profile = this.getPointerProfile();
- if (!profile) {
- await sendResponse(connection, createErrorResponse(message.id, 'command_failed', 'Pointer profile is unavailable.'));
- return;
- }
- this.markConnectionSeen(connection.id, authResult.command.deviceId);
- this.markLastSeen(Date.now());
- await sendResponse(connection, createPointerProfileResponse(message.id, profile));
- return;
- }
-
- if (authResult.command.type === 'connection.disconnecting') {
- this.markConnectionSeen(connection.id, authResult.command.deviceId);
- this.markLastSeen(Date.now());
- this.options.onClientDisconnecting?.(connection.id, authResult.command.deviceId);
- await sendResponse(connection, createAckResponse(message.id));
- return;
- }
-
- const commandResult = await this.executeCommand(authResult.command);
- if (!commandResult.ok) {
- this.options.onError?.(commandResult.message);
- if (shouldSuppressAck(authResult.command)) {
- return;
- }
- await sendResponse(
- connection,
- createErrorResponse(message.id, toProtocolCommandErrorCode(commandResult.code), commandResult.message)
- );
- return;
- }
-
- this.markConnectionSeen(connection.id, authResult.command.deviceId);
- this.markLastSeen(Date.now());
- if (shouldSuppressAck(authResult.command)) {
- return;
- }
- await sendResponse(connection, createAckResponse(message.id));
- }
-
- private async handlePairingApprovalRequest(
- connection: RemoteConnection,
- message: PairingApprovalRequest
- ): Promise {
- const manager = this.options.pairingApprovalManager;
- if (!manager) {
- await sendResponse(connection, createErrorResponse(message.id, 'invalid_type', 'pairing_approval_unavailable'));
- return;
- }
-
- const desktopId = await this.options.pairingManager.getDesktopId();
- if (message.payload.desktopId !== desktopId) {
- await sendResponse(connection, createErrorResponse(message.id, 'invalid_auth', 'pairing_mismatch'));
- return;
- }
-
- const { request, replacedRequestId } = manager.createRequest({
- requestId: message.id,
- deviceId: message.payload.deviceId,
- deviceName: message.payload.deviceName,
- desktopId: message.payload.desktopId,
- requestNonce: message.payload.requestNonce,
- remoteAddress: this.clientRegistry.getRemoteAddress(connection.id)
- });
-
- if (replacedRequestId) {
- const replacedConnection = this.getPendingConnection(replacedRequestId);
- if (replacedConnection) {
- await sendResponse(replacedConnection, createErrorResponse(replacedRequestId, 'invalid_auth', 'pairing_request_expired'));
- await replacedConnection.close();
- }
- this.pendingApprovalConnections.clear(replacedRequestId);
- }
-
- this.pendingApprovalConnections.set(request.requestId, connection.id, request.expiresAt, () => {
- this.expirePendingPairingRequests();
- });
- }
-
- private getPendingConnection(requestId: string): RemoteConnection | null {
- const connectionId = this.pendingApprovalConnections.get(requestId);
- return connectionId ? this.clientRegistry.getConnection(connectionId) : null;
- }
-
- private markConnectionSeen(connectionId: string, deviceId: string): void {
- this.clientRegistry.markSeen(connectionId, deviceId);
- this.notifyClientStatusChanged();
- }
-
- private markLastSeen(seenAt: number): void {
- this.options.onLastSeen?.(seenAt);
- }
-
- private async executeCommand(command: CommandRequest): Promise {
- try {
- return (await this.options.onCommand?.(command)) ?? { ok: true };
- } catch (error) {
- return {
- ok: false,
- code: 'adapter_failure',
- message: error instanceof Error ? error.message : 'Command execution failed.'
- };
- }
- }
-
- private getPointerProfile(): PointerMovementProfile | null {
- try {
- return this.options.getPointerProfile?.() ?? null;
- } catch (error) {
- this.options.onError?.(error instanceof Error ? error.message : 'Pointer profile failed.');
- return null;
- }
- }
-
- private expirePendingPairingRequests(): void {
- const expired = this.options.pairingApprovalManager?.expirePendingRequests() ?? [];
- for (const request of expired) {
- const connection = this.getPendingConnection(request.requestId);
- if (connection) {
- void sendResponse(connection, createErrorResponse(request.requestId, 'invalid_auth', 'pairing_request_expired'));
- void connection.close();
- }
- this.pendingApprovalConnections.clear(request.requestId);
- }
- }
-
- private removePendingApprovalsForConnection(connectionId: string): void {
- for (const requestId of this.pendingApprovalConnections.clearForConnection(connectionId)) {
- this.options.pairingApprovalManager?.reject(requestId);
- }
- }
-
- private notifyClientStatusChanged(): void {
- this.options.onClientStatusChange?.();
- }
-}
-
-function commandResponseMode(command: CommandRequest): CommandResponseMode {
- return command.responseMode ?? 'ack';
-}
-
-function shouldSuppressAck(command: CommandRequest): boolean {
- return isNoAckControlCommand(command) && commandResponseMode(command) === 'none';
-}
-
-function isNoAckControlCommand(command: CommandRequest): boolean {
- return (NO_ACK_CONTROL_COMMAND_TYPES as readonly CommandRequest['type'][]).includes(command.type);
-}
diff --git a/src/main/tray.test.ts b/src/main/tray.test.ts
deleted file mode 100644
index 9010848..0000000
--- a/src/main/tray.test.ts
+++ /dev/null
@@ -1,267 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { DEFAULT_BLUETOOTH_STATUS } from '../shared/bluetooth-status';
-import type { PcControlStatus } from '../shared/server-status';
-
-const electronMock = vi.hoisted(() => {
- type Handler = (...args: unknown[]) => void;
- type MenuTemplateItem = {
- label?: string;
- enabled?: boolean;
- click?: () => void;
- type?: string;
- };
- type FakeMenu = {
- template: MenuTemplateItem[];
- once: ReturnType;
- emitMenuWillClose: () => void;
- };
-
- class FakeTray {
- readonly handlers = new Map();
- readonly setToolTip = vi.fn();
- readonly setContextMenu = vi.fn();
- readonly popUpContextMenu = vi.fn();
- readonly focus = vi.fn();
- readonly destroy = vi.fn();
-
- on(event: string, handler: Handler): this {
- const handlers = this.handlers.get(event) ?? [];
- handlers.push(handler);
- this.handlers.set(event, handlers);
- return this;
- }
-
- emit(event: string, ...args: unknown[]): void {
- for (const handler of this.handlers.get(event) ?? []) {
- handler(...args);
- }
- }
- }
-
- const trayInstances: FakeTray[] = [];
- const image = {
- resize: vi.fn(() => ({
- setTemplateImage: vi.fn()
- }))
- };
- const menus: FakeMenu[] = [];
- const buildFromTemplate = vi.fn((template: MenuTemplateItem[]) => {
- let menuWillCloseHandler: Handler | null = null;
- const menu = {
- template,
- once: vi.fn((event: string, handler: Handler) => {
- if (event === 'menu-will-close') {
- menuWillCloseHandler = handler;
- }
- }),
- emitMenuWillClose: () => {
- menuWillCloseHandler?.();
- }
- };
- menus.push(menu);
- return menu;
- });
-
- return {
- FakeTray,
- trayInstances,
- menus,
- buildFromTemplate,
- image
- };
-});
-
-vi.mock('electron', () => ({
- app: {
- isPackaged: false
- },
- Menu: {
- buildFromTemplate: electronMock.buildFromTemplate
- },
- nativeImage: {
- createFromDataURL: vi.fn(() => electronMock.image),
- createFromPath: vi.fn(() => electronMock.image)
- },
- Tray: function Tray() {
- const tray = new electronMock.FakeTray();
- electronMock.trayInstances.push(tray);
- return tray;
- }
-}));
-
-import { createSwitchifyTray } from './tray';
-import { trayMenuPosition } from './tray';
-
-describe('createSwitchifyTray', () => {
- const originalPlatform = process.platform;
-
- beforeEach(() => {
- electronMock.trayInstances.length = 0;
- electronMock.menus.length = 0;
- electronMock.buildFromTemplate.mockClear();
- electronMock.image.resize.mockClear();
- setPlatform('win32');
- });
-
- afterEach(() => {
- setPlatform(originalPlatform);
- vi.clearAllMocks();
- });
-
- it('opens the main window on left-click', () => {
- const callbacks = createCallbacks();
- createSwitchifyTray({
- ...callbacks,
- getStatus: () => status()
- });
-
- tray().emit('click');
-
- expect(callbacks.showWindow).toHaveBeenCalledTimes(1);
- expect(callbacks.openSettings).not.toHaveBeenCalled();
- expect(callbacks.disconnectClients).not.toHaveBeenCalled();
- expect(callbacks.quit).not.toHaveBeenCalled();
- });
-
- it('uses an anchored manual popup on Windows right-click', () => {
- const callbacks = createCallbacks();
- createSwitchifyTray({
- ...callbacks,
- getStatus: () => status()
- });
-
- expect(tray().setContextMenu).not.toHaveBeenCalled();
- expect(tray().handlers.has('right-click')).toBe(true);
-
- tray().emit('right-click', {}, { x: 100, y: 100, width: 16, height: 24 });
-
- expect(tray().popUpContextMenu).toHaveBeenCalledTimes(1);
- expect(tray().popUpContextMenu).toHaveBeenCalledWith(lastMenu(), { x: 100, y: 124 });
- });
-
- it('refreshes the context menu with the latest status before Windows popup', () => {
- const callbacks = createCallbacks();
- let connectedClientCount = 0;
- createSwitchifyTray({
- ...callbacks,
- getStatus: () => status({ connectedClientCount })
- });
-
- connectedClientCount = 1;
- tray().emit('right-click', {}, { x: 100, y: 100, width: 16, height: 24 });
-
- const disconnectItem = lastMenu().template.find((item) => item.label === 'Disconnect device');
- expect(disconnectItem?.enabled).toBe(true);
- expect(tray().popUpContextMenu).toHaveBeenLastCalledWith(lastMenu(), { x: 100, y: 124 });
- });
-
- it('restores notification area focus when the Windows popup closes', () => {
- const callbacks = createCallbacks();
- createSwitchifyTray({
- ...callbacks,
- getStatus: () => status()
- });
-
- tray().emit('right-click', {}, { x: 100, y: 100, width: 16, height: 24 });
- lastMenu().emitMenuWillClose();
-
- expect(tray().focus).toHaveBeenCalledTimes(1);
- });
-
- it('attaches the context menu on non-Windows platforms', () => {
- setPlatform('darwin');
- const callbacks = createCallbacks();
- createSwitchifyTray({
- ...callbacks,
- getStatus: () => status()
- });
-
- expect(tray().setContextMenu).toHaveBeenCalledTimes(1);
- expect(tray().setContextMenu).toHaveBeenCalledWith(lastMenu());
- expect(tray().popUpContextMenu).not.toHaveBeenCalled();
- expect(tray().handlers.has('right-click')).toBe(false);
- });
-
- it('refreshes the tooltip on update', () => {
- const callbacks = createCallbacks();
- const switchifyTray = createSwitchifyTray({
- ...callbacks,
- getStatus: () => status({ state: 'starting' })
- });
-
- switchifyTray.update();
-
- expect(tray().setToolTip).toHaveBeenLastCalledWith('Switchify PC - starting');
- });
-
- it('destroys the tray', () => {
- const callbacks = createCallbacks();
- const switchifyTray = createSwitchifyTray({
- ...callbacks,
- getStatus: () => status()
- });
-
- switchifyTray.destroy();
-
- expect(tray().destroy).toHaveBeenCalledTimes(1);
- });
-});
-
-describe('trayMenuPosition', () => {
- it('anchors a menu below the tray bounds', () => {
- expect(trayMenuPosition({ x: 10.4, y: 20.2, width: 16, height: 18.6 })).toEqual({ x: 10, y: 39 });
- });
-
- it('returns undefined for missing or invalid bounds', () => {
- expect(trayMenuPosition(undefined)).toBeUndefined();
- expect(trayMenuPosition({ x: Number.NaN, y: 20, width: 16, height: 18 })).toBeUndefined();
- });
-});
-
-function createCallbacks(): Omit[0], 'getStatus'> {
- return {
- showWindow: vi.fn(),
- openSettings: vi.fn(),
- disconnectClients: vi.fn(),
- quit: vi.fn()
- };
-}
-
-function status(patch: Partial = {}): PcControlStatus {
- return {
- state: 'ready',
- desktopId: 'desktop-id',
- connectedClientCount: 0,
- connectedClients: [],
- lastSeenAt: null,
- lastError: null,
- bluetooth: DEFAULT_BLUETOOTH_STATUS,
- ...patch
- };
-}
-
-function tray(): InstanceType {
- const currentTray = electronMock.trayInstances.at(-1);
- if (!currentTray) {
- throw new Error('Expected a tray instance.');
- }
- return currentTray;
-}
-
-function lastMenu(): {
- template: Array<{ label?: string; enabled?: boolean; click?: () => void; type?: string }>;
- emitMenuWillClose: () => void;
-} {
- const menu = electronMock.menus.at(-1);
- if (!menu) {
- throw new Error('Expected a menu template.');
- }
- return menu;
-}
-
-function setPlatform(platform: NodeJS.Platform): void {
- Object.defineProperty(process, 'platform', {
- configurable: true,
- value: platform
- });
-}
diff --git a/src/main/tray.ts b/src/main/tray.ts
deleted file mode 100644
index c392e5a..0000000
--- a/src/main/tray.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-import { app, Menu, nativeImage, Tray, type NativeImage, type Rectangle } from 'electron';
-import { existsSync } from 'node:fs';
-import { join } from 'node:path';
-import type { PcControlStatus } from '../shared/server-status';
-
-export type SwitchifyTrayOptions = {
- getStatus: () => PcControlStatus;
- showWindow: () => void;
- openSettings: () => void;
- disconnectClients: () => void;
- quit: () => void;
-};
-
-export type SwitchifyTray = {
- update: () => void;
- destroy: () => void;
-};
-
-type TrayEventName = 'click' | 'right-click' | 'double-click' | 'mouse-enter' | 'mouse-leave';
-type TrayBounds = Pick;
-
-export function createSwitchifyTray(options: SwitchifyTrayOptions): SwitchifyTray {
- const tray = new Tray(createTrayIcon());
- let currentMenu: Menu | null = null;
-
- const update = (): void => {
- const status = options.getStatus();
- currentMenu = buildTrayMenu(options, status);
- tray.setToolTip(`Switchify PC - ${formatTooltipStatus(status)}`);
- if (process.platform !== 'win32') {
- tray.setContextMenu(currentMenu);
- }
- };
-
- tray.on('click', options.showWindow);
- if (process.platform === 'win32') {
- registerWindowsTrayMenu(tray, update, () => currentMenu);
- registerWindowsTrayDiagnostics(tray);
- }
- update();
-
- return {
- update,
- destroy: () => tray.destroy()
- };
-}
-
-function buildTrayMenu(options: SwitchifyTrayOptions, status: PcControlStatus): Menu {
- return Menu.buildFromTemplate([
- { label: 'Open Switchify PC', click: options.showWindow },
- { label: 'Settings', click: options.openSettings },
- { label: formatMenuStatus(status), enabled: false },
- { type: 'separator' },
- {
- label: 'Disconnect device',
- enabled: status.connectedClientCount > 0,
- click: options.disconnectClients
- },
- { type: 'separator' },
- { label: 'Quit', click: options.quit }
- ]);
-}
-
-function registerWindowsTrayMenu(tray: Tray, update: () => void, getCurrentMenu: () => Menu | null): void {
- tray.on('right-click', (_event, bounds) => {
- update();
- const menu = getCurrentMenu();
- if (!menu) return;
-
- menu.once?.('menu-will-close', () => {
- tray.focus?.();
- });
- tray.popUpContextMenu(menu, trayMenuPosition(bounds));
- });
-}
-
-function registerWindowsTrayDiagnostics(tray: Tray): void {
- tray.on('click', (_event, bounds) => logTrayEvent('click', bounds));
- tray.on('right-click', (_event, bounds) => logTrayEvent('right-click', bounds));
- tray.on('double-click', (_event, bounds) => logTrayEvent('double-click', bounds));
- tray.on('mouse-enter', (_event, position) => logTrayEvent('mouse-enter', isTrayBounds(position) ? position : undefined));
- tray.on('mouse-leave', (_event, position) => logTrayEvent('mouse-leave', isTrayBounds(position) ? position : undefined));
-}
-
-export function trayMenuPosition(bounds: TrayBounds | undefined): { x: number; y: number } | undefined {
- if (!bounds || !isFiniteBounds(bounds)) return undefined;
- return {
- x: Math.round(bounds.x),
- y: Math.round(bounds.y + bounds.height)
- };
-}
-
-function isFiniteBounds(bounds: TrayBounds): boolean {
- return (
- Number.isFinite(bounds.x) &&
- Number.isFinite(bounds.y) &&
- Number.isFinite(bounds.width) &&
- Number.isFinite(bounds.height)
- );
-}
-
-function isTrayBounds(value: unknown): value is TrayBounds {
- if (!value || typeof value !== 'object') return false;
- const maybeBounds = value as Partial;
- return (
- typeof maybeBounds.x === 'number' &&
- typeof maybeBounds.y === 'number' &&
- typeof maybeBounds.width === 'number' &&
- typeof maybeBounds.height === 'number'
- );
-}
-
-function shouldLogTrayDiagnostics(): boolean {
- return process.platform === 'win32' && (app.isPackaged || process.env.SWITCHIFY_TRAY_DEBUG === '1');
-}
-
-function logTrayEvent(eventName: TrayEventName, bounds?: TrayBounds): void {
- if (!shouldLogTrayDiagnostics()) return;
- console.info('[tray]', eventName, bounds ? formatBounds(bounds) : '');
-}
-
-function formatBounds(bounds: TrayBounds): string {
- return `x=${Math.round(bounds.x)} y=${Math.round(bounds.y)} width=${Math.round(bounds.width)} height=${Math.round(bounds.height)}`;
-}
-
-function createTrayIcon(): NativeImage {
- const iconPath = app.isPackaged
- ? join(process.resourcesPath, 'icon.png')
- : join(process.cwd(), 'build', 'icon.png');
- const image = existsSync(iconPath)
- ? nativeImage.createFromPath(iconPath)
- : nativeImage.createFromDataURL(
- `data:image/svg+xml;base64,${Buffer.from('').toString('base64')}`
- );
- const trayImage = image.resize({ width: 16, height: 16, quality: 'best' });
- trayImage.setTemplateImage(false);
- return trayImage;
-}
-
-function formatTooltipStatus(status: PcControlStatus): string {
- if (status.connectedClientCount > 0) return 'device connected';
- if (status.bluetooth.status === 'ready') return 'Bluetooth ready';
- if (status.bluetooth.status === 'unavailable' || status.bluetooth.status === 'error') return 'Bluetooth unavailable';
- if (status.state === 'error') return 'needs attention';
- if (status.state === 'starting') return 'starting';
- return 'not running';
-}
-
-function formatMenuStatus(status: PcControlStatus): string {
- if (status.connectedClientCount > 0) return 'Device connected';
- if (status.bluetooth.status === 'ready') return 'Bluetooth ready';
- if (status.bluetooth.status === 'unavailable' || status.bluetooth.status === 'error') return 'Bluetooth unavailable';
- if (status.state === 'error') return 'Needs attention';
- if (status.state === 'starting') return 'Starting...';
- return 'Not running';
-}
diff --git a/src/main/updates/update-install-diagnostics.test.ts b/src/main/updates/update-install-diagnostics.test.ts
deleted file mode 100644
index c3fd9dd..0000000
--- a/src/main/updates/update-install-diagnostics.test.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import { appendUpdateInstallDiagnostic } from './update-install-diagnostics';
-
-const tempDirs: string[] = [];
-
-describe('appendUpdateInstallDiagnostic', () => {
- afterEach(() => {
- for (const dir of tempDirs.splice(0)) {
- rmSync(dir, { recursive: true, force: true });
- }
- vi.restoreAllMocks();
- });
-
- it('appends a JSONL diagnostics entry', () => {
- const filePath = tempFile();
-
- appendUpdateInstallDiagnostic(filePath, {
- event: 'install_requested',
- at: '2026-06-27T12:00:00.000Z',
- version: '0.1.18'
- });
-
- const lines = readFileSync(filePath, 'utf8').trim().split(/\r?\n/);
- expect(lines).toHaveLength(1);
- expect(JSON.parse(lines[0])).toEqual({
- event: 'install_requested',
- at: '2026-06-27T12:00:00.000Z',
- version: '0.1.18'
- });
- });
-
- it('keeps only the newest 100 lines', () => {
- const filePath = tempFile();
-
- for (let index = 0; index < 105; index++) {
- appendUpdateInstallDiagnostic(filePath, {
- event: 'installer_launch_failed',
- at: `2026-06-27T12:00:${String(index).padStart(2, '0')}.000Z`,
- version: '0.1.18',
- reason: String(index)
- });
- }
-
- const lines = readFileSync(filePath, 'utf8').trim().split(/\r?\n/);
- expect(lines).toHaveLength(100);
- expect(JSON.parse(lines[0]).reason).toBe('5');
- expect(JSON.parse(lines.at(-1) ?? '{}').reason).toBe('104');
- });
-
- it('does not require sensitive fields', () => {
- const filePath = tempFile();
-
- appendUpdateInstallDiagnostic(filePath, {
- event: 'installer_launch_failed',
- at: '2026-06-27T12:00:00.000Z',
- version: '0.1.18'
- });
-
- const entry = JSON.parse(readFileSync(filePath, 'utf8').trim()) as Record;
- expect(entry).not.toHaveProperty('installerPath');
- expect(entry).not.toHaveProperty('thumbprint');
- });
-
- it('swallows write failures with a concise warning', () => {
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
-
- appendUpdateInstallDiagnostic('', {
- event: 'install_requested',
- at: '2026-06-27T12:00:00.000Z',
- version: '0.1.18'
- });
-
- expect(warn).toHaveBeenCalled();
- });
-});
-
-function tempFile(): string {
- const dir = mkdtempSync(join(tmpdir(), 'switchify-update-diagnostics-'));
- tempDirs.push(dir);
- return join(dir, 'nested', 'update-install-diagnostics.jsonl');
-}
diff --git a/src/main/updates/update-install-diagnostics.ts b/src/main/updates/update-install-diagnostics.ts
deleted file mode 100644
index 17000d3..0000000
--- a/src/main/updates/update-install-diagnostics.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
-import { dirname } from 'node:path';
-
-export type UpdateInstallDiagnosticEvent =
- | 'install_requested'
- | 'confirmation_cancelled'
- | 'installer_missing'
- | 'installer_launch_failed'
- | 'installer_started';
-
-export type UpdateInstallDiagnosticEntry = {
- event: UpdateInstallDiagnosticEvent;
- at: string;
- version: string;
- reason?: string;
-};
-
-const MAX_DIAGNOSTIC_LINES = 100;
-
-export function appendUpdateInstallDiagnostic(filePath: string, entry: UpdateInstallDiagnosticEntry): void {
- try {
- mkdirSync(dirname(filePath), { recursive: true });
- const existing = readExistingLines(filePath);
- const lines = [...existing, JSON.stringify(entry)].slice(-MAX_DIAGNOSTIC_LINES);
- writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8');
- } catch (error) {
- console.warn(error instanceof Error ? error.message : 'Switchify update install diagnostics could not be written.');
- }
-}
-
-function readExistingLines(filePath: string): string[] {
- try {
- return readFileSync(filePath, 'utf8')
- .split(/\r?\n/)
- .map((line) => line.trim())
- .filter(Boolean);
- } catch {
- return [];
- }
-}
diff --git a/src/main/updates/update-installer-launcher.test.ts b/src/main/updates/update-installer-launcher.test.ts
deleted file mode 100644
index 8168a08..0000000
--- a/src/main/updates/update-installer-launcher.test.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import { launchWindowsUpdateInstaller, type OpenPath } from './update-installer-launcher';
-
-describe('launchWindowsUpdateInstaller', () => {
- it('returns installer_unavailable when installer path is null', async () => {
- await expect(launchWindowsUpdateInstaller({ installerPath: null })).resolves.toEqual({
- ok: false,
- reason: 'installer_unavailable'
- });
- });
-
- it('returns installer_unavailable when installer file is missing', async () => {
- await expect(
- launchWindowsUpdateInstaller({
- installerPath: 'C:\\cache\\Switchify-PC-Setup.exe',
- fileExists: () => false
- })
- ).resolves.toEqual({ ok: false, reason: 'installer_unavailable' });
- });
-
- it('opens the installer path through shell.openPath', async () => {
- const openPath = vi.fn(async () => '');
-
- await launchWindowsUpdateInstaller({
- installerPath: 'C:\\cache\\Switchify-PC-Setup.exe',
- openPath,
- fileExists: () => true
- });
-
- expect(openPath).toHaveBeenCalledWith('C:\\cache\\Switchify-PC-Setup.exe');
- });
-
- it('returns ok true when openPath resolves to an empty string', async () => {
- await expect(
- launchWindowsUpdateInstaller({
- installerPath: 'C:\\cache\\Switchify-PC-Setup.exe',
- openPath: async () => '',
- fileExists: () => true
- })
- ).resolves.toEqual({ ok: true });
- });
-
- it('returns installer_launch_failed when openPath resolves to a non-empty error string', async () => {
- await expect(
- launchWindowsUpdateInstaller({
- installerPath: 'C:\\cache\\Switchify-PC-Setup.exe',
- openPath: async () => 'Access denied',
- fileExists: () => true
- })
- ).resolves.toEqual({ ok: false, reason: 'installer_launch_failed' });
- });
-
- it('returns installer_launch_failed when openPath rejects', async () => {
- await expect(
- launchWindowsUpdateInstaller({
- installerPath: 'C:\\cache\\Switchify-PC-Setup.exe',
- openPath: async () => {
- throw new Error('failed');
- },
- fileExists: () => true
- })
- ).resolves.toEqual({ ok: false, reason: 'installer_launch_failed' });
- });
-
- it('does not require resourcesPath or a packaged launcher', async () => {
- const openPath = vi.fn(async () => '');
-
- await expect(
- launchWindowsUpdateInstaller({
- installerPath: 'C:\\cache\\Switchify-PC-Setup.exe',
- openPath,
- fileExists: () => true
- })
- ).resolves.toEqual({ ok: true });
- });
-});
diff --git a/src/main/updates/update-installer-launcher.ts b/src/main/updates/update-installer-launcher.ts
deleted file mode 100644
index f8c0759..0000000
--- a/src/main/updates/update-installer-launcher.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { existsSync } from 'node:fs';
-import { shell } from 'electron';
-
-export type UpdateInstallerLaunchReason = 'installer_unavailable' | 'installer_launch_failed';
-
-export type UpdateInstallerLaunchResult = { ok: true } | { ok: false; reason: UpdateInstallerLaunchReason };
-
-export type OpenPath = (path: string) => Promise;
-
-export async function launchWindowsUpdateInstaller({
- installerPath,
- openPath,
- fileExists = existsSync
-}: {
- installerPath: string | null;
- openPath?: OpenPath;
- fileExists?: (path: string) => boolean;
-}): Promise {
- if (!installerPath || !fileExists(installerPath)) {
- return { ok: false, reason: 'installer_unavailable' };
- }
-
- try {
- const errorMessage = await (openPath ?? shell.openPath)(installerPath);
- if (errorMessage) {
- return { ok: false, reason: 'installer_launch_failed' };
- }
- } catch {
- return { ok: false, reason: 'installer_launch_failed' };
- }
-
- return { ok: true };
-}
diff --git a/src/main/updates/update-ipc.test.ts b/src/main/updates/update-ipc.test.ts
deleted file mode 100644
index f364416..0000000
--- a/src/main/updates/update-ipc.test.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { INSTALL_DOWNLOADED_UPDATE_CHANNEL } from '../../shared/ipc-channels';
-import {
- isInstallUpdateConfirmed,
- registerUpdateIpc,
- UPDATE_INSTALL_CONFIRMATION_OPTIONS,
- type UpdateInstallConfirmation
-} from './update-ipc';
-import type { UpdateService } from './update-service';
-import type { UpdateInstallResult } from '../../shared/update';
-
-type IpcHandler = (event: Electron.IpcMainInvokeEvent, ...args: unknown[]) => unknown;
-
-const ipcHandlers = new Map();
-
-vi.mock('electron', () => ({
- BrowserWindow: {
- fromWebContents: vi.fn()
- },
- dialog: {
- showMessageBox: vi.fn()
- },
- ipcMain: {
- handle: vi.fn((channel: string, handler: IpcHandler) => {
- ipcHandlers.set(channel, handler);
- })
- }
-}));
-
-describe('registerUpdateIpc', () => {
- beforeEach(() => {
- ipcHandlers.clear();
- });
-
- it('installs a downloaded update after confirmation', async () => {
- const updateService = createUpdateService({ downloaded: true, installResult: { ok: true } });
- const confirmInstallDownloadedUpdate = vi.fn(async () => true);
-
- registerUpdateIpc(updateService, { confirmInstallDownloadedUpdate });
-
- await expect(invokeInstall()).resolves.toEqual({ ok: true });
- expect(confirmInstallDownloadedUpdate).toHaveBeenCalledTimes(1);
- expect(updateService.installDownloadedUpdate).toHaveBeenCalledTimes(1);
- });
-
- it('returns installer launch failures after confirmation', async () => {
- const updateService = createUpdateService({
- downloaded: true,
- installResult: { ok: false, reason: 'installer_launch_failed' }
- });
- const confirmInstallDownloadedUpdate = vi.fn(async () => true);
-
- registerUpdateIpc(updateService, { confirmInstallDownloadedUpdate });
-
- await expect(invokeInstall()).resolves.toEqual({ ok: false, reason: 'installer_launch_failed' });
- expect(confirmInstallDownloadedUpdate).toHaveBeenCalledTimes(1);
- expect(updateService.installDownloadedUpdate).toHaveBeenCalledTimes(1);
- });
-
- it('does not install a downloaded update when confirmation is cancelled', async () => {
- const updateService = createUpdateService({ downloaded: true, installResult: { ok: true } });
- const confirmInstallDownloadedUpdate = vi.fn(async () => false);
-
- registerUpdateIpc(updateService, { confirmInstallDownloadedUpdate });
-
- await expect(invokeInstall()).resolves.toEqual({ ok: false, reason: 'cancelled' });
- expect(confirmInstallDownloadedUpdate).toHaveBeenCalledTimes(1);
- expect(updateService.installDownloadedUpdate).not.toHaveBeenCalled();
- expect(updateService.recordInstallCancelled).toHaveBeenCalledTimes(1);
- });
-
- it('delegates to the update service without confirmation when no update is downloaded', async () => {
- const updateService = createUpdateService({
- downloaded: false,
- installResult: { ok: false, reason: 'not_downloaded' }
- });
- const confirmInstallDownloadedUpdate = vi.fn(async () => true);
-
- registerUpdateIpc(updateService, { confirmInstallDownloadedUpdate });
-
- await expect(invokeInstall()).resolves.toEqual({ ok: false, reason: 'not_downloaded' });
- expect(confirmInstallDownloadedUpdate).not.toHaveBeenCalled();
- expect(updateService.installDownloadedUpdate).toHaveBeenCalledTimes(1);
- });
-});
-
-describe('update install confirmation options', () => {
- it('warns that the installer may ask the user to close the app', () => {
- expect(UPDATE_INSTALL_CONFIRMATION_OPTIONS.type).toBe('warning');
- expect(UPDATE_INSTALL_CONFIRMATION_OPTIONS.message).toContain('Open the downloaded Switchify PC installer');
- expect(UPDATE_INSTALL_CONFIRMATION_OPTIONS.detail).toContain('installer will open');
- expect(UPDATE_INSTALL_CONFIRMATION_OPTIONS.detail).toContain('may ask you to close Switchify PC');
- expect(UPDATE_INSTALL_CONFIRMATION_OPTIONS.buttons).toEqual(['Open installer', 'Cancel']);
- expect(UPDATE_INSTALL_CONFIRMATION_OPTIONS.defaultId).toBe(1);
- expect(UPDATE_INSTALL_CONFIRMATION_OPTIONS.cancelId).toBe(1);
- });
-
- it('treats only the install button as confirmation', () => {
- expect(isInstallUpdateConfirmed(0)).toBe(true);
- expect(isInstallUpdateConfirmed(1)).toBe(false);
- });
-});
-
-function createUpdateService({
- downloaded,
- installResult
-}: {
- downloaded: boolean;
- installResult: UpdateInstallResult;
-}): UpdateService {
- return {
- getState: vi.fn(() => ({
- info: {
- currentVersion: '0.1.10',
- latestVersion: null,
- releaseName: null,
- releaseNotes: null,
- checkedAt: null,
- status: 'not_checked'
- },
- download: {
- status: downloaded ? 'downloaded' : 'idle',
- downloadedBytes: 0,
- totalBytes: null,
- percent: downloaded ? 100 : null
- }
- })),
- checkForUpdates: vi.fn(),
- downloadUpdate: vi.fn(),
- installDownloadedUpdate: vi.fn(async () => installResult),
- recordInstallCancelled: vi.fn()
- } as unknown as UpdateService;
-}
-
-function invokeInstall(): Promise {
- const handler = ipcHandlers.get(INSTALL_DOWNLOADED_UPDATE_CHANNEL);
- if (!handler) throw new Error('Install handler was not registered.');
- return Promise.resolve(handler({ sender: {} } as Electron.IpcMainInvokeEvent));
-}
diff --git a/src/main/updates/update-ipc.ts b/src/main/updates/update-ipc.ts
deleted file mode 100644
index c02b938..0000000
--- a/src/main/updates/update-ipc.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { BrowserWindow, dialog, ipcMain, type MessageBoxOptions } from 'electron';
-import {
- CHECK_FOR_UPDATES_CHANNEL,
- DOWNLOAD_UPDATE_CHANNEL,
- GET_UPDATE_STATE_CHANNEL,
- INSTALL_DOWNLOADED_UPDATE_CHANNEL
-} from '../../shared/ipc-channels';
-import type { UpdateService } from './update-service';
-
-export const UPDATE_INSTALL_CONFIRMATION_OPTIONS: MessageBoxOptions = {
- type: 'warning',
- title: 'Install update?',
- message: 'Open the downloaded Switchify PC installer?',
- detail:
- 'The installer will open in Windows and may ask you to close Switchify PC before continuing. If you rely on Switchify to control this computer, make sure you have another way to complete the installer before continuing.',
- buttons: ['Open installer', 'Cancel'],
- defaultId: 1,
- cancelId: 1,
- noLink: true
-};
-
-export type UpdateInstallConfirmation = (event: Electron.IpcMainInvokeEvent) => Promise;
-
-export function isInstallUpdateConfirmed(response: number): boolean {
- return response === 0;
-}
-
-async function showNativeUpdateInstallConfirmation(event: Electron.IpcMainInvokeEvent): Promise {
- const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined;
- const result = parentWindow
- ? await dialog.showMessageBox(parentWindow, UPDATE_INSTALL_CONFIRMATION_OPTIONS)
- : await dialog.showMessageBox(UPDATE_INSTALL_CONFIRMATION_OPTIONS);
-
- return isInstallUpdateConfirmed(result.response);
-}
-
-export function registerUpdateIpc(
- updateService: UpdateService,
- options: { confirmInstallDownloadedUpdate?: UpdateInstallConfirmation } = {}
-): void {
- const confirmInstallDownloadedUpdate = options.confirmInstallDownloadedUpdate ?? showNativeUpdateInstallConfirmation;
-
- ipcMain.handle(GET_UPDATE_STATE_CHANNEL, () => updateService.getState());
- ipcMain.handle(CHECK_FOR_UPDATES_CHANNEL, () => updateService.checkForUpdates());
- ipcMain.handle(DOWNLOAD_UPDATE_CHANNEL, () => updateService.downloadUpdate());
- ipcMain.handle(INSTALL_DOWNLOADED_UPDATE_CHANNEL, async (event) => {
- if (updateService.getState().download.status !== 'downloaded') {
- return await updateService.installDownloadedUpdate();
- }
-
- if (!(await confirmInstallDownloadedUpdate(event))) {
- updateService.recordInstallCancelled();
- return { ok: false, reason: 'cancelled' };
- }
-
- return await updateService.installDownloadedUpdate();
- });
-}
diff --git a/src/main/updates/update-service.test.ts b/src/main/updates/update-service.test.ts
deleted file mode 100644
index f5f9546..0000000
--- a/src/main/updates/update-service.test.ts
+++ /dev/null
@@ -1,481 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import {
- INITIAL_UPDATE_POLL_DELAY_MS,
- UPDATE_POLL_INTERVAL_MS,
- UpdateService,
- type ElectronUpdaterAdapter
-} from './update-service';
-import type { UpdateInstallerLaunchResult } from './update-installer-launcher';
-
-type Listener = (...args: unknown[]) => void;
-
-class FakeUpdater implements ElectronUpdaterAdapter {
- autoDownload = true;
- autoInstallOnAppQuit = true;
- readonly quitAndInstall = vi.fn();
- readonly listeners = new Map();
- checkForUpdates = vi.fn(async () => null);
- downloadUpdate = vi.fn(async (): Promise => []);
-
- on(event: string, listener: Listener): void {
- const listeners = this.listeners.get(event) ?? [];
- listeners.push(listener);
- this.listeners.set(event, listeners);
- }
-
- emit(event: string, ...args: unknown[]): void {
- for (const listener of this.listeners.get(event) ?? []) {
- listener(...args);
- }
- }
-}
-
-describe('UpdateService', () => {
- afterEach(() => {
- vi.useRealTimers();
- });
-
- it('disables automatic download and install-on-quit', () => {
- const updater = new FakeUpdater();
- createService({ updater });
-
- expect(updater.autoDownload).toBe(false);
- expect(updater.autoInstallOnAppQuit).toBe(false);
- });
-
- it('reports not_packaged in unpackaged builds', async () => {
- const service = createService({ isPackaged: false });
-
- const state = await service.checkForUpdates();
-
- expect(state.info.status).toBe('check_failed');
- expect(state.info.reason).toBe('not_packaged');
- });
-
- it('reports not_supported on non-Windows platforms', async () => {
- const service = createService({ platform: 'darwin' });
-
- const state = await service.checkForUpdates();
-
- expect(state.info.status).toBe('check_failed');
- expect(state.info.reason).toBe('not_supported');
- });
-
- it('starts automatic update checks after the initial delay', async () => {
- vi.useFakeTimers();
- const updater = new FakeUpdater();
- const service = createService({ updater });
-
- service.startAutomaticUpdateChecks();
- await vi.advanceTimersByTimeAsync(INITIAL_UPDATE_POLL_DELAY_MS - 1);
- expect(updater.checkForUpdates).not.toHaveBeenCalled();
-
- await vi.advanceTimersByTimeAsync(1);
-
- expect(updater.checkForUpdates).toHaveBeenCalledTimes(1);
- });
-
- it('checks for updates every hour', async () => {
- vi.useFakeTimers();
- const updater = new FakeUpdater();
- const service = createService({ updater });
-
- service.startAutomaticUpdateChecks();
- await vi.advanceTimersByTimeAsync(INITIAL_UPDATE_POLL_DELAY_MS);
-
- expect(updater.checkForUpdates).toHaveBeenCalledTimes(1);
-
- await vi.advanceTimersByTimeAsync(UPDATE_POLL_INTERVAL_MS);
-
- expect(updater.checkForUpdates).toHaveBeenCalledTimes(2);
- });
-
- it('does not start automatic checks in unpackaged builds', async () => {
- vi.useFakeTimers();
- const updater = new FakeUpdater();
- const service = createService({ updater, isPackaged: false });
-
- service.startAutomaticUpdateChecks();
- await vi.advanceTimersByTimeAsync(UPDATE_POLL_INTERVAL_MS * 2);
-
- expect(updater.checkForUpdates).not.toHaveBeenCalled();
- });
-
- it('does not start automatic checks on non-Windows platforms', async () => {
- vi.useFakeTimers();
- const updater = new FakeUpdater();
- const service = createService({ updater, platform: 'darwin' });
-
- service.startAutomaticUpdateChecks();
- await vi.advanceTimersByTimeAsync(UPDATE_POLL_INTERVAL_MS * 2);
-
- expect(updater.checkForUpdates).not.toHaveBeenCalled();
- });
-
- it('does not start duplicate polling timers', async () => {
- vi.useFakeTimers();
- const updater = new FakeUpdater();
- const service = createService({ updater });
-
- service.startAutomaticUpdateChecks();
- service.startAutomaticUpdateChecks();
- await vi.advanceTimersByTimeAsync(UPDATE_POLL_INTERVAL_MS);
-
- expect(updater.checkForUpdates).toHaveBeenCalledTimes(2);
- });
-
- it('stops automatic update checks', async () => {
- vi.useFakeTimers();
- const updater = new FakeUpdater();
- const service = createService({ updater });
-
- service.startAutomaticUpdateChecks();
- service.stopAutomaticUpdateChecks();
- await vi.advanceTimersByTimeAsync(UPDATE_POLL_INTERVAL_MS * 2);
-
- expect(updater.checkForUpdates).not.toHaveBeenCalled();
- });
-
- it('automatic checks skip while another operation is active', async () => {
- vi.useFakeTimers();
- const updater = new FakeUpdater();
- let resolveCheck: () => void = () => undefined;
- updater.checkForUpdates.mockImplementation(() => new Promise((resolve) => {
- resolveCheck = () => resolve(null);
- }));
- const service = createService({ updater });
-
- service.startAutomaticUpdateChecks();
- await vi.advanceTimersByTimeAsync(INITIAL_UPDATE_POLL_DELAY_MS);
- await vi.advanceTimersByTimeAsync(UPDATE_POLL_INTERVAL_MS);
-
- expect(updater.checkForUpdates).toHaveBeenCalledTimes(1);
- resolveCheck();
- });
-
- it('automatic checks skip when an update is already downloaded', async () => {
- vi.useFakeTimers();
- const updater = new FakeUpdater();
- updater.checkForUpdates.mockImplementation(async () => {
- updater.emit('update-available', updateInfo({ version: '0.1.1' }));
- return null;
- });
- updater.downloadUpdate.mockImplementation(async () => {
- updater.emit('update-downloaded', updateInfo({ version: '0.1.1' }));
- return ['C:\\cache\\installer.exe'];
- });
- const service = createService({ updater });
- await service.checkForUpdates();
- await service.downloadUpdate();
- updater.checkForUpdates.mockClear();
-
- service.startAutomaticUpdateChecks();
- await vi.advanceTimersByTimeAsync(UPDATE_POLL_INTERVAL_MS);
-
- expect(updater.checkForUpdates).not.toHaveBeenCalled();
- });
-
- it('notifies listeners when update state changes', async () => {
- const updater = new FakeUpdater();
- const onStateChanged = vi.fn();
- updater.checkForUpdates.mockImplementation(async () => {
- updater.emit('update-available', updateInfo({ version: '0.1.1' }));
- return null;
- });
- const service = createService({ updater, onStateChanged });
-
- await service.checkForUpdates();
-
- expect(onStateChanged).toHaveBeenCalledWith(
- expect.objectContaining({
- info: expect.objectContaining({
- status: 'update_available',
- latestVersion: '0.1.1'
- })
- })
- );
- });
-
- it('maps update-not-available to up_to_date', async () => {
- const updater = new FakeUpdater();
- updater.checkForUpdates.mockImplementation(async () => {
- updater.emit('update-not-available', updateInfo({ version: '0.1.0' }));
- return null;
- });
- const service = createService({ updater });
-
- const state = await service.checkForUpdates();
-
- expect(state.info.status).toBe('up_to_date');
- expect(state.info.latestVersion).toBe('0.1.0');
- });
-
- it('maps update-available to update_available', async () => {
- const updater = new FakeUpdater();
- updater.checkForUpdates.mockImplementation(async () => {
- updater.emit('update-available', updateInfo({ version: '0.1.1', releaseName: 'v0.1.1' }));
- return null;
- });
- const service = createService({ updater });
-
- const state = await service.checkForUpdates();
-
- expect(state.info.status).toBe('update_available');
- expect(state.info.latestVersion).toBe('0.1.1');
- expect(state.info.releaseName).toBe('v0.1.1');
- });
-
- it('returns not_available when there is no update to download', async () => {
- const service = createService();
-
- const state = await service.downloadUpdate();
-
- expect(state.download.status).toBe('download_failed');
- expect(state.download.reason).toBe('not_available');
- });
-
- it('maps download progress', async () => {
- const updater = new FakeUpdater();
- updater.checkForUpdates.mockImplementation(async () => {
- updater.emit('update-available', updateInfo({ version: '0.1.1' }));
- return null;
- });
- updater.downloadUpdate.mockImplementation(async () => {
- updater.emit('download-progress', { transferred: 25, total: 100, percent: 25 });
- return [];
- });
- const service = createService({ updater });
- await service.checkForUpdates();
-
- const state = await service.downloadUpdate();
-
- expect(state.download.status).toBe('downloading');
- expect(state.download.downloadedBytes).toBe(25);
- expect(state.download.totalBytes).toBe(100);
- expect(state.download.percent).toBe(25);
- });
-
- it('maps update-downloaded to downloaded', async () => {
- const updater = new FakeUpdater();
- updater.checkForUpdates.mockImplementation(async () => {
- updater.emit('update-available', updateInfo({ version: '0.1.1' }));
- return null;
- });
- updater.downloadUpdate.mockImplementation(async () => {
- updater.emit('download-progress', { transferred: 100, total: 100, percent: 100 });
- updater.emit('update-downloaded', updateInfo({ version: '0.1.1' }));
- return [];
- });
- const service = createService({ updater });
- await service.checkForUpdates();
-
- const state = await service.downloadUpdate();
-
- expect(state.download.status).toBe('downloaded');
- expect(state.download.percent).toBe(100);
- });
-
- it('does not install before an update is downloaded', async () => {
- const updater = new FakeUpdater();
- const service = createService({ updater });
-
- await expect(service.installDownloadedUpdate()).resolves.toEqual({ ok: false, reason: 'not_downloaded' });
- expect(updater.quitAndInstall).not.toHaveBeenCalled();
- });
-
- it('opens the downloaded installer and keeps the app running', async () => {
- const updater = new FakeUpdater();
- const launchInstaller = vi.fn(async (): Promise => ({ ok: true }));
- updater.checkForUpdates.mockImplementation(async () => {
- updater.emit('update-available', updateInfo({ version: '0.1.1' }));
- return null;
- });
- updater.downloadUpdate.mockImplementation(async () => {
- updater.emit('update-downloaded', updateInfo({ version: '0.1.1' }));
- return ['C:\\Users\\owen\\AppData\\Local\\switchify-pc-updater\\pending\\Switchify-PC-Setup-0.1.1-x64.exe'];
- });
- const service = createService({ updater, launchInstaller });
- await service.checkForUpdates();
- await service.downloadUpdate();
-
- await expect(service.installDownloadedUpdate()).resolves.toEqual({ ok: true });
- expect(launchInstaller).toHaveBeenCalledWith({
- installerPath: 'C:\\Users\\owen\\AppData\\Local\\switchify-pc-updater\\pending\\Switchify-PC-Setup-0.1.1-x64.exe'
- });
- expect(updater.quitAndInstall).not.toHaveBeenCalled();
- });
-
- it('uses the first downloaded path when no exe path is returned', async () => {
- const updater = new FakeUpdater();
- const launchInstaller = vi.fn(async (): Promise => ({ ok: true }));
- updater.checkForUpdates.mockImplementation(async () => {
- updater.emit('update-available', updateInfo({ version: '0.1.1' }));
- return null;
- });
- updater.downloadUpdate.mockImplementation(async () => {
- updater.emit('update-downloaded', updateInfo({ version: '0.1.1' }));
- return ['C:\\cache\\download.bin'];
- });
- const service = createService({ updater, launchInstaller });
- await service.checkForUpdates();
- await service.downloadUpdate();
-
- await service.installDownloadedUpdate();
-
- expect(launchInstaller).toHaveBeenCalledWith({
- installerPath: 'C:\\cache\\download.bin'
- });
- });
-
- it('returns installer_unavailable and keeps running when no installer path was captured', async () => {
- const updater = new FakeUpdater();
- const launchInstaller = vi.fn(async (): Promise => ({
- ok: false,
- reason: 'installer_unavailable'
- }));
- const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
- updater.checkForUpdates.mockImplementation(async () => {
- updater.emit('update-available', updateInfo({ version: '0.1.1' }));
- return null;
- });
- updater.downloadUpdate.mockImplementation(async () => {
- updater.emit('update-downloaded', updateInfo({ version: '0.1.1' }));
- return [];
- });
- const service = createService({ updater, launchInstaller });
- await service.checkForUpdates();
- await service.downloadUpdate();
-
- await expect(service.installDownloadedUpdate()).resolves.toEqual({ ok: false, reason: 'installer_unavailable' });
- expect(launchInstaller).toHaveBeenCalledWith({
- installerPath: null
- });
- error.mockRestore();
- });
-
- it('returns installer_launch_failed and keeps running when open fails', async () => {
- const updater = new FakeUpdater();
- const launchInstaller = vi.fn(async (): Promise => ({
- ok: false,
- reason: 'installer_launch_failed'
- }));
- const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
- updater.checkForUpdates.mockImplementation(async () => {
- updater.emit('update-available', updateInfo({ version: '0.1.1' }));
- return null;
- });
- updater.downloadUpdate.mockImplementation(async () => {
- updater.emit('update-downloaded', updateInfo({ version: '0.1.1' }));
- return ['C:\\cache\\installer.exe'];
- });
- const service = createService({ updater, launchInstaller });
- await service.checkForUpdates();
- await service.downloadUpdate();
-
- await expect(service.installDownloadedUpdate()).resolves.toEqual({ ok: false, reason: 'installer_launch_failed' });
- error.mockRestore();
- });
-
- it('records install diagnostics without full installer paths', async () => {
- const tempDir = mkdtempSync(join(tmpdir(), 'switchify-update-service-'));
- const diagnosticsFilePath = join(tempDir, 'update-install-diagnostics.jsonl');
- const updater = new FakeUpdater();
- const launchInstaller = vi.fn(async (): Promise => ({
- ok: false,
- reason: 'installer_launch_failed'
- }));
- const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
- updater.checkForUpdates.mockImplementation(async () => {
- updater.emit('update-available', updateInfo({ version: '0.1.1' }));
- return null;
- });
- updater.downloadUpdate.mockImplementation(async () => {
- updater.emit('update-downloaded', updateInfo({ version: '0.1.1' }));
- return ['C:\\cache\\installer.exe'];
- });
- const service = createService({ updater, launchInstaller, diagnosticsFilePath });
- await service.checkForUpdates();
- await service.downloadUpdate();
-
- await service.installDownloadedUpdate();
-
- const log = readFileSync(diagnosticsFilePath, 'utf8');
- expect(log).toContain('"event":"install_requested"');
- expect(log).toContain('"event":"installer_launch_failed"');
- expect(log).not.toContain('C:\\cache\\installer.exe');
- error.mockRestore();
- rmSync(tempDir, { recursive: true, force: true });
- });
-
- it('maps updater errors during checks to check_failed', async () => {
- const updater = new FakeUpdater();
- updater.checkForUpdates.mockImplementation(async () => {
- updater.emit('error', new Error('failed'));
- return null;
- });
- const service = createService({ updater });
-
- const state = await service.checkForUpdates();
-
- expect(state.info.status).toBe('check_failed');
- expect(state.info.reason).toBe('network_error');
- });
-
- it('maps updater errors during downloads to download_failed', async () => {
- const updater = new FakeUpdater();
- updater.checkForUpdates.mockImplementation(async () => {
- updater.emit('update-available', updateInfo({ version: '0.1.1' }));
- return null;
- });
- updater.downloadUpdate.mockImplementation(async () => {
- updater.emit('error', new Error('failed'));
- return [];
- });
- const service = createService({ updater });
- await service.checkForUpdates();
-
- const state = await service.downloadUpdate();
-
- expect(state.download.status).toBe('download_failed');
- expect(state.download.reason).toBe('network_error');
- });
-});
-
-function createService({
- updater = new FakeUpdater(),
- launchInstaller = vi.fn(async (): Promise => ({ ok: true })),
- diagnosticsFilePath = null,
- onStateChanged = vi.fn(),
- isPackaged = true,
- platform = 'win32'
-}: {
- updater?: FakeUpdater;
- launchInstaller?: typeof import('./update-installer-launcher').launchWindowsUpdateInstaller;
- diagnosticsFilePath?: string | null;
- onStateChanged?: (state: import('../../shared/update').UpdateState) => void;
- isPackaged?: boolean;
- platform?: NodeJS.Platform;
-} = {}): UpdateService {
- return new UpdateService({
- currentVersion: '0.1.0',
- isPackaged,
- platform,
- autoUpdater: updater,
- launchInstaller,
- diagnosticsFilePath,
- onStateChanged,
- now: () => new Date('2026-06-12T12:00:00.000Z')
- });
-}
-
-function updateInfo(overrides: Record = {}): Record {
- return {
- version: '0.1.0',
- releaseName: null,
- releaseNotes: null,
- ...overrides
- };
-}
diff --git a/src/main/updates/update-service.ts b/src/main/updates/update-service.ts
deleted file mode 100644
index 85833e1..0000000
--- a/src/main/updates/update-service.ts
+++ /dev/null
@@ -1,447 +0,0 @@
-import { autoUpdater as defaultAutoUpdater } from 'electron-updater';
-import type { UpdateInfo as ElectronUpdateInfo, UpdateCheckResult } from 'electron-updater';
-import type { UpdateDownloadProgress, UpdateInfo, UpdateInstallResult, UpdateState } from '../../shared/update';
-import { createInitialUpdateState } from '../../shared/update';
-import { appendUpdateInstallDiagnostic, type UpdateInstallDiagnosticEvent } from './update-install-diagnostics';
-import { launchWindowsUpdateInstaller, type UpdateInstallerLaunchReason } from './update-installer-launcher';
-
-type ElectronDownloadProgress = {
- transferred?: number;
- total?: number;
- percent?: number;
-};
-
-export type ElectronUpdaterAdapter = {
- autoDownload: boolean;
- autoInstallOnAppQuit: boolean;
- checkForUpdates(): Promise;
- downloadUpdate(): Promise>;
- on(event: string, listener: (...args: unknown[]) => void): void;
-};
-
-export type UpdateServiceOptions = {
- currentVersion: string;
- isPackaged: boolean;
- platform: NodeJS.Platform;
- autoUpdater?: ElectronUpdaterAdapter;
- launchInstaller?: typeof launchWindowsUpdateInstaller;
- now?: () => Date;
- diagnosticsFilePath?: string | null;
- setInterval?: typeof setInterval;
- clearInterval?: typeof clearInterval;
- setTimeout?: typeof setTimeout;
- clearTimeout?: typeof clearTimeout;
- onStateChanged?: (state: UpdateState) => void;
-};
-
-type UpdateOperation = 'idle' | 'checking' | 'downloading';
-
-export const UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000;
-export const INITIAL_UPDATE_POLL_DELAY_MS = 30 * 1000;
-
-export class UpdateService {
- private readonly isPackaged: boolean;
- private readonly platform: NodeJS.Platform;
- private readonly autoUpdater: ElectronUpdaterAdapter;
- private readonly launchInstaller: typeof launchWindowsUpdateInstaller;
- private readonly now: () => Date;
- private readonly diagnosticsFilePath: string | null;
- private readonly setPollInterval: typeof setInterval;
- private readonly clearPollInterval: typeof clearInterval;
- private readonly setPollTimeout: typeof setTimeout;
- private readonly clearPollTimeout: typeof clearTimeout;
- private readonly notifyStateChanged: (state: UpdateState) => void;
- private state: UpdateState;
- private operation: UpdateOperation = 'idle';
- private checkingPromise: Promise | null = null;
- private downloadPromise: Promise | null = null;
- private downloadedInstallerPath: string | null = null;
- private pollInterval: NodeJS.Timeout | null = null;
- private initialPollTimeout: NodeJS.Timeout | null = null;
-
- constructor(options: UpdateServiceOptions) {
- this.isPackaged = options.isPackaged;
- this.platform = options.platform;
- this.autoUpdater = options.autoUpdater ?? defaultAutoUpdater;
- this.launchInstaller = options.launchInstaller ?? launchWindowsUpdateInstaller;
- this.now = options.now ?? (() => new Date());
- this.diagnosticsFilePath = options.diagnosticsFilePath ?? null;
- this.setPollInterval = options.setInterval ?? setInterval;
- this.clearPollInterval = options.clearInterval ?? clearInterval;
- this.setPollTimeout = options.setTimeout ?? setTimeout;
- this.clearPollTimeout = options.clearTimeout ?? clearTimeout;
- this.notifyStateChanged = options.onStateChanged ?? (() => undefined);
- this.state = createInitialUpdateState(options.currentVersion);
-
- this.autoUpdater.autoDownload = false;
- this.autoUpdater.autoInstallOnAppQuit = false;
- this.registerUpdaterEvents();
- }
-
- getState(): UpdateState {
- return cloneState(this.state);
- }
-
- startAutomaticUpdateChecks(): void {
- if (this.unsupportedReason()) return;
- if (this.pollInterval || this.initialPollTimeout) return;
-
- this.initialPollTimeout = this.setPollTimeout(() => {
- this.initialPollTimeout = null;
- void this.runAutomaticUpdateCheck();
- }, INITIAL_UPDATE_POLL_DELAY_MS);
-
- this.pollInterval = this.setPollInterval(() => {
- void this.runAutomaticUpdateCheck();
- }, UPDATE_POLL_INTERVAL_MS);
- }
-
- stopAutomaticUpdateChecks(): void {
- if (this.initialPollTimeout) {
- this.clearPollTimeout(this.initialPollTimeout);
- this.initialPollTimeout = null;
- }
-
- if (this.pollInterval) {
- this.clearPollInterval(this.pollInterval);
- this.pollInterval = null;
- }
- }
-
- checkForUpdates(): Promise {
- const unsupportedReason = this.unsupportedReason();
- if (unsupportedReason) {
- this.setState({
- ...this.state,
- info: {
- ...this.state.info,
- checkedAt: this.now().toISOString(),
- status: 'check_failed',
- reason: unsupportedReason
- },
- download: createIdleDownload()
- });
- return Promise.resolve(this.getState());
- }
-
- if (this.checkingPromise) return Promise.resolve(this.getState());
-
- this.operation = 'checking';
- this.downloadedInstallerPath = null;
- this.setState({
- info: {
- ...this.state.info,
- latestVersion: null,
- releaseName: null,
- releaseNotes: null,
- checkedAt: null,
- status: 'checking',
- reason: undefined
- },
- download: createIdleDownload()
- });
-
- this.checkingPromise = this.autoUpdater
- .checkForUpdates()
- .then(() => this.getState())
- .catch((error) => {
- console.error('Switchify update check failed.', error);
- this.setState({
- ...this.state,
- info: {
- ...this.state.info,
- checkedAt: this.now().toISOString(),
- status: 'check_failed',
- reason: 'network_error'
- }
- });
- return this.getState();
- })
- .finally(() => {
- this.operation = 'idle';
- this.checkingPromise = null;
- });
-
- return this.checkingPromise;
- }
-
- async downloadUpdate(): Promise {
- const unsupportedReason = this.unsupportedReason();
- if (unsupportedReason) {
- this.setState({
- ...this.state,
- download: {
- ...createIdleDownload(),
- status: 'download_failed',
- reason: unsupportedReason
- }
- });
- return this.getState();
- }
-
- if (this.downloadPromise) return this.getState();
-
- if (this.state.info.status !== 'update_available') {
- this.setState({
- ...this.state,
- download: {
- ...createIdleDownload(),
- status: 'download_failed',
- reason: 'not_available'
- }
- });
- return this.getState();
- }
-
- this.operation = 'downloading';
- this.downloadedInstallerPath = null;
- this.setState({
- ...this.state,
- download: {
- status: 'downloading',
- downloadedBytes: 0,
- totalBytes: null,
- percent: null
- }
- });
-
- this.downloadPromise = this.autoUpdater
- .downloadUpdate()
- .then((downloadedPaths) => {
- this.downloadedInstallerPath = firstInstallerPath(downloadedPaths);
- return this.getState();
- })
- .catch((error) => {
- console.error('Switchify update download failed.', error);
- this.downloadedInstallerPath = null;
- this.setState({
- ...this.state,
- download: {
- ...this.state.download,
- status: 'download_failed',
- reason: 'network_error'
- }
- });
- return this.getState();
- })
- .finally(() => {
- this.operation = 'idle';
- this.downloadPromise = null;
- });
-
- return this.downloadPromise;
- }
-
- async installDownloadedUpdate(): Promise {
- this.recordInstallDiagnostic('install_requested');
- const unsupportedReason = this.unsupportedReason();
- if (unsupportedReason) {
- return { ok: false, reason: unsupportedReason };
- }
-
- if (this.state.download.status !== 'downloaded') {
- return { ok: false, reason: 'not_downloaded' };
- }
-
- const result = await this.launchInstaller({ installerPath: this.downloadedInstallerPath });
-
- if (!result.ok) {
- console.error('Switchify update installer could not be opened.', result.reason);
- this.recordInstallDiagnostic(diagnosticEventForLaunchFailure(result.reason), result.reason);
- return { ok: false, reason: result.reason };
- }
-
- this.recordInstallDiagnostic('installer_started');
- return { ok: true };
- }
-
- recordInstallCancelled(): void {
- this.recordInstallDiagnostic('confirmation_cancelled');
- }
-
- private registerUpdaterEvents(): void {
- this.autoUpdater.on('update-available', (rawInfo) => {
- const info = rawInfo as ElectronUpdateInfo;
- this.downloadedInstallerPath = null;
- this.setState({
- info: updateInfo(this.state.info, info, {
- checkedAt: this.now().toISOString(),
- status: 'update_available',
- reason: undefined
- }),
- download: createIdleDownload()
- });
- });
-
- this.autoUpdater.on('update-not-available', (rawInfo) => {
- const info = rawInfo as ElectronUpdateInfo;
- this.downloadedInstallerPath = null;
- this.setState({
- ...this.state,
- info: updateInfo(this.state.info, info, {
- checkedAt: this.now().toISOString(),
- latestVersion: info.version || this.state.info.currentVersion,
- status: 'up_to_date',
- reason: undefined
- }),
- download: createIdleDownload()
- });
- });
-
- this.autoUpdater.on('download-progress', (rawProgress) => {
- const progress = rawProgress as ElectronDownloadProgress;
- const downloadedBytes = Number.isFinite(progress.transferred) ? Number(progress.transferred) : 0;
- const totalBytes = Number.isFinite(progress.total) && Number(progress.total) > 0 ? Number(progress.total) : null;
- const percent =
- Number.isFinite(progress.percent) && Number(progress.percent) >= 0
- ? Math.min(100, Math.round(Number(progress.percent)))
- : totalBytes
- ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))
- : null;
-
- this.setState({
- ...this.state,
- download: {
- status: 'downloading',
- downloadedBytes,
- totalBytes,
- percent
- }
- });
- });
-
- this.autoUpdater.on('update-downloaded', (rawInfo) => {
- const info = rawInfo as ElectronUpdateInfo;
- this.setState({
- ...this.state,
- info: updateInfo(this.state.info, info),
- download: {
- status: 'downloaded',
- downloadedBytes: this.state.download.downloadedBytes,
- totalBytes: this.state.download.totalBytes,
- percent: 100
- }
- });
- });
-
- this.autoUpdater.on('error', (error) => {
- console.error('Switchify updater error.', error);
- if (this.operation === 'downloading') {
- this.downloadedInstallerPath = null;
- this.setState({
- ...this.state,
- download: {
- ...this.state.download,
- status: 'download_failed',
- reason: 'network_error'
- }
- });
- return;
- }
-
- this.setState({
- ...this.state,
- info: {
- ...this.state.info,
- checkedAt: this.now().toISOString(),
- status: 'check_failed',
- reason: 'network_error'
- }
- });
- });
- }
-
- private async runAutomaticUpdateCheck(): Promise {
- if (this.operation !== 'idle') return;
- if (this.state.download.status === 'downloading' || this.state.download.status === 'downloaded') return;
-
- await this.checkForUpdates();
- }
-
- private setState(state: UpdateState): void {
- this.state = state;
- this.notifyStateChanged(this.getState());
- }
-
- private unsupportedReason(): 'not_packaged' | 'not_supported' | null {
- if (!this.isPackaged) return 'not_packaged';
- if (this.platform !== 'win32') return 'not_supported';
- return null;
- }
-
- private recordInstallDiagnostic(event: UpdateInstallDiagnosticEvent, reason?: string): void {
- if (!this.diagnosticsFilePath) return;
-
- appendUpdateInstallDiagnostic(this.diagnosticsFilePath, {
- event,
- at: this.now().toISOString(),
- version: this.state.info.currentVersion,
- ...(reason ? { reason } : {})
- });
- }
-
-}
-
-function firstInstallerPath(paths: string[]): string | null {
- return paths.find((path) => path.toLowerCase().endsWith('.exe')) ?? paths[0] ?? null;
-}
-
-function diagnosticEventForLaunchFailure(reason: UpdateInstallerLaunchReason): UpdateInstallDiagnosticEvent {
- switch (reason) {
- case 'installer_unavailable':
- return 'installer_missing';
- case 'installer_launch_failed':
- return 'installer_launch_failed';
- }
-}
-
-
-function updateInfo(
- previous: UpdateInfo,
- info: ElectronUpdateInfo,
- patch: Partial = {}
-): UpdateInfo {
- return {
- ...previous,
- latestVersion: info.version || previous.latestVersion,
- releaseName: toNullableString(info.releaseName),
- releaseNotes: normalizeReleaseNotes(info.releaseNotes),
- ...patch
- };
-}
-
-function normalizeReleaseNotes(notes: unknown): string | null {
- if (typeof notes === 'string') return notes;
- if (Array.isArray(notes)) {
- const text = notes
- .map((note) => {
- if (typeof note === 'string') return note;
- if (note && typeof note === 'object' && 'note' in note && typeof note.note === 'string') return note.note;
- return null;
- })
- .filter(Boolean)
- .join('\n\n');
- return text.length > 0 ? text : null;
- }
- return null;
-}
-
-function toNullableString(value: unknown): string | null {
- return typeof value === 'string' ? value : null;
-}
-
-function createIdleDownload(): UpdateDownloadProgress {
- return {
- status: 'idle',
- downloadedBytes: 0,
- totalBytes: null,
- percent: null
- };
-}
-
-function cloneState(state: UpdateState): UpdateState {
- return {
- info: { ...state.info },
- download: { ...state.download }
- };
-}
diff --git a/src/main/updates/version.test.ts b/src/main/updates/version.test.ts
deleted file mode 100644
index 66558a1..0000000
--- a/src/main/updates/version.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { compareVersions, normalizeVersion, parseVersion } from './version';
-
-describe('compareVersions', () => {
- it('detects a patch version update', () => {
- expect(compareVersions('0.1.1', '0.1.0')).toBe(1);
- });
-
- it('treats a v-prefixed matching version as equal', () => {
- expect(compareVersions('v0.1.0', '0.1.0')).toBe(0);
- });
-
- it('detects a minor version update', () => {
- expect(compareVersions('0.2.0', '0.1.9')).toBe(1);
- });
-
- it('detects a major version update', () => {
- expect(compareVersions('1.0.0', '0.9.9')).toBe(1);
- });
-
- it('rejects invalid versions', () => {
- expect(parseVersion('0.1')).toBeNull();
- expect(compareVersions('latest', '0.1.0')).toBeNull();
- });
-
- it('rejects prerelease-style versions for the first update pass', () => {
- expect(normalizeVersion('0.1.0-beta.1')).toBeNull();
- });
-});
diff --git a/src/main/updates/version.ts b/src/main/updates/version.ts
deleted file mode 100644
index d2629a2..0000000
--- a/src/main/updates/version.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-export type ParsedVersion = {
- major: number;
- minor: number;
- patch: number;
-};
-
-const VERSION_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)$/;
-
-export function parseVersion(value: string): ParsedVersion | null {
- const match = VERSION_PATTERN.exec(value.trim());
- if (!match) return null;
-
- const [, major, minor, patch] = match;
- return {
- major: Number(major),
- minor: Number(minor),
- patch: Number(patch)
- };
-}
-
-export function normalizeVersion(value: string): string | null {
- const parsed = parseVersion(value);
- if (!parsed) return null;
- return `${parsed.major}.${parsed.minor}.${parsed.patch}`;
-}
-
-export function compareVersions(left: string, right: string): number | null {
- const parsedLeft = parseVersion(left);
- const parsedRight = parseVersion(right);
- if (!parsedLeft || !parsedRight) return null;
-
- for (const key of ['major', 'minor', 'patch'] as const) {
- if (parsedLeft[key] > parsedRight[key]) return 1;
- if (parsedLeft[key] < parsedRight[key]) return -1;
- }
-
- return 0;
-}
diff --git a/src/main/windows-app-user-model-id.ts b/src/main/windows-app-user-model-id.ts
deleted file mode 100644
index 108a0bf..0000000
--- a/src/main/windows-app-user-model-id.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const WINDOWS_APP_USER_MODEL_ID = 'app.switchify.pc';
diff --git a/src/main/windows-startup-registry.test.ts b/src/main/windows-startup-registry.test.ts
deleted file mode 100644
index 5d1d776..0000000
--- a/src/main/windows-startup-registry.test.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import {
- createWindowsStartupRegistry,
- startupCommandFor,
- type CommandRunner
-} from './windows-startup-registry';
-
-describe('startupCommandFor', () => {
- it('quotes the executable path and appends arguments', () => {
- expect(startupCommandFor('C:\\Program Files\\Switchify PC\\Switchify PC.exe', ['--start-hidden'])).toBe(
- '"C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden'
- );
- });
-
- it('rejects executable paths containing quotes', () => {
- expect(() => startupCommandFor('C:\\Bad"Path\\Switchify PC.exe', ['--start-hidden'])).toThrow(
- 'Startup executable path cannot contain quotes.'
- );
- });
-
- it('rejects arguments containing quotes', () => {
- expect(() => startupCommandFor('C:\\Switchify PC.exe', ['--bad"arg'])).toThrow(
- 'Startup arguments cannot contain quotes.'
- );
- });
-});
-
-describe('createWindowsStartupRegistry', () => {
- it('returns command and enabled StartupApproved state', async () => {
- const runner = createRunner({
- runQuery: ' app.switchify.pc REG_SZ "C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden',
- approvedQuery: ' app.switchify.pc REG_BINARY 020000000000000000000000'
- });
-
- await expect(createWindowsStartupRegistry(runner).getEntry('app.switchify.pc')).resolves.toEqual({
- command: '"C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden',
- startupApproved: 'enabled'
- });
- });
-
- it('returns null command when the Run value is missing', async () => {
- const runner = createRunner({
- runMissing: true,
- approvedQuery: ' app.switchify.pc REG_BINARY 020000000000000000000000'
- });
-
- await expect(createWindowsStartupRegistry(runner).getEntry('app.switchify.pc')).resolves.toEqual({
- command: null,
- startupApproved: 'enabled'
- });
- });
-
- it('returns missing StartupApproved when that value is missing', async () => {
- const runner = createRunner({
- runQuery: ' app.switchify.pc REG_SZ "C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden',
- approvedMissing: true
- });
-
- await expect(createWindowsStartupRegistry(runner).getEntry('app.switchify.pc')).resolves.toEqual({
- command: '"C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden',
- startupApproved: 'missing'
- });
- });
-
- it('parses disabled StartupApproved state', async () => {
- const runner = createRunner({
- runQuery: ' app.switchify.pc REG_SZ "C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden',
- approvedQuery: ' app.switchify.pc REG_BINARY 030000000000000000000000'
- });
-
- await expect(createWindowsStartupRegistry(runner).getEntry('app.switchify.pc')).resolves.toMatchObject({
- startupApproved: 'disabled'
- });
- });
-
- it('parses unknown StartupApproved state', async () => {
- const runner = createRunner({
- runQuery: ' app.switchify.pc REG_SZ "C:\\Program Files\\Switchify PC\\Switchify PC.exe" --start-hidden',
- approvedQuery: ' app.switchify.pc REG_BINARY 090000000000000000000000'
- });
-
- await expect(createWindowsStartupRegistry(runner).getEntry('app.switchify.pc')).resolves.toMatchObject({
- startupApproved: 'unknown'
- });
- });
-
- it('writes Run and StartupApproved values when setting an entry', async () => {
- const runner = vi.fn(async () => ({ stdout: '', stderr: '' }));
-
- await createWindowsStartupRegistry(runner).setEntry('app.switchify.pc', '"C:\\Switchify PC.exe" --start-hidden');
-
- expect(runner).toHaveBeenCalledWith('reg.exe', [
- 'add',
- 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run',
- '/v',
- 'app.switchify.pc',
- '/t',
- 'REG_SZ',
- '/d',
- '"C:\\Switchify PC.exe" --start-hidden',
- '/f'
- ]);
- expect(runner).toHaveBeenCalledWith('reg.exe', [
- 'add',
- 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\StartupApproved\\Run',
- '/v',
- 'app.switchify.pc',
- '/t',
- 'REG_BINARY',
- '/d',
- '020000000000000000000000',
- '/f'
- ]);
- });
-
- it('deletes both Run and StartupApproved values and ignores missing values', async () => {
- const runner = vi.fn(async () => {
- throw Object.assign(new Error('The system was unable to find the specified registry key or value.'), {
- code: 1
- });
- });
-
- await expect(createWindowsStartupRegistry(runner).deleteEntry('app.switchify.pc')).resolves.toBeUndefined();
- expect(runner).toHaveBeenCalledTimes(2);
- });
-});
-
-function createRunner(options: {
- runQuery?: string;
- approvedQuery?: string;
- runMissing?: boolean;
- approvedMissing?: boolean;
-}): CommandRunner {
- return vi.fn(async (_file, args) => {
- const key = args[1];
- if (key === 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run') {
- if (options.runMissing) throw missingRegistryValueError();
- return { stdout: options.runQuery ?? '', stderr: '' };
- }
-
- if (key === 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\StartupApproved\\Run') {
- if (options.approvedMissing) throw missingRegistryValueError();
- return { stdout: options.approvedQuery ?? '', stderr: '' };
- }
-
- throw new Error(`Unexpected registry key: ${key}`);
- });
-}
-
-function missingRegistryValueError(): Error & { code: number } {
- return Object.assign(new Error('The system was unable to find the specified registry key or value.'), { code: 1 });
-}
diff --git a/src/main/windows-startup-registry.ts b/src/main/windows-startup-registry.ts
deleted file mode 100644
index bb37a3d..0000000
--- a/src/main/windows-startup-registry.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import { execFile } from 'node:child_process';
-
-export type StartupApprovedState = 'enabled' | 'disabled' | 'missing' | 'unknown';
-
-export type StartupRegistryEntry = {
- command: string | null;
- startupApproved: StartupApprovedState;
-};
-
-export type CommandRunner = (
- file: string,
- args: string[]
-) => Promise<{ stdout: string; stderr: string }>;
-
-export type WindowsStartupRegistry = {
- getEntry(valueName: string): Promise;
- setEntry(valueName: string, command: string): Promise;
- deleteEntry(valueName: string): Promise;
-};
-
-const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
-const STARTUP_APPROVED_RUN_KEY =
- 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\StartupApproved\\Run';
-const STARTUP_APPROVED_ENABLED_HEX = '020000000000000000000000';
-
-export function createWindowsStartupRegistry(commandRunner: CommandRunner = runCommand): WindowsStartupRegistry {
- return {
- async getEntry(valueName) {
- const command = await queryRunCommand(commandRunner, valueName);
- const startupApproved = await queryStartupApproved(commandRunner, valueName);
- return { command, startupApproved };
- },
- async setEntry(valueName, command) {
- await commandRunner('reg.exe', ['add', RUN_KEY, '/v', valueName, '/t', 'REG_SZ', '/d', command, '/f']);
- await commandRunner('reg.exe', [
- 'add',
- STARTUP_APPROVED_RUN_KEY,
- '/v',
- valueName,
- '/t',
- 'REG_BINARY',
- '/d',
- STARTUP_APPROVED_ENABLED_HEX,
- '/f'
- ]);
- },
- async deleteEntry(valueName) {
- await ignoreMissingValue(commandRunner('reg.exe', ['delete', RUN_KEY, '/v', valueName, '/f']));
- await ignoreMissingValue(commandRunner('reg.exe', ['delete', STARTUP_APPROVED_RUN_KEY, '/v', valueName, '/f']));
- }
- };
-}
-
-export function startupCommandFor(executablePath: string, args: string[]): string {
- if (executablePath.includes('"')) {
- throw new Error('Startup executable path cannot contain quotes.');
- }
-
- for (const arg of args) {
- if (arg.includes('"')) {
- throw new Error('Startup arguments cannot contain quotes.');
- }
- }
-
- return [`"${executablePath}"`, ...args].join(' ');
-}
-
-async function queryRunCommand(commandRunner: CommandRunner, valueName: string): Promise {
- try {
- const { stdout } = await commandRunner('reg.exe', ['query', RUN_KEY, '/v', valueName]);
- return parseRegistryValue(stdout, valueName, 'REG_SZ');
- } catch (error) {
- if (isMissingRegistryValueError(error)) return null;
- throw error;
- }
-}
-
-async function queryStartupApproved(
- commandRunner: CommandRunner,
- valueName: string
-): Promise {
- try {
- const { stdout } = await commandRunner('reg.exe', ['query', STARTUP_APPROVED_RUN_KEY, '/v', valueName]);
- const value = parseRegistryValue(stdout, valueName, 'REG_BINARY');
- return startupApprovedStateFromHex(value);
- } catch (error) {
- if (isMissingRegistryValueError(error)) return 'missing';
- throw error;
- }
-}
-
-function parseRegistryValue(stdout: string, valueName: string, valueType: 'REG_SZ' | 'REG_BINARY'): string | null {
- const escapedName = valueName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- const pattern = new RegExp(`^\\s*${escapedName}\\s+${valueType}\\s+(.+?)\\s*$`, 'im');
- const match = stdout.match(pattern);
- return match?.[1]?.trim() ?? null;
-}
-
-function startupApprovedStateFromHex(value: string | null): StartupApprovedState {
- if (!value) return 'unknown';
-
- const firstByte = value.replace(/\s+/g, '').slice(0, 2).toLowerCase();
- if (firstByte === '02') return 'enabled';
- if (firstByte === '03') return 'disabled';
- return 'unknown';
-}
-
-async function ignoreMissingValue(promise: Promise): Promise {
- try {
- await promise;
- } catch (error) {
- if (!isMissingRegistryValueError(error)) throw error;
- }
-}
-
-function isMissingRegistryValueError(error: unknown): boolean {
- if (!error || typeof error !== 'object') return false;
-
- const maybeError = error as { code?: unknown; stdout?: unknown; stderr?: unknown; message?: unknown };
- const output = `${String(maybeError.stdout ?? '')}\n${String(maybeError.stderr ?? '')}\n${String(
- maybeError.message ?? ''
- )}`.toLowerCase();
-
- return maybeError.code === 1 || output.includes('unable to find') || output.includes('cannot find');
-}
-
-function runCommand(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
- return new Promise((resolve, reject) => {
- execFile(file, args, { windowsHide: true }, (error, stdout, stderr) => {
- if (error) {
- reject(Object.assign(error, { stdout, stderr }));
- return;
- }
-
- resolve({ stdout, stderr });
- });
- });
-}
diff --git a/src/preload/index.ts b/src/preload/index.ts
deleted file mode 100644
index a3838c8..0000000
--- a/src/preload/index.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
-import type { PairingApprovalDecision, PendingPairingApprovalView } from '../shared/pairing-approval';
-import type { PairedDeviceView, PcControlStatus } from '../shared/server-status';
-import type { CursorOverlaySettings } from '../shared/cursor-overlay-settings';
-import type { PointerMovementSettings } from '../shared/pointer-movement-settings';
-import {
- APP_WINDOW_CLOSE_CHANNEL,
- APP_WINDOW_MINIMIZE_CHANNEL,
- CHECK_FOR_UPDATES_CHANNEL,
- DISCONNECT_CLIENTS_CHANNEL,
- DOWNLOAD_UPDATE_CHANNEL,
- FORGET_PAIRED_DEVICE_CHANNEL,
- GET_CURSOR_OVERLAY_ENABLED_CHANNEL,
- GET_CURSOR_OVERLAY_SETTINGS_CHANNEL,
- GET_PAIRED_DEVICES_CHANNEL,
- GET_PENDING_PAIRING_REQUESTS_CHANNEL,
- GET_POINTER_MOVEMENT_SETTINGS_CHANNEL,
- GET_SYSTEM_STARTUP_SETTINGS_CHANNEL,
- GET_UPDATE_STATE_CHANNEL,
- INSTALL_DOWNLOADED_UPDATE_CHANNEL,
- OPEN_EXTERNAL_URL_CHANNEL,
- OPEN_SETTINGS_WINDOW_CHANNEL,
- RESPOND_TO_PAIRING_REQUEST_CHANNEL,
- SERVER_STATUS_CHANNEL,
- SET_CURSOR_OVERLAY_ENABLED_CHANNEL,
- SET_CURSOR_OVERLAY_SETTINGS_CHANNEL,
- SET_POINTER_MOVEMENT_SETTINGS_CHANNEL,
- SET_START_WITH_SYSTEM_CHANNEL,
- SHOW_SETTINGS_SECTION_CHANNEL,
- UPDATE_STATE_CHANGED_CHANNEL,
-} from '../shared/ipc-channels';
-import type { SettingsSectionId } from '../shared/settings';
-import type { SystemStartupSettings } from '../shared/system-startup';
-import type { UpdateInstallResult, UpdateState } from '../shared/update';
-
-contextBridge.exposeInMainWorld('switchifyPc', {
- appName: 'Switchify PC',
- minimizeWindow: (): Promise => ipcRenderer.invoke(APP_WINDOW_MINIMIZE_CHANNEL),
- closeWindow: (): Promise => ipcRenderer.invoke(APP_WINDOW_CLOSE_CHANNEL),
- getServerStatus: (): Promise => ipcRenderer.invoke(SERVER_STATUS_CHANNEL),
- getPairedDevices: (): Promise => ipcRenderer.invoke(GET_PAIRED_DEVICES_CHANNEL),
- disconnectClients: (): Promise => ipcRenderer.invoke(DISCONNECT_CLIENTS_CHANNEL),
- forgetPairedDevice: (
- deviceId: string
- ): Promise<
- { ok: true; pairedDevices: PairedDeviceView[]; status: PcControlStatus } | { ok: false; reason: string }
- > => ipcRenderer.invoke(FORGET_PAIRED_DEVICE_CHANNEL, deviceId),
- getCursorOverlayEnabled: (): Promise => ipcRenderer.invoke(GET_CURSOR_OVERLAY_ENABLED_CHANNEL),
- setCursorOverlayEnabled: (enabled: boolean): Promise =>
- ipcRenderer.invoke(SET_CURSOR_OVERLAY_ENABLED_CHANNEL, enabled),
- getCursorOverlaySettings: (): Promise => ipcRenderer.invoke(GET_CURSOR_OVERLAY_SETTINGS_CHANNEL),
- setCursorOverlaySettings: (settings: CursorOverlaySettings): Promise =>
- ipcRenderer.invoke(SET_CURSOR_OVERLAY_SETTINGS_CHANNEL, settings),
- getPointerMovementSettings: (): Promise =>
- ipcRenderer.invoke(GET_POINTER_MOVEMENT_SETTINGS_CHANNEL),
- setPointerMovementSettings: (settings: PointerMovementSettings): Promise =>
- ipcRenderer.invoke(SET_POINTER_MOVEMENT_SETTINGS_CHANNEL, settings),
- openSettingsWindow: (section?: SettingsSectionId): Promise =>
- ipcRenderer.invoke(OPEN_SETTINGS_WINDOW_CHANNEL, section),
- onShowSettingsSection: (handler: (section: SettingsSectionId) => void): (() => void) => {
- const listener = (_event: IpcRendererEvent, section: SettingsSectionId): void => handler(section);
- ipcRenderer.on(SHOW_SETTINGS_SECTION_CHANNEL, listener);
- return () => ipcRenderer.removeListener(SHOW_SETTINGS_SECTION_CHANNEL, listener);
- },
- openExternalUrl: (url: string): Promise<{ ok: boolean }> =>
- ipcRenderer.invoke(OPEN_EXTERNAL_URL_CHANNEL, url),
- getPendingPairingRequests: (): Promise =>
- ipcRenderer.invoke(GET_PENDING_PAIRING_REQUESTS_CHANNEL),
- respondToPairingRequest: (requestId: string, decision: PairingApprovalDecision): Promise<{ ok: boolean; reason?: string }> =>
- ipcRenderer.invoke(RESPOND_TO_PAIRING_REQUEST_CHANNEL, requestId, decision),
- getUpdateState: (): Promise => ipcRenderer.invoke(GET_UPDATE_STATE_CHANNEL),
- checkForUpdates: (): Promise => ipcRenderer.invoke(CHECK_FOR_UPDATES_CHANNEL),
- downloadUpdate: (): Promise => ipcRenderer.invoke(DOWNLOAD_UPDATE_CHANNEL),
- onUpdateStateChanged: (handler: (state: UpdateState) => void): (() => void) => {
- const listener = (_event: IpcRendererEvent, state: UpdateState): void => handler(state);
- ipcRenderer.on(UPDATE_STATE_CHANGED_CHANNEL, listener);
- return () => ipcRenderer.removeListener(UPDATE_STATE_CHANGED_CHANNEL, listener);
- },
- getSystemStartupSettings: (): Promise =>
- ipcRenderer.invoke(GET_SYSTEM_STARTUP_SETTINGS_CHANNEL),
- setStartWithSystem: (enabled: boolean): Promise =>
- ipcRenderer.invoke(SET_START_WITH_SYSTEM_CHANNEL, enabled),
- installDownloadedUpdate: (): Promise =>
- ipcRenderer.invoke(INSTALL_DOWNLOADED_UPDATE_CHANNEL)
-});
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
deleted file mode 100644
index 23ff1a3..0000000
--- a/src/renderer/App.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import type { ReactElement } from 'react';
-import { AndroidDownloadPanel } from './components/AndroidDownloadPanel';
-import { PairingApprovalRequests } from './components/PairingApprovalRequests';
-import { PrimaryContent } from './components/PrimaryContent';
-import { StatusHeader } from './components/StatusHeader';
-import { TroubleshootingDetails } from './components/TroubleshootingDetails';
-import { UpdateBanner } from './components/UpdateBanner';
-import { WindowChrome } from './components/WindowTitleBar';
-import { SettingsApp } from './SettingsApp';
-import { useSwitchifyPcStatus } from './useSwitchifyPcStatus';
-import { useUpdateState } from './useUpdateState';
-
-export function App(): ReactElement {
- if (window.location.hash === '#/settings' || window.location.hash.startsWith('#/settings/')) {
- return ;
- }
-
- return ;
-}
-
-function MainApp(): ReactElement {
- const bridge = window.switchifyPc;
- const status = useSwitchifyPcStatus(bridge);
- const { updateState } = useUpdateState(bridge);
-
- return (
- bridge.openSettingsWindow('updates')}
- >
-
-
-
- bridge.openSettingsWindow('updates')} />
-
-
-
-
-
- {status.uiState === 'connected' ? null : (
-
- )}
-
-
-
-
- );
-}
diff --git a/src/renderer/SettingsApp.tsx b/src/renderer/SettingsApp.tsx
deleted file mode 100644
index 8232bf0..0000000
--- a/src/renderer/SettingsApp.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import { useCallback, useEffect, useState, type ReactElement } from 'react';
-import type { SystemStartupSettings } from '../shared/system-startup';
-import { SettingsView } from './components/SettingsPanel';
-import { WindowChrome } from './components/WindowTitleBar';
-import { settingsSectionFromHash } from './settings-route';
-import { updateInstallMessage } from './updates';
-import { useSwitchifyPcStatus } from './useSwitchifyPcStatus';
-import { useUpdateState } from './useUpdateState';
-
-export function SettingsApp(): ReactElement {
- const bridge = window.switchifyPc;
- const status = useSwitchifyPcStatus(bridge);
- const { updateState, setUpdateState } = useUpdateState(bridge);
- const [systemStartupSettings, setSystemStartupSettings] = useState(null);
- const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
- const [isDownloadingUpdate, setIsDownloadingUpdate] = useState(false);
- const [isInstallingUpdate, setIsInstallingUpdate] = useState(false);
- const [updateInstallError, setUpdateInstallError] = useState(null);
- const [isUpdatingSystemStartup, setIsUpdatingSystemStartup] = useState(false);
-
- useEffect(() => {
- let cancelled = false;
- void bridge.getSystemStartupSettings().then(
- (systemStartupSettings) => {
- if (!cancelled) {
- setSystemStartupSettings(systemStartupSettings);
- }
- }
- );
-
- return () => {
- cancelled = true;
- };
- }, [bridge]);
-
- const checkForUpdates = useCallback(async (): Promise => {
- setIsCheckingForUpdates(true);
- try {
- setUpdateState(await bridge.checkForUpdates());
- } finally {
- setIsCheckingForUpdates(false);
- }
- }, [bridge]);
-
- const downloadUpdate = useCallback(async (): Promise => {
- setIsDownloadingUpdate(true);
- setUpdateState((current) =>
- current
- ? {
- ...current,
- download: {
- status: 'downloading',
- downloadedBytes: 0,
- totalBytes: null,
- percent: null
- }
- }
- : current
- );
- const progressInterval = window.setInterval(() => {
- void bridge.getUpdateState().then(setUpdateState);
- }, 500);
- try {
- setUpdateState(await bridge.downloadUpdate());
- } finally {
- window.clearInterval(progressInterval);
- setIsDownloadingUpdate(false);
- }
- }, [bridge]);
-
- const installDownloadedUpdate = useCallback(async (): Promise => {
- setIsInstallingUpdate(true);
- setUpdateInstallError(null);
- try {
- const result = await bridge.installDownloadedUpdate();
- if (!result.ok) {
- setUpdateInstallError(updateInstallMessage(result.reason));
- }
- } finally {
- setIsInstallingUpdate(false);
- }
- }, [bridge]);
-
- const setStartWithSystem = useCallback(
- async (enabled: boolean): Promise => {
- setIsUpdatingSystemStartup(true);
- try {
- setSystemStartupSettings(await bridge.setStartWithSystem(enabled));
- } finally {
- setIsUpdatingSystemStartup(false);
- }
- },
- [bridge]
- );
-
- return (
-
-
-
-
-
- );
-}
diff --git a/src/renderer/api.d.ts b/src/renderer/api.d.ts
deleted file mode 100644
index 680a632..0000000
--- a/src/renderer/api.d.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-export {};
-
-import type { PairingApprovalDecision, PendingPairingApprovalView } from '../shared/pairing-approval';
-import type { PairedDeviceView, PcControlStatus } from '../shared/server-status';
-import type { CursorOverlaySettings } from '../shared/cursor-overlay-settings';
-import type { PointerMovementSettings } from '../shared/pointer-movement-settings';
-import type { SettingsSectionId } from '../shared/settings';
-import type { SystemStartupSettings } from '../shared/system-startup';
-import type { UpdateInstallResult, UpdateState } from '../shared/update';
-
-declare global {
- interface Window {
- switchifyPc: {
- appName: string;
- minimizeWindow: () => Promise;
- closeWindow: () => Promise;
- getServerStatus: () => Promise;
- getPairedDevices: () => Promise;
- disconnectClients: () => Promise;
- forgetPairedDevice: (
- deviceId: string
- ) => Promise<
- { ok: true; pairedDevices: PairedDeviceView[]; status: PcControlStatus } | { ok: false; reason: string }
- >;
- getCursorOverlayEnabled: () => Promise;
- setCursorOverlayEnabled: (enabled: boolean) => Promise;
- getCursorOverlaySettings: () => Promise;
- setCursorOverlaySettings: (settings: CursorOverlaySettings) => Promise;
- getPointerMovementSettings: () => Promise;
- setPointerMovementSettings: (settings: PointerMovementSettings) => Promise;
- openSettingsWindow: (section?: SettingsSectionId) => Promise;
- onShowSettingsSection: (handler: (section: SettingsSectionId) => void) => () => void;
- openExternalUrl: (url: string) => Promise<{ ok: boolean }>;
- getPendingPairingRequests: () => Promise;
- respondToPairingRequest: (
- requestId: string,
- decision: PairingApprovalDecision
- ) => Promise<{ ok: boolean; reason?: string }>;
- getUpdateState: () => Promise