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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jobs:
SWITCHIFY_SIGNING_MODE: certum-simplysign
SWITCHIFY_CERTUM_CERT_THUMBPRINT: ${{ vars.CERTUM_CERT_THUMBPRINT }}
SWITCHIFY_CERTUM_TIMESTAMP_URL: http://time.certum.pl
TIMBERLOGS_API_KEY: ${{ secrets.TIMBERLOGS_API_KEY }}

steps:
- name: Resolve release tag
Expand Down Expand Up @@ -55,6 +56,13 @@ jobs:
- name: Restore
run: dotnet restore src/SwitchifyPc.sln

- name: Verify Timberlogs configuration
shell: powershell
run: |
if ([string]::IsNullOrWhiteSpace($env:TIMBERLOGS_API_KEY)) {
throw 'TIMBERLOGS_API_KEY secret is not available to the release workflow.'
}

- name: Build
run: dotnet build src/SwitchifyPc.sln -c Release --no-restore

Expand Down
109 changes: 107 additions & 2 deletions src/SwitchifyPc.App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@ public partial class App : System.Windows.Application
private readonly object bluetoothConnectionSync = new();
private readonly HashSet<string> authenticatedBluetoothConnections = new(StringComparer.Ordinal);
private DispatcherTimer? pairingExpiryTimer;
private DispatcherTimer? telemetryFlushTimer;
private AppThemeManager? themeManager;
private JsonTelemetrySettingsStore? telemetrySettingsStore;
private ITelemetryReporter? telemetryReporter;
private readonly HashSet<Exception> reportedExceptions = new(ReferenceEqualityComparer.Instance);
private readonly object reportedExceptionsSync = new();
private bool diagnosticsConsentShown;
private string? lastLoggedUpdateCheckStatus;
private string? lastLoggedUpdateDownloadStatus;
private bool isQuitting;
Expand Down Expand Up @@ -82,6 +88,8 @@ protected override void OnStartup(StartupEventArgs e)
return;
}

InitializeTelemetry();

existingInstanceSignal = new WindowsExistingInstanceSignal();
existingInstanceSignal.Start(
() => Dispatcher.BeginInvoke(ShowMainWindow),
Expand All @@ -94,6 +102,8 @@ protected override void OnStartup(StartupEventArgs e)
RefreshPairingApprovals();
updateService.StartAutomaticUpdateChecks();
StartPairingExpiryTimer();
StartTelemetryFlushTimer();
_ = telemetryReporter?.FlushAsync();
_ = StartBluetoothAsync();
_ = InitializeStartupRegistrationAsync(e.Args, launchOptions.StartHidden);
trayIcon = new NativeTrayIcon(
Expand Down Expand Up @@ -123,6 +133,8 @@ protected override void OnExit(ExitEventArgs e)
updateService = null;
pairingExpiryTimer?.Stop();
pairingExpiryTimer = null;
telemetryFlushTimer?.Stop();
telemetryFlushTimer = null;
mouseRepeatController?.StopAllAsync().GetAwaiter().GetResult();
mouseRepeatController = null;
bluetoothServer?.Dispose();
Expand All @@ -145,6 +157,9 @@ protected override void OnExit(ExitEventArgs e)
trayIcon = null;
settingsWindow = null;
setupGuideWindow = null;
telemetryReporter?.Dispose();
telemetryReporter = null;
telemetrySettingsStore = null;
base.OnExit(e);
}

Expand Down Expand Up @@ -236,7 +251,9 @@ private SetupGuideWindow CreateSetupGuideWindow()
AcceptPairingApprovalAsync,
RejectPairingApproval,
SetSetupStartWithSystemAsync,
CompleteSetupGuide);
CompleteSetupGuide,
() => Dispatcher.BeginInvoke(ShowDiagnosticsConsentIfNeeded),
setShareDiagnosticData: SetShareDiagnosticData);
window.Closing += (_, eventArgs) =>
{
if (isQuitting) return;
Expand Down Expand Up @@ -276,7 +293,9 @@ private SettingsController CreateSettingsController()
new JsonMouseRepeatSettingsStore(Path.Combine(userDataDirectory, "mouse-repeat-settings.json")),
new JsonCursorOverlaySettingsStore(Path.Combine(userDataDirectory, "cursor-overlay-settings.json")),
updateService ?? CreateUpdateService(),
new JsonPairingStore(Path.Combine(userDataDirectory, "pairing-state.json")));
new JsonPairingStore(Path.Combine(userDataDirectory, "pairing-state.json")),
telemetrySettingsStore ?? CreateTelemetrySettingsStore(),
telemetryReporter ?? CreateDisabledTelemetryReporter());
}

private SystemStartupService CreateStartupService()
Expand Down Expand Up @@ -367,6 +386,7 @@ private async Task StartBluetoothAsync()
{
bluetoothStatusTracker?.SetError(error.Message);
RecordRuntimeDiagnostic("bluetooth.start.failed", reason: "startup_failed");
ReportException(error, "bluetooth");
}
}

Expand Down Expand Up @@ -551,6 +571,10 @@ await Dispatcher.BeginInvoke(() =>
MarkSetupGuideShown();
ShowSetupGuideWindowCore();
}
else
{
ShowDiagnosticsConsentIfNeeded();
}
});
}

Expand Down Expand Up @@ -594,6 +618,70 @@ private void CompleteSetupGuide()
store.Save(SetupGuidePrompt.MarkCompleted(settings));
}

private JsonTelemetrySettingsStore CreateTelemetrySettingsStore()
{
return new JsonTelemetrySettingsStore(Path.Combine(UserDataDirectory(), "telemetry-settings.json"));
}

private ITelemetryReporter CreateDisabledTelemetryReporter()
{
return new TelemetryReporter(CreateTelemetrySettingsStore(), new HttpClient(), string.Empty, CurrentVersion,
Path.Combine(UserDataDirectory(), "telemetry-queue.json"));
}

private void InitializeTelemetry()
{
telemetrySettingsStore = CreateTelemetrySettingsStore();
string apiKey = typeof(App).Assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
.FirstOrDefault(attribute => attribute.Key == "TimberlogsApiKey")?.Value ?? string.Empty;
telemetryReporter = new TelemetryReporter(
telemetrySettingsStore,
new HttpClient(),
apiKey,
CurrentVersion,
Path.Combine(UserDataDirectory(), "telemetry-queue.json"));

DispatcherUnhandledException += (_, args) => ReportException(args.Exception, "dispatcher");
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
{
if (args.ExceptionObject is Exception error) ReportException(error, "appdomain");
};
TaskScheduler.UnobservedTaskException += (_, args) => ReportException(args.Exception, "task");
}

private void SetShareDiagnosticData(bool enabled)
{
telemetryReporter?.SetEnabled(enabled);
setupGuideViewModel.SetShareDiagnosticData(enabled);
if (enabled) _ = telemetryReporter?.FlushAsync();
}

private void ShowDiagnosticsConsentIfNeeded()
{
if (diagnosticsConsentShown || telemetrySettingsStore?.Load().ConsentRecorded != false || MainWindow is null) return;
diagnosticsConsentShown = true;
DiagnosticsConsentWindow prompt = new() { Owner = MainWindow };
prompt.ShowDialog();
SetShareDiagnosticData(prompt.Choice == true);
}

private void ReportException(Exception error, string dataset)
{
lock (reportedExceptionsSync)
{
if (!reportedExceptions.Add(error)) return;
if (reportedExceptions.Count > 100) reportedExceptions.Clear();
}
_ = telemetryReporter?.ReportExceptionAsync(error, dataset);
}

private void StartTelemetryFlushTimer()
{
telemetryFlushTimer = new DispatcherTimer { Interval = TimeSpan.FromMinutes(5) };
telemetryFlushTimer.Tick += (_, _) => _ = telemetryReporter?.FlushAsync();
telemetryFlushTimer.Start();
}

private void StartPairingExpiryTimer()
{
pairingExpiryTimer = new DispatcherTimer
Expand Down Expand Up @@ -727,6 +815,23 @@ private void RecordRuntimeDiagnostic(
Status: SafeDiagnosticText(status),
Reason: SafeDiagnosticText(reason),
Message: SafeDiagnosticText(message)));

telemetryReporter?.RecordBreadcrumb(eventName);
if (IsRemoteHealthEvent(eventName))
{
Dictionary<string, string> data = [];
if (!string.IsNullOrWhiteSpace(status)) data["status"] = status;
if (!string.IsNullOrWhiteSpace(reason)) data["reason"] = reason;
_ = telemetryReporter?.ReportHealthAsync(eventName, data);
}
}

private static bool IsRemoteHealthEvent(string eventName)
{
return eventName is "app.startup.completed" or "app.exit" or "bluetooth.ready" or
"bluetooth.unavailable" or "bluetooth.connected" or "bluetooth.disconnected" or
"bluetooth.error" or "bluetooth.start.failed" or "cursor.overlay.disabled" or
"startup.registration.repair.failed" or "update.state.changed";
}

private static string? SafeDiagnosticText(string? value)
Expand Down
29 changes: 29 additions & 0 deletions src/SwitchifyPc.App/DiagnosticsConsentWindow.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Window x:Class="SwitchifyPc.App.DiagnosticsConsentWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Switchify PC privacy"
Width="520" Height="330"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource AppBackground}"
Foreground="{DynamicResource Text}"
FontFamily="Segoe UI"
Icon="Assets/icon.ico">
<Grid Margin="28">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel>
<TextBlock Text="Help improve Switchify PC" FontSize="22" FontWeight="Bold" />
<TextBlock Margin="0,14,0,0" TextWrapping="Wrap" LineHeight="21"
Text="May Switchify PC send sanitized crashes, exceptions, and app health events? Reports use a random identifier for this installation." />
<TextBlock Margin="0,12,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextMuted}"
Text="Typed text, commands, pairing secrets, device names, and full file paths are never included. You can change this later in Settings > Privacy." />
</StackPanel>
<StackPanel Grid.Row="1" HorizontalAlignment="Right" Orientation="Horizontal">
<Button MinWidth="110" MinHeight="38" Content="No thanks" Click="Decline_Click" />
<Button Margin="10,0,0,0" MinWidth="170" MinHeight="38" Content="Share diagnostic data" Click="Accept_Click" />
</StackPanel>
</Grid>
</Window>
26 changes: 26 additions & 0 deletions src/SwitchifyPc.App/DiagnosticsConsentWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.Windows;

namespace SwitchifyPc.App;

public partial class DiagnosticsConsentWindow : Window
{
public DiagnosticsConsentWindow()
{
InitializeComponent();
Closing += (_, _) => Choice ??= false;
}

public bool? Choice { get; private set; }

private void Accept_Click(object sender, RoutedEventArgs e)
{
Choice = true;
DialogResult = true;
}

private void Decline_Click(object sender, RoutedEventArgs e)
{
Choice = false;
DialogResult = false;
}
}
30 changes: 30 additions & 0 deletions src/SwitchifyPc.App/SettingsWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,11 @@
Style="{StaticResource NavRadio}"
Tag="updates"
Checked="SectionNav_Checked" />
<RadioButton Content="Privacy"
GroupName="SettingsSections"
Style="{StaticResource NavRadio}"
Tag="privacy"
Checked="SectionNav_Checked" />
</StackPanel>
</Border>

Expand Down Expand Up @@ -757,6 +762,31 @@
</StackPanel>
</Border>
</StackPanel>

<StackPanel x:Name="PrivacyPanel" Visibility="Collapsed">
<Border Style="{StaticResource PanelBorder}">
<StackPanel>
<TextBlock Text="Privacy" Style="{StaticResource SectionTitle}" />
<TextBlock Margin="0,6,0,0"
Text="Choose whether Switchify PC may send diagnostic information."
Style="{StaticResource MutedText}" />
<Border Margin="0,16,0,0" Style="{StaticResource SubtlePanel}">
<StackPanel>
<CheckBox Content="Share diagnostic data"
Click="ShareDiagnosticData_Click"
IsChecked="{Binding ShareDiagnosticData, Mode=OneWay}" />
<TextBlock Margin="0,8,0,0"
Text="Sends sanitized crashes, exceptions, and app health events. Typed text, commands, pairing secrets, device names, and full file paths are not sent. Turning this off deletes queued reports."
Style="{StaticResource MutedText}" />
<Button Margin="0,14,0,0"
HorizontalAlignment="Left"
Content="Read privacy policy"
Click="OpenPrivacyPolicy_Click" />
</StackPanel>
</Border>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</ScrollViewer>
</Grid>
Expand Down
22 changes: 22 additions & 0 deletions src/SwitchifyPc.App/SettingsWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Windows;
using System.Diagnostics;
using SwitchifyPc.Core.Ui;
using SwitchifyPc.Core.Updates;
using WpfButton = System.Windows.Controls.Button;
Expand Down Expand Up @@ -104,6 +105,27 @@ private void SectionNav_Checked(object sender, RoutedEventArgs e)
GeneralPanel.Visibility = section == "general" ? Visibility.Visible : Visibility.Collapsed;
PointerPanel.Visibility = section == "pointer" ? Visibility.Visible : Visibility.Collapsed;
UpdatesPanel.Visibility = section == "updates" ? Visibility.Visible : Visibility.Collapsed;
PrivacyPanel.Visibility = section == "privacy" ? Visibility.Visible : Visibility.Collapsed;
}

private void ShareDiagnosticData_Click(object sender, RoutedEventArgs e)
{
if (!isApplyingSettings && settingsLoaded && controller is not null && sender is WpfCheckBox checkBox)
{
controller.SetShareDiagnosticData(checkBox.IsChecked == true);
}
}

private void OpenPrivacyPolicy_Click(object sender, RoutedEventArgs e)
{
try
{
Process.Start(new ProcessStartInfo("https://switchifyapp.com/privacy") { UseShellExecute = true });
}
catch
{
WpfMessageBox.Show("The privacy policy could not be opened.", "Switchify PC settings", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}

private async void ForgetPairedDevice_Click(object sender, RoutedEventArgs e)
Expand Down
24 changes: 24 additions & 0 deletions src/SwitchifyPc.App/SetupGuideWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,30 @@
</Border>
</StackPanel>
</Border>

<Border Style="{StaticResource Panel}"
Visibility="{Binding IsDiagnosticsStep, Converter={StaticResource BooleanToVisibilityConverter}}">
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Help improve Switchify PC"
Foreground="{DynamicResource Text}"
FontSize="21"
FontWeight="Bold" />
<TextBlock Margin="0,8,0,0"
Text="Choose whether to share sanitized crashes, exceptions, and app health events. This never includes typed text, commands, pairing secrets, device names, or full file paths."
Style="{StaticResource MutedText}" />
<WrapPanel Margin="0,22,0,0">
<Button Content="Share diagnostic data"
Style="{StaticResource PrimaryButton}"
Click="ShareDiagnostics_Click" />
<Button Margin="10,0,0,0"
Content="No thanks"
Click="DeclineDiagnostics_Click" />
</WrapPanel>
<TextBlock Margin="0,14,0,0"
Text="Your choice can be changed later in Settings > Privacy."
Style="{StaticResource MutedText}" />
</StackPanel>
</Border>
</Grid>

<Grid Grid.Row="2">
Expand Down
Loading