Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

383 lines
20 KiB
Swift

import Combine
import Foundation
import OSLog
import MacToolsPluginKit
/// Owns the persisted custom layout and republishes it so the (otherwise non-reactive)
/// overlay grid re-renders on change.
///
/// `ObservableObject` is load-bearing: the overlay grid's only other reactive source is the
/// catalog, and it does NOT observe `onStateChange`, so the grid must inject this store as
/// `@ObservedObject` to ever see a reorder (design §5.5 / risk R1). Persistence mirrors
/// `AppHotkeyStore`'s `JSONEncoder` + `data(forKey:)` write-through; decode failures fall
/// back to alphabetical (`nil`) and never crash.
@MainActor
final class LaunchpadLayoutStore: ObservableObject {
private enum Keys {
static let customLayout = "customLayout"
}
/// `nil` == no custom layout == alphabetical order. Non-nil == custom-sort mode.
@Published private(set) var layout: LaunchpadLayout?
private let storage: PluginStorage
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools",
category: "LaunchpadLayoutStore"
)
init(storage: PluginStorage) {
self.storage = storage
self.layout = Self.loadLayout(from: storage, decoder: decoder, logger: logger)
}
/// On the first user reorder, freeze the current alphabetical sequence into an all-`.app`
/// layout so subsequent moves are relative to a stable snapshot. No-op once a layout
/// exists (design §5.3). The caller must pass the frozen drag-session snapshot, NOT a live
/// `filtered` read, to avoid racing an async catalog reload.
func materializeIfNeeded(from apps: [LaunchpadAppItem]) {
guard layout == nil else { return }
setLayout(LaunchpadLayout(nodes: apps.map(Self.node(for:))))
}
/// Ensure the layout exists and references every currently-visible app, so a following
/// `move` can target any of them — including reconcile-appended new installs that aren't
/// yet in the layout (Codex P2). Materializes the full snapshot when none exists;
/// otherwise appends the missing visible apps at the tail, preserving existing order and
/// any offscreen/orphan entries (the tolerance window).
func captureVisibleOrder(_ apps: [LaunchpadAppItem]) {
materializeIfNeeded(from: apps)
guard let current = layout else { return }
let present = Set(current.nodes.flatMap(\.appIDs))
let missing = apps.filter { !present.contains($0.id) }
guard !missing.isEmpty else { return }
setLayout(LaunchpadLayout(version: current.version, nodes: current.nodes + missing.map(Self.node(for:))))
}
private static func node(for app: LaunchpadAppItem) -> LaunchpadLayoutNode {
.app(LaunchpadAppRef(id: app.id, name: app.name))
}
/// Move `id` to immediately before `targetID` in the root node order.
func move(id: String, before targetID: String) {
reorder(id: id, relativeTo: targetID, placeAfter: false)
}
/// Move `id` to immediately after `targetID` in the root node order.
func move(id: String, after targetID: String) {
reorder(id: id, relativeTo: targetID, placeAfter: true)
}
/// Drop the custom layout entirely → back to alphabetical (design §5.4). No-op (and no
/// write) when already alphabetical.
func resetToAlphabetical() {
guard layout != nil else { return }
layout = nil
storage.removeObject(forKey: Keys.customLayout)
}
// MARK: - Folders (19b)
/// Stack two root apps into a new folder occupying `targetID`'s slot, carrying both apps;
/// the dragged app is removed from the root. The apps must already be root nodes (the
/// caller captures the visible order first, exactly as for a reorder). `id` is injectable
/// for deterministic tests; production passes a fresh UUID.
func makeFolder(target targetID: String, dragged draggedID: String,
name: String, id: String = UUID().uuidString) {
guard let current = layout, targetID != draggedID else { return }
var nodes = current.nodes
guard let targetIndex = nodes.firstIndex(where: { $0.rootID == targetID }),
case .app(let targetRef) = nodes[targetIndex],
let draggedNode = nodes.first(where: { $0.rootID == draggedID }),
case .app(let draggedRef) = draggedNode
else { return }
nodes[targetIndex] = .folder(id: id, name: name, children: [targetRef, draggedRef])
nodes.removeAll { $0.rootID == draggedID } // drop the now-foldered dragged app from root
setLayout(LaunchpadLayout(version: current.version, nodes: nodes))
}
/// Move a root app into an existing folder (appended to its children, removed from root).
func addToFolder(_ folderID: String, app appID: String) {
guard let current = layout else { return }
var nodes = current.nodes
guard let folderIndex = nodes.firstIndex(where: { $0.rootID == folderID }),
case .folder(let fid, let fname, var children) = nodes[folderIndex],
let appNode = nodes.first(where: { $0.rootID == appID }),
case .app(let appRef) = appNode,
!children.contains(where: { $0.id == appID })
else { return }
children.append(appRef)
nodes[folderIndex] = .folder(id: fid, name: fname, children: children)
nodes.removeAll { $0.rootID == appID }
setLayout(LaunchpadLayout(version: current.version, nodes: nodes))
}
/// Remove an app from a folder back to the root tail. Dropping to a single remaining child
/// auto-dissolves the folder, lifting the survivor back into the folder's slot (design §7).
func removeFromFolder(_ folderID: String, app appID: String) {
guard let current = layout else { return }
var nodes = current.nodes
guard let folderIndex = nodes.firstIndex(where: { $0.rootID == folderID }),
case .folder(let fid, let fname, var children) = nodes[folderIndex],
let childIndex = children.firstIndex(where: { $0.id == appID })
else { return }
let removed = children.remove(at: childIndex)
if children.count <= 1 {
// Auto-dissolve: survivor(s) take the folder's slot, removed app goes to the tail.
nodes.replaceSubrange(folderIndex...folderIndex, with: children.map(LaunchpadLayoutNode.app))
} else {
nodes[folderIndex] = .folder(id: fid, name: fname, children: children)
}
nodes.append(.app(removed))
setLayout(LaunchpadLayout(version: current.version, nodes: nodes))
}
/// Move an app OUT of a folder and drop it at a chosen ROOT position (iOS finger-bound exit) —
/// not the tail. Auto-dissolve still applies (a single remaining child lifts the survivor into
/// the folder's slot); a target that referenced the dissolving folder's own tile follows the
/// survivor that takes its slot. `target == nil`, or a target whose id no longer resolves,
/// falls back to the tail (the app is never lost).
func moveOutOfFolder(_ folderID: String, app appID: String, to target: LaunchpadDropTarget?) {
guard let current = layout else { return }
var nodes = current.nodes
guard let folderIndex = nodes.firstIndex(where: { $0.rootID == folderID }),
case .folder(let fid, let fname, var children) = nodes[folderIndex],
let childIndex = children.firstIndex(where: { $0.id == appID })
else { return }
let removed = children.remove(at: childIndex)
var resolvedTarget = target
if children.count <= 1 {
// Dropping "beside the folder" from a 2-app folder: the folder id vanishes with the
// dissolve, so redirect the target to the survivor occupying its slot — otherwise the
// stale id would send the app to the tail instead of where the user dropped it.
if let survivor = children.first {
switch target {
case .before(let id) where id == folderID: resolvedTarget = .before(survivor.id)
case .after(let id) where id == folderID: resolvedTarget = .after(survivor.id)
default: break
}
}
nodes.replaceSubrange(folderIndex...folderIndex, with: children.map(LaunchpadLayoutNode.app))
} else {
nodes[folderIndex] = .folder(id: fid, name: fname, children: children)
}
insert(.app(removed), relativeTo: resolvedTarget, into: &nodes)
setLayout(LaunchpadLayout(version: current.version, nodes: nodes))
}
/// Insert a node at a root drop target (before/after an existing root id); a nil or stale
/// target appends at the tail.
private func insert(_ node: LaunchpadLayoutNode, relativeTo target: LaunchpadDropTarget?,
into nodes: inout [LaunchpadLayoutNode]) {
switch target {
case .none:
nodes.append(node)
case .before(let id):
if let i = nodes.firstIndex(where: { $0.rootID == id }) { nodes.insert(node, at: i) }
else { nodes.append(node) }
case .after(let id):
if let i = nodes.firstIndex(where: { $0.rootID == id }) { nodes.insert(node, at: i + 1) }
else { nodes.append(node) }
}
}
/// Eject `app` from its `source` folder AND fold it into a NEW folder occupying `target`'s root
/// slot — in ONE mutation (no intermediate tail hop / double render). Auto-dissolve of the now
/// smaller source still applies. Falls back to a tail insert (app never lost) if the target no
/// longer resolves to a root app after the source dissolves.
func ejectIntoNewFolder(source: String, app appID: String, target targetID: String, name: String, id: String) {
guard let current = layout, targetID != source, appID != targetID else { return }
var nodes = current.nodes
guard let removed = detachChild(appID, fromFolder: source, in: &nodes) else { return }
guard let targetIndex = nodes.firstIndex(where: { $0.rootID == targetID }),
case .app(let targetRef) = nodes[targetIndex] else {
nodes.append(.app(removed))
setLayout(LaunchpadLayout(version: current.version, nodes: nodes)); return
}
nodes[targetIndex] = .folder(id: id, name: name, children: [targetRef, removed])
setLayout(LaunchpadLayout(version: current.version, nodes: nodes))
}
/// Eject `app` from its `source` folder AND append it to existing `destination` folder — one
/// mutation. Falls back to a tail insert if the destination no longer resolves to a folder.
func ejectIntoFolder(source: String, app appID: String, destination destID: String) {
guard let current = layout, destID != source else { return }
var nodes = current.nodes
guard let removed = detachChild(appID, fromFolder: source, in: &nodes) else { return }
guard let destIndex = nodes.firstIndex(where: { $0.rootID == destID }),
case .folder(let dfid, let dfname, var dchildren) = nodes[destIndex],
!dchildren.contains(where: { $0.id == appID }) else {
nodes.append(.app(removed))
setLayout(LaunchpadLayout(version: current.version, nodes: nodes)); return
}
dchildren.append(removed)
nodes[destIndex] = .folder(id: dfid, name: dfname, children: dchildren)
setLayout(LaunchpadLayout(version: current.version, nodes: nodes))
}
/// Remove `appID` from `folderID`'s children, auto-dissolving the folder in place when ≤1 child
/// remains. Returns the detached app, or nil if it can't be resolved. Mutates `nodes` only.
private func detachChild(_ appID: String, fromFolder folderID: String,
in nodes: inout [LaunchpadLayoutNode]) -> LaunchpadAppRef? {
guard let folderIndex = nodes.firstIndex(where: { $0.rootID == folderID }),
case .folder(let fid, let fname, var children) = nodes[folderIndex],
let childIndex = children.firstIndex(where: { $0.id == appID })
else { return nil }
let removed = children.remove(at: childIndex)
if children.count <= 1 {
nodes.replaceSubrange(folderIndex...folderIndex, with: children.map(LaunchpadLayoutNode.app))
} else {
nodes[folderIndex] = .folder(id: fid, name: fname, children: children)
}
return removed
}
/// Rename a folder. An empty (or whitespace-only) name falls back to `fallback` so a folder
/// is never nameless; callers pass the localized default. Renaming to the current name —
/// including the trim-equivalent of it — is a no-op and writes nothing (the blur-commits
/// rename UI hits this on every "click in, click away" pass; same discipline as
/// `reorderChild`). The folder id never changes.
func renameFolder(_ folderID: String, name: String, fallback: String = "Untitled") {
guard let current = layout else { return }
var nodes = current.nodes
guard let folderIndex = nodes.firstIndex(where: { $0.rootID == folderID }),
case .folder(let fid, let currentName, let children) = nodes[folderIndex]
else { return }
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
// Trim the fallback too: a whitespace-only fallback (e.g. a bad localization
// payload) would otherwise persist a visually blank folder name. Fall back once
// more to a hard default so a folder is never left nameless.
let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines)
let resolved = trimmed.isEmpty ? (trimmedFallback.isEmpty ? "Untitled" : trimmedFallback) : trimmed
guard resolved != currentName else { return } // no visible change → no write
nodes[folderIndex] = .folder(id: fid, name: resolved, children: children)
setLayout(LaunchpadLayout(version: current.version, nodes: nodes))
}
/// Reorder a child WITHIN a folder: move `appID` to immediately before `siblingID` in that
/// folder's children. No-op (no write) when the folder/child/sibling can't be resolved, when
/// they're the same, or when nothing changes — and, unlike the root reorder, a missing sibling
/// is a no-op rather than an append (the child stays put; design: in-folder order is explicit).
func moveChildWithinFolder(_ folderID: String, child appID: String, before siblingID: String) {
reorderChild(in: folderID, child: appID, relativeTo: siblingID, placeAfter: false)
}
/// Reorder a child WITHIN a folder: move `appID` to immediately after `siblingID`.
func moveChildWithinFolder(_ folderID: String, child appID: String, after siblingID: String) {
reorderChild(in: folderID, child: appID, relativeTo: siblingID, placeAfter: true)
}
private func reorderChild(in folderID: String, child appID: String, relativeTo siblingID: String, placeAfter: Bool) {
guard let current = layout, appID != siblingID,
let folderIndex = current.nodes.firstIndex(where: { $0.rootID == folderID }),
case .folder(let fid, let fname, var children) = current.nodes[folderIndex],
let sourceIndex = children.firstIndex(where: { $0.id == appID })
else { return }
let moved = children.remove(at: sourceIndex)
guard let targetIndex = children.firstIndex(where: { $0.id == siblingID }) else { return } // stale sibling → no-op
children.insert(moved, at: placeAfter ? targetIndex + 1 : targetIndex)
var nodes = current.nodes
let updated = LaunchpadLayoutNode.folder(id: fid, name: fname, children: children)
guard updated != nodes[folderIndex] else { return } // dropped in place → no write (R8)
nodes[folderIndex] = updated
setLayout(LaunchpadLayout(version: current.version, nodes: nodes))
}
/// Explicitly dissolve a folder: release all its children back to the root, in place.
func dissolveFolder(_ folderID: String) {
guard let current = layout else { return }
var nodes = current.nodes
guard let folderIndex = nodes.firstIndex(where: { $0.rootID == folderID }),
case .folder(_, _, let children) = nodes[folderIndex]
else { return }
nodes.replaceSubrange(folderIndex...folderIndex, with: children.map(LaunchpadLayoutNode.app))
setLayout(LaunchpadLayout(version: current.version, nodes: nodes))
}
// MARK: - Loading
/// Decode the stored layout, tolerating absence and corruption.
/// Missing key → `nil` (alphabetical — the zero-migration default for upgrading users).
/// Decode failure or version mismatch → `nil` + a warning, never a crash.
private static func loadLayout(
from storage: PluginStorage,
decoder: JSONDecoder,
logger: Logger
) -> LaunchpadLayout? {
guard let data = storage.data(forKey: Keys.customLayout) else { return nil }
guard let decoded = try? decoder.decode(LaunchpadLayout.self, from: data) else {
logger.warning("Failed to decode custom layout; falling back to alphabetical order.")
return nil
}
// Exact-version match only. Older formats never shipped; a NEWER version (downgraded app)
// may carry fields this build's Codable silently drops — loading it and re-stamping the
// future version onto v2-shaped data on the next save would corrupt it for the newer build.
guard decoded.version == LaunchpadLayout.currentVersion else {
logger.warning(
"Custom layout version \(decoded.version, privacy: .public) != supported \(LaunchpadLayout.currentVersion, privacy: .public); falling back to alphabetical order."
)
return nil
}
guard isSemanticallyValid(decoded) else {
logger.warning("Custom layout failed semantic validation; falling back to alphabetical order.")
return nil
}
return decoded
}
/// Reject persisted blobs that decode but break the store's structural assumptions: every
/// mutation looks nodes up by `rootID` and assumes an app lives in exactly one place, so a
/// duplicate root id or an app referenced twice would make moves target the wrong node and
/// then persist that corruption back. (Empty folders stay valid — the reconcile tolerance
/// window keeps them around deliberately.)
private static func isSemanticallyValid(_ layout: LaunchpadLayout) -> Bool {
var rootIDs = Set<String>()
var appIDs = Set<String>()
for node in layout.nodes {
guard rootIDs.insert(node.rootID).inserted else { return false }
for id in node.appIDs {
guard appIDs.insert(id).inserted else { return false }
}
}
return true
}
// MARK: - Mutation
private func reorder(id: String, relativeTo targetID: String, placeAfter: Bool) {
guard let current = layout,
id != targetID,
let sourceIndex = current.nodes.firstIndex(where: { $0.rootID == id })
else { return }
var nodes = current.nodes
let moved = nodes.remove(at: sourceIndex)
if let targetIndex = nodes.firstIndex(where: { $0.rootID == targetID }) {
nodes.insert(moved, at: placeAfter ? targetIndex + 1 : targetIndex)
} else {
// Target not in the layout yet (e.g. a freshly-installed app rendered at the tail
// but not yet persisted) → fall back to appending at the end.
nodes.append(moved)
}
guard nodes != current.nodes else { return } // dropping in place writes nothing (R8)
setLayout(LaunchpadLayout(version: current.version, nodes: nodes))
}
private func setLayout(_ newLayout: LaunchpadLayout) {
layout = newLayout
persist(newLayout)
}
private func persist(_ layout: LaunchpadLayout) {
guard let data = try? encoder.encode(layout) else {
logger.error("Failed to encode custom layout; skipping persist.")
return
}
storage.set(data, forKey: Keys.customLayout)
}
}