Files
Allofit/sources/ui/PreviewPane.swift
Bitsy 876122ff98 Audit fixes: security hardening, index correctness, performance, UX
Security
- Elevated copies: root only reads the original; the copy is written by
  the user (sudo -u tee), so root never chowns / chmods a user-controlled
  path. Staging folder forced to 0700.
- Service logs moved from fixed /tmp names to /Library/Logs/Allofit
  (root-owned) and ~/Library/Logs/Allofit; Diagnostics reveals instead of
  opening the log.
- Index files are owner-only (0600; the root daemon's belongs to the
  installing user), set on the temp file before an atomic rename.
- Root install passes the plist inline (base64, plutil -lint) instead of a
  user-writable temp file; the binary comes from Bundle.main.
- Cache loader caps and checks the declared payload size; each save uses
  its own temp file.
- Release action pinned to a commit; non-system LC_RPATHs stripped.

Correctness
- Move to Trash removes the files from the index (the watcher ignores the
  app's own operations) and registers Undo (Put Back); trashed folders are
  matched with the original URLs; failures are shown.
- The saved event id stays below pending subtree walks (GUI and service).
- Roots / exclusions changes restart the watcher from the snapshot's id.
- Case-only renames no longer leave a ghost entry.
- Service mode is saved only after a successful install; a saved but
  missing service falls back to the in-process indexer.

Performance
- New folders are merged without the O(n) removal pass.
- Size / date sorts use a compact key array (539 -> 47 ms for 630k).
- Selection, preview and actions use the selected records directly.
- Service saves at most every 15 s; reader reloads pause while hidden and
  are deferred instead of dropped; window close saves only when dirty.

Usability
- Results appear during the first index; empty-list explanations.
- Down arrow moves to the results, Up on the first row back; history on
  Up / Option-Up / Option-Down.
- Search syntax popover and Help menu; shortcuts shown in the context menu;
  confirmations for Clear Cache and Uninstall; privacy usage strings;
  Group Containers excluded by default; wording, VoiceOver labels, plural.
2026-10-02 14:44:49 +02:00

171 lines
5.0 KiB
Swift

import SwiftUI
import AppKit
import Quartz
// QuickLookPreviewView wraps Quartz's QLPreviewView so an inline Quick
// Look preview can be embedded inside a SwiftUI hierarchy. The same
// renderer powers the floating QLPreviewPanel (spacebar), so file-type
// coverage (PDFs, images, video, source files, plists, etc.) is
// identical between the inline pane and the floating panel.
struct QuickLookPreviewView: NSViewRepresentable {
// the file to preview; nil clears the view
let url: URL?
// creates the embedded Quick Look view
func makeNSView(context inContext: Context) -> NSView {
guard let vView = QLPreviewView(frame: .zero, style: .normal) else {
return NSView()
}
// keep the view alive when the parent window closes - we own its
// lifetime via SwiftUI, not via QLPreviewPanel's modal behaviour
vView.shouldCloseWithWindow = false
vView.autostarts = true
return vView
}
// shows the current file
func updateNSView(_ inView: NSView, context inContext: Context) {
guard let vQlView = inView as? QLPreviewView else { return }
vQlView.previewItem = (url as NSURL?)
}
}
// PreviewPane is the right-hand side panel in the main window. When
// exactly one row is selected it renders a Quick Look preview plus a
// small metadata footer. If the file isn't user-readable the preview
// area becomes a single big tap-target showing a lock icon - clicking
// it (or the badge that appears on the selected row when the pane is
// closed) triggers the sudo-elevation flow.
struct PreviewPane: View {
@EnvironmentObject var access: AccessManager
@EnvironmentObject var searchModel: WindowSearchModel
// passed in from ContentView (its @State) so this view re-renders
// whenever the user's selection changes
let selection: [FileRecord]
// the record to preview: only with exactly one selected
private var selectedRecord: FileRecord? {
return selection.count == 1 ? selection.first : nil
}
var body: some View {
VStack(spacing: 0) {
if let vRecord = selectedRecord {
content(for: vRecord)
} else {
emptyState
}
}
.background(Color(NSColor.controlBackgroundColor))
}
// preview + metadata footer for one selected record
private func content(for inRecord: FileRecord) -> some View {
let vEffectiveUrl = access.effectiveURL(for: inRecord)
let vReadable = ElevatedAccess.canRead(path: vEffectiveUrl.path)
return VStack(spacing: 0) {
Group {
if vReadable {
QuickLookPreviewView(url: vEffectiveUrl)
} else {
authorizePrompt(for: inRecord)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
metadata(for: inRecord)
}
}
// full-area authorize hint shown when the selected file isn't
// user-readable. The whole area is the button target so users
// can click anywhere over the locked preview to authorize.
private func authorizePrompt(for inRecord: FileRecord) -> some View {
Button {
Task { await access.authorize(inRecord) }
} label: {
VStack(spacing: 10) {
if access.isAuthorizing(inRecord.id) {
ProgressView()
.controlSize(.regular)
} else {
Image(systemName: "lock.shield.fill")
.font(.system(size: 40))
.foregroundStyle(.orange)
}
Text(access.isAuthorizing(inRecord.id)
? "Authorizing…"
: "Click to authorize preview")
.font(.callout)
.foregroundColor(.secondary)
if let vErr = access.lastError, !access.isAuthorizing(inRecord.id) {
Text(vErr)
.font(.caption)
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(access.isAuthorizing(inRecord.id))
}
// thin metadata bar at the bottom of the preview pane
private func metadata(for inRecord: FileRecord) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(inRecord.name)
.font(.headline)
.lineLimit(2)
.truncationMode(.middle)
Text(inRecord.parentPath)
.font(.caption)
.foregroundColor(.secondary)
.truncationMode(.middle)
.lineLimit(1)
.textSelection(.enabled)
HStack(spacing: 6) {
if !inRecord.isDirectory {
Text(Formatters.size(bytes: inRecord.size))
.monospacedDigit()
Text("·")
}
Text("Modified \(Formatters.date(inRecord.dateModified))")
.monospacedDigit()
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.bar)
}
// placeholder shown when nothing or multiple rows are selected
private var emptyState: some View {
VStack(spacing: 10) {
Image(systemName: "eye.slash")
.font(.system(size: 32))
.foregroundColor(.secondary.opacity(0.6))
Text(placeholderText)
.font(.callout)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 16)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var placeholderText: String {
if selection.isEmpty {
return "Select a file to preview"
}
return "\(selection.count) items selected"
}
}