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
16 changes: 16 additions & 0 deletions Pointframe.Tests/Services/TelemetryServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object?> Scope { get; init; } = [];
}

Expand Down Expand Up @@ -56,6 +57,7 @@ public void Log<TState>(
{
Level = logLevel,
Message = formatter(state, exception),
Exception = exception,
Scope = new Dictionary<string, object?>(_currentScope),
});
}
Expand Down Expand Up @@ -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()
{
Expand Down
17 changes: 16 additions & 1 deletion Pointframe.Tests/Services/TrayIconManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ public void OpenRecentRecordingFolder_Click_WithValidTag_OpensFolder()
InvokePrivate(manager, "OpenRecentRecordingFolder_Click", menuItem, new RoutedEventArgs());

processMock.Verify(process => process.Start(It.Is<ProcessStartInfo>(info =>
info.FileName == "explorer.exe" && info.Arguments == @"C:\temp")), Times.Once);
info.FileName == "explorer.exe" && info.Arguments == "\"C:\\temp\"")), Times.Once);
});
}

Expand Down Expand Up @@ -281,6 +281,21 @@ public void OpenPath_WhenFileExists_StartsProcessWithShellExecute()
});
}

[Fact]
public void OpenLogsFolder_Click_OpensLocalLogsFolder()
{
StaTestHelper.Run(() =>
{
var processMock = new Mock<IProcessService>();
var manager = CreateManager(processService: processMock.Object);

InvokePrivate(manager, "OpenLogsFolder_Click", new object(), new RoutedEventArgs());

processMock.Verify(process => process.Start(It.Is<ProcessStartInfo>(info =>
info.FileName == "explorer.exe" && info.Arguments == $"\"{AppPaths.LogsDirectory}\"")), Times.Once);
});
Comment on lines +284 to +296
}

[Fact]
public void OpenRecentRecording_Click_WithInvalidSender_DoesNothing()
{
Expand Down
8 changes: 2 additions & 6 deletions Pointframe/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,14 @@ 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()
#else
.MinimumLevel.Information()
#endif
.WriteTo.File(
logPath,
AppPaths.RollingLogPath,
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: config.GetValue<int>("Logging:RetainedFileCountLimit", 7),
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}")
Expand Down Expand Up @@ -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();
Expand Down
13 changes: 13 additions & 0 deletions Pointframe/Services/AppPaths.cs
Original file line number Diff line number Diff line change
@@ -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");
}
23 changes: 13 additions & 10 deletions Pointframe/Services/TelemetryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -57,7 +58,7 @@ internal TelemetryService(

public void TrackEvent(string name, IReadOnlyDictionary<string, string>? properties = null)
{
if (_logger is null)
if (_logger is null || _disposed)
{
return;
}
Expand All @@ -72,7 +73,7 @@ public void TrackEvent(string name, IReadOnlyDictionary<string, string>? propert

public void TrackException(Exception exception, string? context = null)
{
if (_logger is null)
if (_logger is null || _disposed)
{
return;
}
Expand All @@ -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");
}
}

Expand Down Expand Up @@ -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();
}
}
}
8 changes: 7 additions & 1 deletion Pointframe/Services/TrayIconManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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()
{
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 26 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion version.json
Original file line number Diff line number Diff line change
@@ -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+"
Expand Down
2 changes: 1 addition & 1 deletion website/free-snagit-alternative-windows.html
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ <h2>Getting started</h2>

<div class="cta-panel">
<h3>Try a free Snagit alternative</h3>
<p>Pointframe is open-source, free, and ships as a signed Windows installer. No account, no subscription, no telemetry.</p>
<p>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.</p>
<div class="hero-actions compact-actions">
<a href="https://github.com/dimitar-radenkov/Pointframe/releases/latest" class="primary-button">Get Pointframe Free</a>
<a href="./screen-recorder-with-annotations-windows.html" class="secondary-link">See the recording workflow</a>
Expand Down
Loading