diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7013ed0..76011cf 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,11 +1,30 @@ -# BarShelf 0.5.1 +# BarShelf 0.6.0 > Unreleased. Requires macOS 14+ on Apple Silicon. -BarShelf 0.5.1 redesigns the whole app: a new BarShelf window with a Shelf -you arrange by dragging, settings that apply as you change them, a popup -that needs no right-click, a better Gallery, a first-run welcome, and a -Korean interface. +BarShelf 0.6.0 puts your widgets on the desktop and in Notification Center, +and redesigns the whole app: a new BarShelf window with a Shelf you arrange +by dragging, settings that apply as you change them, a popup that needs no +right-click, a better Gallery, a first-run welcome, and a Korean interface. + +## Desktop widgets + +- **BarShelf on your desktop.** Right-click the desktop (or open Edit Widgets + in Notification Center), find BarShelf, and place it in small, medium, or + large. Right-click it and choose Edit Widget to pick which BarShelf widget + it shows. +- **Show just what you want.** Pick individual items to show — one account + from aas Usage, CPU and Memory from System, or a few files — in the order + you pick them. +- **Drawn for the desktop.** Items are laid out the way macOS's own widgets + are: a big number, meters (rings in the small size), a list, or a grid of + real file thumbnails. BarShelf picks the style that fits; choose another + under Style. +- **Honest about age.** A widget whose reading is old shows an orange clock + and how long ago it updated; a fresh one shows nothing extra. +- Clicking a desktop widget opens that widget in the BarShelf popup. +- Desktop widgets update at most every 15 minutes, as macOS allows. Sensitive + widgets (one-time codes, clipboard) are never offered. ## Changes diff --git a/Sources/BarShelfCLI/BarShelfKit/BarShelfMain.swift b/Sources/BarShelfCLI/BarShelfKit/BarShelfMain.swift index c3c1407..8201bb7 100644 --- a/Sources/BarShelfCLI/BarShelfKit/BarShelfMain.swift +++ b/Sources/BarShelfCLI/BarShelfKit/BarShelfMain.swift @@ -8,7 +8,7 @@ public enum BarShelfMain { /// Must match `APP_VERSION` in `scripts/build_app.sh`: `release.sh` refuses /// to package a release whose app and CLI report different versions, and /// `scripts/check-release-versions.py` catches the drift in CI first. - public static let version = "0.5.1" + public static let version = "0.6.0" public static let usage = """ barshelf — BarShelf widget developer CLI diff --git a/Sources/MenubucketApp/DesktopWidgets/SharedShelfPublisher.swift b/Sources/MenubucketApp/DesktopWidgets/SharedShelfPublisher.swift index 4ad9cf7..7146598 100644 --- a/Sources/MenubucketApp/DesktopWidgets/SharedShelfPublisher.swift +++ b/Sources/MenubucketApp/DesktopWidgets/SharedShelfPublisher.swift @@ -25,6 +25,13 @@ final class SharedShelfPublisher { private var lastReload: Date? private var pendingReload: DispatchWorkItem? private var lastIndex: SharedShelf.Index? + /// Thumbnail exports still running, one per widget; a newer publish or a + /// withdrawal cancels the one before it. + private var exports: [String: Task] = [:] + /// Bumped on every publish and withdrawal, so an export that finishes + /// late cannot write over something newer — or bring back a widget that + /// was taken out while it ran. + private var generations: [String: Int] = [:] init(container: URL? = SharedShelfPublisher.entitledContainer()) { self.container = container @@ -39,10 +46,15 @@ final class SharedShelfPublisher { guard let container else { return false } let index = SharedShelf.Index(entries: entries) guard index != lastIndex else { return false } - lastIndex = index + let kept = Set(entries.map(\.id)) + for id in Set(exports.keys).subtracting(kept) { + invalidate(id) + } do { try SharedShelf.writeIndex(index, to: container) - SharedShelf.pruneSnapshots(keeping: Set(entries.map(\.id)), in: container) + SharedShelf.pruneSnapshots(keeping: kept, in: container) + // Only once it is written: a failed write is tried again next time. + lastIndex = index } catch { NSLog("barshelf: could not write the widget index: \(error)") } @@ -51,18 +63,36 @@ final class SharedShelfPublisher { func publish(_ snapshot: SharedShelf.Snapshot) { guard let container else { return } + let id = snapshot.widgetID + let generation = invalidate(id) let paths = Self.imagePaths(in: snapshot) - guard !paths.isEmpty, let folder = SharedShelf.imagesDirectory(for: snapshot.widgetID, in: container) else { + guard let folder = SharedShelf.imagesDirectory(for: id, in: container) else { return } + guard !paths.isEmpty else { + // Nothing to picture any more: no thumbnails left behind either. + try? FileManager.default.removeItem(at: folder) write(Self.sharing(snapshot, thumbnails: [:]), to: container) return } - // Thumbnails first, then the snapshot that names them. - Task { @MainActor [weak self] in + // Thumbnails first, then the snapshot that names them — unless the + // widget was published again or taken out in the meantime. + exports[id] = Task { @MainActor [weak self] in let thumbnails = await Self.exportThumbnails(paths, into: folder) - self?.write(Self.sharing(snapshot, thumbnails: thumbnails), to: container) + guard let self, !Task.isCancelled, self.generations[id] == generation else { return } + self.exports[id] = nil + if let index = self.lastIndex, !index.entries.contains(where: { $0.id == id }) { return } + self.write(Self.sharing(snapshot, thumbnails: thumbnails), to: container) } } + /// Cancels any export for the widget and makes older ones stale. + @discardableResult + private func invalidate(_ id: String) -> Int { + exports.removeValue(forKey: id)?.cancel() + let next = (generations[id] ?? 0) + 1 + generations[id] = next + return next + } + private func write(_ snapshot: SharedShelf.Snapshot, to container: URL) { do { try SharedShelf.writeSnapshot(snapshot, to: container) @@ -87,10 +117,13 @@ final class SharedShelfPublisher { } /// The snapshot as it goes into the shared folder: thumbnails named by - /// their exported file, and no local file paths at all. + /// their exported file, and no local file paths, actions, or drag + /// payloads anywhere in it. static func sharing(_ snapshot: SharedShelf.Snapshot, thumbnails: [String: String]) -> SharedShelf.Snapshot { var shared = snapshot + shared.viewTree = shared.viewTree.map(SharedShelf.scrubbed) for index in shared.parts.indices { + shared.parts[index].node = SharedShelf.scrubbed(shared.parts[index].node) guard var summary = shared.parts[index].summary else { continue } summary.thumbnail = summary.imagePath.flatMap { thumbnails[$0] } summary.imagePath = nil @@ -111,6 +144,7 @@ final class SharedShelfPublisher { try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) var names: [String: String] = [:] for path in paths { + if Task.isCancelled { break } let url = URL(fileURLWithPath: path) let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate // `v2`: earlier builds saved stand-in icons under the picture's @@ -137,6 +171,8 @@ final class SharedShelfPublisher { names[path] = icon } } + // A cancelled export leaves the folder to the one that replaced it. + if Task.isCancelled { return names } let keep = Set(names.values) for file in (try? FileManager.default.contentsOfDirectory(atPath: folder.path)) ?? [] where !keep.contains(file) { try? FileManager.default.removeItem(at: folder.appendingPathComponent(file)) @@ -196,8 +232,14 @@ final class SharedShelfPublisher { /// out to be sensitive. func withdraw(widgetID: String) { guard let container else { return } + invalidate(widgetID) SharedShelf.removeSnapshot(widgetID: widgetID, from: container) - scheduleReload() + // Not left on the desktop until the reload window closes: this is + // rare, and worth a reload from the daily budget. + pendingReload?.cancel() + pendingReload = nil + lastReload = Date() + WidgetCenter.shared.reloadTimelines(ofKind: SharedShelf.widgetKind) } private func scheduleReload(now: Date = Date()) { diff --git a/Sources/MenubucketCore/SharedShelf.swift b/Sources/MenubucketCore/SharedShelf.swift index e7871a0..27bd1dd 100644 --- a/Sources/MenubucketCore/SharedShelf.swift +++ b/Sources/MenubucketCore/SharedShelf.swift @@ -174,13 +174,42 @@ public enum SharedShelf { /// Sensors) is split into its rows instead: each stacked block that /// pairs a label with a value or a meter ("CPU 15%" and its bar). /// Pieces without any text are skipped, having nothing to be named by. + /// A node id as it may appear in the shared folder: kept when it is a + /// plain name, hashed when it could carry a file path ("tile-/Users/…"). + public static func shareableID(_ id: String) -> String { + guard id.contains("/") || id.count > 64 else { return id } + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in id.utf8 { + hash = (hash ^ UInt64(byte)) &* 0x0000_0100_0000_01b3 + } + return "h" + String(hash, radix: 16) + } + + /// A tree with everything that points outside the widget taken out — + /// file paths, actions, drag payloads, image URLs, path-like ids — for + /// the shared folder, which the widget extension only draws from. + public static func scrubbed(_ node: UINode) -> UINode { + var node = node + node.id = node.id.map(shareableID) + node.action = nil + node.drag = nil + node.source?.path = nil + node.source?.url = nil + node.children = node.children?.map(scrubbed) + node.items = node.items?.map(scrubbed) + if let child = node.child?.node { + node.child = UINodeBox(scrubbed(child)) + } + return node + } + public static func parts(of tree: UINode) -> [Part] { var parts: [Part] = [] var seen: [String: Int] = [:] func add(_ node: UINode, group: String?) { guard parts.count < maximumParts, let title = displayTitle(of: node) else { return } - var key = node.id.map { "id:" + $0 } ?? "\(group ?? "")/\(title)" + var key = node.id.map { "id:" + shareableID($0) } ?? "\(group ?? "")/\(title)" if let count = seen[key] { seen[key] = count + 1 key += "#\(count + 1)" diff --git a/Tests/MenubucketAppTests/SharedShelfPublisherTests.swift b/Tests/MenubucketAppTests/SharedShelfPublisherTests.swift index 0daeab8..e8cc8d8 100644 --- a/Tests/MenubucketAppTests/SharedShelfPublisherTests.swift +++ b/Tests/MenubucketAppTests/SharedShelfPublisherTests.swift @@ -1,4 +1,5 @@ import XCTest +import MenubucketCore @testable import MenubucketApp final class SharedShelfPublisherTests: XCTestCase { @@ -30,4 +31,64 @@ final class SharedShelfPublisherTests: XCTestCase { let developerIDPath = script[ext.lowerBound.. SharedShelf.Snapshot { + let json = """ + {"type": "grid", "items": [{"type": "vstack", "id": "tile-\(path)", + "action": {"type": "openFile", "path": "\(path)"}, "drag": {"filePath": "\(path)"}, + "children": [{"type": "image", "size": 52, "source": {"kind": "fileThumbnail", "path": "\(path)"}}, + {"type": "text", "role": "caption", "text": "a.png"}]}]} + """ + let tree = try JSONDecoder().decode(UINode.self, from: Data(json.utf8)) + return SharedShelf.Snapshot(widgetID: "files", name: "Files", viewTree: tree, updatedAt: Date()) + } + + func testNoLocalPathReachesTheSharedFolder() throws { + let snapshot = try fileTileSnapshot(path: "/Users/someone/Secret/a.png") + XCTAssertEqual(SharedShelfPublisher.imagePaths(in: snapshot), ["/Users/someone/Secret/a.png"]) + let shared = SharedShelfPublisher.sharing(snapshot, thumbnails: ["/Users/someone/Secret/a.png": "x.png"]) + let encoded = String(decoding: try JSONEncoder().encode(shared), as: UTF8.self) + XCTAssertFalse(encoded.contains("Secret"), encoded) + XCTAssertEqual(shared.parts.first?.summary?.thumbnail, "x.png") + } + + /// A widget withdrawn while its thumbnails are being made stays out. + @MainActor + func testAWithdrawnWidgetIsNotWrittenBackByALateExport() async throws { + let container = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: container, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: container) } + let image = container.appendingPathComponent("a.png") + try Data("not really a picture".utf8).write(to: image) + + let publisher = SharedShelfPublisher(container: container) + publisher.publishIndex([.init(id: "files", name: "Files")]) + publisher.publish(try fileTileSnapshot(path: image.path)) + publisher.withdraw(widgetID: "files") + try await Task.sleep(nanoseconds: 1_500_000_000) + XCTAssertNil(SharedShelf.readSnapshot(widgetID: "files", from: container)) + } + + @MainActor + func testAnExportThatFinishesWritesTheSnapshotWithItsThumbnail() async throws { + let container = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: container, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: container) } + let image = container.appendingPathComponent("a.png") + try Data("not really a picture".utf8).write(to: image) + + let publisher = SharedShelfPublisher(container: container) + publisher.publishIndex([.init(id: "files", name: "Files")]) + publisher.publish(try fileTileSnapshot(path: image.path)) + var shared: SharedShelf.Snapshot? + for _ in 0..<50 where shared == nil { + try await Task.sleep(nanoseconds: 100_000_000) + shared = SharedShelf.readSnapshot(widgetID: "files", from: container) + } + let thumbnail = try XCTUnwrap(shared?.parts.first?.summary?.thumbnail) + let folder = try XCTUnwrap(SharedShelf.imagesDirectory(for: "files", in: container)) + XCTAssertTrue(FileManager.default.fileExists(atPath: folder.appendingPathComponent(thumbnail).path)) + } } diff --git a/scripts/build_app.sh b/scripts/build_app.sh index 5dac72d..765b608 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -13,7 +13,7 @@ APP_BUNDLE_NAME=${APP_BUNDLE_NAME:-"${APP_DISPLAY_NAME}.app"} BUILD_CONFIGURATION=${BUILD_CONFIGURATION:-release} OUTPUT_DIR=${OUTPUT_DIR:-"${PROJECT_ROOT}/dist"} BUNDLE_IDENTIFIER=${BUNDLE_IDENTIFIER:-com.barshelf.app} -APP_VERSION=${APP_VERSION:-0.5.1} +APP_VERSION=${APP_VERSION:-0.6.0} APP_BUILD=${APP_BUILD:-"$(date +%Y%m%d%H%M)"} # Provenance: which commit produced this binary. CFBundleVersion has to stay a # period-separated number for App Store validation, so the commit goes in its