diff --git a/Pointframe.Tests/Services/TelemetryServiceTests.cs b/Pointframe.Tests/Services/TelemetryServiceTests.cs index 5c26582..185d445 100644 --- a/Pointframe.Tests/Services/TelemetryServiceTests.cs +++ b/Pointframe.Tests/Services/TelemetryServiceTests.cs @@ -12,6 +12,7 @@ private sealed class LogEntry { public LogLevel Level { get; init; } public string Message { get; init; } = string.Empty; + public Exception? Exception { get; init; } public Dictionary Scope { get; init; } = []; } @@ -56,6 +57,7 @@ public void Log( { Level = logLevel, Message = formatter(state, exception), + Exception = exception, Scope = new Dictionary(_currentScope), }); } @@ -265,6 +267,20 @@ public void TrackException_LogsAtErrorLevel() Assert.Equal(LogLevel.Error, logger.Entries[0].Level); } + [Fact] + public void TrackException_DoesNotForwardExceptionObjectToRemoteLogger() + { + // Arrange + var logger = new CapturingLogger(); + var sut = CreateSut(logger); + + // Act + sut.TrackException(new InvalidOperationException("local-only details")); + + // Assert + Assert.Null(logger.Entries[0].Exception); + } + [Fact] public void TrackException_IncludesExceptionTypeInScope() { diff --git a/Pointframe.Tests/Services/TrayIconManagerTests.cs b/Pointframe.Tests/Services/TrayIconManagerTests.cs index 04ba123..7c9c5e9 100644 --- a/Pointframe.Tests/Services/TrayIconManagerTests.cs +++ b/Pointframe.Tests/Services/TrayIconManagerTests.cs @@ -172,7 +172,7 @@ public void OpenRecentRecordingFolder_Click_WithValidTag_OpensFolder() InvokePrivate(manager, "OpenRecentRecordingFolder_Click", menuItem, new RoutedEventArgs()); processMock.Verify(process => process.Start(It.Is(info => - info.FileName == "explorer.exe" && info.Arguments == @"C:\temp")), Times.Once); + info.FileName == "explorer.exe" && info.Arguments == "\"C:\\temp\"")), Times.Once); }); } @@ -281,6 +281,21 @@ public void OpenPath_WhenFileExists_StartsProcessWithShellExecute() }); } + [Fact] + public void OpenLogsFolder_Click_OpensLocalLogsFolder() + { + StaTestHelper.Run(() => + { + var processMock = new Mock(); + var manager = CreateManager(processService: processMock.Object); + + InvokePrivate(manager, "OpenLogsFolder_Click", new object(), new RoutedEventArgs()); + + processMock.Verify(process => process.Start(It.Is(info => + info.FileName == "explorer.exe" && info.Arguments == $"\"{AppPaths.LogsDirectory}\"")), Times.Once); + }); + } + [Fact] public void OpenRecentRecording_Click_WithInvalidSender_DoesNothing() { diff --git a/Pointframe/App.xaml.cs b/Pointframe/App.xaml.cs index 9158a93..1bf47e4 100644 --- a/Pointframe/App.xaml.cs +++ b/Pointframe/App.xaml.cs @@ -54,10 +54,6 @@ protected override void OnStartup(StartupEventArgs e) .AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false) .Build(); - var logPath = System.IO.Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Pointframe", "logs", "pointframe-.log"); - Log.Logger = new LoggerConfiguration() #if DEBUG .MinimumLevel.Debug() @@ -65,7 +61,7 @@ protected override void OnStartup(StartupEventArgs e) .MinimumLevel.Information() #endif .WriteTo.File( - logPath, + AppPaths.RollingLogPath, rollingInterval: RollingInterval.Day, retainedFileCountLimit: config.GetValue("Logging:RetainedFileCountLimit", 7), outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}") @@ -241,8 +237,8 @@ protected override void OnExit(ExitEventArgs e) _captureCompletedSubscription?.Dispose(); _globalHotkey.Dispose(); _trayIconManager?.Dispose(); - _telemetry?.Flush(); _host.StopAsync().GetAwaiter().GetResult(); + _telemetry?.Flush(); _host.Dispose(); base.OnExit(e); Log.CloseAndFlush(); diff --git a/Pointframe/Services/AppPaths.cs b/Pointframe/Services/AppPaths.cs new file mode 100644 index 0000000..73123cd --- /dev/null +++ b/Pointframe/Services/AppPaths.cs @@ -0,0 +1,13 @@ +namespace Pointframe.Services; + +internal static class AppPaths +{ + public static string LocalAppDataDirectory => + System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Pointframe"); + + public static string LogsDirectory => System.IO.Path.Combine(LocalAppDataDirectory, "logs"); + + public static string RollingLogPath => System.IO.Path.Combine(LogsDirectory, "pointframe-.log"); +} diff --git a/Pointframe/Services/TelemetryService.cs b/Pointframe/Services/TelemetryService.cs index 557be26..0097249 100644 --- a/Pointframe/Services/TelemetryService.cs +++ b/Pointframe/Services/TelemetryService.cs @@ -11,6 +11,7 @@ internal sealed class TelemetryService : ITelemetryService, IDisposable private readonly IUserSettingsService _userSettings; private readonly string _appVersion; private readonly string _sessionId = Guid.NewGuid().ToString("N"); + private readonly object _syncRoot = new(); private volatile string? _lastEventName; private bool _disposed; @@ -57,7 +58,7 @@ internal TelemetryService( public void TrackEvent(string name, IReadOnlyDictionary? properties = null) { - if (_logger is null) + if (_logger is null || _disposed) { return; } @@ -72,7 +73,7 @@ public void TrackEvent(string name, IReadOnlyDictionary? propert public void TrackException(Exception exception, string? context = null) { - if (_logger is null) + if (_logger is null || _disposed) { return; } @@ -92,7 +93,7 @@ public void TrackException(Exception exception, string? context = null) var scope = BuildScope(extra); using (_logger.BeginScope(scope)) { - _logger.LogError(exception, "{microsoft.custom_event.name}", "unhandled_exception"); + _logger.LogError("{microsoft.custom_event.name}", "unhandled_exception"); } } @@ -123,18 +124,20 @@ public void TrackException(Exception exception, string? context = null) public void Flush() { - // Azure Monitor flushes pending telemetry when the LoggerFactory is disposed. - // Disposing before host shutdown is handled via IDisposable registration. + Dispose(); } public void Dispose() { - if (_disposed) + lock (_syncRoot) { - return; - } + if (_disposed) + { + return; + } - _disposed = true; - _loggerFactory?.Dispose(); + _disposed = true; + _loggerFactory?.Dispose(); + } } } diff --git a/Pointframe/Services/TrayIconManager.cs b/Pointframe/Services/TrayIconManager.cs index fab4bb4..a957d64 100644 --- a/Pointframe/Services/TrayIconManager.cs +++ b/Pointframe/Services/TrayIconManager.cs @@ -155,6 +155,7 @@ private WpfContextMenu CreateTrayContextMenu() contextMenu.Items.Add(new WpfSeparator()); contextMenu.Items.Add(CreateTrayMenuItem("Settings", Settings_Click)); contextMenu.Items.Add(CreateTrayMenuItem("Check for Updates", CheckForUpdates_Click)); + contextMenu.Items.Add(CreateTrayMenuItem("Open Logs Folder", OpenLogsFolder_Click)); contextMenu.Items.Add(CreateTrayMenuItem("About", About_Click)); contextMenu.Items.Add(new WpfSeparator()); contextMenu.Items.Add(CreateTrayMenuItem("Exit", Exit_Click)); @@ -178,6 +179,11 @@ internal static WpfMenuItem CreateTrayMenuItem(string header, RoutedEventHandler private void About_Click(object sender, RoutedEventArgs e) => _onShowAbout(); private void OpenImage_Click(object sender, RoutedEventArgs e) => _onOpenImage(); private void Exit_Click(object sender, RoutedEventArgs e) => WpfApplication.Current.Shutdown(); + private void OpenLogsFolder_Click(object sender, RoutedEventArgs e) + { + Directory.CreateDirectory(AppPaths.LogsDirectory); + OpenFolder(AppPaths.LogsDirectory); + } private void InitializeRecentCapturesMenu() { @@ -476,7 +482,7 @@ private void OpenPath(string path) private void OpenFolder(string path) { - _processService.Start(new ProcessStartInfo("explorer.exe", path)); + _processService.Start(new ProcessStartInfo("explorer.exe", $"\"{path}\"")); } private void SimulateUiError_Click(object sender, RoutedEventArgs e) diff --git a/README.md b/README.md index 23156d9..8f2c7a7 100644 --- a/README.md +++ b/README.md @@ -270,30 +270,41 @@ Pointframe is built on a very clean, modern stack (.NET 10, WPF, CommunityToolki ## Privacy & Telemetry -Pointframe collects **anonymous, privacy-safe usage telemetry** to help understand how the app is used and catch errors early. No personal data is ever collected. +Pointframe collects **anonymous, privacy-safe usage telemetry** in official builds to help understand how the app is used and catch errors early. Screenshots, recordings, OCR output, file names, file paths, exception messages, and stack traces are not sent as telemetry. ### What is collected | Event | Properties | |---|---| -| `app_started` | — | -| `capture_completed` | `tool` (copy / save / pin) | -| `recording_completed` | `has_audio`, `duration_seconds` | -| `recording_cancelled` | — | -| `gif_export_requested` | — | +| `app_started` | `version`, `os_build`, `screen_count` | +| `startup_completed` | `duration_ms` | +| `app_heartbeat` | `uptime_minutes` (sent about every 4 hours while the tray app remains open) | +| `app_closed` | `session_minutes` | +| `snip_started` | `type` (region / whole_screen), `source` (tray / hotkey) | +| `snip_cancelled` | `type` (region / whole_screen) | +| `capture_delay_used` | `delay_seconds` | +| `capture_completed` | `action` (copy) | +| `capture_pinned` | — | +| `open_image_used` | — | +| `annotation_committed` | `tool` | +| `recording_started` | `type` (region / whole_screen) | +| `recording_completed` | `duration_seconds` when available | +| `ffmpeg_missing` | — | +| `microphone_unavailable` | — | +| `gif_export_started` | — | +| `gif_export_completed` | `success`, `duration_seconds` | | `ocr_used` | — | -| `settings_saved` | — | -| `update_check_triggered` | `source` (auto / manual) | -| `update_download_started` | — | -| `update_download_completed` | — | -| `update_download_cancelled` | — | -| `unhandled_exception` | `exception_type`, `context` | +| `update_check_manual` | — | +| `update_available` | `version` | +| `update_confirmed` | `version` | +| `update_dismissed` | `version` | +| `unhandled_exception` | `exception_type`, `context`, `last_action` when available | -Every event includes an `install_id` and the app `version`. The install ID is a random GUID generated once on first launch and stored locally. It is used only to count unique installs; it cannot be traced back to a person. +Every event includes an app `version`, a per-run `session_id`, and an `install_id` when one is available. The install ID is a random GUID generated once on first launch and stored locally. It is used only to count unique installs; it is not tied to an account or identity. -**Nothing leaves your machine except these anonymised events.** Screenshots, recordings, and OCR output are never transmitted anywhere. +**Nothing leaves your machine except these anonymised events.** Screenshots, recordings, OCR output, file names, and file paths are never transmitted. Local diagnostic logs are stored under `%LOCALAPPDATA%\Pointframe\logs\` and may include local paths to help troubleshoot issues; they are not uploaded automatically. -### Opting out +### Source builds Telemetry is disabled automatically when the `ApplicationInsights:ConnectionString` value in `appsettings.json` is empty (which is the default in the source repository). Only official builds distributed via the installer include the real connection string. diff --git a/version.json b/version.json index 7c211d8..6820b5c 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "5.8", + "version": "5.9", "publicReleaseRefSpec": [ "^refs/heads/master$", "^refs/tags/v\\d+\\.\\d+" diff --git a/website/free-snagit-alternative-windows.html b/website/free-snagit-alternative-windows.html index 2d1277f..bfb83ed 100644 --- a/website/free-snagit-alternative-windows.html +++ b/website/free-snagit-alternative-windows.html @@ -95,7 +95,7 @@

Getting started

Try a free Snagit alternative

-

Pointframe is open-source, free, and ships as a signed Windows installer. No account, no subscription, no telemetry.

+

Pointframe is open-source, free, and ships as a signed Windows installer. No account, no subscription, and only anonymous privacy-safe telemetry in official builds.