From 218e7825c13ba75d869bf8d4b0edd94945136b64 Mon Sep 17 00:00:00 2001 From: Arnaud Bellemare Date: Fri, 10 Jul 2026 13:03:48 -0400 Subject: [PATCH 1/4] Stop ScrollFeel probe from spinning the main queue configureScrollView rescheduled itself unconditionally whenever the probe had no enclosing scroll view, so a detached probe re-queued itself forever and pegged the app at 100% CPU, starving the event loop until even quit stopped working. Bail out when the probe has no window (reattachment reschedules via viewDidMoveToWindow) and cap lookup retries at 40 spaced 50ms apart. Co-Authored-By: Claude Fable 5 --- Sources/Loci/ScrollFeel.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/Loci/ScrollFeel.swift b/Sources/Loci/ScrollFeel.swift index 998844a..eb4969e 100644 --- a/Sources/Loci/ScrollFeel.swift +++ b/Sources/Loci/ScrollFeel.swift @@ -24,6 +24,7 @@ struct LociScrollFeel: NSViewRepresentable { final class ScrollFeelProbeView: NSView { var profile: LociScrollFeel.Profile = .library private weak var configuredScrollView: NSScrollView? + private var retriesRemaining = 0 override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() @@ -40,14 +41,24 @@ final class ScrollFeelProbeView: NSView { } func scheduleConfiguration() { + retriesRemaining = 40 DispatchQueue.main.async { [weak self] in self?.configureScrollView() } } private func configureScrollView() { + // Detached probes get a fresh viewDidMoveToWindow when they return to + // a hierarchy; retrying while detached would spin the main queue at + // 100% CPU forever, so both the window guard and the retry budget are + // load-bearing. + guard window != nil else { return } guard let scrollView = enclosingScrollView else { - scheduleConfiguration() + guard retriesRemaining > 0 else { return } + retriesRemaining -= 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.configureScrollView() + } return } From eef3416e12bceab06b14d4a880bdf5973408c137 Mon Sep 17 00:00:00 2001 From: Arnaud Bellemare Date: Fri, 10 Jul 2026 13:03:48 -0400 Subject: [PATCH 2/4] Open standalone markdown files without importing Declare .md/.markdown document types so Finder offers Loci as a viewer, route incoming file URLs to a per-file viewer window that reads the file in place, and surface an Add to Library toolbar action for when the user does want a copy imported. Markdown now renders as formatted text (headings, lists, quotes, fences, wikilink titles) on the viewer canvas instead of raw monospace, for library items too. Co-Authored-By: Claude Fable 5 --- Sources/Loci/DocumentViewerView.swift | 4 + Sources/Loci/ExtendDocumentViewer.swift | 193 ++++++++++++++++++++ Sources/Loci/LociApp.swift | 57 +++++- Sources/Loci/StandaloneMarkdownViewer.swift | 53 ++++++ Support/Loci.Info.plist | 36 ++++ 5 files changed, 336 insertions(+), 7 deletions(-) create mode 100644 Sources/Loci/StandaloneMarkdownViewer.swift diff --git a/Sources/Loci/DocumentViewerView.swift b/Sources/Loci/DocumentViewerView.swift index 88ffc20..71fdb90 100644 --- a/Sources/Loci/DocumentViewerView.swift +++ b/Sources/Loci/DocumentViewerView.swift @@ -8,6 +8,7 @@ enum DocumentViewerContent: Equatable { case image(URL) case quickLook(URL) case plainText(String) + case markdown(String) case websitePreview(thumbnailURL: URL?, websiteURL: URL?) case preparing(String) case unsupported(String) @@ -85,6 +86,9 @@ enum DocumentViewerContentResolver { !DocumentPreviewConverter.isOfficeDocument(originalURL), let text = try? String(contentsOf: originalURL, encoding: .utf8), !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if ext == "md" || ext == "markdown" { + return Result(content: .markdown(text), pdfPageCount: 0) + } return Result(content: .plainText(text), pdfPageCount: 0) } diff --git a/Sources/Loci/ExtendDocumentViewer.swift b/Sources/Loci/ExtendDocumentViewer.swift index 7af8286..e109bbf 100644 --- a/Sources/Loci/ExtendDocumentViewer.swift +++ b/Sources/Loci/ExtendDocumentViewer.swift @@ -10,6 +10,9 @@ struct ExtendDocumentViewer: View { var item: ReferenceItem var originalURL: URL? @Binding var pageIndex: Int + /// Set when viewing a standalone file that is not in the library yet; + /// surfaces an "Add to Library" action in the toolbar. + var onAddToLibrary: (() -> Void)? @State private var content: DocumentViewerContent = .unsupported("Loading…") @State private var pageCount = 0 @@ -178,6 +181,17 @@ struct ExtendDocumentViewer: View { .background(Color.white.opacity(0.10), in: RoundedRectangle(cornerRadius: 6, style: .continuous)) } + if let onAddToLibrary { + Button("Add to Library", action: onAddToLibrary) + .buttonStyle(.plain) + .lociFont(size: 10, weight: .semibold, relativeTo: .caption2) + .foregroundStyle(.white.opacity(0.82)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.white.opacity(0.10), in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .help("Import a copy of this file into the Loci library") + } + if let websiteURL = item.websiteURL, isWebsitePreview { Button("Open in Browser") { NSWorkspace.shared.open(websiteURL) @@ -247,6 +261,8 @@ struct ExtendDocumentViewer: View { .frame(maxWidth: .infinity, maxHeight: .infinity) case .plainText(let text): ExtendTextCanvas(text: text) + case .markdown(let text): + ExtendMarkdownCanvas(text: text) case .websitePreview(let thumbnailURL, let websiteURL): ExtendWebsitePreviewCanvas( item: item, @@ -651,6 +667,183 @@ private struct ExtendTextCanvas: View { } } +/// Renders markdown as formatted text on the dark viewer canvas. Block +/// structure (headings, lists, quotes, fences) is parsed by hand because +/// AttributedString's markdown init flattens it; inline emphasis, code, and +/// links go through AttributedString. Obsidian-style [[wikilinks]] display as +/// their titles — there is no vault context to resolve them against here. +private struct ExtendMarkdownCanvas: View { + private enum Block { + case heading(level: Int, text: AttributedString) + case paragraph(AttributedString) + case listItem(marker: String, text: AttributedString, indent: Int) + case quote(AttributedString) + case code(String) + case rule + } + + private let blocks: [Block] + + init(text: String) { + blocks = Self.parse(text) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + blockView(block) + } + } + .frame(maxWidth: 720, alignment: .leading) + .frame(maxWidth: .infinity) + .padding(24) + } + .textSelection(.enabled) + .background(Color(red: 0.07, green: 0.07, blue: 0.08)) + } + + @ViewBuilder + private func blockView(_ block: Block) -> some View { + switch block { + case .heading(let level, let text): + Text(text) + .lociFont( + size: [20, 17, 14.5, 13, 12.5, 12][min(level, 6) - 1], + weight: .semibold, + relativeTo: .headline + ) + .foregroundStyle(.white.opacity(0.94)) + .padding(.top, level <= 2 ? 8 : 4) + case .paragraph(let text): + Text(text) + .lociFont(size: 12.5, relativeTo: .body) + .lineSpacing(3.5) + .foregroundStyle(.white.opacity(0.82)) + case .listItem(let marker, let text, let indent): + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(marker) + .lociFont(size: 12, weight: .semibold, design: .rounded, relativeTo: .body) + .foregroundStyle(.white.opacity(0.45)) + Text(text) + .lociFont(size: 12.5, relativeTo: .body) + .lineSpacing(3) + .foregroundStyle(.white.opacity(0.82)) + } + .padding(.leading, CGFloat(indent) * 16) + case .quote(let text): + HStack(alignment: .top, spacing: 10) { + RoundedRectangle(cornerRadius: 1.5) + .fill(Color.white.opacity(0.26)) + .frame(width: 3) + Text(text) + .lociFont(size: 12.5, relativeTo: .body) + .italic() + .lineSpacing(3) + .foregroundStyle(.white.opacity(0.64)) + } + .fixedSize(horizontal: false, vertical: true) + case .code(let code): + Text(code) + .lociFont(size: 11, design: .monospaced, relativeTo: .caption) + .foregroundStyle(.white.opacity(0.80)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + case .rule: + Rectangle() + .fill(Color.white.opacity(0.12)) + .frame(height: 1) + .padding(.vertical, 4) + } + } + + private static func parse(_ raw: String) -> [Block] { + var blocks: [Block] = [] + var paragraph: [String] = [] + var fenceLines: [String]? + var lines = raw.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")[...] + + // YAML frontmatter is metadata, not prose; show it as a code block. + if lines.first?.trimmingCharacters(in: .whitespaces) == "---", + let closing = lines.dropFirst().firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "---" }) { + blocks.append(.code(lines[lines.startIndex + 1..") { + flushParagraph() + let content = trimmed.dropFirst().trimmingCharacters(in: .whitespaces) + blocks.append(.quote(inline(content))) + continue + } + let indent = line.prefix(while: { $0 == " " || $0 == "\t" }).reduce(0) { $0 + ($1 == "\t" ? 2 : 1) } / 2 + if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") { + flushParagraph() + blocks.append(.listItem(marker: "•", text: inline(String(trimmed.dropFirst(2))), indent: indent)) + continue + } + if let match = trimmed.firstMatch(of: /^(\d{1,3})\.\s+(.*)$/) { + flushParagraph() + blocks.append(.listItem(marker: "\(match.1).", text: inline(String(match.2)), indent: indent)) + continue + } + paragraph.append(trimmed) + } + if let fenceLines { + blocks.append(.code(fenceLines.joined(separator: "\n"))) + } + flushParagraph() + return blocks + } + + private static func inline(_ text: String) -> AttributedString { + var normalized = text.replacing(/\[\[([^\]|]+)\|([^\]]+)\]\]/) { String($0.output.2) } + normalized = normalized.replacing(/\[\[([^\]]+)\]\]/) { String($0.output.1) } + let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + return (try? AttributedString(markdown: normalized, options: options)) ?? AttributedString(normalized) + } +} + private struct ExtendPreparingCanvas: View { var message: String diff --git a/Sources/Loci/LociApp.swift b/Sources/Loci/LociApp.swift index abdab99..1ae0464 100644 --- a/Sources/Loci/LociApp.swift +++ b/Sources/Loci/LociApp.swift @@ -23,6 +23,9 @@ extension Notification.Name { final class LociAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSToolbarDelegate { private var window: NSWindow? private var settingsWindow: NSWindow? + private var markdownWindows: [NSWindow] = [] + /// Files handed to us before the main window finished launching. + private var pendingMarkdownURLs: [URL] = [] private var localAPI: LocalReferenceAPIServer? private var libraryStore: LibraryStore? @@ -74,6 +77,12 @@ final class LociAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak libraryStore] in libraryStore?.finishDeferredStartupWork() } + + let queued = pendingMarkdownURLs + pendingMarkdownURLs.removeAll() + for url in queued { + openStandaloneMarkdown(url) + } } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -81,18 +90,52 @@ final class LociAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, } func application(_ application: NSApplication, open urls: [URL]) { - for url in urls where url.scheme == "loci" { - Task { - do { - try await XOAuthManager.shared.completeAuthorization(from: url) - openSettings() - } catch { - ErrorPresenter.shared.show(.networkError("X sign-in failed: \(error.localizedDescription)")) + for url in urls { + if url.scheme == "loci" { + Task { + do { + try await XOAuthManager.shared.completeAuthorization(from: url) + openSettings() + } catch { + ErrorPresenter.shared.show(.networkError("X sign-in failed: \(error.localizedDescription)")) + } + } + } else if url.isFileURL, ["md", "markdown"].contains(url.pathExtension.lowercased()) { + if window == nil { + pendingMarkdownURLs.append(url) + } else { + openStandaloneMarkdown(url) } } } } + /// Opens a markdown file in its own viewer window, reading it in place — + /// no library import happens unless the user asks from the viewer. + private func openStandaloneMarkdown(_ url: URL) { + let viewer = StandaloneMarkdownViewer(fileURL: url, store: libraryStore) + let hostingController = NSHostingController(rootView: viewer) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 780, height: 660), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = url.lastPathComponent + window.contentMinSize = NSSize(width: 480, height: 360) + window.contentViewController = hostingController + // NSHostingController shrinks the window to the SwiftUI ideal size + // (the frame minimums here); restore the intended reading size. + window.setContentSize(NSSize(width: 780, height: 660)) + window.isReleasedWhenClosed = false + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + markdownWindows.removeAll { !$0.isVisible && !$0.isMiniaturized } + markdownWindows.append(window) + } + private func setupMenus() { let mainMenu = NSMenu() let appName = AppBrand.name diff --git a/Sources/Loci/StandaloneMarkdownViewer.swift b/Sources/Loci/StandaloneMarkdownViewer.swift new file mode 100644 index 0000000..7415a8c --- /dev/null +++ b/Sources/Loci/StandaloneMarkdownViewer.swift @@ -0,0 +1,53 @@ +import AppKit +import SwiftUI + +/// Viewer for markdown files opened from Finder ("Open With → Loci") or via +/// drag onto the app icon. The file is read in place from wherever it lives on +/// disk — nothing is copied into the library unless the user asks with the +/// toolbar's Add to Library action. +struct StandaloneMarkdownViewer: View { + let fileURL: URL + var store: LibraryStore? + + @State private var itemID = UUID() + @State private var pageIndex = 0 + @State private var addedToLibrary = false + + private var item: ReferenceItem { + ReferenceItem( + id: itemID, + title: fileURL.deletingPathExtension().lastPathComponent, + subtitle: fileURL.deletingLastPathComponent().lastPathComponent, + fileName: fileURL.lastPathComponent, + kind: .typography, + group: .file, + theme: .paper, + aspectRatio: 0.77, + collectionID: nil, + isInbox: false, + isTrashed: false, + canvasPosition: .zero, + infinityPosition: .zero + ) + } + + var body: some View { + ExtendDocumentViewer( + item: item, + originalURL: fileURL, + pageIndex: $pageIndex, + onAddToLibrary: addToLibraryAction + ) + .padding(10) + .frame(minWidth: 480, minHeight: 360) + .background(Color(red: 0.07, green: 0.07, blue: 0.08)) + } + + private var addToLibraryAction: (() -> Void)? { + guard let store, !addedToLibrary else { return nil } + return { + store.importFiles([fileURL]) + addedToLibrary = true + } + } +} diff --git a/Support/Loci.Info.plist b/Support/Loci.Info.plist index d7504e5..fc0f5da 100644 --- a/Support/Loci.Info.plist +++ b/Support/Loci.Info.plist @@ -18,6 +18,42 @@ Loci CFBundlePackageType APPL + CFBundleDocumentTypes + + + CFBundleTypeName + Markdown Document + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + net.daringfireball.markdown + + + + UTImportedTypeDeclarations + + + UTTypeIdentifier + net.daringfireball.markdown + UTTypeDescription + Markdown Document + UTTypeConformsTo + + public.plain-text + + UTTypeTagSpecification + + public.filename-extension + + md + markdown + + + + CFBundleURLTypes From 2a214fd983f545b7faafcb34c477c4d731e1e558 Mon Sep 17 00:00:00 2001 From: Arnaud Bellemare Date: Fri, 10 Jul 2026 13:03:48 -0400 Subject: [PATCH 3/4] Enable telemetry by default Anonymous analytics now default on; an explicit choice under the current or legacy defaults key still wins, and the Settings privacy toggle keeps working as the opt-out. Co-Authored-By: Claude Fable 5 --- Sources/Loci/LociTelemetry.swift | 7 ++++++- Sources/Loci/SettingsView.swift | 2 +- Tests/LociTests/TelemetryTests.swift | 9 +++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Sources/Loci/LociTelemetry.swift b/Sources/Loci/LociTelemetry.swift index 6acb926..90bee6f 100644 --- a/Sources/Loci/LociTelemetry.swift +++ b/Sources/Loci/LociTelemetry.swift @@ -66,12 +66,17 @@ enum LociTelemetry { "x_bookmark_count" ] + /// Enabled by default; users opt out from Settings → Privacy. An explicit + /// choice under either the current or legacy key always wins. static var isEnabled: Bool { get { if UserDefaults.standard.object(forKey: enabledKey) != nil { return UserDefaults.standard.bool(forKey: enabledKey) } - return UserDefaults.standard.bool(forKey: legacyEnabledKey) + if UserDefaults.standard.object(forKey: legacyEnabledKey) != nil { + return UserDefaults.standard.bool(forKey: legacyEnabledKey) + } + return true } set { UserDefaults.standard.set(newValue, forKey: enabledKey) } } diff --git a/Sources/Loci/SettingsView.swift b/Sources/Loci/SettingsView.swift index f7a38e7..f46c658 100644 --- a/Sources/Loci/SettingsView.swift +++ b/Sources/Loci/SettingsView.swift @@ -44,7 +44,7 @@ struct SettingsView: View { @AppStorage("LociAutoCompile") private var autoCompile = false @AppStorage("LociVaultPath") private var vaultPath = "" @AppStorage("LociXRedirectMode") private var xRedirectModeRaw = XOAuthRedirectMode.recommended.rawValue - @AppStorage(LociTelemetry.enabledKey) private var telemetryEnabled = false + @AppStorage(LociTelemetry.enabledKey) private var telemetryEnabled = true @AppStorage(LociTelemetry.endpointKey) private var telemetryEndpoint = "" @StateObject private var xOAuth = XOAuthManager.shared diff --git a/Tests/LociTests/TelemetryTests.swift b/Tests/LociTests/TelemetryTests.swift index c834c63..d9ff3e2 100644 --- a/Tests/LociTests/TelemetryTests.swift +++ b/Tests/LociTests/TelemetryTests.swift @@ -4,10 +4,15 @@ import Testing @Suite("Telemetry") struct TelemetryTests { - @Test("Telemetry is opt-in and allowlisted") - func telemetryOptInAndAllowlist() async throws { + @Test("Telemetry defaults on, honors opt-out, and allowlists properties") + func telemetryDefaultAndAllowlist() async throws { LociTelemetry.clearLocalQueue() LociTelemetry.endpointString = "" + + UserDefaults.standard.removeObject(forKey: LociTelemetry.enabledKey) + UserDefaults.standard.removeObject(forKey: "AtlasTelemetryEnabled") + #expect(LociTelemetry.isEnabled) + LociTelemetry.isEnabled = false LociTelemetry.record(.importCompleted, properties: [ From 125a9e1d076ff20392ae8c386ec334c9acbcd5b9 Mon Sep 17 00:00:00 2001 From: Arnaud Bellemare Date: Fri, 10 Jul 2026 13:18:46 -0400 Subject: [PATCH 4/4] Match standalone viewer chrome and pin menu widths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone markdown window drew the app-wide light titlebar over the viewer's dark canvas; give it a darkAqua appearance and matching background. The zoom and ellipsis menus use the borderless-button style, which greedily absorbs free width in wide windows and strands their dropdown chevrons at the trailing edge — fix them at their content size. Co-Authored-By: Claude Fable 5 --- Sources/Loci/ExtendDocumentViewer.swift | 2 ++ Sources/Loci/LociApp.swift | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/Sources/Loci/ExtendDocumentViewer.swift b/Sources/Loci/ExtendDocumentViewer.swift index e109bbf..8c79589 100644 --- a/Sources/Loci/ExtendDocumentViewer.swift +++ b/Sources/Loci/ExtendDocumentViewer.swift @@ -159,6 +159,7 @@ struct ExtendDocumentViewer: View { .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 6, style: .continuous)) } .menuStyle(.borderlessButton) + .fixedSize() viewerIconButton("plus") { adjustZoom(by: 10) } } } @@ -216,6 +217,7 @@ struct ExtendDocumentViewer: View { .foregroundStyle(.white.opacity(0.55)) } .menuStyle(.borderlessButton) + .fixedSize() } } .padding(.horizontal, 14) diff --git a/Sources/Loci/LociApp.swift b/Sources/Loci/LociApp.swift index 1ae0464..346df35 100644 --- a/Sources/Loci/LociApp.swift +++ b/Sources/Loci/LociApp.swift @@ -122,6 +122,10 @@ final class LociAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, defer: false ) window.title = url.lastPathComponent + // The document viewer draws a dark canvas; give this window a dark + // titlebar (overriding the app-wide light pin) so the chrome matches. + window.appearance = NSAppearance(named: .darkAqua) + window.backgroundColor = NSColor(srgbRed: 0.07, green: 0.07, blue: 0.08, alpha: 1) window.contentMinSize = NSSize(width: 480, height: 360) window.contentViewController = hostingController // NSHostingController shrinks the window to the SwiftUI ideal size