From e2f60603b40f828037777564585cc3d9a3bfddf0 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Fri, 30 Oct 2026 09:23:57 -0500 Subject: [PATCH 1/2] feat(diagnostics): capture shareable FileMaker clipboard snapshots Preserve untouched clipboard payloads, format metadata, and independent framing interpretations in diagnostic ZIP snapshots. Refs #186 --- README.md | 2 + docs/filemaker-clipboard-diagnostics.md | 69 ++++++ .../186-filemaker-clipboard-diagnostics.md | 160 ++++++++++++ .../ClipboardPayloadInterpreter.cs | 97 ++++++++ src/SharpFM/Diagnostics/ClipboardSnapshot.cs | 71 ++++++ .../Diagnostics/ClipboardSnapshotCapture.cs | 151 ++++++++++++ .../Diagnostics/ClipboardSnapshotExporter.cs | 60 +++++ .../Diagnostics/ClipboardSnapshotSummary.cs | 48 ++++ .../Diagnostics/RawClipboardWindow.axaml | 58 ++--- .../Diagnostics/RawClipboardWindow.axaml.cs | 138 ++++++----- src/SharpFM/MainWindow.axaml | 2 +- src/SharpFM/MainWindow.axaml.cs | 2 +- .../ClipboardPayloadInterpreterTests.cs | 126 ++++++++++ .../ClipboardSnapshotCaptureTests.cs | 228 ++++++++++++++++++ .../ClipboardSnapshotExporterTests.cs | 155 ++++++++++++ .../ClipboardSnapshotSummaryTests.cs | 44 ++++ .../Diagnostics/ClipboardTestTransfer.cs | 26 ++ 17 files changed, 1352 insertions(+), 85 deletions(-) create mode 100644 docs/filemaker-clipboard-diagnostics.md create mode 100644 docs/plans/186-filemaker-clipboard-diagnostics.md create mode 100644 src/SharpFM/Diagnostics/ClipboardPayloadInterpreter.cs create mode 100644 src/SharpFM/Diagnostics/ClipboardSnapshot.cs create mode 100644 src/SharpFM/Diagnostics/ClipboardSnapshotCapture.cs create mode 100644 src/SharpFM/Diagnostics/ClipboardSnapshotExporter.cs create mode 100644 src/SharpFM/Diagnostics/ClipboardSnapshotSummary.cs create mode 100644 tests/SharpFM.Tests/Diagnostics/ClipboardPayloadInterpreterTests.cs create mode 100644 tests/SharpFM.Tests/Diagnostics/ClipboardSnapshotCaptureTests.cs create mode 100644 tests/SharpFM.Tests/Diagnostics/ClipboardSnapshotExporterTests.cs create mode 100644 tests/SharpFM.Tests/Diagnostics/ClipboardSnapshotSummaryTests.cs create mode 100644 tests/SharpFM.Tests/Diagnostics/ClipboardTestTransfer.cs diff --git a/README.md b/README.md index 0b1f468e..a28759c6 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ Grab the latest build for your platform from [Releases](https://github.com/fuzzz ## Troubleshooting +For FileMaker clipboard problems, use **Tools > FileMaker Clipboard Diagnostics…** to capture untouched payloads and save a ZIP snapshot for an issue report. Copy a small, non-sensitive example first: snapshots include full clipboard data, including accompanying text. Nothing is uploaded automatically. See [capture instructions and privacy details](docs/filemaker-clipboard-diagnostics.md). + Logs are stored in `${specialfolder:folder=CommonApplicationData}\SharpFM` and rotate after thirty days. ## Similar Tools diff --git a/docs/filemaker-clipboard-diagnostics.md b/docs/filemaker-clipboard-diagnostics.md new file mode 100644 index 00000000..2ab31502 --- /dev/null +++ b/docs/filemaker-clipboard-diagnostics.md @@ -0,0 +1,69 @@ +# FileMaker clipboard diagnostic snapshots + +Use **Tools > FileMaker Clipboard Diagnostics…** when copying between FileMaker and SharpFM fails, or when maintainers need a real clipboard payload from your platform. The helper works independently of normal FileMaker decoding: it does not strip a length prefix or modify the clipboard. + +## Capture and share + +1. In FileMaker, copy a **small, non-sensitive example**: a script, script steps, table, fields, or layout. +2. In SharpFM, open **Tools > FileMaker Clipboard Diagnostics…**. +3. Optionally enter your FileMaker version and describe what you copied. These fields are saved in the snapshot. +4. Choose **Capture FileMaker Clipboard**. Review the discovered formats, retrieval outcomes, and interpretation summaries. Select an entry to see its details. +5. Choose **Save Snapshot…** and select a local ZIP destination. Saving exports the retained capture, not a fresh clipboard read. +6. Attach the ZIP to the issue only if its contents are safe to share. Include the copy/paste direction, what happened, and what you expected. + +A partial capture is still useful. Save it even if some formats return null, are unsupported, or fail to read. The ZIP includes successful payloads and failure metadata. A capture with no payloads can still explain a discovery/access failure. + +For a two-direction investigation, save separate snapshots immediately after copying in FileMaker and after using SharpFM's normal **Copy to FileMaker** command. Describe which is which. The diagnostic helper does not replay or write custom clipboard payloads itself. + +## Privacy + +**Snapshots contain full clipboard data, not just diagnostic metadata.** They capture all formats Avalonia exposes from that copy, including accompanying text and unfamiliar identifiers. They may contain proprietary scripts, credentials, personal information, or other sensitive content. The optional description, XML root/type observations, and leading-byte previews may also contain private information. + +- Use a newly created, non-sensitive FileMaker example where possible. +- Capturing does not upload anything. Saving writes a local ZIP; attaching it to an issue is your choice. +- Payloads are not written to application logs or persisted automatically. +- The ZIP is not encrypted. Protect it like the original data. On Unix systems, newly exported archives are created with owner-only read/write permissions. +- Inspect `manifest.json` and the files under `payloads/` locally if necessary. Summaries are not a redacted substitute for reviewing the full capture. +- Closing the helper discards its in-memory capture. Delete exported snapshots when no longer needed. + +## What is captured + +The manifest records: + +- Schema version, UTC capture time, SharpFM build version, OS version, process architecture, and Avalonia version. +- Optional FileMaker version and description. +- Every format identifier exposed by the transfer and its exposed representation/kind. +- Per-item/per-format retrieval outcomes, returned runtime types, sizes, sanitized errors, payload references, and SHA-256 checksums. +- Independent interpretation results and explicit capture limitations. + +Every successfully retained byte array is exported **exactly as returned by Avalonia**. Supported text values are encoded as strict UTF-8 without a BOM and explicitly labeled as text representations, not original wire bytes. There is no XML formatting, prefix removal, newline normalization, arbitrary object serialization, or dereferencing of clipboard file objects. + +One transfer is held open while its items are read, then disposed. This is a best-effort capture through Avalonia, **not a complete native macOS pasteboard/Windows clipboard dump**. Avalonia may expose converted representations or omit native formats. Atomicity and clipboard-change detection are not guaranteed. Unknown object representations are reported by type but not persisted. + +The retention budget is 128 MiB per capture. Entries exceeding the remaining budget are marked `ResourceLimit`, with their observed size and no payload file; they are not silently truncated. Smaller subsequent entries can still be retained. The platform API may allocate data before the helper can check its size, so use small examples. + +## Interpretation summaries + +For retained payloads, the helper reports: + +- Up to 32 leading bytes as hex and a BOM observation. +- The first four bytes as an unsigned little-endian length and whether that matches the remaining byte count. +- Strict UTF-8 decoding and XML well-formedness probes starting at offsets **0** and **4**, with BOM observations at each offset. +- XML root name and `fmxmlsnippet` type if parsing succeeds. Long values are abbreviated only in the summary. + +These are hypotheses, not a platform-specific decoding decision. XML probes are limited to 2 MiB each; larger probes are explicitly skipped while retained payloads remain complete. DTDs and external XML resolution are disabled. Errors omit exception messages to avoid embedding clipboard contents or local paths. + +For issue [#186](https://github.com/fuzzzerd/SharpFM/issues/186), a matching length plus valid XML at offset 4 supports the prefixed-payload hypothesis. Valid XML at offset 0 supports the unprefixed hypothesis. Neither replaces checking the real bytes and testing both copy/paste directions in FileMaker. + +## Archive layout (schema version 1) + +```text +manifest.json +payloads/0000.bin +payloads/0001.txt +... +``` + +`manifest.json` uses camel-case property names and string retrieval outcomes. Each entry includes its zero-based `itemIndex`, `format`, `outcome`, and, when retained, `payloadFile`, `payloadRepresentation`, `byteLength`, and `sha256`. Payload filenames are generated independently of format identifiers. Missing or unsupported values have no payload file. `isPartial`, `issues`, `captureBoundary`, and `limits` describe omissions and collection constraints. + +No ZIP import or special viewer is required: maintainers can read the JSON and use a `.bin` file directly as a regression fixture. Export writes a temporary archive beside the destination and replaces the destination only after the archive is complete. If saving fails, the in-memory capture remains available for retry. diff --git a/docs/plans/186-filemaker-clipboard-diagnostics.md b/docs/plans/186-filemaker-clipboard-diagnostics.md new file mode 100644 index 00000000..4d0a5dc3 --- /dev/null +++ b/docs/plans/186-filemaker-clipboard-diagnostics.md @@ -0,0 +1,160 @@ +# FileMaker clipboard diagnostic snapshots + +Status: implemented; real FileMaker platform validation/captures pending +Related issue: [#186](https://github.com/fuzzzerd/SharpFM/issues/186) + +## Goal + +Let a FileMaker user on Windows, macOS, or Linux capture clipboard evidence and save a snapshot they can attach to an issue. Preserve the original payloads independently of SharpFM's normal decoding assumptions, so maintainers can diagnose format discovery, data retrieval, encoding, framing, and round-trip failures. + +This is a FileMaker support tool, not a general-purpose clipboard editor or a collection of hex/text/XML viewers. + +## Current limitation + +In v2.0.0, **Tools → Raw Clipboard Viewer**: + +- Filters identifiers to `Mac-XM*` and reads only the first matching format. +- Uses the normal clipboard service, which requests a byte representation. +- Calls `Clip.FromWireBytes`, which unconditionally skips four bytes. +- Shows decoded XML and a byte count, but cannot export untouched bytes. + +The viewer therefore shares the assumption investigated by #186. It cannot provide an independent payload fixture. + +Relevant implementation: + +- `src/SharpFM/Diagnostics/RawClipboardWindow.axaml.cs` +- `src/SharpFM/Diagnostics/RawClipboardWindow.axaml` +- `src/SharpFM/Services/ClipboardService.cs` +- `src/SharpFM.Model/Clip.cs` + +## Scope + +Expand the existing helper into **FileMaker Clipboard Diagnostics** with two primary actions: + +1. **Capture FileMaker Clipboard** — read the clipboard and retain an in-memory snapshot. +2. **Save Snapshot…** — export that captured snapshot without rereading the clipboard. + +Keep the UI to a capture summary, a simple format/result list, interpretation summaries, and export status. No dedicated payload viewers are required. + +Do not change normal FileMaker import/export behavior or introduce a platform-specific prefix fix in this work. + +## Capture model + +Separate capture, interpretation, and export from the window and from `Clip.FromWireBytes`. + +### Environment + +Record: + +- Snapshot schema version and UTC capture time. +- SharpFM version/build, OS version, process architecture, and Avalonia version. +- Optional user-entered FileMaker version and description of what was copied. +- Capture source explicitly identified as Avalonia's clipboard API. + +Do not collect usernames, machine names, unrelated filesystem paths, or other unnecessary identifying details. + +### Formats and payloads + +- Enumerate every format exposed by Avalonia. Do not filter discovery by `Mac-XM*`; different platform identifiers may be part of the failure. +- Attempt to capture the available payloads from that FileMaker copy, preserving every successfully retrieved byte payload in full. +- Where the API exposes a supported non-byte representation, record its representation/type explicitly; preserve text separately without calling it original wire bytes. +- For each entry, record its identifier, requested/exposed representation where available, retrieval outcome, returned runtime type, payload length, and sanitized error details. +- Keep null, unsupported, and failed retrievals visible. One failing format must not discard successful captures. +- Never strip a prefix, pretty-print XML, normalize line endings, or re-encode a byte payload before storing it. +- Prefer one clipboard transfer/read session per capture, retaining it until retrieval completes. Do not use a fresh clipboard read for each format as the existing service does. +- A capture is best-effort, not a guarantee of an atomic native pasteboard dump. Record limitations and any detected clipboard change. + +Audit Avalonia's format/retrieval APIs before implementing the collector. The current `GetDataAsync(string)` forces a byte-format request and is insufficient to describe every exposed representation. Keep any new diagnostic access separate from normal paste behavior. + +The snapshot preserves what Avalonia returns. Do not claim it includes native formats or bytes that Avalonia cannot expose. Native platform collectors are a possible follow-up only if real captures demonstrate that gap. + +## Non-destructive interpretations + +Run interpretations against stored payloads, never against the live clipboard. Retain results separately from original bytes; interpretation failures must not prevent export. + +For plausible FileMaker byte payloads, summarize: + +- Total byte length and a limited leading-byte hex preview. +- First four bytes interpreted as an unsigned little-endian length, when present. +- Whether that value matches the remaining payload length. +- Strict UTF-8 decoding and XML well-formedness results at offsets **0** and **4**. +- XML root name and snippet type, where parsing succeeds. +- Encoding/BOM observations and concise errors where relevant. + +Label offset-0 and offset-4 results as hypotheses, not a detected platform rule. Do not route these probes through `Clip.FromWireBytes` or require domain-model parsing. + +Bound interpretation work for large payloads, disable XML external resolution/DTDs, and report skipped probes explicitly. Limits on interpretation must not silently truncate exported raw payloads. + +## Shareable snapshot format + +Export a ZIP containing: + +- `manifest.json`: versioned environment, format/retrieval metadata, interpretation results, and payload references. +- `payloads/`: exact captured byte payloads and explicitly identified supported non-byte representations. + +Use generated filenames rather than clipboard identifiers as paths. Record byte lengths and SHA-256 checksums for binary payloads. Include a brief description in the manifest of the capture boundary and any incomplete entries. + +A successful export means the saved payloads match the in-memory capture. If export fails, show the error, retain the snapshot for retry, and avoid leaving a misleading completed archive. + +The archive must be useful without opening it in SharpFM: maintainers should be able to inspect the JSON and use a binary payload directly as a regression fixture. Importing snapshots into the app is not required initially. + +## User experience and privacy + +- Explain the workflow: copy a small, non-sensitive example in FileMaker, switch to SharpFM, capture, then save and attach the ZIP to an issue. +- Show capture time, all discovered identifiers, retrieval outcomes, sizes, interpretation summaries, and whether capture was partial. +- Disable Save until a snapshot exists. Export the retained snapshot even if the live clipboard has subsequently changed. +- Warn that the archive contains full clipboard contents, including accompanying text formats, and may contain proprietary scripts, credentials, or personal data. +- Never upload automatically or log payload contents. Do not present a hex preview or summary as automatically safe to share. +- Capture, interpretation, and export must not modify the clipboard. Avoid retaining the existing **Copy XML** action in this capture-focused workflow. +- Keep the UI responsive during capture/export. If a resource limit prevents complete capture, report that entry as incomplete rather than silently exporting truncated bytes. + +## Validation + +Unit-test capture orchestration using a fake clipboard source, with interpretation and export tests independent of Avalonia windows: + +- Multiple identifiers, including unfamiliar/non-`Mac-XM` identifiers. +- Null, unsupported, non-byte, and throwing retrievals alongside successful entries. +- One capture session and export without a clipboard reread. +- Prefixed XML, unprefixed XML, mismatched length headers, empty/short payloads, invalid UTF-8, and malformed XML. +- Safe XML parsing and explicitly skipped oversized interpretations. +- Exact binary preservation, checksums, generated safe filenames, schema versioning, and partial capture metadata. +- Export failure/retry and large-payload behavior without silent truncation. + +Manually validate against real FileMaker copies on Windows and macOS, recording FileMaker versions. Validate the Linux capture/export path where an appropriate clipboard source is available; do not imply FileMaker runs natively there. + +## Delivery order + +1. Define the snapshot schema and diagnostic collector boundary. +2. Implement untouched capture, independent interpretations, and archive export with tests. +3. Replace the current viewer UI with the compact capture/save workflow. +4. Document capture instructions and the privacy warning in user-facing documentation. +5. Collect real Mac and Windows snapshots for #186, then use the evidence to plan any wire-format correction and regression fixtures separately. + +## Implementation and validation + +- Implemented on `fuzzz/186-filemaker-clipboard-diagnostics`, based on latest `origin/master` at `f09da3e`. +- Replaced the existing helper with a compact capture/save workflow; normal clipboard import/export is unchanged. +- Added a version-1 JSON/ZIP snapshot format, per-item/per-format collection through one Avalonia transfer, exact byte preservation, strict UTF-8 text export, independent interpretations, and atomic local-file export. +- Retention is limited to 128 MiB per capture, with explicit omitted-entry outcomes; XML probes are limited to 2 MiB without truncating retained payloads. +- Added [user instructions, archive details, limitations, and privacy guidance](../filemaker-clipboard-diagnostics.md). +- Added 31 automated tests under `tests/SharpFM.Tests/Diagnostics/` covering capture, interpretations, summaries, and export. +- `dotnet build SharpFM.sln --no-restore` passes (existing unrelated warnings remain). +- `dotnet test SharpFM.sln --no-restore --disable-build-servers -m:1` passes: 2,374 tests across both test projects. A default parallel invocation encountered a transient CLR host abort; two subsequent isolated full-suite runs passed. +- `dotnet format whitespace SharpFM.sln --no-restore --verify-no-changes --include src/SharpFM/Diagnostics tests/SharpFM.Tests/Diagnostics` passes. +- Real FileMaker captures and native UI smoke tests on macOS/Windows are still required; no prefix correction has been inferred or implemented. + +### Remaining platform validation + +1. On Windows and macOS, copy a small non-sensitive FileMaker example, capture it, save the ZIP, and verify the listed formats and exported byte lengths/checksums. +2. Repeat after copying from SharpFM; label the two archives by direction and test actual pasting into FileMaker separately. +3. Confirm partial failures remain exportable and saving does not replace the live clipboard. +4. On Linux, validate native clipboard capture/save with an appropriate source, without implying FileMaker runs natively there. +5. Attach safe real captures to #186 and derive framing changes/regression fixtures from that evidence. + +## Deferred + +- Dedicated hex, text, or XML viewers. +- Snapshot comparison UI or in-app snapshot import. +- Arbitrary clipboard editing or payload replay. +- Native platform collectors without demonstrated need. +- Any automatic prefix correction or platform-specific import/export change. diff --git a/src/SharpFM/Diagnostics/ClipboardPayloadInterpreter.cs b/src/SharpFM/Diagnostics/ClipboardPayloadInterpreter.cs new file mode 100644 index 00000000..87028950 --- /dev/null +++ b/src/SharpFM/Diagnostics/ClipboardPayloadInterpreter.cs @@ -0,0 +1,97 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Xml; + +namespace SharpFM.Diagnostics; + +internal sealed record ClipboardXmlProbe( + int Offset, string Utf8Status, string XmlStatus, + string? RootName = null, string? SnippetType = null, string? Detail = null, string? Bom = null); + +internal sealed record ClipboardPayloadInterpretation( + string LeadingHex, string Bom, uint? LittleEndianLength, + bool? LengthMatchesRemainingBytes, IReadOnlyList XmlHypotheses); + +/// Independent, non-destructive probes. Never invokes the normal clip decoder. +internal static class ClipboardPayloadInterpreter +{ + public const int MaxProbeBytes = 2 * 1024 * 1024; + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + public static ClipboardPayloadInterpretation Interpret(byte[] bytes) + { + uint? length = bytes.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(bytes) : null; + return new ClipboardPayloadInterpretation( + Convert.ToHexString(bytes.AsSpan(0, Math.Min(32, bytes.Length))), + Bom(bytes), length, length.HasValue ? length.Value == (long)bytes.Length - 4 : null, + [Probe(bytes, 0), Probe(bytes, 4)]); + } + + private static ClipboardXmlProbe Probe(byte[] bytes, int offset) => + ProbeXml(bytes, offset) with { Bom = offset <= bytes.Length ? Bom(bytes.AsSpan(offset)) : null }; + + private static ClipboardXmlProbe ProbeXml(byte[] bytes, int offset) + { + if (offset > bytes.Length) + return new(offset, "Unavailable", "NotAttempted", Detail: "Offset exceeds payload length."); + if (bytes.Length - offset > MaxProbeBytes) + return new(offset, "Skipped", "NotAttempted", Detail: $"Probe exceeds {MaxProbeBytes} bytes; full payload retained."); + + string text; + try + { + text = StrictUtf8.GetString(bytes, offset, bytes.Length - offset); + } + catch (DecoderFallbackException) + { + return new(offset, "Invalid", "NotAttempted", Detail: "Not strict UTF-8."); + } + + try + { + // A byte-order mark is an encoding marker, not XML content. Only the + // probe accepts it; the captured bytes remain untouched. + using var input = new StringReader(text.StartsWith('\uFEFF') ? text[1..] : text); + using var reader = XmlReader.Create(input, new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + MaxCharactersInDocument = MaxProbeBytes, + ConformanceLevel = ConformanceLevel.Document + }); + string? root = null; + string? snippetType = null; + while (reader.Read()) + { + if (reader.NodeType == XmlNodeType.Element && reader.Depth == 0) + { + root = Abbreviate(reader.Name); + if (reader.LocalName == "fmxmlsnippet") snippetType = Abbreviate(reader.GetAttribute("type")); + } + } + return root is null + ? new(offset, "Valid", "Invalid", Detail: "XML has no root element.") + : new(offset, "Valid", "Valid", root, snippetType); + } + catch (XmlException ex) + { + return new(offset, "Valid", "Invalid", Detail: $"XML rejected at line {ex.LineNumber}, position {ex.LinePosition}; DTDs are prohibited."); + } + } + + private static string? Abbreviate(string? value) => + value?.Length > 200 ? value[..200] + "… (summary truncated; full payload retained)" : value; + + private static string Bom(ReadOnlySpan bytes) + { + if (bytes.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) return "UTF-8"; + if (bytes.StartsWith(new byte[] { 0xFF, 0xFE, 0x00, 0x00 })) return "UTF-32 LE"; + if (bytes.StartsWith(new byte[] { 0x00, 0x00, 0xFE, 0xFF })) return "UTF-32 BE"; + if (bytes.StartsWith(new byte[] { 0xFF, 0xFE })) return "UTF-16 LE"; + if (bytes.StartsWith(new byte[] { 0xFE, 0xFF })) return "UTF-16 BE"; + return "None"; + } +} diff --git a/src/SharpFM/Diagnostics/ClipboardSnapshot.cs b/src/SharpFM/Diagnostics/ClipboardSnapshot.cs new file mode 100644 index 00000000..ef73abd6 --- /dev/null +++ b/src/SharpFM/Diagnostics/ClipboardSnapshot.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text.Json.Serialization; +using Avalonia; + +namespace SharpFM.Diagnostics; + +internal sealed record ClipboardCaptureEnvironment( + string SharpFMVersion, string OSVersion, string ProcessArchitecture, string AvaloniaVersion) +{ + public static ClipboardCaptureEnvironment Current() => new( + VersionOf(typeof(ClipboardSnapshot).Assembly), + RuntimeInformation.OSDescription, + RuntimeInformation.ProcessArchitecture.ToString(), + VersionOf(typeof(Application).Assembly)); + + private static string VersionOf(Assembly assembly) => + assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() ?? "unknown"; +} + +internal sealed record ClipboardFormatInfo(string Identifier, string Kind, string ExposedRepresentation); + +internal enum ClipboardCaptureOutcome { Captured, Null, Unsupported, Failed, ResourceLimit } + +internal sealed record ClipboardSnapshotEntry( + int ItemIndex, + ClipboardFormatInfo Format, + ClipboardCaptureOutcome Outcome, + string? RuntimeType = null, + long? ByteLength = null, + string? PayloadFile = null, + string? PayloadRepresentation = null, + string? Sha256 = null, + string? Error = null, + ClipboardPayloadInterpretation? Interpretation = null, + [property: JsonIgnore] byte[]? Data = null); + +internal sealed record ClipboardCaptureLimits(long MaxRetainedPayloadBytes, int MaxXmlProbeBytes); + +internal sealed record ClipboardSnapshot( + int SchemaVersion, + DateTimeOffset CapturedAtUtc, + ClipboardCaptureEnvironment Environment, + string? FileMakerVersion, + string? Description, + string CaptureBoundary, + ClipboardCaptureLimits Limits, + IReadOnlyList Formats, + IReadOnlyList Entries, + IReadOnlyList Issues) +{ + public bool IsPartial => Issues.Count > 0 || Entries.Any(e => e.Outcome != ClipboardCaptureOutcome.Captured || e.Error is not null); + + public const string Boundary = + "Best-effort capture through Avalonia's clipboard API, not a native pasteboard dump. " + + "Only representations exposed by Avalonia are available; bytes are preserved as returned by Avalonia. " + + "One transfer is used, but atomicity and clipboard-change detection are not guaranteed. " + + "Text payloads are exported as strict UTF-8 without a BOM, not as original wire bytes. " + + "Unsupported object values are described by type only. Errors omit messages to avoid leaking paths or contents. " + + "The retained-payload budget cannot bound allocations performed inside the platform API during retrieval."; +} + +internal static class ClipboardDiagnosticError +{ + public static string Describe(Exception exception) => + $"{exception.GetType().FullName} (0x{exception.HResult:X8}); message omitted for privacy."; +} diff --git a/src/SharpFM/Diagnostics/ClipboardSnapshotCapture.cs b/src/SharpFM/Diagnostics/ClipboardSnapshotCapture.cs new file mode 100644 index 00000000..bb7a2a4b --- /dev/null +++ b/src/SharpFM/Diagnostics/ClipboardSnapshotCapture.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Avalonia.Input; + +namespace SharpFM.Diagnostics; + +/// Reads one transfer, preserving each item/format without FileMaker decoding. +internal sealed class ClipboardSnapshotCapture(Func> openTransfer, + long maxCaptureBytes = 128L * 1024 * 1024) +{ + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + public async Task CaptureAsync( + ClipboardCaptureEnvironment environment, string? fileMakerVersion = null, string? description = null) + { + var capturedAt = DateTimeOffset.UtcNow; + var formats = new List(); + var entries = new List(); + var issues = new List(); + long retainedBytes = 0; + + try + { + using var transfer = await openTransfer(); + if (transfer is null) + { + issues.Add("No clipboard transfer was available."); + } + else + { + try { formats.AddRange(transfer.Formats.Select(Describe)); } + catch (Exception ex) + { + issues.Add($"Transfer format discovery: {ClipboardDiagnosticError.Describe(ex)}"); + } + var items = transfer.Items; + for (var itemIndex = 0; itemIndex < items.Count; itemIndex++) + { + IAsyncDataTransferItem item; + DataFormat[] itemFormats; + try + { + item = items[itemIndex]; + itemFormats = item.Formats.ToArray(); + } + catch (Exception ex) + { + issues.Add($"Item {itemIndex} format discovery: {ClipboardDiagnosticError.Describe(ex)}"); + continue; + } + + foreach (var format in itemFormats) + { + // Retain identifiers present on items even if absent from the transfer's union. + var info = Describe(format); + if (!formats.Contains(info)) formats.Add(info); + var entry = new ClipboardSnapshotEntry(itemIndex, info, ClipboardCaptureOutcome.Failed); + try + { + // Do not reconstruct the format as a byte request: that can hide + // representation differences and platform-specific identifiers. + var value = await item.TryGetRawAsync(format); + entry = entry with { RuntimeType = value?.GetType().FullName }; + if (value is null) + { + entry = entry with { Outcome = ClipboardCaptureOutcome.Null }; + } + else if (value is byte[] || value is string) + { + var size = value is byte[] raw ? raw.LongLength + : await Task.Run(() => (long)StrictUtf8.GetByteCount((string)value)); + entry = entry with { ByteLength = size }; + if (size > maxCaptureBytes - retainedBytes) + { + entry = entry with + { + Outcome = ClipboardCaptureOutcome.ResourceLimit, + Error = $"Capture budget of {maxCaptureBytes} bytes exceeded; this payload was not retained or truncated." + }; + } + else + { + var isText = value is string; + var payloadFile = $"payloads/{entries.Count:D4}.{(isText ? "txt" : "bin")}"; + // Copy before disposing the transfer. CPU-heavy work is off the UI thread. + entry = await Task.Run(() => + { + var data = value is byte[] bytes ? (byte[])bytes.Clone() : StrictUtf8.GetBytes((string)value); + var captured = entry with + { + Outcome = ClipboardCaptureOutcome.Captured, + Data = data, + PayloadFile = payloadFile, + PayloadRepresentation = isText ? "Text encoded as UTF-8 (not wire bytes)" : "Bytes returned by Avalonia", + Sha256 = Convert.ToHexString(SHA256.HashData(data)) + }; + try + { + return captured with { Interpretation = ClipboardPayloadInterpreter.Interpret(data) }; + } + catch (Exception ex) + { + // Interpretation must never prevent preserving the original payload. + return captured with { Error = $"Interpretation failed: {ClipboardDiagnosticError.Describe(ex)}" }; + } + }); + retainedBytes += size; + } + } + else + { + entry = entry with + { + Outcome = ClipboardCaptureOutcome.Unsupported, + Error = "Only byte arrays and text are persisted; no arbitrary object serialization or file access was attempted." + }; + } + } + catch (Exception ex) + { + entry = entry with { Outcome = ClipboardCaptureOutcome.Failed, Error = ClipboardDiagnosticError.Describe(ex) }; + } + entries.Add(entry); + await Task.Yield(); + } + } + } + } + catch (Exception ex) + { + issues.Add($"Clipboard access/disposal: {ClipboardDiagnosticError.Describe(ex)}"); + } + + if (formats.Any(f => !entries.Any(e => e.Format == f))) + issues.Add("One or more advertised formats had no accessible item; see the discovered format list."); + + return new ClipboardSnapshot(1, capturedAt, environment, EmptyToNull(fileMakerVersion), EmptyToNull(description), + ClipboardSnapshot.Boundary, new ClipboardCaptureLimits(maxCaptureBytes, ClipboardPayloadInterpreter.MaxProbeBytes), + formats.ToArray(), entries.ToArray(), issues.ToArray()); + } + + private static string? EmptyToNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static ClipboardFormatInfo Describe(DataFormat format) => new( + format.Identifier, format.Kind.ToString(), + format.GetType().GenericTypeArguments.FirstOrDefault()?.FullName ?? "Unknown"); +} diff --git a/src/SharpFM/Diagnostics/ClipboardSnapshotExporter.cs b/src/SharpFM/Diagnostics/ClipboardSnapshotExporter.cs new file mode 100644 index 00000000..5f35bf9b --- /dev/null +++ b/src/SharpFM/Diagnostics/ClipboardSnapshotExporter.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace SharpFM.Diagnostics; + +internal static class ClipboardSnapshotExporter +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + + /// Write alongside the destination, then publish only a completed ZIP. + public static async Task SaveAsync(ClipboardSnapshot snapshot, string destination) + { + var fullPath = Path.GetFullPath(destination); + var temporary = Path.Combine(Path.GetDirectoryName(fullPath)!, $".sharpfm-capture-{Guid.NewGuid():N}.tmp"); + try + { + var options = new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + BufferSize = 81920, + Options = FileOptions.Asynchronous + }; + if (!OperatingSystem.IsWindows()) + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + await using (var output = new FileStream(temporary, options)) + { + using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var entry in snapshot.Entries) + { + if (entry.Data is null || entry.PayloadFile is null) continue; + var payload = archive.CreateEntry(entry.PayloadFile, CompressionLevel.Fastest); + await using var stream = payload.Open(); + await stream.WriteAsync(entry.Data); + } + var manifest = archive.CreateEntry("manifest.json", CompressionLevel.Fastest); + await using var manifestStream = manifest.Open(); + await JsonSerializer.SerializeAsync(manifestStream, snapshot, JsonOptions); + } + await output.FlushAsync(); + } + File.Move(temporary, fullPath, overwrite: true); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } +} diff --git a/src/SharpFM/Diagnostics/ClipboardSnapshotSummary.cs b/src/SharpFM/Diagnostics/ClipboardSnapshotSummary.cs new file mode 100644 index 00000000..86a21893 --- /dev/null +++ b/src/SharpFM/Diagnostics/ClipboardSnapshotSummary.cs @@ -0,0 +1,48 @@ +using System.Linq; +using System.Text; + +namespace SharpFM.Diagnostics; + +internal static class ClipboardSnapshotSummary +{ + public static string Describe(ClipboardSnapshot snapshot) + { + var captured = snapshot.Entries.Count(e => e.Outcome == ClipboardCaptureOutcome.Captured); + var summary = new StringBuilder() + .AppendLine($"Captured {snapshot.CapturedAtUtc:u} — {(snapshot.IsPartial ? "partial capture" : "capture complete through Avalonia")}") + .AppendLine($"{snapshot.Formats.Count} formats; {captured}/{snapshot.Entries.Count} item/format payloads retained.") + .AppendLine("All formats exposed by Avalonia (including accompanying text) are included; not a native pasteboard dump."); + foreach (var issue in snapshot.Issues) summary.AppendLine(issue); + foreach (var format in snapshot.Formats) + summary.AppendLine($"• {format.Identifier} [{format.Kind}; {format.ExposedRepresentation}]"); + return summary.ToString(); + } + + public static string Label(ClipboardSnapshotEntry entry) => + $"Item {entry.ItemIndex} · {entry.Format.Identifier} · {entry.Outcome} · {entry.ByteLength?.ToString() ?? "?"} bytes"; + + public static string Describe(ClipboardSnapshotEntry entry) + { + var summary = new StringBuilder() + .AppendLine(Label(entry)) + .AppendLine($"Exposed representation: {entry.Format.ExposedRepresentation}") + .AppendLine($"Returned type: {entry.RuntimeType ?? "(none)"}"); + if (entry.PayloadRepresentation is not null) summary.AppendLine(entry.PayloadRepresentation); + if (entry.Error is not null) summary.AppendLine(entry.Error); + if (entry.Interpretation is { } interpretation) + { + summary.AppendLine($"Leading bytes (hex): {interpretation.LeadingHex}") + .AppendLine($"BOM at offset 0: {interpretation.Bom}") + .AppendLine($"First four bytes as uint32 LE: {interpretation.LittleEndianLength?.ToString() ?? "unavailable"}") + .AppendLine($"Matches remaining byte count: {interpretation.LengthMatchesRemainingBytes?.ToString() ?? "unavailable"}") + .AppendLine("Independent hypotheses, not detected platform rules:"); + foreach (var probe in interpretation.XmlHypotheses) + { + summary.AppendLine($" Offset {probe.Offset}: UTF-8 {probe.Utf8Status}; XML {probe.XmlStatus}; BOM {probe.Bom ?? "unavailable"}."); + if (probe.RootName is not null) summary.AppendLine($" Root: {probe.RootName}; snippet type: {probe.SnippetType ?? "(absent)"}"); + if (probe.Detail is not null) summary.AppendLine($" {probe.Detail}"); + } + } + return summary.ToString(); + } +} diff --git a/src/SharpFM/Diagnostics/RawClipboardWindow.axaml b/src/SharpFM/Diagnostics/RawClipboardWindow.axaml index 46d68f5e..5dfff6c9 100644 --- a/src/SharpFM/Diagnostics/RawClipboardWindow.axaml +++ b/src/SharpFM/Diagnostics/RawClipboardWindow.axaml @@ -1,42 +1,44 @@ - - - -