eleven / messenger Public

native: performance instrumentation — signposts, hang watchdog, MetricKit (consent-gated) (#1195)

* native: performance instrumentation — signposts, hang watchdog, MetricKit (consent-gated)

The APM answer to "lots of little cuts": a shared Perf layer in LChatCore
(both apps, never the extensions) — os_signpost intervals at the hot seams
(API, page decrypt, thread-open, socket connect, image load), a main-thread
hang watchdog (ClickLab's ticker generalised), and a MetricKit subscriber
filing daily payloads + hang call trees. Slow moments land in a local JSONL
ledger, exportable from Settings as one self-contained report. Debug builds
record by default; Release only behind the new consent toggle (iOS server
settings + Mac Settings > Advanced). Nothing recorded carries content,
names, or keys, and nothing leaves the device. docs/native-perf.md has the
map + the follow-up plan (opt-in webhook-feed streaming).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* native: review fixes — unique MetricKit filenames, socket span on stop, live summary refresh

Batched MetricKit payloads arrive within one second and were overwriting
each other's filed JSON (the ISO stamp alone named the file) — a random
suffix keeps every payload. Also: stop() closes a mid-handshake connect
span unrecorded, the iOS summary re-reads when the toggle flips
(.task(id:), matching the Mac), and setRecording documents its main-thread
contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e070569ef684fb2a2d1b9e0681db326d23c4fc63 parent ee7658c
16 files changed, +806 −1
  • docs/native-perf.md +87 −0
  • ios/LChat/LChatApp.swift +1 −0
  • ios/LChat/PushManager.swift +2 −0
  • ios/LChat/Views/ServerSettingsSheet.swift +44 −0
  • ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift +3 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +5 −0
  • ios/LChatCore/Sources/LChatCore/ChatSocket.swift +12 −0
  • ios/LChatCore/Sources/LChatCore/HangWatchdog.swift +40 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +5 −0
  • ios/LChatCore/Sources/LChatCore/MetricKitCollector.swift +117 −0
  • ios/LChatCore/Sources/LChatCore/Perf.swift +376 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +9 −1
  • ios/LChatCore/Tests/LChatCoreTests/PerfTests.swift +56 −0
  • ios/LChatMac/CLIToolSection.swift +1 −0
  • ios/LChatMac/LChatMacApp.swift +2 −0
  • ios/LChatMac/PerfSettingsSection.swift +46 −0
diff --git a/docs/native-perf.md b/docs/native-perf.md
new file mode 100644
index 0000000..e464e26
--- /dev/null
+++ b/docs/native-perf.md
@@ -0,0 +1,87 @@
+# 🤖 Native performance instrumentation (iOS + Mac)
+
+The answer to "the app has lots of little cuts": both native apps carry a
+small, always-there instrumentation layer (`ios/LChatCore/Sources/LChatCore/
+Perf.swift`) so a slow moment on any device leaves a record — without a
+third-party APM SDK, and without anything leaving the device. Streaming
+member telemetry to Datadog/Sentry/Firebase would sit badly with the E2EE
+posture (and add the kind of dependency the second rule exists to avoid), so
+the stack is Apple-native + self-owned end to end.
+
+## The three layers
+
+1. **Signposts (always on, every build).** `os_signpost` intervals around
+ the seams that hurt, via `Perf.begin("name", slowMs:)` → `span.end()`.
+ Cost is nanoseconds; nothing is stored or sent. Attach Instruments to any
+ build (subsystem = the bundle id, category `perf`) and the seams below
+ appear as named intervals; hangs and first-frame land in the
+ Points of Interest track.
+
+2. **The slow-event ledger (consent-gated).** A span that beats its budget,
+ a main-thread hang (`HangWatchdog` — ClickLab's 200ms ping generalised
+ into LChatCore for both apps), or a noteworthy event (socket reconnect)
+ appends one JSON line to `Library/Logs/perf-events.jsonl` in the app's
+ container. ~512KB cap with one `.prev` rotation. Events carry a fixed
+ name, a duration, and at most a URL path or row count — never content,
+ names, or keys.
+
+3. **MetricKit (consent-gated).** `MetricKitCollector` subscribes to the
+ OS's own APM: daily payloads (launch-time histograms, hang totals,
+ memory) and hang diagnostics **with call trees** — real-device stack
+ traces for hangs nobody was profiling. Raw payloads are filed under
+ `Library/Logs/perf-diagnostics/` (newest 24 kept) with a compact ledger
+ line each. The Mac delivers a subset (no hang diagnostics) — that's why
+ the watchdog exists on both platforms.
+
+## The gate
+
+- **Debug builds record by default** (opt out: the Settings toggle, or
+ `defaults write <bundle id> perfDiagnostics -bool NO`).
+- **Release builds record only with the consent toggle** — iOS: the server
+ settings cog → Performance; Mac: Settings → Advanced → Performance
+ (Paul's call, 2026-07-20). Signposts stay on either way.
+- The extensions (NSE, share) never call `Perf.install()`, so for them the
+ layer is inert.
+
+## Instrumented seams
+
+| Span | Where | Budget |
+|---|---|---|
+| `boot.firstFrame` | launch hook → RootView first appear (iOS) | recorded always |
+| `threads.hydrate` / `threads.refresh` | ThreadStore | 500ms / 2500ms |
+| `feed.openThread` | ChatFeedModel.loadHistory (open a chat → rendered) | 1500ms |
+| `feed.foldPage` | ChatFeedModel.foldPageDetached (200-row decrypt+fold) | 250ms |
+| `api.get` / `api.post` | LChatAPI choke points (detail = path, never query/token) | 1000ms |
+| `socket.connect` | ChatSocket connect → handshake (failed attempts unrecorded) | 3000ms |
+| `image.load` | BlobImageLoader fetch+decrypt+decode | 1000ms |
+| `main.hang` | HangWatchdog (>400ms main-thread wedge) | — |
+| `socket.reconnect` | note event, 30s same-name floor | — |
+
+Adding one is two lines (`Perf.begin`/`end`) — budgets are per-call-site, so
+pick the number at which you'd actually want a report.
+
+## Getting the data off a device
+
+- **Settings → Performance → Prepare performance report** (iOS shares the
+ file; the Mac reveals it in Finder): one self-contained
+ `performance-report.json` — device model, OS, app version, the ledger,
+ and the newest MetricKit payloads (2MB cap).
+- Dev pull without the UI:
+ `xcrun devicectl device copy from --device <id> --source Library/Logs/perf-events.jsonl --domain-type appDataContainer --domain-identifier com.elevenmessenger.messenger` (same pattern as `hitches.log`).
+- Also free, zero code: **Xcode Organizer** (Hangs / Launch / Terminations
+ from TestFlight + opted-in App Store users) and the App Store Connect
+ perf-power-metrics + diagnostic-signature APIs (the existing ASC key can
+ drive a periodic pull).
+
+## What this is not (yet)
+
+The report is the streaming unit by design (`PerfExport` is
+self-contained), but nothing streams anywhere today. The intended follow-up
+is an **opt-in** upload of that JSON to a webhook-ingest feed
+(`docs/webhooks.md`) so reports land as encrypted messages in a team
+thread — an Aikido-style stream we own, with the server still seeing only
+ciphertext. Third-party APM stays off the table.
+
+Related: `HitchLogger.swift` (iOS, DEBUG-only frame-drop breadcrumbs —
+still marked temporary) and `ClickLab.swift` (Mac click-eating rig) predate
+this layer and stay as-is; fold them in only when their bugs close.
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index aa85c13..a2d4744 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -360,6 +360,7 @@ struct RootView: View {
// the toolbar space button and the switcher show them without opening anything.
.task(id: Set(app.instances.map(\.id))) { await app.loadServerAvatars() }
.onAppear {
+ Perf.noteFirstFrame()
if nav.instanceID == nil {
// The space you were last in opens straight to its chats; a
// device that doesn't KNOW where you live (fresh sign-in,
diff --git a/ios/LChat/PushManager.swift b/ios/LChat/PushManager.swift
index a1ee0e9..3f43a39 100644
--- a/ios/LChat/PushManager.swift
+++ b/ios/LChat/PushManager.swift
@@ -45,6 +45,8 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs
UNUserNotificationCenter.current().delegate = self
// BGTaskScheduler requires registration before launch finishes.
BackgroundRefresh.register()
+ // Signposts + (consent-gated) perf recording — apps only, never the NSE.
+ Perf.install()
return true
}
diff --git a/ios/LChat/Views/ServerSettingsSheet.swift b/ios/LChat/Views/ServerSettingsSheet.swift
index 6e1859b..04de197 100644
--- a/ios/LChat/Views/ServerSettingsSheet.swift
+++ b/ios/LChat/Views/ServerSettingsSheet.swift
@@ -10,6 +10,11 @@ struct ServerSettingsSheet: View {
@Environment(\.dismiss) private var dismiss
@AppStorage("nativeTheme") private var themeName = "classic"
@AppStorage("formatAsYouType") private var formatAsYouType = true
+ // Perf recording consent (app-wide, like the theme): Debug builds default
+ // on, Release waits for this switch — see Perf.swift.
+ @AppStorage(Perf.consentKey) private var perfDiagnostics = Perf.defaultEnabled
+ @State private var perfSummary: String?
+ @State private var perfReportURL: URL?
@State private var name = ""
@State private var saving = false
@@ -90,6 +95,28 @@ struct ServerSettingsSheet: View {
// verbatim: the hint TEACHES the syntax — the markers must show.
Text(verbatim: "e.g. **bold**, *italic* (or _italic_) and ~strikethrough~")
}
+ Section {
+ Toggle("Record performance diagnostics", isOn: $perfDiagnostics)
+ .onChange(of: perfDiagnostics) { _, next in
+ Perf.setRecording(next)
+ if !next { perfReportURL = nil }
+ }
+ if perfDiagnostics {
+ if let url = perfReportURL {
+ ShareLink(item: url) {
+ Label("Share performance report", systemImage: "square.and.arrow.up")
+ }
+ } else {
+ // Assembled on demand — the report concatenates the
+ // ledger + MetricKit payloads, so build it when asked.
+ Button("Prepare performance report") { preparePerfReport() }
+ }
+ }
+ } header: {
+ Text("Performance")
+ } footer: {
+ Text(perfFooter)
+ }
Section {
Text("Notifications are managed in Settings ▸ Notifications ▸ Eleven.")
.font(.footnote)
@@ -138,6 +165,23 @@ struct ServerSettingsSheet: View {
let tiers = await store.membershipTiers()
hasPaidLevels = tiers.contains { $0.sellable || $0.subscribed }
}
+ .task(id: perfDiagnostics) {
+ guard perfDiagnostics else { return }
+ let events = await Task.detached { Perf.recordedEvents() }.value
+ perfSummary = PerfEvent.summary(events)
+ }
+ }
+
+ private var perfFooter: String {
+ var text = "Keeps a private log on this device of how long things take — starting the app, opening chats, moments it freezes. It never includes what's in your messages, and it never leaves this device unless you share the report yourself."
+ if let perfSummary { text += "\nRecorded so far: \(perfSummary)." }
+ return text
+ }
+
+ private func preparePerfReport() {
+ Task {
+ perfReportURL = await Task.detached { Perf.assembleExport() }.value
+ }
}
private func setAway(_ next: Bool) {
diff --git a/ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift b/ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift
index 2826d7f..0674f27 100644
--- a/ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift
+++ b/ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift
@@ -114,6 +114,7 @@ public final class BlobImageLoader {
if let task = inFlight[key] { return await task.value }
let session = self.session
+ let span = Perf.begin("image.load", slowMs: 1000)
// Detached so the fetch + AES-GCM open + bitmap decode stay off the
// main thread; only the cache bookkeeping below runs on the main actor.
let task = Task.detached { () -> PlatformImage? in
@@ -127,6 +128,8 @@ public final class BlobImageLoader {
}
inFlight[key] = task
let img = await task.value
+ // A nil image is a failure, not a slow load — don't record it.
+ span.end(record: img != nil)
inFlight.removeValue(forKey: key)
if let img { store(img, for: key) }
return img
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index b268918..bad154b 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -834,9 +834,12 @@ public final class ChatFeedModel: ObservableObject {
private func loadHistory(_ api: LChatAPI) async {
loadingHistory = true
+ // The perceived "open a chat" latency: store render + fetch + fold.
+ let span = Perf.begin("feed.openThread", slowMs: 1500)
defer {
loadingHistory = false
lastHistoryLoad = Date()
+ span.end("\(messages.count) messages")
}
// INSTANT open: the local store (decrypted once, at ingest) renders
// before the network answers; the fetch below then converges by
@@ -975,6 +978,7 @@ public final class ChatFeedModel: ObservableObject {
let myUserId = self.myUserId
let screenName = self.instance.screenName
let wantGroupApps = threadKind == "group"
+ let foldSpan = Perf.begin("feed.foldPage", slowMs: 250)
let (fresh, stored, appEvents, reactionRows): ([Message], [MessageStore.StoredMessage],
[(Int64, GroupApps.Event)], [MessagePayload]) =
await Task.detached(priority: .userInitiated) {
@@ -1012,6 +1016,7 @@ public final class ChatFeedModel: ObservableObject {
}
return (out, toStore, events, reacts)
}.value
+ foldSpan.end("\(rows.count) rows")
// Tamper-evidence (history): compare gossip from every message + reaction
// this page decrypted — the server can route forged messages via
// catch-up, so history must count too, not just live frames.
diff --git a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
index cce375f..766afb7 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
@@ -49,6 +49,9 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
// Bumped on every start()/stop(); callbacks from a previous generation's
// task (its error, its reconnect timer) see a stale value and stand down.
private var generation = 0
+ // The connect→handshake signpost, open between connect() and didOpen.
+ // Main-thread state, like everything else here (delegateQueue: .main).
+ private var connectSpan: Perf.Span?
// baseURL is the instance origin (https://host[:port]); the socket rides
// ws(s)://host/ws?token=…. The Origin header must be set explicitly — the
@@ -74,6 +77,8 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
public func stop() {
generation += 1
+ connectSpan?.end(record: false) // mid-handshake stop isn't a slow connect
+ connectSpan = nil
pingTimer?.invalidate()
pingTimer = nil
task?.cancel(with: .goingAway, reason: nil)
@@ -106,11 +111,17 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask,
didOpenWithProtocol protocol: String?) {
guard webSocketTask === task else { return }
+ connectSpan?.end()
+ connectSpan = nil
onStatus?("connected")
}
private func connect(gen: Int) {
guard gen == generation else { return }
+ // A dangling span from a failed attempt closes unrecorded — only a
+ // completed handshake is an honest connect duration.
+ connectSpan?.end(record: false)
+ connectSpan = Perf.begin("socket.connect", slowMs: 3000)
var req = URLRequest(url: url)
req.setValue(origin, forHTTPHeaderField: "Origin")
if session == nil {
@@ -148,6 +159,7 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
case .failure:
// Dropped (sleep, network blip, server restart) — reconnect and
// let the consumer refetch what it missed.
+ Perf.event("socket.reconnect")
self.onStatus?("reconnecting…")
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.connect(gen: gen)
diff --git a/ios/LChatCore/Sources/LChatCore/HangWatchdog.swift b/ios/LChatCore/Sources/LChatCore/HangWatchdog.swift
new file mode 100644
index 0000000..02820f1
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/HangWatchdog.swift
@@ -0,0 +1,40 @@
+import Foundation
+
+// 🤖 The main-thread hang watchdog — ClickLab's stall ticker (LChatMac),
+// generalised so both apps report hangs into the perf ledger. A background
+// timer pings the main queue every 200ms; any ping serviced late means main
+// was wedged, and the lag ≈ how long. Cheap enough to run for a whole
+// session: one async block per 200ms, no stack sampling (MetricKit's hang
+// diagnostics carry the call trees; this catches the sub-hang stutters —
+// 400ms–2s — that MetricKit's threshold ignores but fingers feel).
+final class HangWatchdog {
+ private var ticker: DispatchSourceTimer?
+ // A long hang queues several pings that all drain in one burst; the first
+ // serviced carries the full lag, the rest shrink toward zero. Report the
+ // first, swallow the burst. Main-thread state (pings land on main).
+ private var suppressUntil = DispatchTime.now()
+
+ func start() {
+ guard ticker == nil else { return }
+ let t = DispatchSource.makeTimerSource(queue: DispatchQueue(label: "lchat.perf.watchdog"))
+ t.schedule(deadline: .now() + 1, repeating: .milliseconds(200))
+ t.setEventHandler {
+ let sent = DispatchTime.now()
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+ let now = DispatchTime.now()
+ let lagMs = Double(now.uptimeNanoseconds &- sent.uptimeNanoseconds) / 1e6
+ guard lagMs > 400, now > self.suppressUntil else { return }
+ self.suppressUntil = now + .seconds(1)
+ Perf.recordHang(ms: lagMs)
+ }
+ }
+ t.resume()
+ ticker = t
+ }
+
+ func stop() {
+ ticker?.cancel()
+ ticker = nil
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index 6335cac..5e317fd 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -469,6 +469,9 @@ public struct LChatAPI {
) async throws -> T {
var comps = URLComponents(url: baseURL.appending(path: path), resolvingAgainstBaseURL: false)!
comps.queryItems = query ?? (authed ? [URLQueryItem(name: "token", value: token)] : nil)
+ // The path alone — never the query (it carries the token).
+ let span = Perf.begin("api.get", slowMs: 1000)
+ defer { span.end(path) }
let (data, _) = try await session.data(from: comps.url!)
return try JSONDecoder().decode(T.self, from: data)
}
@@ -743,6 +746,8 @@ public struct LChatAPI {
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = data
+ let span = Perf.begin("api.post", slowMs: 1000)
+ defer { span.end(path) }
let resp = try? await session.data(for: req)
return resp != nil
}
diff --git a/ios/LChatCore/Sources/LChatCore/MetricKitCollector.swift b/ios/LChatCore/Sources/LChatCore/MetricKitCollector.swift
new file mode 100644
index 0000000..73d4ae7
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/MetricKitCollector.swift
@@ -0,0 +1,117 @@
+import Foundation
+#if canImport(MetricKit)
+import MetricKit
+#endif
+
+// 🤖 MetricKit intake: the OS's own APM. Payloads arrive about once a day
+// (metrics: launch-time histograms, hang totals, memory peaks; diagnostics:
+// hang call trees — the gold for chasing "little cuts" on devices nobody is
+// profiling). Each arrival is filed verbatim as JSON under
+// Library/Logs/perf-diagnostics/ plus one compact ledger line, so the
+// Settings report carries both the summary and the raw material. Started and
+// stopped by Perf.setRecording — no consent, no collection.
+#if canImport(MetricKit)
+final class MetricKitCollector: NSObject, MXMetricManagerSubscriber {
+ private var started = false
+
+ func start() {
+ guard !started else { return }
+ started = true
+ MXMetricManager.shared.add(self)
+ }
+
+ func stop() {
+ guard started else { return }
+ started = false
+ MXMetricManager.shared.remove(self)
+ }
+
+ func didReceive(_ payloads: [MXMetricPayload]) {
+ for p in payloads {
+ file(json: p.jsonRepresentation(), prefix: "metrics")
+ Perf.record(kind: "metric", name: "metrickit.daily", ms: nil, detail: summarize(p))
+ }
+ }
+
+ func didReceive(_ payloads: [MXDiagnosticPayload]) {
+ for p in payloads {
+ file(json: p.jsonRepresentation(), prefix: "diagnostics")
+ let hangs = p.hangDiagnostics ?? []
+ guard !hangs.isEmpty else { continue }
+ let total = hangs.reduce(0.0) {
+ $0 + $1.hangDuration.converted(to: .milliseconds).value
+ }
+ Perf.record(kind: "diag", name: "metrickit.hangs", ms: total,
+ detail: "\(hangs.count) \(hangs.count == 1 ? "hang" : "hangs") with call trees")
+ }
+ }
+
+ /// "launch avg 812ms · hang 3.2s/day" — enough for the ledger to show
+ /// trend; the filed payload holds the full histograms.
+ private func summarize(_ p: MXMetricPayload) -> String {
+ var parts: [String] = []
+ if let avg = Self.histogramAverageMs(p.applicationLaunchMetrics?.histogrammedTimeToFirstDraw) {
+ parts.append("launch avg \(Int(avg))ms")
+ }
+ if let hang = Self.histogramTotalMs(p.applicationResponsivenessMetrics?.histogrammedApplicationHangTime) {
+ parts.append("hang total \((hang / 1000).formatted(.number.precision(.fractionLength(1))))s")
+ }
+ return parts.isEmpty ? "payload filed" : parts.joined(separator: " · ")
+ }
+
+ // MXHistogram walks: buckets carry (start, end, count); average uses the
+ // bucket midpoint, total sums midpoint×count — the standard reduction.
+ private static func histogramAverageMs(_ h: MXHistogram<UnitDuration>?) -> Double? {
+ var weighted = 0.0, count = 0.0
+ forEachBucket(h) { midMs, n in
+ weighted += midMs * n
+ count += n
+ }
+ return count > 0 ? weighted / count : nil
+ }
+
+ private static func histogramTotalMs(_ h: MXHistogram<UnitDuration>?) -> Double? {
+ var total = 0.0, any = false
+ forEachBucket(h) { midMs, n in
+ total += midMs * n
+ any = true
+ }
+ return any ? total : nil
+ }
+
+ private static func forEachBucket(_ h: MXHistogram<UnitDuration>?,
+ _ body: (_ midMs: Double, _ count: Double) -> Void) {
+ guard let h else { return }
+ let e = h.bucketEnumerator
+ while let b = e.nextObject() as? MXHistogramBucket<UnitDuration> {
+ let mid = (b.bucketStart.converted(to: .milliseconds).value
+ + b.bucketEnd.converted(to: .milliseconds).value) / 2
+ body(mid, Double(b.bucketCount))
+ }
+ }
+
+ /// One file per arrival, ISO-stamped so a directory sort is
+ /// chronological; keep the newest 24 (a few weeks of dailies). The stamp
+ /// alone isn't unique — a batch (first subscribe delivers the accumulated
+ /// backlog in one call) lands within a second — so a random suffix keeps
+ /// same-second payloads from overwriting each other.
+ private func file(json: Data, prefix: String) {
+ guard Perf.isRecording else { return }
+ let stamp = ISO8601DateFormatter().string(from: Date())
+ let unique = UUID().uuidString.prefix(8)
+ let url = Perf.diagnosticsDir.appendingPathComponent("\(stamp)-\(prefix)-\(unique).json")
+ try? json.write(to: url, options: .atomic)
+ let fm = FileManager.default
+ guard let names = try? fm.contentsOfDirectory(atPath: Perf.diagnosticsDir.path),
+ names.count > 24 else { return }
+ for name in names.sorted().dropLast(24) {
+ try? fm.removeItem(at: Perf.diagnosticsDir.appendingPathComponent(name))
+ }
+ }
+}
+#else
+final class MetricKitCollector: NSObject {
+ func start() {}
+ func stop() {}
+}
+#endif
diff --git a/ios/LChatCore/Sources/LChatCore/Perf.swift b/ios/LChatCore/Sources/LChatCore/Perf.swift
new file mode 100644
index 0000000..e73daf9
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/Perf.swift
@@ -0,0 +1,376 @@
+import Foundation
+import os
+
+// 🤖 Perf — the shared performance-instrumentation core for both native apps.
+// Three layers, all local to the device:
+//
+// 1. os_signpost intervals around the seams that hurt (API calls, page
+// decrypt, thread-open, socket connects). Always on: signposts cost
+// nanoseconds, never leave the device, and make any build inspectable
+// in Instruments (os_signpost track, subsystem = bundle id).
+// 2. A slow-event ledger: a span that overruns its budget, a main-thread
+// hang (HangWatchdog), or a noteworthy event (socket reconnect) appends
+// one JSON line to Library/Logs/perf-events.jsonl — so a device
+// accumulates its own "little cuts" record in the field, exportable
+// from Settings as a single report.
+// 3. MetricKit daily payloads + hang diagnostics (MetricKitCollector),
+// filed beside the ledger.
+//
+// Recording (layers 2–3) is gated: Debug builds record by default; Release
+// builds only when the Settings consent toggle is on. Nothing recorded ever
+// includes message content, names, or keys — a span carries a fixed name, a
+// duration, and at most a URL path or a row count. The apps call install()
+// at launch; the extensions (NSE/share) never do, so for them this whole
+// file is inert signpost emission.
+public enum Perf {
+ /// The consent/preference key ("perfDiagnostics"): unset means the build
+ /// default below; the Settings toggle writes it on both platforms.
+ public static let consentKey = "perfDiagnostics"
+
+ /// Debug daily-drivers record without being asked; Release waits for the
+ /// consent toggle — the deal Paul set for shipping APM at all.
+ public static var defaultEnabled: Bool {
+ #if DEBUG
+ return true
+ #else
+ return false
+ #endif
+ }
+
+ public static var enabledPreference: Bool {
+ (UserDefaults.standard.object(forKey: consentKey) as? Bool) ?? defaultEnabled
+ }
+
+ // MARK: - Lifecycle (apps only)
+
+ private static let lock = NSLock()
+ private static var installed = false
+ private static var recording = false
+ private static var installedAt = DispatchTime.now()
+ private static var watchdog: HangWatchdog?
+ private static var metrics: MetricKitCollector?
+
+ static var isRecording: Bool {
+ lock.lock(); defer { lock.unlock() }
+ return recording
+ }
+
+ /// Call once from each app's launch hook (never from an extension).
+ public static func install() {
+ lock.lock()
+ let already = installed
+ installed = true
+ installedAt = .now()
+ lock.unlock()
+ guard !already else { return }
+ setRecording(enabledPreference)
+ }
+
+ /// The Settings toggle's runtime half (the toggle itself persists the
+ /// preference). Starts/stops the watchdog + MetricKit subscription and
+ /// gates ledger writes; signposts stay on either way. Main thread only
+ /// (install() and the Settings toggles) — watchdog/metrics are unlocked.
+ public static func setRecording(_ on: Bool) {
+ lock.lock()
+ recording = on
+ lock.unlock()
+ if on {
+ if watchdog == nil { watchdog = HangWatchdog() }
+ watchdog?.start()
+ if metrics == nil { metrics = MetricKitCollector() }
+ metrics?.start()
+ } else {
+ watchdog?.stop()
+ metrics?.stop()
+ }
+ }
+
+ // MARK: - Signpost spans
+
+ private static let subsystem = Bundle.main.bundleIdentifier ?? "com.elevenmessenger.messenger"
+ private static let signposter = OSSignposter(subsystem: subsystem, category: "perf")
+ // Points-of-interest events (hangs, first frame) show up in Time
+ // Profiler's track without opening the os_signpost instrument.
+ private static let poi = OSSignposter(logHandle: OSLog(subsystem: subsystem, category: .pointsOfInterest))
+
+ /// An open interval. end() closes the signpost and, when the elapsed time
+ /// beats the span's budget, files a "slow" ledger event.
+ public struct Span {
+ fileprivate let name: StaticString
+ fileprivate let slowMs: Double
+ fileprivate let start: DispatchTime
+ fileprivate let state: OSSignpostIntervalState
+
+ /// `record: false` closes the signpost without a ledger event — for
+ /// spans that ended abnormally (a failed socket connect isn't a slow
+ /// one) where a duration would mislead.
+ public func end(_ detail: String? = nil, record: Bool = true) {
+ Perf.endSpan(self, detail: detail, record: record)
+ }
+ }
+
+ public static func begin(_ name: StaticString, slowMs: Double = 500) -> Span {
+ let id = signposter.makeSignpostID()
+ let state = signposter.beginInterval(name, id: id)
+ return Span(name: name, slowMs: slowMs, start: .now(), state: state)
+ }
+
+ private static func endSpan(_ span: Span, detail: String?, record: Bool) {
+ if let detail {
+ signposter.endInterval(span.name, span.state, "\(detail)")
+ } else {
+ signposter.endInterval(span.name, span.state)
+ }
+ guard record else { return }
+ let ms = Double(DispatchTime.now().uptimeNanoseconds &- span.start.uptimeNanoseconds) / 1e6
+ if ms >= span.slowMs {
+ self.record(kind: "slow", name: "\(span.name)", ms: ms, detail: detail)
+ }
+ }
+
+ // MARK: - Ledger events
+
+ // A same-name floor so a repeating condition (socket retrying every 3s on
+ // a dead network) can't flood the ledger: at most one event per name per
+ // 30s window. Slow spans skip the floor — each one is a distinct cut.
+ private static var eventFloor: [String: DispatchTime] = [:]
+
+ /// A noteworthy moment with no duration (e.g. "socket.reconnect").
+ public static func event(_ name: StaticString, detail: String? = nil) {
+ guard isRecording else { return }
+ let key = "\(name)"
+ lock.lock()
+ if let last = eventFloor[key],
+ DispatchTime.now().uptimeNanoseconds &- last.uptimeNanoseconds < 30_000_000_000 {
+ lock.unlock()
+ return
+ }
+ eventFloor[key] = .now()
+ lock.unlock()
+ poi.emitEvent(name)
+ record(kind: "note", name: key, ms: nil, detail: detail)
+ }
+
+ /// The watchdog's report: main was wedged for ~ms.
+ static func recordHang(ms: Double) {
+ poi.emitEvent("hang")
+ record(kind: "hang", name: "main.hang", ms: ms, detail: nil)
+ }
+
+ private static var firstFrameNoted = false
+
+ /// Called by the app when its first real screen is up — the honest
+ /// "launch felt like this" number (since install(), not process start;
+ /// MetricKit's launch histograms cover the pre-main part).
+ public static func noteFirstFrame() {
+ lock.lock()
+ let already = firstFrameNoted
+ firstFrameNoted = true
+ let since = Double(DispatchTime.now().uptimeNanoseconds &- installedAt.uptimeNanoseconds) / 1e6
+ lock.unlock()
+ guard !already else { return }
+ poi.emitEvent("firstFrame")
+ record(kind: "boot", name: "boot.firstFrame", ms: since, detail: nil)
+ }
+
+ static func record(kind: String, name: String, ms: Double?, detail: String?) {
+ guard isRecording else { return }
+ let event = PerfEvent(t: Int64(Date().timeIntervalSince1970 * 1000),
+ kind: kind, name: name,
+ ms: ms.map { ($0 * 10).rounded() / 10 }, detail: detail)
+ PerfLedger.shared.append(event)
+ }
+
+ // MARK: - Files & export
+
+ static var logsDir: URL {
+ let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]
+ .appendingPathComponent("Logs", isDirectory: true)
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ return dir
+ }
+
+ static var eventsURL: URL { logsDir.appendingPathComponent("perf-events.jsonl") }
+ static var diagnosticsDir: URL {
+ let dir = logsDir.appendingPathComponent("perf-diagnostics", isDirectory: true)
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ return dir
+ }
+
+ /// Everything recorded so far, oldest first (current + rotated file).
+ public static func recordedEvents() -> [PerfEvent] {
+ let prev = (try? String(contentsOf: PerfLedger.rotatedURL, encoding: .utf8)) ?? ""
+ let cur = (try? String(contentsOf: eventsURL, encoding: .utf8)) ?? ""
+ return PerfEvent.parse(jsonl: prev + cur)
+ }
+
+ /// Assemble the shareable report (device/OS/app + the ledger + the most
+ /// recent MetricKit payloads, size-capped) and return its file URL.
+ /// Blocking file IO — call off the main actor.
+ public static func assembleExport() -> URL? {
+ var diags: [String] = []
+ if let names = try? FileManager.default.contentsOfDirectory(atPath: diagnosticsDir.path) {
+ // Filenames start with an ISO timestamp, so lexicographic = chronological.
+ for name in names.sorted().reversed() {
+ guard let data = try? Data(contentsOf: diagnosticsDir.appendingPathComponent(name)),
+ let json = String(data: data, encoding: .utf8) else { continue }
+ diags.append(json)
+ }
+ }
+ let info = Bundle.main.infoDictionary
+ let export = PerfExport(
+ app: Bundle.main.bundleIdentifier ?? "?",
+ version: "\(info?["CFBundleShortVersionString"] ?? "?") (\(info?["CFBundleVersion"] ?? "?"))",
+ os: ProcessInfo.processInfo.operatingSystemVersionString,
+ device: hardwareModel(),
+ generated: Int64(Date().timeIntervalSince1970 * 1000),
+ events: recordedEvents(),
+ metrickit: PerfExport.capDiagnostics(diags, maxBytes: 2_000_000))
+ guard let data = try? JSONEncoder().encode(export) else { return nil }
+ let url = logsDir.appendingPathComponent("performance-report.json")
+ guard (try? data.write(to: url, options: .atomic)) != nil else { return nil }
+ return url
+ }
+
+ /// Wipe the ledger + collected payloads (the Settings "clear" affordance).
+ public static func clearRecorded() {
+ PerfLedger.shared.clear()
+ let fm = FileManager.default
+ if let names = try? fm.contentsOfDirectory(atPath: diagnosticsDir.path) {
+ for name in names { try? fm.removeItem(at: diagnosticsDir.appendingPathComponent(name)) }
+ }
+ }
+
+ /// "hw.machine" (iPhone17,1 / Mac15,6) — a model id, not an identifier of
+ /// the person; it's what makes a hang report actionable across devices.
+ private static func hardwareModel() -> String {
+ var size = 0
+ sysctlbyname("hw.machine", nil, &size, nil, 0)
+ guard size > 0 else { return "?" }
+ var chars = [CChar](repeating: 0, count: size)
+ sysctlbyname("hw.machine", &chars, &size, nil, 0)
+ return String(cString: chars)
+ }
+}
+
+// MARK: - The on-disk ledger
+
+/// Serialized appends to perf-events.jsonl with ClickLab-style rotation: the
+/// file caps at ~512KB and rotates once to .prev — the interesting window is
+/// recent, and unbounded append only grows.
+final class PerfLedger {
+ static let shared = PerfLedger()
+ static var rotatedURL: URL { Perf.eventsURL.deletingPathExtension().appendingPathExtension("prev.jsonl") }
+
+ private let queue = DispatchQueue(label: "lchat.perf.ledger", qos: .utility)
+ private var handle: FileHandle?
+ private var bytes: Int = -1 // unknown until first append
+
+ func append(_ event: PerfEvent) {
+ queue.async { [self] in
+ guard var data = try? JSONEncoder().encode(event) else { return }
+ data.append(0x0A)
+ let url = Perf.eventsURL
+ if bytes < 0 {
+ let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
+ bytes = (attrs?[.size] as? Int) ?? 0
+ }
+ if bytes > 512 * 1024 {
+ try? handle?.close()
+ handle = nil
+ try? FileManager.default.removeItem(at: Self.rotatedURL)
+ try? FileManager.default.moveItem(at: url, to: Self.rotatedURL)
+ bytes = 0
+ }
+ if handle == nil {
+ if !FileManager.default.fileExists(atPath: url.path) {
+ FileManager.default.createFile(atPath: url.path, contents: nil)
+ }
+ handle = try? FileHandle(forWritingTo: url)
+ _ = try? handle?.seekToEnd()
+ }
+ try? handle?.write(contentsOf: data)
+ bytes += data.count
+ }
+ }
+
+ func clear() {
+ queue.async { [self] in
+ try? handle?.close()
+ handle = nil
+ bytes = 0
+ try? FileManager.default.removeItem(at: Perf.eventsURL)
+ try? FileManager.default.removeItem(at: Self.rotatedURL)
+ }
+ }
+}
+
+// MARK: - Event & export shapes (pure; pinned by PerfTests)
+
+/// One ledger line. Kinds: "slow" (a span beat its budget), "hang" (main
+/// thread wedged), "boot" (first frame), "note" (an event), "metric" /
+/// "diag" (MetricKit arrivals).
+public struct PerfEvent: Codable, Equatable, Sendable {
+ public var t: Int64 // epoch ms
+ public var kind: String
+ public var name: String
+ public var ms: Double?
+ public var detail: String?
+
+ public init(t: Int64, kind: String, name: String, ms: Double? = nil, detail: String? = nil) {
+ self.t = t; self.kind = kind; self.name = name; self.ms = ms; self.detail = detail
+ }
+
+ /// Lenient JSONL parse: a torn or garbage line (a crash mid-append) skips,
+ /// never sinks the export.
+ public static func parse(jsonl: String) -> [PerfEvent] {
+ jsonl.split(separator: "\n").compactMap {
+ try? JSONDecoder().decode(PerfEvent.self, from: Data($0.utf8))
+ }
+ }
+
+ /// The Settings footer's one-liner: counts, pluralised, worst slow span
+ /// last. Nil when there's nothing to say.
+ public static func summary(_ events: [PerfEvent]) -> String? {
+ let hangs = events.filter { $0.kind == "hang" }.count
+ let slows = events.filter { $0.kind == "slow" }
+ guard hangs > 0 || !slows.isEmpty else { return nil }
+ var parts: [String] = []
+ if hangs > 0 {
+ parts.append("\(hangs.formatted()) \(hangs == 1 ? "hang" : "hangs")")
+ }
+ if !slows.isEmpty {
+ parts.append("\(slows.count.formatted()) slow \(slows.count == 1 ? "moment" : "moments")")
+ }
+ if let worst = slows.max(by: { ($0.ms ?? 0) < ($1.ms ?? 0) }), let ms = worst.ms {
+ parts.append("worst \(worst.name) \((ms / 1000).formatted(.number.precision(.fractionLength(1))))s")
+ }
+ return parts.joined(separator: " · ")
+ }
+}
+
+/// The shareable report — also the unit a future opt-in stream would POST to
+/// a webhook-ingest feed, so its shape is deliberately self-contained.
+public struct PerfExport: Codable {
+ public var app: String
+ public var version: String
+ public var os: String
+ public var device: String
+ public var generated: Int64
+ public var events: [PerfEvent]
+ public var metrickit: [String] // raw MetricKit payload JSONs, newest first
+
+ /// Keep the newest payloads under a byte budget — hang diagnostics carry
+ /// full call trees and a report must stay shareable, not gigantic.
+ public static func capDiagnostics(_ newestFirst: [String], maxBytes: Int) -> [String] {
+ var kept: [String] = []
+ var total = 0
+ for json in newestFirst {
+ let size = json.utf8.count
+ if total + size > maxBytes { break }
+ kept.append(json)
+ total += size
+ }
+ return kept
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index d33d7de..9fcb53d 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -108,6 +108,8 @@ public final class ThreadStore: ObservableObject {
public func hydrate() async {
guard threads.isEmpty else { return }
let origin = instance.origin
+ let span = Perf.begin("threads.hydrate", slowMs: 500)
+ defer { span.end() }
guard let snap = await Task.detached(priority: .userInitiated, operation: {
ThreadListSnapshot.load(origin: origin)
}).value else { return }
@@ -119,7 +121,13 @@ public final class ThreadStore: ObservableObject {
public func refresh() async {
guard let api else { loadError = "This instance's address is invalid."; return }
loading = true
- defer { loading = false }
+ // Refires on every socket frame, so the budget is generous — only a
+ // genuinely wedged refresh (slow roster + key unwraps) should record.
+ let span = Perf.begin("threads.refresh", slowMs: 2500)
+ defer {
+ loading = false
+ span.end("\(threads.count) threads")
+ }
await api.ensurePubkey(instance.userPub)
diff --git a/ios/LChatCore/Tests/LChatCoreTests/PerfTests.swift b/ios/LChatCore/Tests/LChatCoreTests/PerfTests.swift
new file mode 100644
index 0000000..dff02d6
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/PerfTests.swift
@@ -0,0 +1,56 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 The perf ledger's pure halves: JSONL parsing (lenient — a torn line from
+// a crash mid-append must never sink the export), the Settings summary line
+// (pluralisation is a shipping requirement), and the export's diagnostic
+// byte cap.
+final class PerfTests: XCTestCase {
+ func testEventRoundtripAndLenientParse() throws {
+ let event = PerfEvent(t: 1_753_000_000_000, kind: "slow", name: "feed.openThread",
+ ms: 1812.5, detail: "214 messages")
+ let line = String(data: try JSONEncoder().encode(event), encoding: .utf8)!
+ let jsonl = line + "\n{\"torn\":tru\n" + line + "\nnot json at all\n"
+ let parsed = PerfEvent.parse(jsonl: jsonl)
+ XCTAssertEqual(parsed, [event, event])
+ }
+
+ func testSummaryEmpty() {
+ XCTAssertNil(PerfEvent.summary([]))
+ // Boot/metric/note events alone aren't "cuts" — nothing to say.
+ XCTAssertNil(PerfEvent.summary([PerfEvent(t: 0, kind: "boot", name: "boot.firstFrame", ms: 500)]))
+ }
+
+ func testSummarySingularAndPlural() {
+ let oneEach = [
+ PerfEvent(t: 0, kind: "hang", name: "main.hang", ms: 900),
+ PerfEvent(t: 1, kind: "slow", name: "api.get", ms: 1500),
+ ]
+ XCTAssertEqual(PerfEvent.summary(oneEach), "1 hang · 1 slow moment · worst api.get 1.5s")
+
+ let several = [
+ PerfEvent(t: 0, kind: "hang", name: "main.hang", ms: 900),
+ PerfEvent(t: 1, kind: "hang", name: "main.hang", ms: 450),
+ PerfEvent(t: 2, kind: "slow", name: "api.get", ms: 1500),
+ PerfEvent(t: 3, kind: "slow", name: "feed.openThread", ms: 2400),
+ ]
+ XCTAssertEqual(PerfEvent.summary(several), "2 hangs · 2 slow moments · worst feed.openThread 2.4s")
+ }
+
+ func testSummaryHangsOnly() {
+ let hangs = [PerfEvent(t: 0, kind: "hang", name: "main.hang", ms: 600)]
+ XCTAssertEqual(PerfEvent.summary(hangs), "1 hang")
+ }
+
+ func testCapDiagnosticsKeepsNewestUnderBudget() {
+ let a = String(repeating: "a", count: 100)
+ let b = String(repeating: "b", count: 100)
+ let c = String(repeating: "c", count: 100)
+ // Newest first in, newest kept: the cap stops at the first payload
+ // that would overflow, never skipping ahead to a smaller older one.
+ XCTAssertEqual(PerfExport.capDiagnostics([a, b, c], maxBytes: 250), [a, b])
+ XCTAssertEqual(PerfExport.capDiagnostics([a, b, c], maxBytes: 1000), [a, b, c])
+ XCTAssertEqual(PerfExport.capDiagnostics([a], maxBytes: 50), [])
+ XCTAssertEqual(PerfExport.capDiagnostics([], maxBytes: 1000), [])
+ }
+}
diff --git a/ios/LChatMac/CLIToolSection.swift b/ios/LChatMac/CLIToolSection.swift
index 14e8dbe..c9414d2 100644
--- a/ios/LChatMac/CLIToolSection.swift
+++ b/ios/LChatMac/CLIToolSection.swift
@@ -27,6 +27,7 @@ struct MacAdvancedSettingsView: View {
.font(.footnote).foregroundStyle(.secondary)
}
}
+ PerfSettingsSection()
}
.formStyle(.grouped)
// No frame — the Settings window's single fixed size lives on the
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index d032fbf..7952730 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -179,6 +179,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
func applicationDidFinishLaunching(_ notification: Notification) {
ClickLab.start()
+ // Signposts + (consent-gated) perf recording, shared with iOS (Perf.swift).
+ Perf.install()
// The DEK-sync migrate-by-addition moment (#1089, flag-gated): an
// existing signed-in install adds the synchronizable DEK item on its
// first unlocked launch — write-gated on the account holding a SiwA
diff --git a/ios/LChatMac/PerfSettingsSection.swift b/ios/LChatMac/PerfSettingsSection.swift
new file mode 100644
index 0000000..8a98dab
--- /dev/null
+++ b/ios/LChatMac/PerfSettingsSection.swift
@@ -0,0 +1,46 @@
+import AppKit
+import LChatCore
+import SwiftUI
+
+// 🤖 Settings → Advanced → "Performance": the Mac face of the shared perf
+// instrumentation (LChatCore Perf.swift) — the same consent toggle as the
+// iOS sheet, plus Reveal-in-Finder for the assembled report (the Mac idiom;
+// iOS shares a file instead).
+struct PerfSettingsSection: View {
+ @AppStorage(Perf.consentKey) private var perfDiagnostics = Perf.defaultEnabled
+ @State private var summary: String?
+
+ var body: some View {
+ Section {
+ Toggle("Record performance diagnostics", isOn: $perfDiagnostics)
+ .onChange(of: perfDiagnostics) { _, next in Perf.setRecording(next) }
+ if perfDiagnostics {
+ HStack {
+ Text(summary.map { "Recorded so far: \($0)." } ?? "Nothing recorded yet.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Button("Show Report in Finder") { revealReport() }
+ }
+ }
+ Text("A private log of how long things take — starting the app, opening chats, moments it freezes. It never includes what's in your messages, and it never leaves this Mac unless you share the report yourself.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ } header: {
+ Text("Performance")
+ }
+ .task(id: perfDiagnostics) {
+ guard perfDiagnostics else { return }
+ let events = await Task.detached { Perf.recordedEvents() }.value
+ summary = PerfEvent.summary(events)
+ }
+ }
+
+ private func revealReport() {
+ Task {
+ let url = await Task.detached { Perf.assembleExport() }.value
+ guard let url else { return }
+ NSWorkspace.shared.activateFileViewerSelecting([url])
+ }
+ }
+}