Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion Sources/BarShelfCLI/BarShelfKit/BarShelfMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 50 additions & 8 deletions Sources/MenubucketApp/DesktopWidgets/SharedShelfPublisher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>] = [:]
/// 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
Expand All @@ -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)")
}
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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()) {
Expand Down
31 changes: 30 additions & 1 deletion Sources/MenubucketCore/SharedShelf.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
61 changes: 61 additions & 0 deletions Tests/MenubucketAppTests/SharedShelfPublisherTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import XCTest
import MenubucketCore
@testable import MenubucketApp

final class SharedShelfPublisherTests: XCTestCase {
Expand Down Expand Up @@ -30,4 +31,64 @@ final class SharedShelfPublisherTests: XCTestCase {
let developerIDPath = script[ext.lowerBound..<app.upperBound]
XCTAssertFalse(developerIDPath.contains("--deep"))
}

/// A file tile as Stashbar draws it: its path is in the id, the image,
/// the open action, and the drag payload.
private func fileTileSnapshot(path: String) throws -> 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))
}
}
2 changes: 1 addition & 1 deletion scripts/build_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading