diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index ea85132..e06a0c8 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -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": { diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a3aa55f..52fc0ec 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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. diff --git a/Sources/BarShelfCLI/BarShelfKit/AgentSpecEmbedded.swift b/Sources/BarShelfCLI/BarShelfKit/AgentSpecEmbedded.swift index 2c1d151..26dd2bc 100644 --- a/Sources/BarShelfCLI/BarShelfKit/AgentSpecEmbedded.swift +++ b/Sources/BarShelfCLI/BarShelfKit/AgentSpecEmbedded.swift @@ -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 @@ -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 diff --git a/Sources/MenubucketApp/DesktopWidgets/SharedShelfPublisher.swift b/Sources/MenubucketApp/DesktopWidgets/SharedShelfPublisher.swift index 7146598..f896dea 100644 --- a/Sources/MenubucketApp/DesktopWidgets/SharedShelfPublisher.swift +++ b/Sources/MenubucketApp/DesktopWidgets/SharedShelfPublisher.swift @@ -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 = [] + + /// 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.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 @@ -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)") @@ -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() diff --git a/Sources/MenubucketApp/Hub/ShelfView.swift b/Sources/MenubucketApp/Hub/ShelfView.swift index e2ef21b..edad213 100644 --- a/Sources/MenubucketApp/Hub/ShelfView.swift +++ b/Sources/MenubucketApp/Hub/ShelfView.swift @@ -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 diff --git a/Sources/MenubucketApp/WidgetRuntime.swift b/Sources/MenubucketApp/WidgetRuntime.swift index 0e12640..ec9cbef 100644 --- a/Sources/MenubucketApp/WidgetRuntime.swift +++ b/Sources/MenubucketApp/WidgetRuntime.swift @@ -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) { @@ -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 @@ -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() { @@ -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 )) } diff --git a/Sources/MenubucketCore/Manifest.swift b/Sources/MenubucketCore/Manifest.swift index 0c53d9e..7cf8878 100644 --- a/Sources/MenubucketCore/Manifest.swift +++ b/Sources/MenubucketCore/Manifest.swift @@ -29,6 +29,30 @@ public struct Manifest: Codable, Equatable { /// to update, rather than failing with "unknown function"; a host from /// before this field existed ignores it. public var minHostVersion: String? + /// How the widget appears as a macOS desktop widget (0.6.1). Optional; + /// without it BarShelf reads the view tree. + public var desktop: Desktop? + + /// `desktop` in widget.json. Decoded leniently: a wrong value is + /// dropped, never a reason to refuse the widget. + public struct Desktop: Codable, Equatable, Sendable { + /// The layout to use when the user leaves Style on Automatic: + /// `bigValue`, `meters`, `list`, or `grid`. + public var style: String? + /// `false` keeps the widget out of the desktop widget's list. + public var offer: Bool? + + public init(style: String? = nil, offer: Bool? = nil) { + self.style = style + self.offer = offer + } + + public init(from decoder: Decoder) throws { + let container = try? decoder.container(keyedBy: CodingKeys.self) + style = (try? container?.decodeIfPresent(String.self, forKey: .style)) ?? nil + offer = (try? container?.decodeIfPresent(Bool.self, forKey: .offer)) ?? nil + } + } /// Why this widget cannot run on `hostVersion`, or nil when it can. A /// nil host version is a development build, which runs everything. @@ -52,9 +76,11 @@ public struct Manifest: Codable, Equatable { permissions: Permissions? = nil, settings: [Setting]? = nil, appearance: WidgetAppearance? = nil, - minHostVersion: String? = nil + minHostVersion: String? = nil, + desktop: Desktop? = nil ) { self.minHostVersion = minHostVersion + self.desktop = desktop self.schemaVersion = schemaVersion self.id = id self.name = name diff --git a/Sources/MenubucketCore/SharedShelf.swift b/Sources/MenubucketCore/SharedShelf.swift index 27bd1dd..5546b43 100644 --- a/Sources/MenubucketCore/SharedShelf.swift +++ b/Sources/MenubucketCore/SharedShelf.swift @@ -91,12 +91,14 @@ public enum SharedShelf { /// When this reading should be called out as old: about two refresh /// intervals after `updatedAt`. Until then a widget shows no time. public var staleAfter: Date? + /// The author's layout for Style ▸ Automatic (`Template` raw value). + public var preferredStyle: String? public init( widgetID: String, name: String, icon: String? = nil, accent: String? = nil, viewTree: UINode? = nil, updatedAt: Date? = nil, error: String? = nil, statusLabel: String? = nil, statusTint: String? = nil, parts: [Part]? = nil, - staleAfter: Date? = nil + staleAfter: Date? = nil, preferredStyle: String? = nil ) { self.widgetID = widgetID self.name = name @@ -110,6 +112,7 @@ public enum SharedShelf { self.parts = parts ?? viewTree.map(SharedShelf.parts(of:)) ?? [] self.summary = viewTree.map { SharedShelf.summarize($0, fallbackTitle: name) } self.staleAfter = staleAfter + self.preferredStyle = preferredStyle } public init(from decoder: Decoder) throws { @@ -126,6 +129,7 @@ public enum SharedShelf { parts = try c.decodeIfPresent([Part].self, forKey: .parts) ?? [] summary = try c.decodeIfPresent(Summary.self, forKey: .summary) staleAfter = try c.decodeIfPresent(Date.self, forKey: .staleAfter) + preferredStyle = try c.decodeIfPresent(String.self, forKey: .preferredStyle) } } @@ -191,6 +195,10 @@ public enum SharedShelf { public static func scrubbed(_ node: UINode) -> UINode { var node = node node.id = node.id.map(shareableID) + // What a desktop widget must not show is not shared at all. + if node.desktopRole == "hide" { + return UINode(type: "none", hidden: true) + } node.action = nil node.drag = nil node.source?.path = nil @@ -220,7 +228,7 @@ public enum SharedShelf { } func walk(_ node: UINode, group: String?) { - guard node.hidden != true, parts.count < maximumParts else { return } + guard node.hidden != true, node.desktopRole != "hide", parts.count < maximumParts else { return } switch node.type { case "card": add(node, group: group) @@ -244,6 +252,12 @@ public enum SharedShelf { } } + // The author named the items: those, in tree order, and nothing else. + let marked = markedItems(in: tree) + if !marked.isEmpty { + for item in marked.prefix(maximumParts) { add(item.node, group: item.group) } + return parts + } walk(tree, group: nil) if parts.isEmpty { for row in rows(of: tree) { add(row, group: nil) } @@ -251,6 +265,24 @@ public enum SharedShelf { return parts } + /// Nodes marked `"desktopRole": "item"`, with the titled section each + /// sits in. + static func markedItems(in tree: UINode) -> [(node: UINode, group: String?)] { + var found: [(UINode, String?)] = [] + func walk(_ node: UINode, group: String?) { + guard node.hidden != true, node.desktopRole != "hide" else { return } + if node.desktopRole == "item" { + found.append((node, group)) + return + } + let title = node.type == "section" ? node.title?.trimmingCharacters(in: .whitespacesAndNewlines) : nil + let inner = (title?.isEmpty == false) ? title : group + for child in children(of: node) { walk(child, group: inner) } + } + walk(tree, group: nil) + return found + } + /// Whether a node holds a card, a section, or a list somewhere inside. static func containsStructure(_ node: UINode) -> Bool { let inner = (node.children ?? []) + (node.items ?? []) + [node.child?.node].compactMap { $0 } @@ -362,6 +394,53 @@ public enum SharedShelf { } } + // MARK: - Refresh requests + + /// Posted (as a Darwin notification, which a sandboxed extension may + /// send) after the widget extension asks for a refresh. It carries no + /// data: BarShelf reads the request files, which only apps of this team + /// can write. + public static let refreshRequestNotification = appGroupID + ".refresh-request" + static let requestsDirectoryName = "requests" + /// How long a request shows as pending before the widget gives up on it. + public static let refreshRequestLifetime: TimeInterval = 60 + + /// The widget extension asks BarShelf to refresh one widget now. + public static func requestRefresh(widgetID: String, in container: URL, now: Date = Date()) throws { + guard let name = snapshotFileName(for: widgetID) else { return } + try write(now, to: container + .appendingPathComponent(requestsDirectoryName, isDirectory: true) + .appendingPathComponent(name)) + } + + /// Widgets with a refresh asked for and not yet answered, with when. + /// Requests older than `refreshRequestLifetime` are dropped. + public static func pendingRefreshRequests(in container: URL, now: Date = Date()) -> [String: Date] { + let directory = container.appendingPathComponent(requestsDirectoryName, isDirectory: true) + var pending: [String: Date] = [:] + for file in (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? [] where file.hasSuffix(".json") { + let id = String(file.dropLast(5)) + let url = directory.appendingPathComponent(file) + guard snapshotFileName(for: id) == file, + let asked = read(Date.self, from: url), + now.timeIntervalSince(asked) < refreshRequestLifetime + else { + try? FileManager.default.removeItem(at: url) + continue + } + pending[id] = asked + } + return pending + } + + /// BarShelf has answered (or given up on) a widget's refresh request. + public static func clearRefreshRequest(widgetID: String, in container: URL) { + guard let name = snapshotFileName(for: widgetID) else { return } + try? FileManager.default.removeItem(at: container + .appendingPathComponent(requestsDirectoryName, isDirectory: true) + .appendingPathComponent(name)) + } + private static func write(_ value: Value, to url: URL) throws { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 diff --git a/Sources/MenubucketCore/SharedShelfSummary.swift b/Sources/MenubucketCore/SharedShelfSummary.swift index 40ab5fb..8664cd7 100644 --- a/Sources/MenubucketCore/SharedShelfSummary.swift +++ b/Sources/MenubucketCore/SharedShelfSummary.swift @@ -126,7 +126,7 @@ extension SharedShelf { var cardTone: String? func texts(in node: UINode, skipping skip: (UINode) -> Bool) -> [TextBit] { - if node.hidden == true || skip(node) { return [] } + if node.hidden == true || node.desktopRole == "hide" || skip(node) { return [] } if node.type == "text", let text = node.text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { return [TextBit(text: text, role: node.role, size: node.size, foreground: node.foreground)] } @@ -169,7 +169,7 @@ extension SharedShelf { } func walk(_ node: UINode) { - guard node.hidden != true else { return } + guard node.hidden != true, node.desktopRole != "hide" else { return } if node.type == "card", cardTone == nil { cardTone = node.tone ?? node.tint } if node.type == "image", let source = node.source { switch source.kind { @@ -257,13 +257,21 @@ extension SharedShelf { ? rest.prefix(2).map { $0.text }.joined(separator: " · ").nilIfEmpty : metricContext.joined(separator: " · ") + // What the author said outranks what BarShelf read. + let said = authorRoles(in: node) + title = said["title"] ?? title + subtitle = said["subtitle"] ?? subtitle + status = said["status"] ?? status + if let saidValue = said["value"] { value = saidValue } + let saidDetail = said["detail"] + return Summary( title: title ?? fallbackTitle, subtitle: subtitle, value: value, fraction: metrics.first?.fraction, tone: metrics.first?.tone ?? cardTone, - detail: detail, + detail: saidDetail ?? detail, status: status, metrics: metrics, symbol: symbol, @@ -281,6 +289,34 @@ extension SharedShelf { return abs(fraction - percent) > 0.25 && abs(1 - fraction - percent) < 0.05 ? 1 - fraction : fraction } + /// The first text under each node an author gave a `desktopRole` + /// (other than `item` and `hide`). + static func authorRoles(in node: UINode) -> [String: String] { + var roles: [String: String] = [:] + func firstText(_ node: UINode) -> String? { + guard node.hidden != true, node.desktopRole != "hide" else { return nil } + if let text = (node.type == "text" ? node.text : node.type == "badge" ? (node.text ?? node.title) : nil)? + .trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { + return text + } + if let title = node.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty, + ["card", "section"].contains(node.type) { + return title + } + return children(of: node).lazy.compactMap(firstText).first + } + func walk(_ node: UINode) { + guard node.hidden != true, node.desktopRole != "hide" else { return } + if let role = node.desktopRole, ["title", "subtitle", "value", "detail", "status"].contains(role), + roles[role] == nil, let text = firstText(node) { + roles[role] = text + } + for child in children(of: node) { walk(child) } + } + walk(node) + return roles + } + static func children(of node: UINode) -> [UINode] { (node.children ?? []) + (node.items ?? []) + [node.child?.node].compactMap { $0 } } diff --git a/Sources/MenubucketCore/UINode.swift b/Sources/MenubucketCore/UINode.swift index b216ad7..6e31033 100644 --- a/Sources/MenubucketCore/UINode.swift +++ b/Sources/MenubucketCore/UINode.swift @@ -96,6 +96,11 @@ public struct UINode: Codable, Equatable { public var accessibility: Accessibility? /// `spacer` minimum length in points. public var minLength: Double? + /// What this node is to a macOS desktop widget, for an author who wants + /// to say rather than leave it to BarShelf's reading of the tree: + /// `item` (one choosable item), `title`, `subtitle`, `value`, `detail`, + /// `status`, or `hide` (left out of desktop widgets). The popup ignores it. + public var desktopRole: String? public struct Accessibility: Codable, Equatable, Sendable { public var label: String? @@ -154,7 +159,8 @@ public struct UINode: Codable, Equatable { drag: DragSpec? = nil, accessibilityLabel: String? = nil, accessibility: Accessibility? = nil, - minLength: Double? = nil + minLength: Double? = nil, + desktopRole: String? = nil ) { self.id = id self.type = type @@ -193,6 +199,7 @@ public struct UINode: Codable, Equatable { self.accessibilityLabel = accessibilityLabel self.accessibility = accessibility self.minLength = minLength + self.desktopRole = desktopRole } public var effectiveAccessibilityLabel: String? { diff --git a/Tests/MenubucketAppTests/SharedShelfPublisherTests.swift b/Tests/MenubucketAppTests/SharedShelfPublisherTests.swift index e8cc8d8..3c4463f 100644 --- a/Tests/MenubucketAppTests/SharedShelfPublisherTests.swift +++ b/Tests/MenubucketAppTests/SharedShelfPublisherTests.swift @@ -91,4 +91,29 @@ final class SharedShelfPublisherTests: XCTestCase { let folder = try XCTUnwrap(SharedShelf.imagesDirectory(for: "files", in: container)) XCTAssertTrue(FileManager.default.fileExists(atPath: folder.appendingPathComponent(thumbnail).path)) } + + /// The refresh button on the desktop: the request reaches the runtime + /// once, the answer is written at once, and the request is cleared. + @MainActor + func testADesktopRefreshRequestIsAnsweredAndCleared() 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 publisher = SharedShelfPublisher(container: container) + publisher.publishIndex([.init(id: "sys", name: "System")]) + var asked: [String] = [] + publisher.onRefreshRequest = { asked.append($0) } + + try SharedShelf.requestRefresh(widgetID: "sys", in: container) + try SharedShelf.requestRefresh(widgetID: "not-offered", in: container) + publisher.takeRefreshRequests() + publisher.takeRefreshRequests() + XCTAssertEqual(asked, ["sys"], "once per request, and only for offered widgets") + XCTAssertTrue(publisher.isAnswering("sys")) + + publisher.publish(SharedShelf.Snapshot(widgetID: "sys", name: "System", viewTree: UINode(type: "text", text: "CPU 3%"))) + XCTAssertFalse(publisher.isAnswering("sys")) + XCTAssertNotNil(SharedShelf.readSnapshot(widgetID: "sys", from: container)) + XCTAssertTrue(SharedShelf.pendingRefreshRequests(in: container).isEmpty) + } } diff --git a/Tests/MenubucketCoreTests/DesktopHintsTests.swift b/Tests/MenubucketCoreTests/DesktopHintsTests.swift new file mode 100644 index 0000000..c89ceff --- /dev/null +++ b/Tests/MenubucketCoreTests/DesktopHintsTests.swift @@ -0,0 +1,77 @@ +import XCTest +@testable import MenubucketCore + +/// What a widget author can say about its desktop widget (0.6.1), and the +/// refresh button's request files. +final class DesktopHintsTests: XCTestCase { + private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(type, from: Data(json.utf8)) + } + + func testMarkedItemsAreTheOnlyItems() throws { + let tree = try decode(UINode.self, """ + {"type": "vstack", "children": [ + {"type": "text", "text": "Header that is not an item"}, + {"type": "section", "title": "Disks", "children": [ + {"type": "hstack", "desktopRole": "item", "children": [ + {"type": "text", "text": "Macintosh HD"}, {"type": "text", "text": "61%"}]}, + {"type": "hstack", "desktopRole": "item", "children": [ + {"type": "text", "text": "Backup"}, {"type": "text", "text": "12%"}]}]}, + {"type": "card", "title": "Not marked"}]} + """) + let parts = SharedShelf.parts(of: tree) + XCTAssertEqual(parts.map(\.title), ["Macintosh HD", "Backup"]) + XCTAssertEqual(parts.map(\.group), ["Disks", "Disks"]) + } + + func testHiddenNodesAreLeftOutAndNotShared() throws { + let tree = try decode(UINode.self, """ + {"type": "vstack", "children": [ + {"type": "card", "title": "Shown"}, + {"type": "card", "title": "Private", "desktopRole": "hide"}]} + """) + XCTAssertEqual(SharedShelf.parts(of: tree).map(\.title), ["Shown"]) + let encoded = String(decoding: try JSONEncoder().encode(SharedShelf.scrubbed(tree)), as: UTF8.self) + XCTAssertFalse(encoded.contains("Private")) + } + + func testAuthorRolesOutrankTheGuess() throws { + let tree = try decode(UINode.self, """ + {"type": "vstack", "children": [ + {"type": "text", "text": "Sensors", "role": "caption"}, + {"type": "text", "text": "CPU", "desktopRole": "title"}, + {"type": "text", "text": "41°", "size": 12, "desktopRole": "value"}, + {"type": "text", "text": "fan 1200 rpm", "size": 24}, + {"type": "text", "text": "8 cores", "desktopRole": "detail"}]} + """) + let summary = SharedShelf.summarize(tree) + XCTAssertEqual(summary.title, "CPU") + XCTAssertEqual(summary.value, "41°") + XCTAssertEqual(summary.detail, "8 cores") + } + + func testTheManifestDesktopBlockIsLenient() throws { + let good = try decode(Manifest.Desktop.self, #"{"style": "meters", "offer": false}"#) + XCTAssertEqual(good, Manifest.Desktop(style: "meters", offer: false)) + // Wrong types are dropped, not a reason to refuse the widget. + XCTAssertEqual(try decode(Manifest.Desktop.self, #"{"style": 3, "offer": "no"}"#), Manifest.Desktop()) + XCTAssertEqual(try decode(Manifest.Desktop.self, #""meters""#), Manifest.Desktop()) + } + + func testRefreshRequestsAreReadOnceAndLapse() throws { + let container = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: container) } + let now = Date(timeIntervalSince1970: 1_000_000) + try SharedShelf.requestRefresh(widgetID: "dev.barshelf.system", in: container, now: now) + try SharedShelf.requestRefresh(widgetID: "../escape", in: container, now: now) + XCTAssertEqual(SharedShelf.pendingRefreshRequests(in: container, now: now).keys.sorted(), ["dev.barshelf.system"]) + // Past its lifetime a request is gone, file and all. + let later = now.addingTimeInterval(SharedShelf.refreshRequestLifetime + 1) + XCTAssertTrue(SharedShelf.pendingRefreshRequests(in: container, now: later).isEmpty) + XCTAssertTrue(SharedShelf.pendingRefreshRequests(in: container, now: now).isEmpty) + + try SharedShelf.requestRefresh(widgetID: "dev.barshelf.system", in: container, now: now) + SharedShelf.clearRefreshRequest(widgetID: "dev.barshelf.system", in: container) + XCTAssertTrue(SharedShelf.pendingRefreshRequests(in: container, now: now).isEmpty) + } +} diff --git a/WidgetExtension/BarShelfWidgets/Localizable.xcstrings b/WidgetExtension/BarShelfWidgets/Localizable.xcstrings index 353ee9b..fb2f4cd 100644 --- a/WidgetExtension/BarShelfWidgets/Localizable.xcstrings +++ b/WidgetExtension/BarShelfWidgets/Localizable.xcstrings @@ -91,6 +91,36 @@ } } }, + "Refresh": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로 고침" + } + } + } + }, + "Refresh Widget": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "위젯 새로 고침" + } + } + } + }, + "Refreshing": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로 고치는 중" + } + } + } + }, "Right-click here and choose Edit Widget.": { "localizations": { "ko": { diff --git a/WidgetExtension/BarShelfWidgets/ShelfTemplates.swift b/WidgetExtension/BarShelfWidgets/ShelfTemplates.swift index 38fc27b..2f3ccf5 100644 --- a/WidgetExtension/BarShelfWidgets/ShelfTemplates.swift +++ b/WidgetExtension/BarShelfWidgets/ShelfTemplates.swift @@ -26,6 +26,8 @@ enum WidgetMetrics { case (.bigValue, _): return 1 case (.grid, .systemSmall): return 4 case (.grid, .systemMedium): return 4 + case (.grid, .systemExtraLarge): return 12 + case (_, .systemExtraLarge): return 14 case (.grid, _): return 12 case (.meters, .systemSmall): return 3 case (_, .systemSmall): return 3 @@ -36,6 +38,13 @@ enum WidgetMetrics { } } +extension WidgetFamily { + /// Large and extra large: room for headings and a line of detail. + var isRoomy: Bool { self == .systemLarge || self == .systemExtraLarge } + /// Extra large lays rows out in two columns. + var columnCount: Int { self == .systemExtraLarge ? 2 : 1 } +} + // MARK: - Big value struct BigValueTemplate: View { @@ -100,7 +109,10 @@ struct MetersTemplate: View { if family == .systemSmall, items.allSatisfy({ $0.summary.fraction != nil }) { rings } else { - VStack(alignment: .leading, spacing: family == .systemLarge ? 10 : 7) { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 18, alignment: .top), count: family.columnCount), + alignment: .leading, spacing: family.isRoomy ? 10 : 7 + ) { ForEach(items) { item in row(item.summary) } } .frame(maxWidth: .infinity, alignment: .topLeading) @@ -170,15 +182,25 @@ struct ListTemplate: View { let family: WidgetFamily var body: some View { - VStack(alignment: .leading, spacing: family == .systemLarge ? 8 : 6) { - ForEach(Array(items.enumerated()), id: \.element.id) { index, item in - if family == .systemLarge, let group = item.group, index == 0 || items[index - 1].group != group { - Text(group) - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - .padding(.top, index == 0 ? 0 : 2) + // Extra large: the same list in two columns, split in the middle. + let half = (items.count + 1) / 2 + let columns = family.columnCount == 2 && items.count > 4 + ? [Array(items.prefix(half)), Array(items.dropFirst(half))] + : [items] + HStack(alignment: .top, spacing: 18) { + ForEach(Array(columns.enumerated()), id: \.offset) { _, column in + VStack(alignment: .leading, spacing: family.isRoomy ? 8 : 6) { + ForEach(Array(column.enumerated()), id: \.element.id) { index, item in + if family.isRoomy, let group = item.group, index == 0 || column[index - 1].group != group { + Text(group) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.top, index == 0 ? 0 : 2) + } + row(item.summary) + } } - row(item.summary) + .frame(maxWidth: .infinity, alignment: .topLeading) } } .frame(maxWidth: .infinity, alignment: .topLeading) @@ -207,7 +229,7 @@ struct ListTemplate: View { MeterBar(fraction: fraction, tone: tone ?? accent, height: 3) .padding(.leading, 21) } - if family == .systemLarge, let line = summary.detail ?? summary.subtitle { + if family.isRoomy, let line = summary.detail ?? summary.subtitle { Text(line) .font(.caption2) .foregroundStyle(.secondary) @@ -251,7 +273,7 @@ struct GridTemplate: View { let family: WidgetFamily var body: some View { - let columns = family == .systemSmall ? 2 : 4 + let columns = family == .systemSmall ? 2 : family == .systemExtraLarge ? 6 : 4 let spacing: CGFloat = family == .systemSmall ? 5 : 8 LazyVGrid( columns: Array(repeating: GridItem(.flexible(), spacing: spacing, alignment: .top), count: columns), diff --git a/WidgetExtension/BarShelfWidgets/ShelfWidget.swift b/WidgetExtension/BarShelfWidgets/ShelfWidget.swift index be28de6..55e9611 100644 --- a/WidgetExtension/BarShelfWidgets/ShelfWidget.swift +++ b/WidgetExtension/BarShelfWidgets/ShelfWidget.swift @@ -10,6 +10,8 @@ struct ShelfEntry: TimelineEntry { /// Part keys the user picked, in their order; empty lets the widget pick. var partKeys: [String] = [] var style: ShelfWidgetStyle = .automatic + /// A refresh was asked for from this widget and is not answered yet. + var refreshing = false } struct ShelfProvider: AppIntentTimelineProvider { @@ -31,6 +33,13 @@ struct ShelfProvider: AppIntentTimelineProvider { func timeline(for configuration: SelectShelfWidgetIntent, in context: Context) async -> Timeline { let now = Date() var entries = [entry(for: configuration, at: now)] + // A refresh BarShelf never answers (it is not running) stops showing + // as pending when the request lapses. + if let id = configuration.widget?.id, let asked = SharedContainer.pendingRefresh(for: id) { + var settled = entry(for: configuration, at: asked.addingTimeInterval(SharedShelf.refreshRequestLifetime)) + settled.refreshing = false + entries.append(settled) + } // A second entry at the moment the reading turns old, so the widget // says so without BarShelf spending a reload on it. if let staleAfter = entries[0].snapshot?.staleAfter, staleAfter > now { @@ -48,7 +57,8 @@ struct ShelfProvider: AppIntentTimelineProvider { let keys = (configuration.parts ?? []).filter { $0.widgetID == id }.map(\.key) return ShelfEntry( date: date, widgetID: id, snapshot: SharedContainer.snapshot(for: id), - partKeys: keys, style: configuration.style + partKeys: keys, style: configuration.style, + refreshing: SharedContainer.pendingRefresh(for: id) != nil ) } @@ -93,7 +103,7 @@ struct ShelfWidget: Widget { } .configurationDisplayName("BarShelf Widget") .description("Shows one of your BarShelf widgets, or just the items you pick from it.") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge]) } } @@ -191,11 +201,14 @@ struct ShelfWidgetView: View { ) } - var template = entry.style.template ?? SharedShelf.automaticTemplate(for: all.map(\.summary)) - if entry.style.template == nil, family == .systemSmall, template == .list { + // The user's Style, else the widget author's, else BarShelf's guess + // — which the small size narrows to what fits. + let authored = snapshot.preferredStyle.flatMap(SharedShelf.Template.init(rawValue:)) + var template = entry.style.template ?? authored ?? SharedShelf.automaticTemplate(for: all.map(\.summary)) + if entry.style.template == nil, authored == nil, family == .systemSmall, template == .list { template = .bigValue } - if template == .meters, entry.style.template == nil, family == .systemSmall, all.count > 3 { + if template == .meters, entry.style.template == nil, authored == nil, family == .systemSmall, all.count > 3 { template = .bigValue } @@ -231,20 +244,41 @@ struct ShelfWidgetView: View { .lineLimit(1) .truncationMode(.middle) Spacer(minLength: 4) - if stale, let updated = snapshot.updatedAt { - // Only an old reading says how old it is; the small size has - // room for the clock alone beside the name. + if entry.refreshing { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityLabel(Text("Refreshing")) + } else if stale, family == .systemSmall, let id = entry.widgetID { + // The small size has room for the clock alone, which is also + // its refresh button. + Button(intent: RefreshShelfWidgetIntent(widgetID: id)) { + Image(systemName: "clock.arrow.circlepath") + .font(.caption2) + .foregroundStyle(.orange) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Refresh")) + } else if stale, let updated = snapshot.updatedAt { + // Only an old reading says how old it is. HStack(spacing: 3) { Image(systemName: "clock.arrow.circlepath") - if family != .systemSmall { - Text(updated, style: .relative) - } + Text(updated, style: .relative) } .font(.caption2) .foregroundStyle(.orange) .lineLimit(1) .layoutPriority(-1) } + if !entry.refreshing, family != .systemSmall, let id = entry.widgetID { + Button(intent: RefreshShelfWidgetIntent(widgetID: id)) { + Image(systemName: "arrow.clockwise") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Refresh")) + } } } diff --git a/WidgetExtension/BarShelfWidgets/ShelfWidgetIntent.swift b/WidgetExtension/BarShelfWidgets/ShelfWidgetIntent.swift index 3a4ce2c..38c18e9 100644 --- a/WidgetExtension/BarShelfWidgets/ShelfWidgetIntent.swift +++ b/WidgetExtension/BarShelfWidgets/ShelfWidgetIntent.swift @@ -157,6 +157,11 @@ enum SharedContainer { url.flatMap(SharedShelf.readIndex(from:)) } + /// When a refresh of this widget was asked for and not answered yet. + static func pendingRefresh(for widgetID: String) -> Date? { + url.flatMap { SharedShelf.pendingRefreshRequests(in: $0)[widgetID] } + } + static func snapshot(for widgetID: String) -> SharedShelf.Snapshot? { guard var snapshot = url.flatMap({ SharedShelf.readSnapshot(widgetID: widgetID, from: $0) }) else { return nil } // Written by a BarShelf from before items existed: split it here. @@ -166,3 +171,32 @@ enum SharedContainer { return snapshot } } + +/// The refresh button on a desktop widget: asks BarShelf to refresh that +/// widget now. BarShelf answers by publishing the new render and reloading +/// the desktop widgets; until then the widget says it is refreshing. +struct RefreshShelfWidgetIntent: AppIntent { + static let title: LocalizedStringResource = "Refresh Widget" + static let isDiscoverable = false + + @Parameter(title: "Widget") + var widgetID: String + + init() {} + + init(widgetID: String) { + self.widgetID = widgetID + } + + func perform() async throws -> some IntentResult { + if let container = SharedContainer.url { + try? SharedShelf.requestRefresh(widgetID: widgetID, in: container) + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(SharedShelf.refreshRequestNotification as CFString), + nil, nil, true + ) + } + return .result() + } +} diff --git a/WidgetExtension/PreviewTool/main.swift b/WidgetExtension/PreviewTool/main.swift index 5e4219e..1e40985 100644 --- a/WidgetExtension/PreviewTool/main.swift +++ b/WidgetExtension/PreviewTool/main.swift @@ -17,6 +17,7 @@ let sizes: [(String, NSSize, WidgetFamily)] = [ ("small", NSSize(width: 170, height: 170), .systemSmall), ("medium", NSSize(width: 364, height: 170), .systemMedium), ("large", NSSize(width: 364, height: 382), .systemLarge), + ("xlarge", NSSize(width: 760, height: 382), .systemExtraLarge), ] var entries: [ShelfEntry] = (SharedContainer.index()?.entries ?? []).map { ShelfEntry(date: Date(), widgetID: $0.id, snapshot: SharedContainer.snapshot(for: $0.id)) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index ae2770d..c9a0648 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -331,6 +331,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 @@ -395,7 +424,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 diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 686e111..2f79dea 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -174,7 +174,8 @@ BarShelf 위젯을 macOS 위젯으로도 띄울 수 있다. 데스크톱을 우 - **목록:** 이름·값 줄. 큰 크기에서는 묶음 제목과 한 줄 설명이 붙는다. - **격자:** 파일 썸네일과 이름. 썸네일은 BarShelf가 작게 만들어 공유하고, 더 쓰지 않으면 지운다. -작게·중간·크게를 지원하고, 누르면 BarShelf 팝업에서 그 위젯이 열린다. +작게·중간·크게·아주 크게를 지원하고, 누르면 BarShelf 팝업에서 그 위젯이 열린다. 중간 크기 이상은 +머리글의 ↻ 버튼으로 바로 새로 고칠 수 있다. 작은 크기는 값이 오래됐을 때 나오는 주황색 시계가 새로 고침 버튼이다. - macOS 위젯은 BarShelf가 마지막으로 읽은 값을 보여 준다. macOS가 위젯 갱신 횟수를 제한하므로 최대 15분마다 바뀐다. diff --git a/docs/WIDGET-SPEC.md b/docs/WIDGET-SPEC.md index 3a05d8a..18f692d 100644 --- a/docs/WIDGET-SPEC.md +++ b/docs/WIDGET-SPEC.md @@ -83,6 +83,7 @@ | `statusItem` | 아니오 | 메뉴바 status item 표시 정책. | | `permissions` | 아니오 | exec, env, 파일, storage, notifications, 네트워크, Keychain 권한 선언. | | `settings` | 아니오 | 설정 UI를 자동 생성하기 위한 선언. 값은 workflow의 `${settings.key}`와 script context로 전달된다. | +| `desktop` | 아니오 | macOS 데스크톱 위젯으로 보일 때의 설정(0.6.1). `style`은 사용자가 스타일을 자동으로 둘 때 쓸 레이아웃(`bigValue`, `meters`, `list`, `grid`)이고, `offer: false`면 데스크톱 위젯 목록에 나오지 않는다. 값이 틀려도 위젯을 거부하지 않고 무시한다. | ### `bucket` @@ -283,6 +284,12 @@ Workflow DSL 상세 계약은 [`docs/WORKFLOW.md`](WORKFLOW.md)를 따른다. ``` 반복 row/tile에는 안정적인 `id`를 권장한다. 호스트는 identity와 액션 routing에 이 값을 사용할 수 있다. + +`desktopRole`(0.6.1)은 데스크톱 위젯에서 이 노드가 무엇인지 알려 준다. 팝업은 무시한다. + +- `item`: 사용자가 고를 수 있는 항목 하나. 하나라도 있으면 표시한 노드만 항목이 된다. +- `title`, `subtitle`, `value`, `detail`, `status`: 그 칸에 이 노드의 첫 텍스트를 쓴다. +- `hide`: 데스크톱 위젯에서 빼고, 공유 폴더에도 쓰지 않는다. `drag.filePath`는 파일 row/tile 같은 반복 노드에 붙이면 가장 자연스럽다. ### 접근성 (`accessibilityLabel`)