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
10 changes: 10 additions & 0 deletions Localization/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -5771,6 +5771,16 @@
}
}
},
"Put these widgets on your desktop too: right-click the desktop, choose Edit Widgets, and find BarShelf.": {
"localizations": {
"ko": {
"stringUnit": {
"state": "translated",
"value": "이 위젯들을 데스크톱에도 놓을 수 있습니다. 데스크톱을 우클릭해 위젯 편집을 고른 뒤 BarShelf를 찾으세요."
}
}
}
},
"Quit BarShelf": {
"localizations": {
"ko": {
Expand Down
114 changes: 16 additions & 98 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,104 +2,22 @@

> Unreleased. Requires macOS 14+ on Apple Silicon.

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.
BarShelf 0.6.1 lets you refresh a desktop widget right where it sits, adds an
extra-large size, and lets widget authors say how their widget should look on
the desktop.

## Changes

- **A new BarShelf window.** The sidebar now has your workspace (Shelf, Menu
Bar, Gallery, Create, Automation) and, below it, Settings split into
General, Shortcuts, Updates, Privacy, and Advanced — one page each, no tabs
inside tabs.
- **Menu Bar page.** Choose which widgets show a value in the menu bar, put
them next to the BarShelf icon or on their own, reorder them, and set the
style for all of them in one place.
- **Record your shortcut.** Click the shortcut field and press the keys,
instead of typing "cmd+shift+b".
- **Updates you control.** Turn off the automatic check, see when BarShelf
last checked, and skip a version you don't want to be reminded about.
- **Privacy page.** See what each widget is allowed to do in plain words, and
allow, deny, or revoke it there.
- Resetting the layout now asks first, and also restores the order of pages.
- **The Shelf.** Your pages side by side, each widget where it sits in the
popup. Drag a widget to reorder it or onto another page to move it, and
select it to change its settings in the inspector next to it.
- **Settings apply as you change them.** No more Save button; ⌘Z undoes a
change. A widget's settings are in four parts: General (page, size, and
its own options), Look, Menu Bar, and About (version, permissions, and
Remove).
- **A better Gallery.** Search with a result count, filter by type
(Command, Workflow, Script) and category, and open any widget to see a
large preview, its full description, what it's allowed to do in plain
words, which tools it needs (with the install command), and Install,
Update, Open, or Remove. Install from URL and Create Widget sit right above
the grid.
- The Gallery no longer uses CPU in the background while its window is
minimized or covered (#1).
- **No more double titles.** Bundled widgets no longer repeat their own name
and icon under the card's header ("Battery" then "Battery" again), so the
card goes straight to the reading. Your installed copies update on launch.
- **Everything in the popup, no right-click needed.** The page name at the top
is now a menu of all your pages. A new **⋯** button holds Edit Shelf (⌘E),
Add Widget…, Menu Bar, Open BarShelf…, Settings… (⌘,), Check for Updates…, and Quit —
the same list the menu bar icon shows when you right-click it.
- **Edit Shelf.** Press ⌘E (or ⋯ ▸ Edit Shelf) to rearrange: every card shows
a drag handle, a Half Width / Full Width switch, and a remove button. Drag a
card onto a page dot to move it to that page. Press Esc or Done to finish.
- **Card headers are on.** Each card shows its name, when it last updated, and
a spinner while it refreshes. Widgets that turned the header off keep it off.
- **Errors say what to do.** A failing widget explains the cause in plain
words — a missing command, a timeout, no connection, an HTTP error, bad data,
a blocked permission — with a Retry button, the full message under Details,
and a link to its settings. A widget showing older data marks it "Cached".
- **Clearer permission requests.** New widgets list what they want to do, with
Allow and Deny buttons. A denied widget shrinks to a small card where you can
review its permissions again or remove it. A widget stopped after crashing
offers Restart and Open Logs.
- **A dot on the menu bar icon** when a widget is waiting for your approval or
has an error.
- **The popup fits its content.** It is as tall as your widgets need, up to
480 points as before; choose Tall or Fit to Screen in Settings ▸ General ▸
Popup height for more.
- A widget's card opened from its own menu bar item closes with Esc, shows
"Copied" when you copy, and no longer offers page options that do not apply
there.
- Pin explains its limit: with two cards pinned it reads "Pin (2 max — unpin
one first)".
- The gear on a card opens that widget's settings in the BarShelf window.
- **A welcome on first launch.** Keep the starter widgets you want, set a
shortcut and login item, and pick values for the menu bar, then BarShelf
opens its popup.
- **Updates wait for you.** When a new version is out, the menu bar icon gets
a dot and the ⋯ menu offers "Update to BarShelf …" — no dialog appears over
your work at launch.
- macOS asks about notifications when you allow a widget that sends them,
not the first time one happens to fire.
- The Create preview now looks exactly like the card it makes, and widgets
made with Create no longer show their name twice.
- Every control in BarShelf has a name for VoiceOver.
- **BarShelf speaks Korean.** Menus, the popup, the BarShelf window, alerts,
and onboarding appear in Korean when Korean is your Mac's preferred
language.

- **Refresh from the desktop.** Medium and larger desktop widgets have a ↻
button in their header; in the small size, the orange clock that marks an
old reading is the button. The widget shows that it is refreshing and
updates as soon as BarShelf has the new reading.
- **Extra large.** Desktop widgets come in an extra-large size: lists and
meters in two columns, and up to twelve file thumbnails.
- **A tip on the Shelf** says how to put BarShelf on your desktop, until you
dismiss it.
- **For widget authors:** `desktop.style` picks the layout BarShelf uses on
Automatic, `desktop.offer: false` keeps a widget off the desktop list, and
`desktopRole` on any node marks items (`item`), sets the title, value,
detail, or status, or leaves a node out (`hide`). See WIDGET-SPEC and
AGENTS.md §8B. Older versions of BarShelf ignore them.
31 changes: 30 additions & 1 deletion Sources/BarShelfCLI/BarShelfKit/AgentSpecEmbedded.swift
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,35 @@ sharing) the BarShelf mark.

---

## 8B. Desktop widgets (`desktop`, `desktopRole`) — BarShelf 0.6.1

BarShelf mirrors widgets onto macOS desktop widgets. It reads the view tree to
split it into items and to find each item's title, value, bar, and detail. That
works for cards, sections, list rows, and label–value rows. When it guesses
wrong, say what you mean. Older hosts ignore both fields.

```json
"desktop": { "style": "meters", "offer": true }
```

- `style` is the layout used while the user leaves Style on Automatic. The
values are `bigValue`, `meters`, `list`, and `grid`.
- `offer: false` keeps the widget out of the desktop widget's list. Sensitive
widgets are never offered anyway.

On any UINode, `desktopRole` marks what the node is on the desktop:

- `item` marks one choosable item. Once any node is marked `item`, only the
marked nodes become items.
- `title`, `subtitle`, `value`, `detail`, and `status` set that field to the
node's first text.
- `hide` leaves the node out of desktop widgets entirely. It is not even
written to the shared folder.

The popup ignores `desktopRole`.

---

## 9. Permission model

Declared in `permissions`; approval is **per-widget and all-at-once**: the host
Expand Down Expand Up @@ -401,7 +430,7 @@ an unsupported placeholder today):
Shared/common fields: `id` (stable identity, needed for lists & action routing),
`padding` (points), `widthFill` (bool), `tint`/`tone`/`foreground`
(`primary`|`secondary`|`tertiary`|`accent`|`good`|`warning`|`danger`|`neutral`),
`accessibilityLabel`.
`accessibilityLabel`, `desktopRole` (desktop widgets only, see §8B).

### Containers

Expand Down
71 changes: 71 additions & 0 deletions Sources/MenubucketApp/DesktopWidgets/SharedShelfPublisher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,72 @@ final class SharedShelfPublisher {
/// was taken out while it ran.
private var generations: [String: Int] = [:]

/// Widgets whose refresh a desktop widget asked for: their next publish
/// goes out at once, and reloads the desktop without waiting.
private var answering: Set<String> = []

/// Called on the main thread with a widget id when its desktop widget's
/// refresh button is pressed.
var onRefreshRequest: ((String) -> Void)?

init(container: URL? = SharedShelfPublisher.entitledContainer()) {
self.container = container
}

var isEnabled: Bool { container != nil }

// MARK: - Refresh requests

/// Starts listening for the extension's refresh button.
func observeRefreshRequests() {
guard container != nil else { return }
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
Unmanaged.passUnretained(self).toOpaque(),
{ _, observer, _, _, _ in
guard let observer else { return }
let publisher = Unmanaged<SharedShelfPublisher>.fromOpaque(observer).takeUnretainedValue()
DispatchQueue.main.async { publisher.takeRefreshRequests() }
},
SharedShelf.refreshRequestNotification as CFString, nil, .deliverImmediately
)
takeRefreshRequests()
}

deinit {
CFNotificationCenterRemoveEveryObserver(
CFNotificationCenterGetDarwinNotifyCenter(), Unmanaged.passUnretained(self).toOpaque()
)
}

/// Reads the request files and hands each new one to the runtime. One
/// that gets no answer — the widget failed, or is not offered — is
/// dropped after the request lifetime so the widget stops saying
/// "Refreshing".
func takeRefreshRequests(now: Date = Date()) {
guard let container else { return }
let offered = Set(lastIndex?.entries.map(\.id) ?? [])
for id in SharedShelf.pendingRefreshRequests(in: container, now: now).keys where !answering.contains(id) {
guard offered.contains(id) else {
SharedShelf.clearRefreshRequest(widgetID: id, in: container)
continue
}
answering.insert(id)
onRefreshRequest?(id)
DispatchQueue.main.asyncAfter(deadline: .now() + SharedShelf.refreshRequestLifetime) { [weak self] in
guard let self, self.answering.remove(id) != nil else { return }
SharedShelf.clearRefreshRequest(widgetID: id, in: container)
self.reloadNow()
}
}
}

/// Whether a widget's next render answers a desktop refresh request, so
/// it should be published without the usual throttle.
func isAnswering(_ widgetID: String) -> Bool {
answering.contains(widgetID)
}

/// The list a user picks from when configuring a widget. Returns whether
/// it changed.
@discardableResult
Expand Down Expand Up @@ -96,6 +156,12 @@ final class SharedShelfPublisher {
private func write(_ snapshot: SharedShelf.Snapshot, to container: URL) {
do {
try SharedShelf.writeSnapshot(snapshot, to: container)
if answering.remove(snapshot.widgetID) != nil {
// The user pressed refresh on the desktop and is watching.
SharedShelf.clearRefreshRequest(widgetID: snapshot.widgetID, in: container)
reloadNow()
return
}
scheduleReload()
} catch {
NSLog("barshelf: could not share \(snapshot.widgetID) with widgets: \(error)")
Expand Down Expand Up @@ -236,6 +302,11 @@ final class SharedShelfPublisher {
SharedShelf.removeSnapshot(widgetID: widgetID, from: container)
// Not left on the desktop until the reload window closes: this is
// rare, and worth a reload from the daily budget.
reloadNow()
}

/// Reloads widgets now, folding in any reload that was waiting.
private func reloadNow() {
pendingReload?.cancel()
pendingReload = nil
lastReload = Date()
Expand Down
19 changes: 19 additions & 0 deletions Sources/MenubucketApp/Hub/ShelfView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,26 @@ struct ShelfView: View {

// MARK: - Board

/// Dismissed for good once closed; desktop widgets are easy to miss.
@AppStorage("desktopWidgetTipDismissed") private var desktopTipDismissed = false

private var board: some View {
VStack(spacing: 0) {
if !desktopTipDismissed, runtime.sharedShelf.isEnabled {
StatusBanner(
tone: .info,
message: String(localized: "Put these widgets on your desktop too: right-click the desktop, choose Edit Widgets, and find BarShelf."),
symbol: "macwindow.on.rectangle"
) {
Button("Dismiss") { desktopTipDismissed = true }
}
.padding([.horizontal, .top], Spacing.m)
}
shelfColumns
}
}

private var shelfColumns: some View {
ScrollView([.horizontal, .vertical]) {
HStack(alignment: .top, spacing: Spacing.m) {
let pages = shelfPages
Expand Down
25 changes: 23 additions & 2 deletions Sources/MenubucketApp/WidgetRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,14 @@ final class WidgetRuntime: ObservableObject {
loadWidgets()
startHotReload()
startMenuBarStalenessTicker()
// The refresh button on a desktop widget.
sharedShelf.onRefreshRequest = { [weak self] widgetID in
guard let self,
let widget = self.desktopWidgetCandidates().first(where: { $0.id == widgetID })
else { return }
self.refresh(widget, manual: true)
}
sharedShelf.observeRefreshRequests()
}

private func applyAppPreferences(_ preferences: AppPreferences) {
Expand Down Expand Up @@ -2863,6 +2871,14 @@ final class WidgetRuntime: ObservableObject {

private func persistSnapshot(_ snapshot: WidgetSnapshot) {
let id = snapshot.widgetID
if sharedShelf.isAnswering(id) {
// Someone pressed refresh on a desktop widget: this render goes
// out now, and the cache is written with it.
cancelPendingPersist(id)
Self.persistQueue.async { Self.writeCachedSnapshot(snapshot) }
publishToDesktopWidgets(snapshot)
return
}
unpersistedSnapshots[id] = snapshot
guard pendingPersists[id] == nil else { return }
let item = DispatchWorkItem { [weak self] in
Expand All @@ -2886,7 +2902,10 @@ final class WidgetRuntime: ObservableObject {
/// Widgets a desktop widget may mirror: everything switched on that is
/// not sensitive, by manifest or by what it last rendered.
private func desktopWidgetCandidates() -> [LoadedWidget] {
widgets.filter { !$0.isSensitive && !sensitiveRenderIDs.contains($0.id) && !prefs.isDisabled($0.id) }
widgets.filter {
!$0.isSensitive && !sensitiveRenderIDs.contains($0.id) && !prefs.isDisabled($0.id)
&& $0.manifest.desktop?.offer != false
}
}

func publishDesktopWidgetIndex() {
Expand Down Expand Up @@ -2934,7 +2953,9 @@ final class WidgetRuntime: ObservableObject {
updatedAt: snapshot.updatedAt,
interval: widget.manifest.refresh?.interval.map { $0 * appPrefs.preferences.refreshMultiplier },
fallback: widget.manifest.refresh?.staleAfterSec ?? 3600
)
),
preferredStyle: widget.manifest.desktop?.style
.flatMap(SharedShelf.Template.init(rawValue:))?.rawValue
))
}

Expand Down
Loading
Loading