diff --git a/.github/scripts/privacy-check.py b/.github/scripts/privacy-check.py index 5cf98431..f75d1045 100755 --- a/.github/scripts/privacy-check.py +++ b/.github/scripts/privacy-check.py @@ -86,6 +86,7 @@ ("tag", re.compile(r"\btags?\b")), ("excluded items", re.compile(r"\bexcluded\b")), ("error", re.compile(r"\berror\b(?!\.code)")), + ("window title", re.compile(r"\b(?:title|windowTitle|kCGWindowName)\b")), ] PUBLIC = re.compile(r"privacy:\s*\.public\b") diff --git a/.github/scripts/strings-check.py b/.github/scripts/strings-check.py new file mode 100755 index 00000000..1ba60d83 --- /dev/null +++ b/.github/scripts/strings-check.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +# +# strings-check.py +# +# Proves that holzBar speaks its five languages (ICE-05): English, the source +# language, and German, French, Italian and Romansh, the languages of +# https://holzcloud.ch/holzbar. +# +# 1. Every String Catalog in holzBar/Resources parses as JSON with English as +# its source language. +# 2. Every entry that is to be translated has a translated, non-empty German, +# French, Italian and Romansh value (plural variations included), and each +# translation keeps the format specifiers of its key. +# 3. Every string literal in a localisable position of holzBar's Swift code +# (SwiftUI initialisers and modifiers, String(localized:), App Intents +# titles, NSMenuItem titles, alert texts, tooltips, accessibility labels) +# has an entry; each interpolation \(...) stands for a format specifier. +# +# It runs in the strings job of .github/workflows/build.yml and locally: +# +# python3 .github/scripts/strings-check.py +# python3 .github/scripts/strings-check.py --list # the keys the code uses +# +# Every problem is printed as a GitHub annotation and makes the script exit +# with status 1. Python standard library only. + +import argparse +import json +import os +import re +import sys + +RESOURCES = "holzBar/Resources" +SOURCE_DIR = "holzBar" +LANGUAGES = ("de", "fr", "it", "rm") + +# A format specifier as Xcode writes it into a catalog key. +SPECIFIER = r"%(?:\d+\$)?(?:@|lld|llu|ld|lu|d|u|lf|f|g)" +SPECIFIER_RE = re.compile(SPECIFIER) + +# Where a string literal is localised. Each pattern ends right before the +# opening quote of the literal. +POSITIONS = [ + r"\b(?:Text|Toggle|Button|Label|Menu|ColorPicker|TextField|Picker|Section|LabeledContent|Stepper|HolzBarSection|HolzBarPicker)\(\s*", + r"\.(?:annotation|help|navigationTitle|accessibilityLabel|accessibilityHint|alert|confirmationDialog)\(\s*", + r"\.accessibilityAction\(\s*named:\s*", + r"\bString\(\s*localized:\s*", + r"\bLocalizedStringResource\(\s*", + r"\bLocalizedStringKey\(\s*", + r"\bNSMenuItem\(\s*title:\s*", + r"\b(?:messageText|informativeText|toolTip|title|prompt|message)\s*=\s*", + r"\baddButton\(\s*withTitle:\s*", + r"\bsetAccessibilityLabel\(\s*", + r"\bAcknowledgementsGroup\(\s*title:\s*", + # App Intents + r"\bstatic let title: LocalizedStringResource\s*=\s*", + r"\bTypeDisplayRepresentation\s*=\s*", + r"@Parameter\(\s*title:\s*", + r"\bshortTitle:\s*", + r"^\s*\.\w+:\s*", # caseDisplayRepresentations entries such as `.hidden: "Hidden",` +] +POSITION_RE = re.compile("(?:" + "|".join(POSITIONS) + r')(?=#?")', re.MULTILINE) + +# Computed properties whose string literals are localised keys. +KEY_BLOCK_RE = re.compile(r"(?:var \w+: LocalizedStringKey|-> LocalizedStringKey|var localizedStringResource: LocalizedStringResource)\s*\{") + +# Files whose literals in the positions above are not shown to the user. +IGNORED_FILES = { + "holzBar/MenuBar/Appearance/MenuBarOverlayPanel.swift", # the overlay's window title +} + +# Positions that are only localisable in some files: the generic `title =`, +# `message =` and `prompt =` assignments and dictionary entries. +GENERIC_POSITION_RE = re.compile(r"^(?:\b(?:title|prompt|message)\s*=\s*|\s*\.\w+:\s*)$", re.MULTILINE) +GENERIC_POSITION_FILES = { + "holzBar/Main/HolzBarIntents.swift", + "holzBar/Utilities/SettingsBackup.swift", + "holzBar/Utilities/SettingsSync.swift", + "holzBar/MenuBar/MenuBarItems/ItemIconStore.swift", + "holzBar/MenuBar/Groups/MenuBarItemGroups.swift", +} + + +def annotate(path, line, message): + print(f"::error file={path},line={line}::{message}") + + +def line_of(text, offset): + return text.count("\n", 0, offset) + 1 + + +def parse_literal(text, start): + """Parses the string literal at `start` (a quote, or # before a quote). + + Returns (key, end) where key is the literal's text with each interpolation + replaced by U+FFFC, or (None, end) for a raw string with interpolations it + cannot read. + """ + hashes = 0 + while text[start + hashes] == "#": + hashes += 1 + index = start + hashes + multiline = text.startswith('"""', index) + delimiter = '"""' if multiline else '"' + index += len(delimiter) + escape = "\\" + "#" * hashes + closing = delimiter + "#" * hashes + parts = [] + while index < len(text): + if text.startswith(closing, index): + index += len(closing) + break + if text.startswith(escape, index): + index += len(escape) + char = text[index] + if char == "(": + depth = 1 + index += 1 + while index < len(text) and depth: + if text[index] == "(": + depth += 1 + elif text[index] == ")": + depth -= 1 + elif text[index] == '"': + # A string literal inside the interpolation. + _, index = parse_literal(text, index) + continue + index += 1 + parts.append("\ufffc") + continue + if char == "u" and text[index + 1] == "{": + end = text.index("}", index) + parts.append(chr(int(text[index + 2:end], 16))) + index = end + 1 + continue + if char == "\n": + # A line continuation in a multi-line literal. + index += 1 + while index < len(text) and text[index] in " \t": + index += 1 + continue + parts.append({"n": "\n", "t": "\t", "0": "\0", "\\": "\\", '"': '"', "'": "'"}.get(char, char)) + index += 1 + continue + parts.append(text[index]) + index += 1 + value = "".join(parts) + if multiline: + lines = value.split("\n") + # Drop the line of the opening and closing delimiters and the indentation. + if lines and not lines[0].strip(): + lines = lines[1:] + if lines and not lines[-1].strip(): + indent = len(lines[-1]) + lines = lines[:-1] + else: + indent = 0 + value = "\n".join(line[indent:] for line in lines) + return value, index + + +def code_keys(root): + """Yields (path, line, key) for every localised literal in the code.""" + base = os.path.join(root, SOURCE_DIR) + for current, dirs, files in os.walk(base): + dirs.sort() + for name in sorted(files): + if not name.endswith(".swift"): + continue + full = os.path.join(current, name) + path = os.path.relpath(full, root).replace(os.sep, "/") + # Core is Foundation only; the views translate its texts from the catalog. + if path in IGNORED_FILES or path.startswith("holzBar/Core/") or "/MacOS27/Core/" in path: + continue + with open(full, encoding="utf-8") as file: + text = strip_comments(file.read()) + for match in POSITION_RE.finditer(text): + if GENERIC_POSITION_RE.match(match.group(0)) and path not in GENERIC_POSITION_FILES: + continue + key, _ = parse_literal(text, match.end()) + if key is not None and is_text(key): + yield path, line_of(text, match.start()), key + for match in KEY_BLOCK_RE.finditer(text): + end = block_end(text, match.end()) + index = match.end() + while index < end: + if text[index] != '"': + index += 1 + continue + key, after = parse_literal(text, index) + if key is not None and is_text(key): + yield path, line_of(text, index), key + index = after + + +def strip_comments(text): + """Blanks out // comments outside string literals, keeping the offsets.""" + result = [] + index = 0 + while index < len(text): + if text.startswith("//", index): + end = text.find("\n", index) + end = len(text) if end < 0 else end + result.append(" " * (end - index)) + index = end + continue + if text[index] == '"' or (text[index] == "#" and text.startswith('#"', index)): + _, end = parse_literal(text, index) + result.append(text[index:end]) + index = end + continue + result.append(text[index]) + index += 1 + return "".join(result) + + +def block_end(text, start): + depth = 1 + index = start + while index < len(text) and depth: + if text[index] == '"': + _, index = parse_literal(text, index) + continue + if text[index] == "{": + depth += 1 + elif text[index] == "}": + depth -= 1 + index += 1 + return index + + +def is_text(key): + """Whether a literal is text for people: it has letters beyond placeholders.""" + return re.search(r"[A-Za-z]{2,}", key.replace("\ufffc", "")) is not None + + +def key_pattern(key): + """A pattern of the catalog keys of a literal with interpolations: each one is a + format specifier, and a percent sign of the text is written twice.""" + parts = key.split("\ufffc") + return re.compile("^" + SPECIFIER.join(re.escape(part.replace("%", "%%")) for part in parts) + "$", re.DOTALL) + + +def string_units(entry): + """Yields (language, unit) for every string unit of every localization.""" + for language, localization in entry.get("localizations", {}).items(): + stack = [localization] + while stack: + node = stack.pop() + if not isinstance(node, dict): + continue + if "stringUnit" in node: + yield language, node["stringUnit"] + for value in node.values(): + if isinstance(value, dict): + stack.extend(value.values() if "stringUnit" not in value else [value]) + + +def specifiers(text): + """The kinds of the format specifiers of a text; a translation may number + them (%1$@, %2$@) to change their order.""" + return sorted(re.sub(r"\d+\$", "", found) for found in SPECIFIER_RE.findall(text.replace("%%", ""))) + + +def check_catalog(path, data): + problems = 0 + if data.get("sourceLanguage") != "en": + annotate(path, 1, "The source language must be en") + problems += 1 + strings = data.get("strings", {}) + for key, entry in strings.items(): + if entry.get("shouldTranslate") is False: + continue + units = {} + for language, unit in string_units(entry): + units.setdefault(language, []).append(unit) + for language in LANGUAGES: + found = units.get(language, []) + if not found: + annotate(path, 1, f"{key!r} has no {language} translation") + problems += 1 + continue + for unit in found: + value = unit.get("value", "") + if unit.get("state") != "translated" or not value.strip(): + annotate(path, 1, f"{key!r} has an unfinished {language} translation") + problems += 1 + elif specifiers(value) != specifiers(key) and "variations" not in entry.get("localizations", {}).get(language, {}): + annotate(path, 1, f"{key!r}: the {language} translation changes the format specifiers") + problems += 1 + return problems + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=".") + parser.add_argument("--list", action="store_true", help="print the keys the code uses as JSON") + args = parser.parse_args() + root = args.root + + uses = list(code_keys(root)) + if args.list: + json.dump( + sorted({key.replace("\ufffc", "\\(…)") for _, _, key in uses}), + sys.stdout, + ensure_ascii=False, + indent=1, + ) + print() + return 0 + + problems = 0 + catalogs = {} + directory = os.path.join(root, RESOURCES) + names = sorted(name for name in os.listdir(directory) if name.endswith(".xcstrings")) + if "Localizable.xcstrings" not in names: + annotate(f"{RESOURCES}/Localizable.xcstrings", 1, "The String Catalog is missing") + return 1 + for name in names: + path = f"{RESOURCES}/{name}" + try: + with open(os.path.join(directory, name), encoding="utf-8") as file: + catalogs[name] = json.load(file) + except (OSError, ValueError) as error: + annotate(path, 1, f"Not a String Catalog: {error}") + problems += 1 + continue + problems += check_catalog(path, catalogs[name]) + + keys = set(catalogs.get("Localizable.xcstrings", {}).get("strings", {})) + for path, line, key in uses: + if "\ufffc" not in key and key in keys: + continue + pattern = key_pattern(key) + if any(pattern.match(candidate) for candidate in keys): + continue + shown = key.replace("\ufffc", "\\(…)") + annotate(path, line, f"No String Catalog entry for {shown!r}") + problems += 1 + + total = sum(len(catalog.get("strings", {})) for catalog in catalogs.values()) + if problems: + print(f"==> {problems} String Catalog problem(s)") + return 1 + print(f"==> String Catalogs complete: {total} strings in {1 + len(LANGUAGES)} languages") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f3f33ef8..99e17ad9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,11 +1,6 @@ name: Build on: pull_request: - push: - branches: [ "main" ] - paths: - - "**/*.swift" - - "holzBar.xcodeproj/**" permissions: contents: read @@ -161,6 +156,30 @@ jobs: fi echo "==> Acknowledgements: $COUNT license files in the app" + # The String Catalogs must reach the app in every language, and the App Intents + # metadata must be extracted, or Shortcuts shows none of holzBar's actions. + - name: Check the languages and the Shortcuts actions + run: | + RESOURCES=build/Build/Products/Release/holzBar.app/Contents/Resources + FAILED=0 + echo "Resources: $(ls "$RESOURCES" | tr '\n' ' ')" + for LANGUAGE in en de fr it rm; do + FILES=$(ls "$RESOURCES/$LANGUAGE.lproj" 2>/dev/null | tr '\n' ' ' || true) + echo "$LANGUAGE.lproj: ${FILES:-missing}" + if [ "$LANGUAGE" != en ] && [ ! -f "$RESOURCES/$LANGUAGE.lproj/Localizable.strings" ]; then + echo "::error::$LANGUAGE.lproj/Localizable.strings is missing from the built holzBar.app" + FAILED=1 + fi + done + if [ ! -d "$RESOURCES/Metadata.appintents" ]; then + echo "::error::Metadata.appintents is missing from the built holzBar.app" + FAILED=1 + fi + if [ "$FAILED" -ne 0 ]; then + exit 1 + fi + echo "==> Five languages and the App Intents metadata in the app" + # The app's former name must not appear anywhere in the repository (see CLAUDE.md). # .planning/ and .claude/ are GSD's working state; .github/cms-version.py is a copy # of a tool maintained in holzcloud-design. The pattern is written so that it does @@ -208,6 +227,18 @@ jobs: - name: Logs keep personal data private run: python3 .github/scripts/privacy-check.py logs + # holzBar speaks English, German, French, Italian and Romansh. Fails every pull + # request that adds a text without its translations, or a translation that is + # unfinished or changes the format specifiers of its text. + # Runs locally too: python3 .github/scripts/strings-check.py + strings: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Every text in five languages + run: python3 .github/scripts/strings-check.py + test: runs-on: macos-26 steps: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7d58c7e8..15ec6e0f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,11 +1,5 @@ name: Lint Swift Files on: - push: - branches: [ "main" ] - paths: - - ".github/workflows/lint.yml" - - ".swiftlint.yml" - - "**/*.swift" pull_request: paths: - ".github/workflows/lint.yml" diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 12d197ab..7f188415 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -79,34 +79,34 @@ Source: `.planning/codebase/CONCERNS.md` (file:line references there). ### Thaw fixes and speed -- [ ] **THAW-01**: see `.planning/research/THAW.md` -- [ ] **THAW-02**: see `.planning/research/THAW.md` -- [ ] **THAW-03**: see `.planning/research/THAW.md` -- [ ] **THAW-04**: see `.planning/research/THAW.md` -- [ ] **THAW-05**: see `.planning/research/THAW.md` -- [ ] **THAW-06**: see `.planning/research/THAW.md` -- [ ] **THAW-07**: see `.planning/research/THAW.md` -- [ ] **THAW-08**: see `.planning/research/THAW.md` - -- [ ] **ICE-01**: see `.planning/research/THAW.md` (Ice upstream scan) -- [ ] **ICE-02**: see THAW.md -- [ ] **ICE-03**: see THAW.md -- [ ] **ICE-04**: see THAW.md -- [ ] **ICE-06**: see THAW.md +- [x] **THAW-01**: see `.planning/research/THAW.md` +- [x] **THAW-02**: see `.planning/research/THAW.md` +- [x] **THAW-03**: see `.planning/research/THAW.md` +- [x] **THAW-04**: see `.planning/research/THAW.md` +- [x] **THAW-05**: see `.planning/research/THAW.md` +- [x] **THAW-06**: see `.planning/research/THAW.md` +- [x] **THAW-07**: see `.planning/research/THAW.md` +- [x] **THAW-08**: see `.planning/research/THAW.md` + +- [x] **ICE-01**: see `.planning/research/THAW.md` (Ice upstream scan) +- [x] **ICE-02**: see THAW.md +- [x] **ICE-03**: see THAW.md +- [x] **ICE-04**: see THAW.md +- [x] **ICE-06**: see THAW.md ### Thaw features -- [ ] **THAW-10**: see `.planning/research/THAW.md` -- [ ] **THAW-11**: see `.planning/research/THAW.md` -- [ ] **THAW-12**: see `.planning/research/THAW.md` -- [ ] **THAW-13**: see `.planning/research/THAW.md` -- [ ] **THAW-14**: see `.planning/research/THAW.md` -- [ ] **THAW-15**: see `.planning/research/THAW.md` -- [ ] **THAW-16**: see `.planning/research/THAW.md` -- [ ] **THAW-17**: see `.planning/research/THAW.md` +- [x] **THAW-10**: see `.planning/research/THAW.md` +- [x] **THAW-11**: see `.planning/research/THAW.md` +- [x] **THAW-12**: see `.planning/research/THAW.md` +- [x] **THAW-13**: see `.planning/research/THAW.md` +- [x] **THAW-14**: see `.planning/research/THAW.md` +- [x] **THAW-15**: see `.planning/research/THAW.md` +- [x] **THAW-16**: see `.planning/research/THAW.md` +- [x] **THAW-17**: see `.planning/research/THAW.md` -- [ ] **SYNC-01**: Settings sync works with any synced folder, not only iCloud Drive: the user picks the folder (iCloud Drive as default, or Nextcloud, Dropbox, OneDrive, Syncthing, a network share …) in an open panel; holzBar only reads and writes `holzBar/Settings.plist` there with file coordination and file presentation (no polling, no network code — the folder's own client syncs); the choice is stored as a bookmark and survives renames; the Settings pane shows the folder and lets the user change or turn off sync; README, permissions table and comparison tables updated ("Settings sync: iCloud Drive or any synced folder") -- [ ] **ICE-05**: localization (String Catalog: en, de, fr, it, rm) — see THAW.md +- [x] **SYNC-01**: Settings sync works with any synced folder, not only iCloud Drive: the user picks the folder (iCloud Drive as default, or Nextcloud, Dropbox, OneDrive, Syncthing, a network share …) in an open panel; holzBar only reads and writes `holzBar/Settings.plist` there with file coordination and file presentation (no polling, no network code — the folder's own client syncs); the choice is stored as a bookmark and survives renames; the Settings pane shows the folder and lets the user change or turn off sync; README, permissions table and comparison tables updated ("Settings sync: iCloud Drive or any synced folder") +- [x] **ICE-05**: localization (String Catalog: en, de, fr, it, rm) — see THAW.md ### Security and performance @@ -218,29 +218,29 @@ Source: `.planning/codebase/CONCERNS.md` (file:line references there). | PRIV-01 | Phase 05.1 | Complete | | PRIV-02 | Phase 05.1 | Complete | | PERM-01 | Phase 05.1 | Complete | -| THAW-01 | Phase 05.1.1 | Pending | -| THAW-02 | Phase 05.1.1 | Pending | -| THAW-03 | Phase 05.1.1 | Pending | -| THAW-04 | Phase 05.1.1 | Pending | -| THAW-05 | Phase 05.1.1 | Pending | -| THAW-06 | Phase 05.1.1 | Pending | -| THAW-07 | Phase 05.1.1 | Pending | -| THAW-08 | Phase 05.1.1 | Pending | -| THAW-10 | Phase 05.1.1.1 | Pending | -| THAW-11 | Phase 05.1.1.1 | Pending | -| THAW-12 | Phase 05.1.1.1 | Pending | -| THAW-13 | Phase 05.1.1.1 | Pending | -| THAW-14 | Phase 05.1.1.1 | Pending | -| THAW-15 | Phase 05.1.1.1 | Pending | -| THAW-16 | Phase 05.1.1.1 | Pending | -| THAW-17 | Phase 05.1.1.1 | Pending | -| ICE-01 | Phase 05.1.1 | Pending | -| ICE-02 | Phase 05.1.1 | Pending | -| ICE-03 | Phase 05.1.1 | Pending | -| ICE-04 | Phase 05.1.1 | Pending | -| ICE-06 | Phase 05.1.1 | Pending | -| SYNC-01 | Phase 05.1.1.1 | Pending | -| ICE-05 | Phase 05.1.1.1 | Pending | +| THAW-01 | Phase 05.1.1 | Complete | +| THAW-02 | Phase 05.1.1 | Complete | +| THAW-03 | Phase 05.1.1 | Complete | +| THAW-04 | Phase 05.1.1 | Complete | +| THAW-05 | Phase 05.1.1 | Complete | +| THAW-06 | Phase 05.1.1 | Complete | +| THAW-07 | Phase 05.1.1 | Complete | +| THAW-08 | Phase 05.1.1 | Complete | +| THAW-10 | Phase 05.1.1.1 | Complete | +| THAW-11 | Phase 05.1.1.1 | Complete | +| THAW-12 | Phase 05.1.1.1 | Complete | +| THAW-13 | Phase 05.1.1.1 | Complete | +| THAW-14 | Phase 05.1.1.1 | Complete | +| THAW-15 | Phase 05.1.1.1 | Complete | +| THAW-16 | Phase 05.1.1.1 | Complete | +| THAW-17 | Phase 05.1.1.1 | Complete | +| ICE-01 | Phase 05.1.1 | Complete | +| ICE-02 | Phase 05.1.1 | Complete | +| ICE-03 | Phase 05.1.1 | Complete | +| ICE-04 | Phase 05.1.1 | Complete | +| ICE-06 | Phase 05.1.1 | Complete | +| SYNC-01 | Phase 05.1.1.1 | Complete | +| ICE-05 | Phase 05.1.1.1 | Complete | | AUDIT-01 | Phase 6 | Pending | | COMPAT-01 | Phase 06.1 | Pending | | COMPAT-02 | Phase 06.1 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 8a52b966..0f7c9598 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -170,24 +170,24 @@ Plans: **Goal:** The bugs Thaw fixed that holzBar shares are fixed, and revealing is fast and never freezes input **Requirements**: THAW-01, THAW-02, THAW-03, THAW-04, THAW-05, THAW-06, THAW-07, THAW-08, ICE-01, ICE-02, ICE-03, ICE-04, ICE-06 **Depends on:** Phase 05.1 -**Plans:** 3 plans (sequential waves; one PR "Thaw and Ice fixes and features" for Phases 05.1.1 and 05.1.1.1, pushed once per plan) +**Plans:** 3/3 plans executed (sequential waves; one PR "Thaw and Ice fixes and features" for Phases 05.1.1 and 05.1.1.1, pushed once per plan) Plans: -- [ ] 05.1.1-01-PLAN.md — Tracer: no input path waits on another app (cached application menu frame, AX timeouts) and show on click reveals from cached state; taps recover, automatic moves back off, rest while locked/asleep; the look follows the icons and survives sleep and login; validated hotkeys and colours; draft PR opened -- [ ] 05.1.1-02-PLAN.md — Tracer: item identity survives title changes and one restore path brings items back to their section; only real menus delay rehiding, clock clicks inside the bar, no Dock icon, notch apps left alone; Shelf clicks on 26, spacing relaunch, URL commands that rearrange ask first -- [ ] 05.1.1-03-PLAN.md — Tracer: macOS 27 hit tests on the second display and the icon out from under the notch; no screenshot under the bar on 27 (desktop picture, clock cover), unsquashed launching apps; rest of jordanbaird/Ice#1001; upstream bug list and README +- [x] 05.1.1-01-PLAN.md — Tracer: no input path waits on another app (cached application menu frame, AX timeouts) and show on click reveals from cached state; taps recover, automatic moves back off, rest while locked/asleep; the look follows the icons and survives sleep and login; validated hotkeys and colours; draft PR opened +- [x] 05.1.1-02-PLAN.md — Tracer: item identity survives title changes and one restore path brings items back to their section; only real menus delay rehiding, clock clicks inside the bar, no Dock icon, notch apps left alone; Shelf clicks on 26, spacing relaunch, URL commands that rearrange ask first +- [x] 05.1.1-03-PLAN.md — Tracer: macOS 27 hit tests on the second display and the icon out from under the notch; no screenshot under the bar on 27 (desktop picture, clock cover), unsquashed launching apps; rest of jordanbaird/Ice#1001; upstream bug list and README ### Phase 05.1.1.1: Thaw features (INSERTED) **Goal:** holzBar gains Thaw's best features within its principles (no network, least privilege, lean) **Requirements**: THAW-10, THAW-11, THAW-12, THAW-13, THAW-14, THAW-15, THAW-16, THAW-17, ICE-05, SYNC-01 **Depends on:** Phase 05.1.1 -**Plans:** 3 plans (sequential waves; same PR as Phase 05.1.1, pushed once per plan) +**Plans:** 3/3 plans executed (sequential waves; same PR as Phase 05.1.1, pushed once per plan) Plans: -- [ ] 05.1.1.1-01-PLAN.md — Tracer: Zen mode from a hotkey, the menu, a URL and Shortcuts; Zen while presenting (notifications only); hotkeys per profile and item; App Intents; profiles bound to a display, then a Space -- [ ] 05.1.1.1-02-PLAN.md — Tracer: open an item by letter through one open path; Layout pane and Shelf from the keyboard, undo, VoiceOver actions; opened items stay 0–30 s or open in place; items show briefly when they change -- [ ] 05.1.1.1-03-PLAN.md — Tracer: item images of your choice with app-icon fallback, coloured folders; dashed/dotted borders, wallpaper, accent and glass tints; String Catalogs in en, de, fr, it, rm checked by CI; final README and PR body +- [x] 05.1.1.1-01-PLAN.md — Tracer: Zen mode from a hotkey, the menu, a URL and Shortcuts; Zen while presenting (notifications only); hotkeys per profile and item; App Intents; profiles bound to a display, then a Space +- [x] 05.1.1.1-02-PLAN.md — Tracer: open an item by letter through one open path; Layout pane and Shelf from the keyboard, undo, VoiceOver actions; opened items stay 0–30 s or open in place; items show briefly when they change +- [x] 05.1.1.1-03-PLAN.md — Tracer: item images of your choice with app-icon fallback, coloured folders; dashed/dotted borders, wallpaper, accent and glass tints; String Catalogs in en, de, fr, it, rm checked by CI; final README and PR body ### Phase 05.1.1.1.1: Apple APIs and toolchain (INSERTED) diff --git a/.planning/STATE.md b/.planning/STATE.md index c4346ad8..10b8a8f4 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,16 +4,16 @@ milestone: v0.0.6 current_phase: 1 current_phase_name: CI and build status: verifying -stopped_at: Completed 05.1-03-PLAN.md -last_updated: "2026-10-03T02:39:34.510Z" +stopped_at: Completed 05.1.1.1-03-PLAN.md +last_updated: "2026-10-03T08:14:39.348Z" last_activity: 2026-10-02 last_activity_desc: Roadmap created (6 phases, 37 requirements mapped) -state_head: dd67acf39b15d925a596d85a2a35902070808df4 +state_head: 37ae8076c04bdf4f1db40d027dddd50e84558ec4 progress: - total_phases: 12 + total_phases: 13 completed_phases: 0 - total_plans: 23 - completed_plans: 23 + total_plans: 29 + completed_plans: 29 --- # Project State @@ -78,6 +78,12 @@ Progress: [░░░░░░░░░░] 0% | Phase 05.1 P01 | 23min | 3 tasks | 56 files | | Phase 05.1 P02 | 19min | 3 tasks | 35 files | | Phase 05.1 P03 | 85min | 3 tasks | 138 files | +| Phase 05.1.1 P01 | 95min | 3 tasks | 34 files | +| Phase 05.1.1 P02 | 80min | 3 tasks | 41 files | +| Phase 05.1.1 P03 | 70min | 3 tasks | 24 files | +| Phase 05.1.1.1 P01 | 180min | 3 tasks | 37 files | +| Phase 05.1.1.1 P02 | 150min | 3 tasks | 32 files | +| Phase 05.1.1.1 P03 | 240min | 4 tasks | 50 files | ## Accumulated Context @@ -137,6 +143,16 @@ Recent decisions affecting current work: - [Phase 05.1]: 05.1-03: Swift 6 language mode (SWIFT_VERSION 6.2 = -swift-version 6) with MainActor default isolation; Core, macOS 27 core and Shared explicitly nonisolated - [Phase 05.1]: 05.1-03: @Observable everywhere; ObservationLoop (dedupes equatable values) and Debouncer replace Combine - [Phase 05.1]: 05.1-03: per-macOS behaviour through MenuBarBackends.current (WindowListBackend, ServiceBackend26, AccessibilityBackend27); event posting on the main actor +- [Phase 05.1.1]: 05.1.1-01: Accessibility reads of the application menu run off the main thread in ApplicationMenuFrames; timeouts are per element, never on the system-wide element +- [Phase 05.1.1]: 05.1.1-01: CI runs once for plans 01-03 (single push at the end of 05.1.1-03) +- [Phase 05.1.1]: 05.1.1-02: Before macOS 27, item sections are saved by identity (ItemSections) and restored only through reconcileSections; item list changes place new items only +- [Phase 05.1.1]: 05.1.1-02: Keep the Dock icon hidden (default on) stops automatic hiding of application menus; the toggle hotkey still hides them +- [Phase 05.1.1]: 05.1.1-03: On macOS 27 nothing captures the wallpaper; the strip beside a shape comes from the desktop picture file +- [Phase 05.1.1.1]: Zen mode works like Thaw's; presenting is detected from the screen sharing agent and mirrored displays through notifications, no polling +- [Phase 05.1.1.1]: App Intents reach the app through AppState.current; every way of opening an item goes through ItemOpener +- [Phase 05.1.1.1]: The rehide delay starts when the item's menu closes, capped at 30 s; change reveal is opt-in per item +- [Phase 05.1.1.1]: SYNC-01: settings sync through iCloud Drive or any folder the Macs sync, kept as a bookmark; iCloud users migrated without a prompt +- [Phase 05.1.1.1]: holzBar ships machine-written de, fr, it, rm String Catalogs; a strings CI job proves completeness ### Pending Todos @@ -156,6 +172,6 @@ Items acknowledged and deferred at milestone close, most recent first: ## Session Continuity -Last session: 2026-10-03T02:39:34.408Z -Stopped at: Completed 05.1-03-PLAN.md +Last session: 2026-10-03T08:14:39.280Z +Stopped at: Completed 05.1.1.1-03-PLAN.md Resume file: None diff --git a/.planning/phases/05.1.1-thaw-fixes-and-speed/05.1.1-01-SUMMARY.md b/.planning/phases/05.1.1-thaw-fixes-and-speed/05.1.1-01-SUMMARY.md new file mode 100644 index 00000000..894d285d --- /dev/null +++ b/.planning/phases/05.1.1-thaw-fixes-and-speed/05.1.1-01-SUMMARY.md @@ -0,0 +1,201 @@ +--- +phase: 05.1.1-thaw-fixes-and-speed +plan: 01 +subsystem: input, menu bar items, appearance, hotkeys +status: complete +tags: [THAW-07, THAW-05, THAW-03, ICE-01, accessibility, event-taps, backoff, sleep, appearance, hardening] +requires: [] +provides: + - ApplicationMenuFrames (off-main cache of application menu frames and menu bar validity) + - SystemActivity + SystemActivityMonitor (lock/sleep/session pause, 2 s settle) + - MoveBackoff (pause of automatic moves) + - ColorComponents (stored colour validation) +affects: [05.1.1-02, 05.1.1-03] +tech-stack: + added: [] + patterns: + - "Blocking Accessibility reads on a private serial queue, published to an @Observable cache on the main actor" + - "Event-driven pause/settle state with one bounded one-shot wait" +key-files: + created: + - holzBar/MenuBar/ApplicationMenuFrames.swift + - holzBar/Events/SystemActivityMonitor.swift + - holzBar/Core/SystemActivity.swift + - holzBar/Core/MoveBackoff.swift + - holzBar/Core/ColorComponents.swift + - Tests/HolzBarCoreTests/SystemActivityTests.swift + - Tests/HolzBarCoreTests/MoveBackoffTests.swift + - Tests/HolzBarCoreTests/ColorComponentsTests.swift + modified: + - Shared/Utilities/AXHelpers.swift + - holzBar/Utilities/Extensions.swift + - holzBar/Events/MenuBarHitTesting.swift + - holzBar/Events/HIDEventManager.swift + - holzBar/Events/EventTap.swift + - holzBar/Events/EventMonitor.swift + - holzBar/Main/AppState.swift + - holzBar/MenuBar/MenuBarManager.swift + - holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift + - holzBar/MenuBar/MenuBarItems/MenuBarItemImageCache.swift + - holzBar/MenuBar/LayoutBar/LayoutBarPaddingView.swift + - holzBar/MenuBar/MacOS27/MenuBarItemProvider27.swift + - holzBar/MenuBar/MacOS27/Concealer27.swift + - holzBar/MenuBar/MacOS27/ItemImageStore27.swift + - holzBar/MenuBar/Appearance/MenuBarOverlayPanel.swift + - holzBar/MenuBar/Appearance/MenuBarAppearanceManager.swift + - holzBar/Core/HotkeyStorage.swift + - holzBar/Hotkeys/KeyCombination.swift + - holzBar/Hotkeys/KeyCode.swift + - holzBar/Hotkeys/HotkeyRegistry.swift + - holzBar/Settings/Models/HotkeysSettings.swift + - holzBar/UI/Utilities/HolzBarColor.swift + - holzBar/UI/Utilities/HolzBarGradient.swift + - Tests/HolzBarCoreTests/HotkeyStorageTests.swift + - .github/scripts/privacy-check.py + - NOTICE +decisions: + - "The application menu frame and the menu bar's validity are read off the main thread by ApplicationMenuFrames; no input path asks another app" + - "The 0.25 s messaging timeout is set per element, never on the system-wide element (that would change the timeout of every Accessibility call in the process)" + - "Off-main live reads of macOS 27 item bounds keep the default timeout: ItemClicker27 presses the same element, and a press can wait for the item's menu" + - "MoveBackoff's reset interval is measured from the end of the last pause, so the pause can actually grow to the 600 s cap" + - "Stored colours slightly out of 0...1 are clamped, not rejected, so colours picked in an extended colour space survive; wrong component counts and non-numbers are rejected" + - "Push and PR deferred to the end of plan 05.1.1-03 (coordinator: run CI once for all three plans)" +metrics: + duration: 95min + completed: 2026-10-03 + tasks: 3 + files: 34 +estimate: + tokens: 330000 + tasks: 3 +actuals: + tokens: 26100 + tasks: 3 + commits: 4 +plan_head_before: f57ad439cbbb8265080da03fa2eb170d152e7de9 +plan_head_after: 785c05d93c3591e58c43d5dfc088ef9a779dd86f +--- + +# Phase 05.1.1 Plan 01: Input never waits on another app; robust monitors, moves, sleep and appearance; hardened hotkeys and colours Summary + +holzBar no longer asks another app through Accessibility on any input path: an off-main `ApplicationMenuFrames` cache (0.25 s per-element timeout) serves show on click, hover, the context menu, hiding application menus, temporary showing and the overlay; event taps rebuild invalid ports, automatic moves back off (adapted from Thaw's MoveCircuitBreaker), holzBar rests while the Mac is locked or asleep and settles 2 s after, the menu bar look stands on every desktop, and hostile stored hotkeys and colours are rejected without a trap. + +## What was done + +### Task 1 (tracer): show on click from cached state, nothing on the input path waits (THAW-07, THAW-05) — d7a8a86 +- `holzBar/MenuBar/ApplicationMenuFrames.swift`: `@MainActor @Observable` cache owned by `AppState`. Reads every screen's application menu frame and whether its menu bar window is an Accessibility menu bar, on a private serial queue; a newer read replaces an older one (generation counter). Triggers: KVO of `frontmostApplication` and `menuBarOwningApplication`, `activeSpaceDidChangeNotification`, `didChangeScreenParametersNotification`, setup; each trigger reads now, after 150 ms and after 600 ms, then stops. +- `getApplicationMenuFrame()` moved to the `Sendable` `ApplicationMenuQuery` (built on the main actor from `NSScreen.applicationMenuQuery`), documented as blocking; it is called only from `ApplicationMenuFrames.swift`. +- `AXHelpers`: `applicationMenuTimeout = 0.25`, `application(for:messagingTimeout:)`, `setMessagingTimeout(_:for:)`; `applicationMenuBar(at:)` and every child read of the application menu use the 0.25 s timeout. The XPC service keeps the default. +- Readers switched to the cache: `MenuBarHitTesting.isMouseInsideApplicationMenu`, `MenuBarManager` (hiding application menus), `MenuBarItemManager.temporarilyShow`, the overlay (observes `frames` with `ObservationLoop`; its 1 ms / 1 s read loop is gone; a click only asks for one off-main refresh). +- `MenuBarManager.hasValidMenuBar(in:for:)` (overlay validation, reached from clicks through the overlay's updates) reads validity from the cache instead of a main-thread system-wide hit test. +- `MenuBarItemProvider27.currentBounds(for:)`: on the main thread answers from the last read. +- Show on click logs `Show on click: revealed N ms after the click` (debug) after the toggle (on macOS 27 after `Concealer27.update()`). + +### Task 2: taps recover, moves back off, holzBar rests (THAW-03) — 25fd4fc +- `SystemActivity` (Core): lock / sleep / session flags, `isPaused`, `settled(at:)` 2 s after unlock, wake, session return or a display change. 5 tests. +- `MoveBackoff` (Core, adapted from Thaw's `MoveCircuitBreaker`): window 60 s, same-item limit 4 (5th trips), 8 failures trip, pause 60 s doubling to 600 s, reset 300 s after the last pause ended, user moves clear it. 6 tests. +- `SystemActivityMonitor` (`AppState.systemActivityMonitor`): notification Tasks for workspace sleep/wake/screens sleep/wake/session, `com.apple.screenIsLocked`/`Unlocked`, screen parameters; one bounded `settleDelay` sleep per resume; `onSettled` handlers. +- While paused: no item list reads, no `cacheItemsRegardless`, no `placeNewItems`, no automatic move, no image capture (both backends), `Concealer27.update()` leaves assertions as they are. On settle (`AppState.systemActivityDidSettle`): `HIDEventManager.healthCheck()`, `concealer27.update()`, `appearanceManager.systemActivityDidSettle()`, `cacheItemsRegardless()`, `retryPausedRehide()`, `imageCache.updateCache()`. +- `EventTap` keeps its creation parameters, `recreateIfInvalid()`, `enable()` recreates, adds the run loop source, then enables. `EventMonitor.restart()`. `HIDEventManager.healthCheck()` repairs running taps and monitors and restarts the macOS 27 click bridge; it also runs when Accessibility turns granted. +- `MenuBarItemManager.move(item:to:origin:)` with `MoveOrigin.user/.automatic`; the Layout pane and `temporarilyShow` pass `.user`. Automatic moves throw `EventError.automaticMovesPaused` while paused ("Automatic moves paused until N s from now" logged once per pause). Rehiding waits during a pause (retried on settle, on a user move after the usual interval, or once when the pause ends) instead of retrying every 3 s without end. +- NOTICE credits Thaw's `MoveCircuitBreaker.swift`. + +### Task 3: appearance follows the icons and survives sleep and login; hotkeys and colours validated (THAW-03, ICE-01) — 22b2d33 +- Overlay: `collectionBehavior = [.fullScreenNone, .ignoresCycle, .canJoinAllSpaces, .stationary]`, no re-show on space change (wallpaper update kept); redraws on sections' `isHidden`, `itemManager.itemCache` and (macOS 27) `concealer27.concealedPIDs`; fades to alpha 0 on a fullscreen space and back. +- `MenuBarAppearanceManager.systemActivityDidSettle()` rebuilds when the screens changed or a panel found no menu bar, else refreshes; panels that found no menu bar at launch are retried at most 3 times, 3 s apart. +- `HotkeyStorage.isValid(key:modifiers:)` (0...127, only the four known modifier bits); `decode` and `KeyCombination.init(from:)` reject the rest; `KeyCode.keyEquivalent` uses `UInt16(exactly:)`; `HotkeyRegistry` uses `UInt32(exactly:)` and logs instead of trapping; `HotkeysSettings` ignores and logs invalid or system-reserved stored combinations. 2 new tests. +- `ColorComponents.isValid` / `clamped` (Core, 3 tests); `HolzBarColor.init(from:)` throws on a wrong count or non-numbers; `HolzBarGradient.averageColor` converts to the RGB space, skips colours without four components and returns nil when none is left. +- `privacy-check.py` PERSONAL_DATA gains `window title` (`title|windowTitle|kCGWindowName`); probed: a public `window.title` fails the check; the tree has no hit. + +### Review fix — 785c05d +- Explicit `self.label` in `EventTap`'s OSLog interpolations (now in methods), `ReadResult` moved into the class. + +## Accessibility audit (Task 1 step 6, D-02) + +`git grep -n -E 'AXUIElementCopy|AXUIElementPerform|AXHelpers\.' -- holzBar` after the plan: + +| Call | Where it runs | +|------|---------------| +| `ItemClicker27.swift:76` `AXUIElementPerformAction` | `DispatchQueue.global` (off main) | +| `MenuBarItemProvider27.swift:228` `AXUIElementCopyAttributeValue` (extras menu bar, per-app timeout) | provider `queue` (off main) | +| `MenuBarItemProvider27.swift:373` `value(_:_:)` helper | provider `queue`; `currentBounds` only off the main thread now | +| `SystemItemClickBridge27.swift:240` `AXUIElementPerformAction` | `DispatchQueue.global` (off main) | +| `Permission.swift:253,256` `AXHelpers.isProcessTrusted` | main, local TCC check, never asks another app | +| `Extensions.swift` `ApplicationMenuQuery.getApplicationMenuFrame()` / `hasValidMenuBar()` (`AXHelpers.applicationMenuBar`, `children`, `isEnabled`, `frame`, `element(at:)`, `role`) | `ApplicationMenuFrames` queue (off main) | + +The former main-thread callers (`MenuBarManager.hasValidMenuBar` and the four `getApplicationMenuFrame()` readers) now read the cache. No remaining call that asks another app is reachable on the main actor from an event monitor, a tap callback or a hit test. + +## D-02 evidence (already done / not applicable) +- THAW-05 "double-click Always Hidden": holzBar has no double-click option (Option-click picks Always Hidden); `git grep -n -i doubleClick -- holzBar` is empty. Not applicable. +- ICE-01 window titles: item and error strings are `.private` since 05.1-01 and the `logs` check enforces it; this plan adds window titles to the check. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] No messaging timeout on the system-wide element** +- **Found during:** Task 1 +- **Issue:** `AXUIElementSetMessagingTimeout` on the system-wide element sets the global timeout of every Accessibility call in the process (Apple's AXUIElement.h); 0.25 s globally would cut short the macOS 27 item scan's child reads and the item presses. +- **Fix:** The 0.25 s timeout is set on the menu bar element and on each child read; the pre-27 system-wide hit test keeps the default, which is harmless now that it runs off the main thread. +- **Commit:** d7a8a86 + +**2. [Rule 1 - Bug] No shorter timeout on macOS 27 item elements read off the main thread** +- **Found during:** Task 1 +- **Issue:** `ItemClicker27` presses the same `AXUIElement`; a 0.25 s timeout set on it would make a press that waits for the item's menu fail. +- **Fix:** Off the main thread `currentBounds` reads live with the element's existing timeout; on the main thread it answers from the last read (the plan's main requirement). +- **Commit:** d7a8a86 + +**3. [Rule 2 - Missing critical] Menu bar validity off the main thread** +- **Found during:** Task 1 audit +- **Issue:** `MenuBarManager.hasValidMenuBar` ran a system-wide Accessibility hit test on the main thread for every overlay update, reached from clicks through the overlay. +- **Fix:** `ApplicationMenuFrames` also caches whether each display's menu bar window is a menu bar; the overlay updates once its display becomes valid. +- **Commit:** d7a8a86 + +**4. [Rule 1 - Bug] MoveBackoff reset measured from the end of the pause** +- **Found during:** Task 2 (tests) +- **Issue:** Measured from the last trip (as in Thaw), a 480 s pause always exceeds the 300 s reset, so the pause could never reach the 600 s cap the behaviour requires. +- **Fix:** Reset when 300 s passed after the last pause ended; the pause doubles 60, 120, 240, 480, 600, 600. +- **Commit:** 25fd4fc + +**5. [Rule 1 - Bug] Plan test values for hotkeys contradicted the modifier rule** +- **Found during:** Task 3 +- **Issue:** The behaviour list says `[0, 256]` and `[127, 256]` decode, but 256 is an unknown modifier bit, which the next behaviour rejects. +- **Fix:** The key-code test uses modifier 8 (Command); unknown-bit cases (256, 16, -1) are in the modifier test. +- **Commit:** 22b2d33 + +**6. [Rule 1 - Bug] Colours slightly outside 0...1 clamped, not rejected** +- **Found during:** Task 3 +- **Issue:** Colours picked in an extended colour space can be stored slightly outside 0...1; rejecting them would fail the whole appearance configuration's decoding and reset the user's look. +- **Fix:** `ColorComponents.clamped` before `isValid`; wrong counts, NaN and infinity are still rejected (tests per the plan plus one for clamping). +- **Commit:** 22b2d33 + +**7. [Rule 2] Rehide wait ends with the pause** +- **Found during:** Task 2 +- **Issue:** With only "next settle or user move", shown items could stay out until the next lock/unlock. +- **Fix:** One bounded timer fires as the back-off pause ends (D-03: not a loop). +- **Commit:** 25fd4fc + +### Coordinator change +- Per the coordinator's mid-run decision, nothing is pushed after this plan: plans 01–03 are committed locally and pushed once at the end of 05.1.1-03, which also opens the draft PR. CI for this plan's code runs with that push; its result is in the 05.1.1-03 SUMMARY and the final report. + +## Tests +- New suites: `SystemActivity` (5), `MoveBackoff` (6), `ColorComponents` (3); `Hotkeys` (HotkeyStorageTests) +2. Expected 271 tests in 48 suites (255 in 45 before). No test removed, skipped or disabled. + +## Known Stubs +None. + +## Threat Flags +None beyond the plan's threat model (no new endpoint, permission or entitlement; the distributed screen-lock notifications are read-only). + +## Open human checks (macOS 26 and 27) +- Stop a menu bar app (`kill -STOP`), hover, click and type for 30 s: nothing stalls. +- `log stream ... | grep 'after the click'`: about 70 ms or less. +- Lock 1 min / unlock, sleep / wake, unplug / replug a display: pause and settle logged, no move or capture while locked, items keep their sections, the look is back within about 2 s. +- Launch at login keeps the tint and shape. +- Tint and shape on every desktop, follow an app adding an item, show with the icons, step aside in fullscreen. +- Layout drags always move, also right after a "paused" log line. +- `defaults write com.holzcloud.holzBar Hotkeys -dict-add ToggleHiddenSection -data 5b39393939392c3235365d`, relaunch: ignored without a crash. + +## Self-Check: PASSED +- FOUND: holzBar/MenuBar/ApplicationMenuFrames.swift, holzBar/Events/SystemActivityMonitor.swift, holzBar/Core/SystemActivity.swift, holzBar/Core/MoveBackoff.swift, holzBar/Core/ColorComponents.swift and the three new test files +- FOUND commits: d7a8a86, 25fd4fc, 22b2d33, 785c05d diff --git a/.planning/phases/05.1.1-thaw-fixes-and-speed/05.1.1-02-SUMMARY.md b/.planning/phases/05.1.1-thaw-fixes-and-speed/05.1.1-02-SUMMARY.md new file mode 100644 index 00000000..b694ffd9 --- /dev/null +++ b/.planning/phases/05.1.1-thaw-fixes-and-speed/05.1.1-02-SUMMARY.md @@ -0,0 +1,185 @@ +--- +phase: 05.1.1-thaw-fixes-and-speed +plan: 02 +subsystem: menu bar items, input, activation, URL commands +status: complete +tags: [THAW-01, THAW-02, THAW-08, ICE-02, identity, sections, rehide, clock, dock-icon, notch, spacing, url-commands] +requires: [05.1.1-01] +provides: + - ItemIdentity (stable item keys, learned title-changing apps) + - SectionRestore (one save/restore path for sections before macOS 27) + - InterfaceWindowRule, DockIconPolicy, MenuBarOcclusion (Core rules) + - "Keep the Dock icon hidden" setting (KeepsDockIconHidden) +affects: [05.1.1-03] +tech-stack: + added: [] + patterns: + - "Stored item keys go through MenuBarItemManager.identityKey(for:) / storedIdentityKey(_:)" +key-files: + created: + - holzBar/Core/ItemIdentity.swift + - holzBar/Core/InterfaceWindowRule.swift + - holzBar/Core/DockIconPolicy.swift + - holzBar/Core/MenuBarOcclusion.swift + - holzBar/MenuBar/MenuBarItems/SectionRestore.swift + - Tests/HolzBarCoreTests/ItemIdentityTests.swift + - Tests/HolzBarCoreTests/InterfaceWindowRuleTests.swift + - Tests/HolzBarCoreTests/DockIconPolicyTests.swift + - Tests/HolzBarCoreTests/MenuBarOcclusionTests.swift + modified: + - holzBar/Core/Defaults.swift + - holzBar/Core/InputMonitors.swift + - holzBar/Core/SpacingRelaunch.swift + - holzBar/Core/URLCommand.swift + - holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift + - holzBar/MenuBar/Profiles/LayoutProfiles.swift + - holzBar/MenuBar/Groups/MenuBarItemGroups.swift + - holzBar/MenuBar/LayoutBar/LayoutBarPaddingView.swift + - holzBar/MenuBar/MenuBarManager.swift + - holzBar/MenuBar/Search/MenuBarSearchPanel.swift + - holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift + - holzBar/MenuBar/MacOS27/Core/ClockBridgeZone27.swift + - holzBar/MenuBar/MacOS27/ItemClicker27.swift + - holzBar/MenuBar/MacOS27/SystemItemClickBridge27.swift + - holzBar/Events/HIDEventManager.swift + - holzBar/Events/MenuBarHitTesting.swift + - holzBar/Main/AppState.swift + - holzBar/Main/AppDelegate.swift + - holzBar/Main/URLCommands.swift + - holzBar/Permissions/PermissionsView.swift + - holzBar/Settings/Models/AdvancedSettings.swift + - holzBar/Settings/SettingsPanes/AdvancedSettingsPane.swift + - holzBar/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift + - holzBar/Utilities/Extensions.swift + - Shared/Services/SourcePIDCache.swift + - Shared/Utilities/AXHelpers.swift + - Tests/HolzBarCoreTests/InputMonitorsTests.swift + - Tests/HolzBarCoreTests/SpacingRelaunchTests.swift + - Tests/HolzBarCoreTests/URLCommandTests.swift + - Tests/HolzBarMacOS27CoreTests/MenuBarGeometry27Tests.swift + - Integrations/Raycast/README.md + - NOTICE +decisions: + - "Identifier-like titles (Item-0, com.apple.menuextra.clock) keep their digits; only text titles lose their numbers, so several NSStatusItems of one app keep distinct identities" + - "On every item list change only new items are placed; saved sections are restored on launch, app launch (+2 s re-check), settle and profile" + - "A user's arrangement is recorded before anything restores: restore waits while a save is pending; the mouse-up monitor notices Command-drags before macOS 27" + - "The application-menu toggle hotkey/URL still hides the menus (with the Dock icon); only the automatic hiding respects Keep the Dock icon hidden" + - "Clock bridge rectangle: strip above the visible frame (at least the status bar's thickness); on a fullscreen space only the on-screen menu bar window" +metrics: + duration: 80min + completed: 2026-10-03 + tasks: 3 + files: 41 +estimate: + tokens: 340000 + tasks: 3 +actuals: + tokens: 24000 + tasks: 3 + commits: 3 +plan_head_before: 63124a3826cf0a05d104c1cdecbf78209e64572a +plan_head_after: c194bfc +--- + +# Phase 05.1.1 Plan 02: Item identity and one restore path; menus, clock clicks, Dock icon, notch apps; Shelf clicks, spacing and URL audit Summary + +Items keep one identity when their titles change (numbers canonicalised, title-changing apps learned and keyed by position, several items of one app kept apart), and before macOS 27 their sections are saved and restored through the single `reconcileSections` path; only menus delay rehiding, the macOS 27 clock bridge stays inside the visible bar, Settings and the search no longer show a Dock icon, notch apps above the bar are left alone, Shelf clicks on macOS 26 find the application menu, respawning apps are relaunched once, and `holzbar://profile/` asks first. + +## What was done + +### Task 1 (tracer): identity and the one restore path (THAW-01) — fcc2226 +- `ItemIdentity` (Core): `canonicalTitle` (digits → `#`, byte units normalised; identifier-like titles kept), `learnTitleChangingOwners(previous:current:excluding:)`, `keys(for:titleChangingOwners:)` (`ns:#1`, `ns:#2` for learned apps; `ns:title`, `ns:title:2` for duplicates), `storedKey` (old raw keys canonicalised; keys of this version kept). 5 tests. +- `Defaults.Key.itemSections` (`ItemSections`, dictionary) and `titleChangingItemOwners` (`TitleChangingItemOwners`, string array), with kinds. +- `MenuBarItemManager`: learns title-changing apps after each pre-27 cache (persisted), keeps identity keys per window; `identityKey(for:)` / `storedIdentityKey(_:)` are the only way items become stored keys. +- `SectionRestore.swift`: `saveSections()` (after a Layout drop, a Command-drag, first run), `storeSections(_:)` (profile, placed new items), `saveSectionsSoon()` (one bounded re-read 1.5 s later), `reconcileSections(wanted:trigger:)` — the one path: profile sections, saved sections (launch, app launch + one 2 s re-check, settle) and new items (every item list change); skipped while dragging, while paused, while a save is pending; a second call during a run runs once afterwards; moves are automatic (MoveBackoff). Live Activities logic moved here. `placeNewItems` and `move(itemsTo:)` removed. +- Profiles save identity keys and apply through `reconcileSections`; groups toggle/contain/list by identity (settings pane uses `groups.contains`); `KnownItemTags` holds identity keys; temporarily shown items are found by window, then identity. +- `InputMonitors.Settings.savesUserArrangement` (tested): before macOS 27 the mouse-up monitor runs so a Command-drag on the bar is noticed even without the drag monitors. +- NOTICE credits Thaw's `canonicalMetricTitle`. + +### Task 2: menus, clock, Dock icon, notch apps (THAW-02, ICE-02 #933) — e20fe55 +- `InterfaceWindowRule` (levels 24, 25, 100, 101; owner's new window): `isShowingInterface` and the recorded interface window use it; macOS 27 `ItemClicker27.windowOwners()` lists only menus. 2 tests. +- `ClockBridgeZone27.shouldBridge(...menuBarRect:)`: nil or outside the rect → no bridge; frames grow by 1 pt horizontally only. `SystemItemClickBridge27` passes the clicked display's bar rectangle (strip above the visible frame, at least the status bar's thickness; on a fullscreen space only while the WindowServer menu bar window is on screen). Existing 4 test names kept, plus "A click just below the bar is not bridged" and "Nothing is bridged while the bar is hidden". +- `DockIconPolicy` (settings/search → accessory, permissions → regular, hideApplicationMenus → regular or nil per setting, toggleApplicationMenus → regular). 4 tests. `AppState.activate(for:)` replaces every `activate(withPolicy: .regular)` caller; `activate(withPolicy:)` is private. New setting "Keep the Dock icon hidden" (`KeepsDockIconHidden`, default on) under "Hide app menus…" with the planned annotation. +- `MenuBarOcclusion` (another non-system app's visible window above layer 25 containing the point). 4 tests. `isMouseInsideEmptyMenuBarSpace` checks it last, reading the window list at most every 0.25 s; windows as wide as the display (overlays) are ignored. + +### Task 3: Shelf clicks, spacing, URL audit (ICE-02 #911 #923, THAW-08) — c194bfc +- `SourcePIDCache.failedLookups` (30 s, reset when the running apps change). +- `AXHelpers.applicationMenuBar(in:menuBarHeight:)`: display corner, then 8/20/40 pt in at half the bar's height; a menu bar item leads to its parent bar. `ApplicationMenuQuery` carries the bar height. +- `SpacingRelaunch.isRelaunched(oldPID:bundleID:running:)` (tested); `launchApp` takes the old pid. +- `URLCommand.Action.needsConfirmation` (tested); `holzbar://profile/` shows "Apply the layout profile “”?" / "Another app asked holzBar to rearrange your menu bar." with Cancel (default, Return) and Apply; Escape cancels; activated without a Dock icon. Doc comment and `Integrations/Raycast/README.md` table gain a "Changes" column and the Shortcuts sentence. + +## D-02 evidence (already done / not applicable) +- ICE-02 #900: `InputMonitors.needed` runs no mouse-down monitor when no click feature is on (`holzBar/Core/InputMonitors.swift`, test "Mouse-down is not needed when no click feature is on"). +- THAW-02 Notification Center follows the click: `SystemItemClickBridge27.handle` lifts concealment only until the assertion is released, replays the click and re-conceals after `clickRestoreDelay` (120 ms); no fixed 2 s timer. +- ICE-02 #911: control items are recognised directly since 0.0.5 (`OwnStatusItemWindows`). +- ICE-02 #923: `continue` instead of `break`, a single resume and no force termination were fixed in Phase 2 (BUG-01/02, `SpacingRelaunchTests`). +- THAW-04 spacing parts do not apply: holzBar has one global spacing applied only by the Apply button (`GeneralSettingsPane.swift`, the only caller of `applyOffset()`), no per-display spacing and no prompt after sleep. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Identifier titles keep their digits** +- **Found during:** Task 1 +- **Issue:** Canonicalising every digit turned `Item-0`/`Item-1` (the titles of several status items of one app) into one title, making their identities depend on bar order. +- **Fix:** Titles that are identifiers keep their digits; tests cover it. +- **Commit:** fcc2226 + +**2. [Rule 2 - Missing critical] User arrangements are saved before any restore** +- **Found during:** Task 1 +- **Issue:** Command-drags were only noticed when the drag monitors ran (two settings); a restore triggered by an app launch would have undone the user's move. +- **Fix:** `saveSectionsSoon()` with a bounded re-read; restores wait while a save is pending; the mouse-up monitor (one event per click) runs before macOS 27 (`savesUserArrangement`, tested). +- **Commit:** fcc2226 + +**3. [Rule 1 - Bug] Item list changes place new items only** +- **Found during:** Task 1 +- **Issue:** Restoring saved sections on every cache update would fight the user and macOS; the plan's triggers did not cover new items added by already running apps. +- **Fix:** `.itemListChange` places new items only; saved sections are restored on the plan's triggers. +- **Commit:** fcc2226 + +**4. [Rule 1 - Bug] The application-menu toggle still works** +- **Found during:** Task 2 +- **Issue:** With "Keep the Dock icon hidden" on by default, the "Toggle application menus" hotkey and URL would silently do nothing. +- **Fix:** `DockIconPolicy.ActivationReason.toggleApplicationMenus` → regular (the user asked); automatic hiding follows the setting. +- **Commit:** e20fe55 + +**5. [Rule 1 - Bug] Clock bridge rectangle off fullscreen without the window list** +- **Found during:** Task 2 +- **Issue:** Requiring the WindowServer menu bar window everywhere could disable the bridge on macOS 27 if that window is absent; reading it in the active tap for every click is also costly. +- **Fix:** Off fullscreen the rectangle is the strip above the screen's visible frame (at least the status bar's thickness); the window list is read only on a fullscreen space and only while concealing. +- **Commit:** e20fe55 + +**6. [Rule 1 - Bug] Apple menu at the probe points** +- **Found during:** Task 3 +- **Issue:** Points 8–40 pt in usually hit the Apple menu (`AXMenuBarItem`), not the bar. +- **Fix:** The corner pixel is tried first, and a menu bar item leads to its parent bar. +- **Commit:** c194bfc + +**7. [Rule 1 - Bug] Escape and the default button of the profile question** +- **Found during:** Task 3 +- **Issue:** One NSButton holds one key equivalent, so Cancel cannot be both Return and Escape. +- **Fix:** Cancel is the first (Return) button; a local key monitor during the modal aborts it on Escape (any answer but Apply cancels). +- **Commit:** c194bfc + +## Tests +- New suites: `ItemIdentity` (5), `InterfaceWindowRule` (2), `DockIconPolicy` (4), `MenuBarOcclusion` (4); new tests in `InputMonitors` (+1), `SpacingRelaunch` (+1), `URLCommand` (+1), `ClockBridgeZone27` (+2, the four existing names kept). Plan 01 + 02 expected total: 271 + 20 = 291 tests in 52 suites. No test removed, skipped or disabled. + +## Known Stubs +None. + +## Threat Flags +None beyond the plan's threat model. + +## Open human checks (macOS 26 and 27) +- macOS 26: an app with a changing title (mail counter, date item, iStat Menus) in Hidden stays there after its title changes, a relaunch of it, a restart of holzBar and unplugging/replugging a display; never moved into the new-items section. Two items of one app (OneDrive, Stats) in different sections keep them after a relaunch. A profile applied from the Layout pane still moves every item. A Command-drag on the bar is kept after the next app launch. +- With a Dock-preview or floating-window app running, an item opened from the Shelf hides again after its menu closes. +- macOS 27: a click just below the clock over a maximised window, and near the clock in a fullscreen video with the bar hidden, reaches the app; a click on the clock still opens Notification Center at once. +- Settings, the search and revealing items over a crowded bar show no Dock icon; with "Keep the Dock icon hidden" off, the application menus hide again (with the icon); the toggle hotkey hides them either way. +- With boring.notch (or another notch app), clicking its controls with show on click on does not toggle hidden items. +- macOS 26 on a notched Mac: a Shelf click on a hidden item opens it on the first try. +- Apply spacing relaunches every app, including Spotlight and a respawning helper, once. +- `open "holzbar://profile/Work"` asks; Cancel (Return or Escape) leaves the bar unchanged; Apply applies it. + +## Self-Check: PASSED +- FOUND: holzBar/Core/ItemIdentity.swift, InterfaceWindowRule.swift, DockIconPolicy.swift, MenuBarOcclusion.swift, holzBar/MenuBar/MenuBarItems/SectionRestore.swift and the four new test files +- FOUND commits: fcc2226, e20fe55, c194bfc diff --git a/.planning/phases/05.1.1-thaw-fixes-and-speed/05.1.1-03-SUMMARY.md b/.planning/phases/05.1.1-thaw-fixes-and-speed/05.1.1-03-SUMMARY.md new file mode 100644 index 00000000..797e86d0 --- /dev/null +++ b/.planning/phases/05.1.1-thaw-fixes-and-speed/05.1.1-03-SUMMARY.md @@ -0,0 +1,168 @@ +--- +phase: 05.1.1-thaw-fixes-and-speed +plan: 03 +subsystem: macOS 27 backend, appearance, docs +status: complete +tags: [THAW-04, THAW-06, ICE-03, ICE-04, ICE-06, macos27, second-display, notch, wallpaper, clock-cover, launch-grace, readme] +requires: [05.1.1-02] +provides: + - Per-display drawn frames for macOS 27 hit tests (MenuBarItemProvider27.drawnFrames) + - NotchCover27, LaunchGrace27 (Core, tested) + - DesktopPicture, WallpaperChangeMonitor, ClockCover27 +affects: [05.1.1.1] +tech-stack: + added: [] + patterns: + - "Wallpaper strip from the desktop picture file (ImageIO thumbnail off the main thread); no capture on macOS 27" +key-files: + created: + - holzBar/MenuBar/MacOS27/Core/NotchCover27.swift + - holzBar/MenuBar/MacOS27/Core/LaunchGrace27.swift + - holzBar/MenuBar/MacOS27/ClockCover27.swift + - holzBar/MenuBar/Appearance/DesktopPicture.swift + - holzBar/MenuBar/Appearance/WallpaperChangeMonitor.swift + - Tests/HolzBarMacOS27CoreTests/Displays27Tests.swift + modified: + - holzBar/MenuBar/MacOS27/MenuBarItemProvider27.swift + - holzBar/MenuBar/MacOS27/Core/ItemHitTest27.swift + - holzBar/MenuBar/MacOS27/Concealer27.swift + - holzBar/MenuBar/MacOS27/SystemItemClickBridge27.swift + - holzBar/MenuBar/Backends/AccessibilityBackend27.swift + - holzBar/MenuBar/Appearance/MenuBarOverlayPanel.swift + - holzBar/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift + - holzBar/MenuBar/Search/MenuBarSearchModel.swift + - holzBar/MenuBar/Search/MenuBarSearchPanel.swift + - holzBar/MenuBar/LayoutBar/LayoutBarItemView.swift + - holzBar/MenuBar/ControlItem/ControlItem.swift + - holzBar/Settings/SettingsPanes/AdvancedSettingsPane.swift + - holzBar/Core/ScreenRecordingFeature.swift + - Shared/Utilities/WindowInfo.swift + - Tests/HolzBarMacOS27CoreTests/MenuBarGeometry27Tests.swift + - docs/upstream-bugs.md + - README.md + - NOTICE +decisions: + - "Hit-test parameters for drawn frames default to empty, so Plan2Core27Tests stay untouched" + - "Notch concealment is one app per settled read, transient, never written to MacOS27Layout" + - "The clock cover uses only the cached desktop-picture strip, so the bridged click is never delayed by a decode" + - "README rows: Thaw's 'URL commands that rearrange items ask first' is — (its changelog confirms only relaunch confirmations)" +metrics: + duration: 70min + completed: 2026-10-03 + tasks: 3 + files: 24 +estimate: + tokens: 330000 + tasks: 3 +actuals: + tokens: 22000 + tasks: 3 + commits: 4 +plan_head_before: 80236c4 +plan_head_after: 912fd6f +--- + +# Phase 05.1.1 Plan 03: macOS 27 second display and notch, no screenshots under the bar, launch grace, rest of #1001, docs Summary + +On macOS 27, hit tests on the display whose bar is not active use the frames MenuBarAgent draws there, holzBar's icon is kept out from under the notch by a transient concealment, folded items get a re-layout on a roomier display and the overflow button is bridged; the wallpaper beside a shape comes from the desktop picture file (no capture on 27, a file watcher instead of a timer), the clock click is covered, apps launching into a concealed section get a grace until their item exists, the search opens hidden items through `ItemClicker27`, bundleless items are disabled, settings without effect are hidden, a dropped icon is put back, and the upstream bug list and README describe the phase. + +## What was done + +### Task 1 (tracer): second display and notch (THAW-04, ICE-04 frames) — 0bf1ff2 +- `MenuBarItemProvider27.lastDrawnFramesByDisplay` / `drawnFrames(for:)`: every frame MenuBarAgent draws on displays whose bar is not active. +- `ItemHitTest27.isInsideItem` / `isInsideItemsArea` take `drawnFramesOnDisplay` (default empty; existing tests unchanged); `AccessibilityBackend27` passes the frames of the display under the point. Show on click, hover and the secondary context menu no longer fire on items of the second display (Thaw #1159, #1138, #1137). +- `NotchCover27.appsToConceal` and `adding(_:to:)` (tested). `Concealer27.checkNotchCover()` after each settled read: one visible app right of an icon under the notch joins a transient `notchConcealed` set (part of the target, never saved); cleared on active display change, screen change, app launch and quit. A move of the bar to a display without a notch or a wider one with folded items re-applies concealment once (`suspend` for the 400 ms settle; Thaw #1106). +- The bridge adds the overflow button frame to the system frames (Thaw #1195); test "A click on the overflow button is bridged while concealing". + +### Task 2: no screenshots under the bar, clock cover, launch grace (THAW-06, ICE-03, ICE-04 wallpaper owner) — cf4ce50 +- `DesktopPicture`: `desktopImageURL(for:)` + `desktopImageOptions(for:)`, ImageIO thumbnail at the screen's pixel size on a utility queue, laid out like the desktop (fill/fit/stretch/centre, fill colour), strip under the bar; cached per display by file, modification date and layout; `cachedStrip`, `isReadable(on:)`. +- `WallpaperChangeMonitor` (adapted from Thaw, NOTICE): `DispatchSource` on the wallpaper store's `Index.plist`, re-opened after every event (one 500 ms retry), debounced 500 ms. +- Overlay: wallpaper from `DesktopPicture`; only before macOS 27 with Screen Recording granted does a moving wallpaper fall back to the capture, and only then does the 30 s refresh run; on 27 the shape stands on `flatColor27()`. No wallpaper work without a shape. +- `ScreenRecordingFeature.menuBarShape` reason and summary name the moving wallpaper; the editor's hint shows only before 27 and only when the desktop picture cannot be read. +- `MenuBarSearchModel` uses `flatColor27()` on 27; `WindowInfo.wallpaperWindow` accepts `com.apple.WindowManager`. After this, nothing on 27 captures the wallpaper (D-02). +- `ClockCover27`: a non-activating panel above the status level over the concealed part of the bar (application menu end or notch to the drawn left edge), drawing the cached desktop-picture strip (flat colour otherwise) and a solid tint; shown right before `suspendReleased`, hidden 300 ms after the replay; never for a dismissing click. +- `LaunchGrace27` (3 tests): `Concealer27` shows an app launching into a hidden or always-hidden section until `AccessibilityBackend27.cacheFromLayout` sees its item, or 10 s (one bounded sleep), then ends the allowance once. + +### Task 3: rest of #1001, docs — 22c13cf, 912fd6f +- Search on 27 opens items through `ItemClicker27`; the Layout pane opens no items (D-02: nothing to change). +- Layout pane on 27: items without a bundle identifier are disabled with "macOS 27 hides whole apps, and holzBar can't tell which app this item belongs to."; non-movable system items get "macOS 27 doesn't let apps hide this system item." MenuBarAgent's system items are not cached on 27 (D-02: `canBeHidden`). +- Advanced pane on 27 hides "Show all sections when Command-dragging", "Section divider style", "Keep Live Activities visible", "Hide application menus" and "Keep the Dock icon hidden"; values are kept; the temporarily-shown delay stays. +- `ControlItem.reinsert()`; `Concealer27.checkOwnIcon()` reinserts holzBar's icon after two settled reads without it while concealing, once per concealment change. +- `docs/upstream-bugs.md` and README updated as listed below. + +## README rows added (holzBar vs. Ice and Thaw) +| Row | Ice | Thaw 3.0 beta | holzBar | +|---|---|---|---| +| Input never stalls when an app hangs | ❌ | ✅ | ✅ | +| Items keep their section when an app changes its title | ❌ | ✅ | ✅ | +| Pauses while the screen is locked, settles after wake | ❌ | ✅ | ✅ | +| Look on every desktop, follows the icons, steps aside in fullscreen | ❌ | ✅ | ✅ | +| No screen-recording indicator when showing or hiding (macOS 27) | — | ✅ | ✅ | +| Hover and click on a second display (macOS 27) | — | ✅ | ✅ | +| URL commands that rearrange items ask first | — | — | ✅ | +| Validated hotkeys and colours (no crash loop from bad settings) | ❌ | — | ✅ | + +Thaw cells were checked against `/home/user/thaw-app/thaw/CHANGELOG.md` (frozen app, title changes, locked screen, every desktop, recording dot, second display); its URL confirmation covers app relaunches only, so that cell is "—". Unit-test count updated to 299. Feature list: "Keeps items in their section after app updates, title changes and display changes". macOS 27 section: second display, notch, no screenshots. Permissions: Screen Recording row names the moving wallpaper (before macOS 27). + +## D-02 evidence +- THAW-04 spacing parts do not apply: one global spacing applied only by the Apply button (`GeneralSettingsPane.swift`), no per-display spacing, no relaunch on unplugging, no prompt after sleep. +- The Layout pane opens no items (nothing to route through `ItemClicker27`). +- MenuBarAgent's system items are not cached on 27 (`MenuBarItemTag.canBeHidden` is false for that namespace). +- Nothing on macOS 27 captures the wallpaper after this plan: `git grep -n 'ScreenCapture\.' -- holzBar/MenuBar/Appearance holzBar/MenuBar/MacOS27` lists only the overlay's pre-27 fallback and `ItemImageStore27`'s permission check. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Default arguments keep Plan2Core27Tests untouched** +- **Found during:** Task 1 +- **Issue:** `isInsideItemsArea` is called in `Plan2Core27Tests`, which must stay untouched. +- **Fix:** `drawnFramesOnDisplay: [CGRect] = []` on both hit-test functions. +- **Commit:** 0bf1ff2 + +**2. [Rule 1 - Bug] The clock cover never waits for a decode** +- **Found during:** Task 2 +- **Issue:** Decoding the desktop picture inside the bridge would delay the held-back click. +- **Fix:** The cover draws the cached strip (`DesktopPicture.cachedStrip`) or the flat colour, and refreshes the cache for the next click. +- **Commit:** cf4ce50 + +**3. [Rule 1 - Bug] File watcher without Sendable captures of the dispatch source** +- **Found during:** Task 2 +- **Issue:** Thaw's monitor captures the dispatch source in its own handler, which Swift 6 may reject. +- **Fix:** The handler only calls back on the main actor; the watch is opened anew after every event (the file is replaced atomically anyway). +- **Commit:** cf4ce50 + +**4. [Rule 2] No wallpaper decoding without a shape** +- **Found during:** Task 2 +- **Issue:** The wallpaper was updated for every overlay configuration, though only shapes draw it. +- **Fix:** The overlay reads it only while a shape is chosen and reads it when one is chosen. +- **Commit:** cf4ce50 + +**5. Plan wording: one commit for Task 3** +- Task 3's code and docs were committed separately (`fix` and `docs`) so each commit's type is right. + +### Coordinator change +- Per the coordinator, plans 01–03 were committed locally and pushed once at the end; the draft PR was opened with that push. CI results are in the final report. + +## Tests +- New suites `NotchCover27` (3), `LaunchGrace27` (3); new tests in `ItemHitTest27` (+1) and `ClockBridgeZone27` (+1). Phase total: 299 tests in 54 suites (255 in 45 before). No test removed, skipped or disabled. + +## Known Stubs +None. + +## Threat Flags +None beyond the plan's threat model (the desktop picture file is read with ImageIO thumbnails off the main thread; nil on failure). + +## Open human checks of the whole phase (macOS 26 and 27) +See the 05.1.1-01 and 05.1.1-02 summaries, plus: +- macOS 27, two displays: on the display whose bar is not active, hovering and clicking an item does not reveal hidden items, right-clicking holzBar's icon keeps its menu open, another app's item opens its menu there. +- Notched MacBook with a crowded bar: holzBar's icon is visible after launch and after unplugging an external display; moving the bar to a wide external display brings folded items back; clicking » while items are concealed opens it. +- macOS 27 with a split or rounded shape and a picture wallpaper: the wallpaper shows beside the shape and showing, hiding, the Shelf and the search never light the recording indicator; changing the wallpaper updates the shape within a second. +- Clicking the clock while items are concealed opens Notification Center with no hidden item flashing. +- Launching an app whose section is Hidden (quit first): its item is full width when revealed. +- macOS 26: a shape shows the wallpaper beside it without Screen Recording. +- macOS 27: a hidden item opened from the search opens its menu; an item without a bundle identifier is disabled with the tooltip; the five Advanced settings are hidden; the icon stays reachable after ten minutes of concealing. + +## Self-Check: PASSED +- FOUND: NotchCover27.swift, LaunchGrace27.swift, ClockCover27.swift, DesktopPicture.swift, WallpaperChangeMonitor.swift, Displays27Tests.swift +- FOUND commits: 0bf1ff2, cf4ce50, 22c13cf, 912fd6f diff --git a/.planning/phases/05.1.1.1-thaw-features/05.1.1.1-01-SUMMARY.md b/.planning/phases/05.1.1.1-thaw-features/05.1.1.1-01-SUMMARY.md new file mode 100644 index 00000000..367b6bee --- /dev/null +++ b/.planning/phases/05.1.1.1-thaw-features/05.1.1.1-01-SUMMARY.md @@ -0,0 +1,162 @@ +--- +phase: 05.1.1.1-thaw-features +plan: 01 +subsystem: menu bar, hotkeys, App Intents, profiles +status: complete +tags: [THAW-10, THAW-15, THAW-11, zen-mode, app-intents, shortcuts, hotkeys, profiles, spaces] +requires: [05.1.1-03] +provides: + - ZenMode (Core, tested) and MenuBarManager.zenMode with gates for hover, click, scroll, reveal rules and URL commands + - PresentationSignals (Core, tested) and PresentationMonitor (event driven, no polling) + - HotkeyTarget (Core, tested): hotkeys per layout profile and per menu bar item + - HolzBarIntents (App Intents and HolzBarShortcuts) + - ItemOpener, the single path that opens a menu bar item + - ProfileBinding (Core, tested): profiles bound to a display or a Space +affects: [05.1.1.1-02, 05.1.1.1-03] +tech-stack: + added: [AppIntents (system framework)] + patterns: + - "App Intents: nonisolated struct, static LocalizedStringResource title, @MainActor perform, AppState.current instead of NSApp.delegate" + - "Dynamic hotkeys keyed by HotkeyTarget raw values (ApplyProfile:, OpenItem:)" + - "Display and Space bindings from notifications (screen parameters, active Space), no polling" +key-files: + created: + - holzBar/Core/ZenMode.swift + - holzBar/Core/PresentationSignals.swift + - holzBar/Core/HotkeyTarget.swift + - holzBar/Core/ProfileBinding.swift + - holzBar/MenuBar/PresentationMonitor.swift + - holzBar/MenuBar/MenuBarItems/ItemOpener.swift + - holzBar/Main/HolzBarIntents.swift + - Tests/HolzBarCoreTests/ZenModeTests.swift + - Tests/HolzBarCoreTests/PresentationSignalsTests.swift + - Tests/HolzBarCoreTests/HotkeyTargetTests.swift + - Tests/HolzBarCoreTests/ProfileBindingTests.swift + modified: + - holzBar/Core/HotkeyAction.swift + - holzBar/Core/URLCommand.swift + - holzBar/Core/Defaults.swift + - holzBar/MenuBar/MenuBarManager.swift + - holzBar/MenuBar/RevealRules/RevealRules.swift + - holzBar/MenuBar/ControlItem/ControlItem.swift + - holzBar/MenuBar/Profiles/LayoutProfiles.swift + - holzBar/MenuBar/LayoutBar/LayoutBarItemView.swift + - holzBar/Events/HIDEventManager.swift + - holzBar/Hotkeys/Hotkey.swift + - holzBar/Hotkeys/HotkeyActionPerform.swift + - holzBar/Main/AppState.swift + - holzBar/Main/AppDelegate.swift + - holzBar/Main/URLCommands.swift + - holzBar/Settings/Models/HotkeysSettings.swift + - holzBar/Settings/Models/AdvancedSettings.swift + - holzBar/Settings/SettingsPanes/HotkeysSettingsPane.swift + - holzBar/Settings/SettingsPanes/AdvancedSettingsPane.swift + - holzBar/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift + - Shared/Bridging/Bridging.swift + - Shared/Bridging/Shims.swift + - Tests/HolzBarCoreTests/HotkeyStorageTests.swift + - Tests/HolzBarCoreTests/URLCommandTests.swift + - Integrations/Raycast/README.md + - README.md + - NOTICE +decisions: + - "Zen mode works like Thaw's (user decision in THAW.md): it keeps hidden items hidden against hover, empty-bar clicks, scrolling, reveal rules, URL commands and change reveal; holzBar's icon and the section hotkeys still show them" + - "Screen sharing is detected by the launch and termination of macOS's screen sharing agent (com.apple.screensharing.agent) through NSWorkspace notifications, mirroring by CGDisplayIsInMirrorSet on screen-parameter changes; no process-table polling" + - "App Intents reach the app through AppState.current, because with @NSApplicationDelegateAdaptor NSApp.delegate is not the AppDelegate" + - "Open Menu Bar Item returns at once and opens the item in a Task, so a shortcut never waits on a menu" + - "Profile bindings store the display UUID and the Space UUID; when both match, the Space wins" +metrics: + duration: 180min + completed: 2026-10-03 + tasks: 3 + files: 37 +estimate: + tokens: 340000 + tasks: 3 +actuals: + tokens: 17600 + tasks: 3 + commits: 3 +plan_head_before: 53dc00085c18bcd866f99fb8817e77e90852cd0c +plan_head_after: 42b1e81b9f933bb3ba47acd35681d00ac814db47 +--- + +# Phase 05.1.1.1 Plan 01: Zen mode, Shortcuts actions, hotkeys per profile and item, profiles per display or Space Summary + +Zen mode keeps hidden items hidden from a hotkey, holzBar's menu, `holzbar://zen/toggle`, a Shortcuts action and, optionally, automatically while the screen is mirrored or shared (no permission, no polling); five App Intents with App Shortcuts expose Zen mode, sections, profiles, opening and searching items; every layout profile and every menu bar item can have its own hotkey; and profiles can be bound to a display or a Space. + +## What was done + +### Task 1: Zen mode (THAW-10) — c0f0abc +- `ZenMode` (Core): manual and automatic parts, `isActive`, `allows(_:)` per trigger (hover, empty-bar click, scroll, reveal rule, URL command, change reveal, holzBar icon, section hotkey), `toggled()`; 4 tests. +- `MenuBarManager.toggleZenMode()`, `setAutomaticZenMode(_:)`; turning it on hides every section; the state and its cause are logged publicly (no personal data). +- Gates in `HIDEventManager` (hover, click, scroll), `RevealRules.reveal`, `URLCommands` (logs "Ignored: Zen mode"). +- `HotkeyAction.toggleZenMode` (raw value `ToggleZenMode`, appended to the pinned list), `holzbar://zen/toggle` (no confirmation; test), "Zen Mode" item with a checkmark in holzBar's menu. +- `ToggleZenModeIntent` and `HolzBarShortcuts`. + +### Task 2: Zen mode while presenting, hotkeys per profile and item, Shortcuts actions (THAW-10, THAW-15) — 6f882b0 +- `PresentationSignals` (Core, tested) and `PresentationMonitor`: stored Tasks on screen-parameter changes, app launch and app termination; runs only while "Turn on Zen mode while the screen is mirrored or shared" (`AutoZenWhileSharingScreen`, Advanced) is on; turning the setting off withdraws only the automatic part. +- `HotkeyTarget` (Core, tested): `.action`, `.applyProfile(name)`, `.openItem(identityKey)`; `Hotkey` holds a target; `HotkeysSettings` keeps dynamic hotkeys keyed by target, moves a profile's hotkey on rename and removes it on delete. +- Hotkeys pane: a "Layout Profiles" section with a recorder per profile, item hotkey rows with a remove button, recorders for Zen mode and (plan 02) "Open an item by letter". Layout items: "Set Hotkey…" in their context menu. +- `ItemOpener`: one place that opens an item (used by hotkeys and intents here, by every caller in plan 02). +- Intents: `ChangeSectionIntent`, `ApplyLayoutProfileIntent` (with `LayoutProfileEntity`/`LayoutProfileQuery`), `OpenMenuBarItemIntent` (fuzzy match), `SearchMenuBarItemsIntent`; five App Shortcuts, each phrase with `\(.applicationName)`. + +### Task 3: profiles bound to a display or a Space (THAW-11) — 42b1e81 +- `ProfileBinding` (Core, 4 tests): `displaysConnected(_, activeSpace:)` and `spaceChanged`, `newlyConnected`; the Space wins over the display. +- `Bridging.getDisplayUUIDString(for:)`, `getActiveSpaceUUID()` (shim `CGSCopyManagedDisplaySpaces`; fallback `FirstSpace:`). +- `LayoutProfiles`: bind, unbind, rename, apply after the display settles or on a Space change; no apply while a layout drag runs. +- Layout pane: profile rows with Apply, a Bind menu (to this display, to this Space, unbind), Rename… and Delete… (confirmation). +- README rows and features, Raycast README (Zen mode command, Shortcuts paragraph), NOTICE (PresentationMonitor adapted from Thaw). + +## README rows added (holzBar vs. Ice and Thaw) +| Row | Ice 0.11.12 | Thaw 3.0 beta | holzBar | +|---|---|---|---| +| Zen mode (also while presenting) | ❌ | ✅ | ✅ | +| Hotkeys per profile and per item | ❌ | ✅ | ✅ | +| Shortcuts actions (App Intents) | ❌ | ✅ | ✅ | +| Profiles bound to a display or a Space | ❌ | ✅ | ✅ | + +## Sharing agent +Not confirmed on a Mac (no Mac here). The implementation listens for `com.apple.screensharing.agent` launching and quitting through `NSWorkspace`; the human check below confirms it with `lsappinfo list | grep -i screensharing` during a session. If the agent is not listed, the fallback named in the plan (the Screen Sharing status item in holzBar's item list) is the next step. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] `ItemOpener` created in this plan** +- **Found during:** Task 2 +- **Issue:** Item hotkeys and the Open Menu Bar Item intent need to open an item; plan 02 was to create the single open path. +- **Fix:** `ItemOpener` was created here and plan 02 moved the remaining callers onto it. +- **Commit:** 6f882b0 + +**2. [Rule 1 - Bug] App Intents could not find the app** +- **Found during:** Task 2 +- **Issue:** With `@NSApplicationDelegateAdaptor`, `NSApp.delegate` is a SwiftUI object, not `AppDelegate`. +- **Fix:** `AppState.current` (weak, set in `AppDelegate.init`). +- **Commit:** 6f882b0 + +**3. `openAppWhenRun` omitted** +- holzBar is a menu bar app that runs already; the intents act in place, so no intent asks to open the app. + +### Coordinator change +- Per the coordinator, plans 01–03 were committed locally and pushed once at the end; CI results are in the 05.1.1.1-03 summary. + +## Tests +- New suites `ZenMode` (4), `PresentationSignals`, `HotkeyTarget`, `ProfileBinding` (4); new tests in `URLCommand` (Zen mode toggles) and `HotkeyStorage` (pinned raw values). 15 new tests; none removed, skipped or disabled. + +## Known Stubs +None. + +## Threat Flags +None beyond the plan's threat model: the intents and hotkeys open only items the user names; `holzbar://zen/toggle` changes nothing persistent and only hides. + +## Open human checks (macOS 26 and 27) +- Zen mode from holzBar's menu: hidden items hide; hovering, clicking empty bar space and scrolling reveal nothing, a low-battery or offline rule does not reveal, `open holzbar://show/hidden` is ignored; clicking holzBar's icon and the section hotkey still show them. The Zen mode hotkey and `open holzbar://zen/toggle` toggle it; Shortcuts lists "Toggle Zen Mode" and it works. +- "Turn on Zen mode while the screen is mirrored or shared": mirroring a display (or AirPlay mirroring) and another Mac sharing this screen turn Zen mode on, ending them turns it off; confirm the sharing agent with `lsappinfo list | grep -i screensharing` during a session. +- A profile and an item with a hotkey: the profile applies and the item's menu opens from the keyboard; both survive a relaunch and an export/import. +- Shortcuts: "Apply Layout Profile" lists the profiles; "Change Section", "Open Menu Bar Item" (by name) and "Search Menu Bar Items" work. +- Bind a profile to an external display, unplug and replug it: the profile applies once; bind one to a Space and switch Spaces: it applies; after a restart the bindings still work. + +## Self-Check: PASSED +- FOUND: ZenMode.swift, PresentationSignals.swift, HotkeyTarget.swift, ProfileBinding.swift, PresentationMonitor.swift, ItemOpener.swift, HolzBarIntents.swift +- FOUND commits: c0f0abc, 6f882b0, 42b1e81 diff --git a/.planning/phases/05.1.1.1-thaw-features/05.1.1.1-02-SUMMARY.md b/.planning/phases/05.1.1.1-thaw-features/05.1.1.1-02-SUMMARY.md new file mode 100644 index 00000000..907788df --- /dev/null +++ b/.planning/phases/05.1.1.1-thaw-features/05.1.1.1-02-SUMMARY.md @@ -0,0 +1,164 @@ +--- +phase: 05.1.1.1-thaw-features +plan: 02 +subsystem: menu bar items, layout editor, Shelf, accessibility +status: complete +tags: [THAW-14, THAW-13, THAW-12, item-hints, keyboard, voiceover, undo, rehide, change-reveal] +requires: [05.1.1.1-01] +provides: + - ItemHints (Core, tested) and ItemHintsPanel ("Open an item by letter") + - ItemOpener as the only open path (Shelf, search, groups, hints, hotkeys, intents) + - LayoutKeys (Core, tested), LayoutBarMoves with undo and redo, VoiceOver actions + - Keyboard control of the holzBar Shelf + - ChangeReveal (Core, tested) and ItemChangeWatcher (AXObserver, no polling) + - Rehide delay counted after the menu closes, up to 30 s; open in place without showing the item +affects: [05.1.1.1-03] +tech-stack: + added: [] + patterns: + - "UndoManager handlers use MainActor.assumeIsolated, capture only Sendable values and register the redo synchronously" + - "AXObserver C callback with MainActor.assumeIsolated; elements identified by CFHash" +key-files: + created: + - holzBar/Core/ItemHints.swift + - holzBar/Core/LayoutKeys.swift + - holzBar/Core/ChangeReveal.swift + - holzBar/MenuBar/Hints/ItemHintsPanel.swift + - holzBar/MenuBar/MenuBarItems/ItemChangeWatcher.swift + - Tests/HolzBarCoreTests/ItemHintsTests.swift + - Tests/HolzBarCoreTests/LayoutKeysTests.swift + - Tests/HolzBarCoreTests/ChangeRevealTests.swift + modified: + - holzBar/Core/Defaults.swift + - holzBar/Core/HotkeyAction.swift + - holzBar/Hotkeys/HotkeyActionPerform.swift + - holzBar/Main/AppDelegate.swift + - holzBar/Main/AppState.swift + - holzBar/Main/HolzBarIntents.swift + - holzBar/MenuBar/Groups/MenuBarItemGroups.swift + - holzBar/MenuBar/LayoutBar/LayoutBarContainer.swift + - holzBar/MenuBar/LayoutBar/LayoutBarItemView.swift + - holzBar/MenuBar/LayoutBar/LayoutBarPaddingView.swift + - holzBar/MenuBar/MacOS27/ItemClicker27.swift + - holzBar/MenuBar/MenuBarItems/ItemOpener.swift + - holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift + - holzBar/MenuBar/MenuBarManager.swift + - holzBar/MenuBar/Profiles/LayoutProfiles.swift + - holzBar/MenuBar/Search/MenuBarSearchPanel.swift + - holzBar/MenuBar/Shelf/HolzBarShelf.swift + - holzBar/Settings/Models/AdvancedSettings.swift + - holzBar/Settings/SettingsPanes/AdvancedSettingsPane.swift + - holzBar/Settings/SettingsPanes/HotkeysSettingsPane.swift + - holzBar/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift + - Scripts/macos27/layout-ax.swift + - Tests/HolzBarCoreTests/HotkeyStorageTests.swift + - README.md +decisions: + - "Every way of opening an item goes through ItemOpener.openItem; MenuBarItemActions was removed" + - "With 'Open hidden items in the menu bar' off, an item is pressed in place through Accessibility (0.25 s timeout off the main thread) and falls back to showing it" + - "The rehide delay starts when the item's menu closes and is capped at 30 s, on macOS 27 too (ItemClicker27 waits the same interval)" + - "Change reveal is opt-in per item, at most every 30 s, 5 s long, never in Zen mode; it listens to AX title and value change notifications" + - "Layout moves are undone and redone through the window's UndoManager; profile deletion is undoable with its binding and hotkey" +metrics: + duration: 150min + completed: 2026-10-03 + tasks: 3 + files: 32 +estimate: + tokens: 350000 + tasks: 3 +actuals: + tokens: 21650 + tasks: 3 + commits: 3 +plan_head_before: 42b1e81b9f933bb3ba47acd35681d00ac814db47 +plan_head_after: 7764130811dcf6ce03f07f6d9d79206189f2b39d +--- + +# Phase 05.1.1.1 Plan 02: item hints, keyboard and VoiceOver, rehide after the menu closes, change reveal Summary + +A hotkey shows every menu bar item with a letter under it and opens the one typed; every way of opening an item now goes through one path; the Layout pane and the holzBar Shelf work from the keyboard with undo, redo and VoiceOver actions; opened items stay up to 30 s after their menu closes or open in place without showing; and items marked "Show When It Changes" show for 5 s when their title or value changes. + +## What was done + +### Task 1: open an item by letter, one open path (THAW-14) — c72045f +- `ItemHints` (Core, tested): letters by position, home row first, two-letter hints once the single letters run out (no hint starts another), matching of the typed letters. +- `ItemHintsPanel`: a key panel under the menu bar; typing a letter opens that item (hidden ones included), Escape and losing key status close it; VoiceOver reads "A, Wi-Fi". +- `HotkeyAction.showItemHints` (`ShowItemHints`, appended to the pinned list) and its recorder. +- The Shelf, the search, groups, hotkeys and intents call `ItemOpener.openItem`; `MenuBarItemActions` was removed. + +### Task 2: keyboard, undo, VoiceOver (THAW-13) — 057f052 +- `LayoutKeys` (Core, tested): arrows move focus, Option-Left/Right move one place, Command-1/2/3 set the section; on macOS 27 moves within a section beep and say why. +- `LayoutBarMoves` (in `LayoutBarPaddingView.swift`): move with undo registration, move to end, set section (both backends), undo and synchronous redo. +- `LayoutBarItemView`: focusable with a focus ring, key handling, Command-Z and Shift-Command-Z, button role, label "name, section", custom accessibility actions, announcements. +- Profiles: deleting is undoable with its binding and hotkey. +- holzBar Shelf: becomes key when opened by its hotkey; arrows, Return, Space and Escape; accessibility actions Open and Open Menu. Without Screen Recording it lists the items with a compact hint. + +### Task 3: rehide after the menu closes, open in place, change reveal (THAW-12) — 7764130 +- `MenuBarItemManager`: the rehide delay runs after the opened item's menu closes; Advanced pane "Hide opened items again after" 0…30 s with an annotation; `tempShowInterval` is clamped to 30. +- "Open hidden items in the menu bar" (`OpenHiddenItemsInMenuBar`, default on): off presses the item in place. +- `ChangeReveal` (Core, tested with binary-exact times) and `ItemChangeWatcher` (AXObserver per app for title and value changes of marked items); `RevealOnChangeItems` (string array); "Show When It Changes" in the layout item's context menu. +- README rows and features. + +## README rows added (holzBar vs. Ice and Thaw) +| Row | Ice 0.11.12 | Thaw 3.0 beta | holzBar | +|---|---|---|---| +| Open an item by letter | ❌ | ✅ | ✅ | +| Layout editor and Shelf from the keyboard, undo, VoiceOver actions | ❌ | ✅ | ✅ | +| Opened items stay up to 30 s, or open without showing the item | ❌ | ✅ | ✅ | +| Show an item briefly when it changes | ❌ | ✅ | ✅ opt-in per item | + +## Open-path callers replaced +`HolzBarShelf` item views, `MenuBarSearchPanel`, `MenuBarItemGroups` panels, `ItemHintsPanel`, item hotkeys (`HotkeyTarget.openItem`) and `OpenMenuBarItemIntent` all call `ItemOpener.openItem`. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Redo lost when registered in a Task** +- **Found during:** Task 2 +- **Issue:** A redo registered from an asynchronous move is not recorded as the redo of the undo being performed. +- **Fix:** The undo handler registers the redo synchronously before it starts the move (`registersUndo` parameter). +- **Commit:** 057f052 + +**2. [Rule 2] Edit-menu key equivalents while holzBar is hidden** +- **Found during:** Task 2 +- **Issue:** As an agent app, holzBar's Edit menu did not deliver Command-Z to the Layout pane. +- **Fix:** The Edit menu's items get `allowsKeyEquivalentWhenHidden`; the item view also handles Command-Z and Shift-Command-Z itself. +- **Commit:** 057f052 + +**3. [Rule 2] Shelf without Screen Recording** +- **Found during:** Task 2 +- **Issue:** Without Screen Recording the keyboard-driven Shelf showed nothing to move through. +- **Fix:** It lists the items with a compact Screen Recording hint (plan 03 adds app icons). +- **Commit:** 057f052 + +**4. Layout moves in the padding view** +- `LayoutBarMoves` lives in `LayoutBarPaddingView.swift`, next to the drop code it shares, instead of a new file. + +**5. Every layout item is focusable** +- Not only movable ones, so VoiceOver and the keyboard reach every item and can read why it does not move. + +### Coordinator change +- Plans 01–03 were committed locally and pushed once at the end; CI results are in the 05.1.1.1-03 summary. + +## Tests +- New suites `ItemHints`, `LayoutKeys`, `ChangeReveal`; `HotkeyStorage` pins `ShowItemHints`. 10 new tests; none removed, skipped or disabled. + +## Known Stubs +None. + +## Threat Flags +None beyond the plan's threat model: the AX observer watches only items the user marked, and nothing it reads is logged publicly. + +## Open human checks (macOS 26 and 27) +- "Open an item by letter": every item shows a letter under the menu bar; typing one opens that item's menu (hidden ones included), Escape closes without opening; VoiceOver reads each hint "A, Wi-Fi". The Shelf, the search and a group still open items as before. +- macOS 26 Layout pane: Tab to an item (focus ring), arrows move focus, Option-Right moves it one place, Command-2 moves it to Hidden, Command-Z puts it back and Shift-Command-Z redoes; a mouse drop is undone the same way. macOS 27: Option-Right beeps and says why; Command-1/2/3 change its section and undo. +- Delete a profile and press Command-Z: it returns with its binding and hotkey; the bar does not move. +- VoiceOver: layout items read name and section and offer the move actions; Shelf items offer Open and Open Menu. The Shelf opened by its hotkey works with arrows, Return and Escape. +- With the delay at 10 s an item opened from the Shelf stays 10 s after its menu closes, on 26 and 27; with "Open hidden items in the menu bar" off its menu opens without the item appearing. +- Mark a counter or timer app "Show When It Changes": it shows for 5 s when its title changes, at most every 30 s, never in Zen mode; note which apps post no change. + +## Self-Check: PASSED +- FOUND: ItemHints.swift, LayoutKeys.swift, ChangeReveal.swift, ItemHintsPanel.swift, ItemChangeWatcher.swift +- FOUND commits: c72045f, 057f052, 7764130 diff --git a/.planning/phases/05.1.1.1-thaw-features/05.1.1.1-03-SUMMARY.md b/.planning/phases/05.1.1.1-thaw-features/05.1.1.1-03-SUMMARY.md new file mode 100644 index 00000000..c8bb567c --- /dev/null +++ b/.planning/phases/05.1.1.1-thaw-features/05.1.1.1-03-SUMMARY.md @@ -0,0 +1,173 @@ +--- +phase: 05.1.1.1-thaw-features +plan: 03 +subsystem: item images, groups, appearance, settings sync, localization +status: complete +tags: [THAW-16, THAW-17, ICE-05, SYNC-01, item-images, folders, borders, tints, glass, sync, localization, string-catalogs] +requires: [05.1.1.1-02] +provides: + - ItemIconChoice (Core, tested) and ItemIconStore (item images, app-icon fallback) + - Group colour and image + - BorderPattern and WallpaperPalette (Core, tested); adaptive, accent and system-glass tints + - SettingsSyncLocation (Core, tested); settings sync through any chosen folder (bookmark) + - Localizable, InfoPlist and AppShortcuts String Catalogs (en, de, fr, it, rm) and the strings CI job +affects: [05.1.1.1.1] +tech-stack: + added: [String Catalogs] + patterns: + - "Item pictures: chosen image, then capture, then app icon, from one store" + - "Plain Core strings shown through String.localizedFromCatalog; AppKit strings through String(localized:)" + - "Sync folder kept as a bookmark; DispatchSource on the folder in a nonisolated watcher" +key-files: + created: + - holzBar/Core/ItemIconChoice.swift + - holzBar/Core/BorderPattern.swift + - holzBar/Core/WallpaperPalette.swift + - holzBar/Core/SettingsSyncLocation.swift + - holzBar/MenuBar/MenuBarItems/ItemIconStore.swift + - holzBar/Resources/Localizable.xcstrings + - holzBar/Resources/InfoPlist.xcstrings + - holzBar/Resources/AppShortcuts.xcstrings + - .github/scripts/strings-check.py + - Tests/HolzBarCoreTests/ItemIconChoiceTests.swift + - Tests/HolzBarCoreTests/BorderPatternTests.swift + - Tests/HolzBarCoreTests/WallpaperPaletteTests.swift + - Tests/HolzBarCoreTests/SettingsSyncLocationTests.swift + modified: + - holzBar/Core/Defaults.swift + - holzBar/MenuBar/Groups/MenuBarItemGroups.swift + - holzBar/MenuBar/Appearance/MenuBarOverlayPanel.swift + - holzBar/MenuBar/Appearance/DesktopPicture.swift + - holzBar/MenuBar/Appearance/Configurations/MenuBarTintKind.swift + - holzBar/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV2.swift + - holzBar/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift + - holzBar/Utilities/SettingsSync.swift + - holzBar/Settings/SettingsPanes/AdvancedSettingsPane.swift + - holzBar/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift + - holzBar.xcodeproj/project.pbxproj + - .github/workflows/build.yml + - README.md + - NOTICE +decisions: + - "Item images live as PNG files in Application Support, scaled with ImageIO off the main thread; the defaults hold only the choice per item" + - "New tint kinds decode tolerantly, so an appearance from an older holzBar imports unchanged; system glass only on macOS 26 and later" + - "SYNC-01 (user decision): settings sync through iCloud Drive or any folder the Macs sync, kept as a bookmark; existing iCloud users are moved to a bookmark of their iCloud folder without a question; a folder that cannot be found is not replaced by iCloud Drive" + - "Translations are machine-written (user decision in THAW.md) in Swiss German spelling, French, Italian and Rumantsch Grischun; holzBar and holzBar Shelf stay untranslated, System Settings panes use the names macOS shows (Romansh follows German)" + - "A translation may number its format specifiers (%1$@) to reorder them; the strings check compares the kinds only" +metrics: + duration: 240min + completed: 2026-10-03 + tasks: 4 + files: 50 +estimate: + tokens: 420000 + tasks: 3 +actuals: + tokens: 97500 + tasks: 4 + commits: 4 +plan_head_before: 7764130811dcf6ce03f07f6d9d79206189f2b39d +plan_head_after: 37ae8076c04bdf4f1db40d027dddd50e84558ec4 +--- + +# Phase 05.1.1.1 Plan 03: item images and coloured folders, borders and tints, settings sync through any folder, five languages Summary + +Any menu bar item can carry an image of your choice or its app's icon, and app icons stand in wherever Screen Recording is missing; groups get a colour and an image; borders can be dashed or dotted and the tint can follow the wallpaper, the accent colour or the system's glass; settings sync works through any folder the Macs sync; and holzBar speaks English, German, French, Italian and Romansh, with a CI job that proves every text is translated. + +## What was done + +### Task 1: item images and coloured folders (THAW-16) — 5d34de5 +- `ItemIconChoice` and `HexColor` (Core, tested). +- `ItemIconStore`: choose an image (open panel), store it as a scaled PNG, use the app icon, reset; `image(for:captured:)` gives the chosen image, then the capture, then the app icon. Used by the Shelf, the search, the Layout pane, the hints and groups. +- Layout item context menu: "Choose Image…", "Use App Icon", "Reset Image". +- Groups: colour (`colorHex`), image file, "Choose Image…" and "Use Symbol"; the status item draws them. +- Defaults key `ItemIcons` (dictionary, settings kind declared). + +### Task 2: borders and tints (THAW-17) — 74cfc48 +- `BorderPattern` (Core, tested): solid, dashed, dotted dash patterns scaled to the border width. +- `WallpaperPalette` (Core, tested, adapted from Thaw, NOTICE): colours from the desktop picture strip; `DesktopPicture.palette(for:height:)`. +- `MenuBarTintKind` gains adaptive (wallpaper) and system glass (`NSGlassEffectView`, macOS 26 and later) with a tolerant decoder; "Use Accent Color" follows `NSColor.systemColorsDidChangeNotification`. +- Editor: border style picker, accent colour toggle; widths shown verbatim. + +### Task 4 (user decision): settings sync through any folder (SYNC-01) — df6e9c4 +- `SettingsSyncLocation` (Core, tested): bookmark resolution decisions, sync file path, display name. +- `SettingsSync`: folder bookmark (`SettingsSyncFolderBookmark`), stale bookmarks stored again, iCloud Drive users migrated without a prompt; `NSFilePresenter` plus a nonisolated `DispatchSource` folder watcher; no polling, no network code. +- Advanced pane: "Turn On…", "Change…", "Turn Off", "Through ". + +### Task 3: five languages (ICE-05), README — 37ae807 +- `Localizable.xcstrings` (352 strings), `InfoPlist.xcstrings` (2: the copyright translated, the display name not), `AppShortcuts.xcstrings` (5 phrases, each with `${applicationName}`); 359 strings in total, each with German, French, Italian and Romansh. +- AppKit texts (menus, alerts, tooltips, panels, errors, permissions) go through `String(localized:)`; SwiftUI keys use interpolation instead of concatenation; plain Core texts are looked up with `String.localizedFromCatalog`. +- `knownRegions`: en, Base, de, fr, it, rm. No in-app language switcher: macOS's per-app language chooses it (README). +- `.github/scripts/strings-check.py` and the `strings` job in `build.yml`: every catalog parses with English as its source, every entry has four finished translations with the same format specifiers, and every literal in a localisable position of the code has an entry. +- README: "Five languages" selling point; rows below; feature entries for groups with colour and image, item images and the app-icon fallback, borders and tints, sync through any folder, languages; permissions table "A folder you choose"; Screen Recording row names the app-icon fallback. + +## README rows added (holzBar vs. Ice and Thaw) +| Row | Ice 0.11.12 | Thaw 3.0 beta | holzBar | +|---|---|---|---| +| Folders with their own icon and colour; item images of your choice | ❌ | ✅ | ✅ | +| Dashed and dotted borders, wallpaper, accent and glass tints | ❌ | ✅ | ✅ | +| Export and import settings (split from "Export, import and sync settings") | ❌ | ✅ | ✅ | +| Settings sync: iCloud Drive or any synced folder | ❌ | ❌ | ✅ | +| Languages | English | many, through Crowdin | English, German, French, Italian, Romansh | + +Changed: "Settings sync checks for changes" now reads "when the synced folder delivers them, no polling". + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Folder watcher closure isolation** +- **Found during:** Task 4 +- **Issue:** A handler closure formed in a main-actor context and run by a dispatch source off the main thread would trap at runtime. +- **Fix:** `SettingsSyncFolderWatcher` is a `nonisolated` class whose handler only calls the `@Sendable` callback. +- **Commit:** df6e9c4 + +**2. [Rule 2] Personal data in a log** +- **Found during:** Task 3 +- **Issue:** `SettingsBackup.show` logged its message publicly; with localization the message can carry a file name. +- **Fix:** The message is logged `.private`. +- **Commit:** 37ae807 + +**3. [Rule 1 - Bug] Texts built by concatenation** +- **Found during:** Task 3 +- **Issue:** "Show/Hide
Section", "Click/Show Item", " second(s)" and "Below %" were assembled from pieces no translation can reorder. +- **Fix:** Whole sentences per case, interpolated keys, a formatted percentage. +- **Commit:** 37ae807 + +**4. Positional format specifiers** +- The strings check compares the kinds of format specifiers, so translations may use `%1$@`/`%2$@` to reorder them. + +**5. Extra task** +- SYNC-01 was added to this plan by the user's decision; the plan had 3 tasks, 4 were done. + +### Coordinator change +- Plans 01–03 were committed locally and pushed once at the end (user decision: as few CI runs as possible); fixes followed as new commits. + +## Tests +- New suites `ItemIconChoice`, `BorderPattern`, `WallpaperPalette`, `SettingsSyncLocation`: 14 new tests. Phase total in CI: 338 tests in 65 suites (127 in 25, 205 in 39, 6 in 1; 299 before, +39). None removed, skipped or disabled. + +## CI +- Pushed once with all three plans (60ae8d4): every check green except build, which had two compile errors only a Mac could show: + - `nonisolated` on the action structs with `@Parameter` properties ("'nonisolated' cannot be applied to mutable stored properties"). Fix: those actions and `HolzBarShortcuts` are main-actor isolated, as Thaw declares its intents under the same build settings. + - `DesktopPicture.samplePalette` was too slow to type-check. Fix: a plain loop. +- 40c9b34: all six checks green (build, test, swiftlint, former-name, no-network, strings); BUILD SUCCEEDED, "No network code in the binaries", "String Catalogs complete: 359 strings in 5 languages". App size 16912 KB (16.5 MB; README updated from 13.9 MB). The build log shows one "appintentsmetadataprocessor: Metadata extraction skipped. No AppIntents.framework dependency found." warning for two targets, most likely the XPC service, which does not import AppIntents; the filtered log cannot prove it, so Shortcuts listing holzBar's actions stays a human check. + +## Known Stubs +None. + +## Threat Flags +| Flag | File | Description | +|------|------|-------------| +| threat_flag: file-access | holzBar/Utilities/SettingsSync.swift | Reads and writes `holzBar/Settings.plist` in a folder the user chooses (bookmark); the import accepts only known keys of the right type, as before | +| threat_flag: file-access | holzBar/MenuBar/MenuBarItems/ItemIconStore.swift | Reads an image the user chooses and stores a scaled PNG copy in Application Support | + +## Open human checks of the whole phase (macOS 26 and 27) +See the 05.1.1.1-01 and 05.1.1.1-02 summaries, plus: +- Choose an image for an item in the Layout pane: it shows in the Layout pane, the Shelf, the search and the hints and survives a relaunch; "Use App Icon" and "Reset Image" work. Without Screen Recording (`tccutil reset ScreenCapture com.holzcloud.holzBar`) the Shelf and the search show app icons. A group with a colour and an image shows them in its menu bar icon and its panel. +- Border styles Dashed and Dotted at widths 1 and 3; "Follow Wallpaper" tints with the wallpaper's colours and changes with it, without the recording indicator; "Use Accent Color" follows a change in System Settings at once; "System Glass" looks like the system's glass; an appearance exported before this phase imports unchanged. +- Settings sync: an existing iCloud Drive sync keeps working after the update without a question; "Change…" to a Nextcloud, Dropbox, OneDrive or Syncthing folder on two Macs: a change on one arrives on the other; "Turn Off" stops watching. +- Set holzBar's language to Deutsch, Français, Italiano and Rumantsch (System Settings → General → Language & Region → Applications), relaunch: menus, Settings, alerts, the permissions window and Shortcuts actions are translated, nothing is cut off, holzBar and holzBar Shelf stay as they are. A native speaker (or the user) reads the machine-written translations, Romansh above all. + +## Self-Check: PASSED +- FOUND: ItemIconChoice.swift, BorderPattern.swift, WallpaperPalette.swift, SettingsSyncLocation.swift, ItemIconStore.swift, Localizable.xcstrings, InfoPlist.xcstrings, AppShortcuts.xcstrings, strings-check.py +- FOUND commits: 5d34de5, 74cfc48, df6e9c4, 37ae807 diff --git a/CLAUDE.md b/CLAUDE.md index fc5ded0e..8ee50eb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ holzBar is a fork of [Ice](https://github.com/jordanbaird/Ice) by Jordan Baird, ## Workflow - Work with **GSD** ([OpenGSD gsd-core](https://github.com/open-gsd/gsd-core)) whenever possible. It is installed locally in `.claude/` (commands `/gsd-*`, agents, hooks in `.claude/settings.json`), so every session has it. Planning state lives in `.planning/`. Start with `/gsd-help`; for this existing codebase, `/gsd-map-codebase` maps it and `/gsd-new-project` / `/gsd-plan-phase` / `/gsd-execute-phase` drive the work. -- Save CI runs and credits: group several phases into one pull request where they fit together, and push once per plan (not after every task); wait for CI only after that push. +- Save CI runs and credits: run CI as rarely as possible. Group several phases into one pull request, commit locally and push only once at the end of the work (then fix until green); never push after every task or plan. - Ask the user every question as a multiple choice (the AskUserQuestion tool), never as free text. - Update GSD with `npx -y @opengsd/gsd-core@latest --claude --local` and commit the result. diff --git a/Integrations/Raycast/README.md b/Integrations/Raycast/README.md index 8eeb7356..8b813b54 100644 --- a/Integrations/Raycast/README.md +++ b/Integrations/Raycast/README.md @@ -7,15 +7,22 @@ The scripts open `holzbar://` URLs, so the same commands work from Alfred, Shortcuts or a terminal: -| URL | Action | -|---|---| -| `holzbar://toggle/hidden` | Show or hide the hidden section (`show/…`, `hide/…` also work) | -| `holzbar://toggle/always-hidden` | Show or hide the always-hidden section | -| `holzbar://search` | Open the menu bar item search | -| `holzbar://settings` | Open the settings | -| `holzbar://shelf/toggle` | Turn the holzBar Shelf on or off | -| `holzbar://auto-rehide/toggle` | Turn auto-rehide on or off | -| `holzbar://application-menus/toggle` | Hide or show the application menus | -| `holzbar://profile/` | Apply a saved layout profile | +| URL | Action | Changes | +|---|---|---| +| `holzbar://toggle/hidden` | Show or hide the hidden section (`show/…`, `hide/…` also work) | Nothing persistent | +| `holzbar://toggle/always-hidden` | Show or hide the always-hidden section | Nothing persistent | +| `holzbar://search` | Open the menu bar item search | Nothing persistent | +| `holzbar://settings` | Open the settings | Nothing persistent | +| `holzbar://shelf/toggle` | Turn the holzBar Shelf on or off | A setting the same command flips back | +| `holzbar://auto-rehide/toggle` | Turn auto-rehide on or off | A setting the same command flips back | +| `holzbar://application-menus/toggle` | Hide or show the application menus | Nothing persistent | +| `holzbar://zen/toggle` | Turn Zen mode on or off: hidden items stay hidden until it is off | Nothing persistent | +| `holzbar://profile/` | Apply a saved layout profile | Rearranges items, asks first | + +Any app can open these URLs, so the only command that rearranges the menu bar asks first: holzBar shows "Apply the layout profile …?" with Cancel as the default answer. While Zen mode is on, `show` and `toggle` of a hidden section are ignored. + +## Shortcuts + +The Shortcuts app offers the same actions without a script: **Toggle Zen Mode**, **Change Section** (show, hide or toggle the hidden or always-hidden section), **Apply Layout Profile**, **Open Menu Bar Item** (by name) and **Search Menu Bar Items**. They run inside holzBar without the question, because you built the shortcut yourself, and need no extra permission. `holzbar://ice-bar/toggle`, the command's name in Ice, still works. diff --git a/NOTICE b/NOTICE index 33992192..6e0ae29f 100644 --- a/NOTICE +++ b/NOTICE @@ -16,6 +16,24 @@ which is itself adapted from the PlatformRuntimeKit of Thaw by thaw-app (https://github.com/thaw-app/Thaw). Both are licensed under the GNU General Public License, version 3, like holzBar. +Code adapted from Thaw by thaw-app (https://github.com/thaw-app/Thaw, GPL-3.0), +one line per adapted file: + + - holzBar/Core/MoveBackoff.swift is adapted from Thaw's + Thaw/MenuBar/MenuBarItems/MenuBarItemManager/MoveCircuitBreaker.swift + (Copyright (C) 2026 Toni Förster). + - holzBar/Core/ItemIdentity.swift adapts canonicalMetricTitle from Thaw's + Thaw/MenuBar/MenuBarItems/MenuBarItemTag.swift. + - holzBar/MenuBar/Appearance/WallpaperChangeMonitor.swift is adapted from Thaw's + Thaw/MenuBar/Appearance/WallpaperChangeMonitor.swift (Copyright (C) 2026 Toni Förster). + - holzBar/MenuBar/PresentationMonitor.swift adapts the mirroring check of Thaw's + Thaw/MenuBar/PresentationMonitor.swift (Copyright (C) 2026 Toni Förster). + - holzBar/Core/WallpaperPalette.swift is adapted from Thaw's + Thaw/MenuBar/Appearance/WallpaperPalette.swift (Copyright (C) 2026 Toni Förster). + - holzBar/MenuBar/Appearance/MenuBarOverlayPanel.swift masks the system glass tint to + the menu bar shape the way Thaw's Thaw/MenuBar/Appearance/MenuBarOverlayPanel.swift + does (Copyright (C) 2026 Toni Förster). + The licenses of the open-source packages holzBar links are shown in the app under Settings, About, Acknowledgements. diff --git a/README.md b/README.md index 067e0ff0..14e60e75 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,8 @@ holzBar is a community fork of [Ice](https://github.com/jordanbaird/Ice) by Jord | 🛡️ **Least privilege** | Only Accessibility at first launch; Screen Recording only when a feature needs it. Hardened runtime, private logs. | | 🍃 **Lean** | No polling, no mouse tracking unless you use it, nothing kept in memory that nobody shows. | | 🪵 **macOS 27 compatible** | A dedicated backend for the redesigned macOS 27 menu bar drawn by `MenuBarAgent`. | -| ✨ **More features** | Profiles, groups, spacers, a black menu bar, URL commands, settings sync — [see below](#-features). | +| ✨ **More features** | Profiles, folders, spacers, Zen mode, Shortcuts actions, a black menu bar, URL commands, settings sync through any folder — [see below](#-features). | +| 🌍 **Five languages** | English, German, French, Italian and Romansh. | | 🍺 **Homebrew first** | Install and update with one command. | ## ⚖️ holzBar vs. Ice and Thaw @@ -58,16 +59,36 @@ What the original [Ice](https://github.com/jordanbaird/Ice) 0.11.12 and the othe | Hidden and always-hidden sections, Ice Bar / holzBar Shelf, search, appearance | ✅ | ✅ | ✅ | | Layout profiles | ❌ | ✅ | ✅ | | Groups and spacers | ❌ | ✅ | ✅ | +| Folders with their own icon and colour; item images of your choice | ❌ | ✅ | ✅ | | Choose where new items appear | ❌ | — | ✅ | | Bar only on some displays, notch overflow | ❌ | — | ✅ | | Black menu bar, rounded screen corners | ❌ | corners only | ✅ | +| Dashed and dotted borders, wallpaper, accent and glass tints | ❌ | ✅ | ✅ | | Show hidden items on low battery or when offline | ❌ | — | ✅ | | URL commands and Raycast | ❌ | ✅ | ✅ | -| Export, import and sync settings | ❌ | no sync | ✅ | +| Zen mode (also while presenting) | ❌ | ✅ | ✅ | +| Hotkeys per profile and per item | ❌ | ✅ | ✅ | +| Shortcuts actions (App Intents) | ❌ | ✅ | ✅ | +| Profiles bound to a display or a Space | ❌ | ✅ | ✅ | +| Open an item by letter | ❌ | ✅ | ✅ | +| Layout editor and Shelf from the keyboard, undo, VoiceOver actions | ❌ | ✅ | ✅ | +| Opened items stay up to 30 s, or open without showing the item | ❌ | ✅ | ✅ | +| Show an item briefly when it changes | ❌ | ✅ | ✅ opt-in per item | +| Export and import settings | ❌ | ✅ | ✅ | +| Settings sync: iCloud Drive or any synced folder | ❌ | ❌ | ✅ | +| Languages | English | many, through Crowdin | English, German, French, Italian, Romansh | | Keep Live Activities visible | ❌ | — | ✅ experimental | | Show on scroll with a mouse wheel | ❌ | — | ✅ | | Search tolerates typos and abbreviations | ✅ (library) | ✅ | ✅ (built in) | | Refuses hotkeys macOS cannot register, and says why | ❌ | — | ✅ | +| Input never stalls when an app hangs | ❌ | ✅ | ✅ | +| Items keep their section when an app changes its title | ❌ | ✅ | ✅ | +| Pauses while the screen is locked, settles after wake | ❌ | ✅ | ✅ | +| Look on every desktop, follows the icons, steps aside in fullscreen | ❌ | ✅ | ✅ | +| No screen-recording indicator when showing or hiding (macOS 27) | — | ✅ | ✅ | +| Hover and click on a second display (macOS 27) | — | ✅ | ✅ | +| URL commands that rearrange items ask first | — | — | ✅ | +| Validated hotkeys and colours (no crash loop from bad settings) | ❌ | — | ✅ | | **Privacy and permissions** | | | | | Network connections (update checks, telemetry, analytics) | Sparkle update checks | Sparkle update checks | **none** — enforced by CI | | Personal data (app names, item titles, paths) in logs | partly public | — | private, enforced by CI | @@ -82,12 +103,12 @@ What the original [Ice](https://github.com/jordanbaird/Ice) 0.11.12 and the othe | State management | Combine | mostly `@Observable` | `@Observable`, no Combine | | Mouse event tap when "Show on hover" is off | always running | — | off | | Timers and polling while nothing is shown | yes | — | only while needed | -| Settings sync checks for changes | — (no sync) | — | when iCloud Drive delivers them, no polling | +| Settings sync checks for changes | — (no sync) | — (no sync) | when the synced folder delivers them, no polling | | Settings migration | 6 version steps at every launch | — | once, while importing Ice settings | | Runtime patching of AppKit (method swizzling) | yes | — | none | | Item images in memory | kept | — | released when unused | -| Unit tests run on every change | none | ✅ | ✅ 255 | -| App size | — | — | 13.9 MB | +| Unit tests run on every change | none | ✅ | ✅ 338 | +| App size | — | — | 16.5 MB | | **Distribution and maintenance** | | | | | Install and update with Homebrew | ✅ | ✅ | ✅ | | Updates | Sparkle (dialog can hang on macOS 26) | Sparkle | Homebrew | @@ -112,9 +133,9 @@ Everything holzBar asks macOS for, the feature that needs it and when it is aske | Permission or entitlement | Needed for | When | |---|---|---| | **Accessibility** required | Reading where menu bar items are; moving, showing and clicking them for you; noticing clicks, scrolls and hovers in the menu bar for show on click, scroll and hover | Asked on the first launch | -| **Screen Recording** optional | Pictures of menu bar items in the holzBar Shelf, the search and the Menu Bar Layout pane (on macOS 27 taken once per item), and the wallpaper beside a menu bar shape | Asked the first time you open the holzBar Shelf, the search or the Menu Bar Layout pane, or choose a menu bar shape — never at launch. Without it, everything else works and nothing captures the screen | +| **Screen Recording** optional | Pictures of menu bar items in the holzBar Shelf, the search and the Menu Bar Layout pane (on macOS 27 taken once per item), and a moving wallpaper beside a menu bar shape (before macOS 27; other wallpapers are read from their file) | Asked the first time you open the holzBar Shelf, the search or the Menu Bar Layout pane, or choose a menu bar shape with a moving wallpaper — never at launch. Without it, everything else works, the holzBar Shelf and the search show app icons, and nothing captures the screen | | **Login item** | Starting holzBar when you log in | Only when you turn on "Launch at login" | -| **iCloud Drive file** | Settings sync between your Macs (`iCloud Drive/holzBar/Settings.plist`), read and written with file coordination | Only while settings sync is on; with sync off, holzBar neither watches the folder nor writes to it | +| **A folder you choose** | Settings sync between your Macs (`holzBar/Settings.plist` in iCloud Drive or any folder your Macs sync, such as Nextcloud, Dropbox, OneDrive, Syncthing or a network share), read and written with file coordination; holzBar keeps a bookmark of the folder, the folder's own app does the syncing | Only while settings sync is on; with sync off, holzBar neither watches the folder nor writes to it | | **Entitlements** | None. holzBar runs without the App Sandbox, because Accessibility event taps and the menu bar's private WindowServer calls do not work in it, and it has no network entitlement | — | | **Info.plist usage strings** | None: macOS does not use them for Accessibility and Screen Recording | — | | **Reset and Grant Again** | Runs `tccutil reset` for holzBar's own entry only, when a stale permission keeps the permissions window open | Only when you click it | @@ -177,6 +198,9 @@ macOS 27 no longer draws menu bar items as separate windows — `MenuBarAgent` d - **Item discovery** through Accessibility instead of window lists - **holzBar Shelf** with real item images and even spacing - **Layout editor** that assigns apps to sections — your old layout is carried over on first launch +- **A second display** works like the first: hovering and clicking items there opens their menus instead of revealing hidden items +- **Notched MacBooks**: holzBar's icon is kept out from under the notch, and items folded beside the notch come back on a wider display +- **No screenshots under the bar**: no screen-recording indicator when items are shown or hidden, and a click on the clock no longer flashes hidden items **Known limitations on macOS 27** @@ -195,11 +219,18 @@ macOS 27 no longer draws menu bar items as separate windows — `MenuBarAgent` d - ✅ Show on hover, click or scroll (trackpad **and mouse wheel**) - ✅ Automatic rehide - ✅ Drag-and-drop layout editor -- ✅ **Layout profiles** — "Work", "Home", … one click or URL away -- ✅ **Groups** — several items behind an icon of their own +- ✅ **Layout profiles** — "Work", "Home", … one click, hotkey or URL away +- ✅ **Profiles bound to a display or a Space** — applied when you connect the display or switch to the Space +- ✅ **Groups** (folders) — several items behind an icon of their own, in a colour and with a symbol or image of your choice +- ✅ **Item images of your choice** — give any item its own picture, or its app's icon; without Screen Recording, the holzBar Shelf and the search show app icons - ✅ **Spacers** — empty items of adjustable width - ✅ **Choose where new items appear** +- ✅ **Keeps items in their section after app updates, title changes and display changes** - ✅ Search menu bar items — by abbreviation ("cc" for Control Centre) and despite typos +- ✅ **Open an item by letter** — a hotkey shows a letter under every item; type it to open the item's menu +- ✅ **Keyboard and VoiceOver** — arrange items in the Layout pane with the arrow keys, undo with ⌘Z, move items with VoiceOver actions +- ✅ **Opened items stay** up to 30 s after their menu closes — or open without showing the item at all +- ✅ **Show When It Changes** — a marked item shows for 5 s when its title or value changes - ✅ Item spacing BETA @@ -209,11 +240,14 @@ macOS 27 no longer draws menu bar items as separate windows — `MenuBarAgent` d - ✅ Hidden items in a bar below the menu bar - ✅ **Only on the built-in display or on displays with a notch** - ✅ **Shows items the notch covers** +- ✅ **Works from the keyboard** — open it with its hotkey, then the arrow keys, Return and Escape #### Appearance - ✅ Tint, shadow, border, rounded and split shapes - ✅ **Black menu bar that hides the notch** - ✅ **Rounded screen corners** +- ✅ **Dashed and dotted borders** +- ✅ **Tints from the wallpaper or the accent colour, and the system's glass** glass on macOS 26 and later @@ -222,17 +256,20 @@ macOS 27 no longer draws menu bar items as separate windows — `MenuBarAgent` d #### Automation - ✅ **Show hidden items when the battery is low or the network drops** +- ✅ **Zen mode** — one hotkey, menu item, URL or Shortcuts action keeps hidden items hidden; optionally on while the screen is mirrored or shared, without any permission +- ✅ **Shortcuts actions** — Zen mode, show or hide a section, apply a profile, open an item by name, search - ✅ **`holzbar://` URL commands** and [Raycast script commands](Integrations/Raycast) -- ✅ Hotkeys for sections, search, the holzBar Shelf, app menus, **auto-rehide** and **a quick peek** +- ✅ Hotkeys for sections, search, the holzBar Shelf, app menus, **auto-rehide**, **a quick peek**, **Zen mode**, **each layout profile** and **each menu bar item** #### Settings - ✅ **Export and import** all settings -- ✅ **Sync between Macs** through iCloud Drive — changes from another Mac arrive as soon as iCloud Drive delivers them, with no polling +- ✅ **Sync between Macs** through iCloud Drive or any folder your Macs sync (Nextcloud, Dropbox, OneDrive, Syncthing, a network share) — changes from another Mac arrive as soon as the folder delivers them, with no polling - ✅ **Imports your Ice settings** on first launch - ✅ Launch at login +- ✅ **English, German, French, Italian and Romansh** — holzBar follows your Mac's language; choose another one for holzBar alone in System Settings → General → Language & Region → Applications diff --git a/Scripts/macos27/layout-ax.swift b/Scripts/macos27/layout-ax.swift index 9e74cf51..9c72bb38 100644 --- a/Scripts/macos27/layout-ax.swift +++ b/Scripts/macos27/layout-ax.swift @@ -30,7 +30,8 @@ func walk(_ element: AXUIElement, depth: Int) { if sidebar == nil, role == kAXStaticTextRole, text == "Menu Bar Layout", let f = frame(element) { sidebar = f } - if role == kAXImageRole, let label = value(element, kAXDescriptionAttribute) as? String, !label.isEmpty, let f = frame(element), f.width < 80 { + // Layout items are buttons since holzBar 0.0.6 (images before). + if role == kAXImageRole || role == kAXButtonRole, let label = value(element, kAXDescriptionAttribute) as? String, !label.isEmpty, let f = frame(element), f.width < 80 { images.append((f, label)) } for child in value(element, kAXChildrenAttribute) as? [AXUIElement] ?? [] { diff --git a/Shared/Bridging/Bridging.swift b/Shared/Bridging/Bridging.swift index bcb14d5b..d9f12461 100644 --- a/Shared/Bridging/Bridging.swift +++ b/Shared/Bridging/Bridging.swift @@ -112,6 +112,18 @@ nonisolated extension Bridging { // MARK: Public Display API + /// Returns the UUID of the given display as a string, which stays the same across + /// restarts and reconnections (unlike the display identifier). + static func getDisplayUUIDString(for displayID: CGDirectDisplayID) -> String? { + guard + let uuid = getDisplayUUID(for: displayID), + let string = CFUUIDCreateString(nil, uuid) + else { + return nil + } + return string as String + } + /// Returns the identifier of the display with the active menu bar. static func getActiveMenuBarDisplayID() -> CGDirectDisplayID? { guard let string = CGSCopyActiveMenuBarDisplayIdentifier(getMainConnection()) else { @@ -164,6 +176,40 @@ nonisolated extension Bridging { CGSGetActiveSpace(getMainConnection()) } + /// Returns a stable identifier for the active space, which, unlike its number, stays + /// the same across restarts: the space's UUID from WindowServer. + /// + /// The first desktop of a display can have an empty UUID; it is then named after its + /// display. Every value is type-checked, so an unexpected shape returns `nil`. + static func getActiveSpaceUUID() -> String? { + let activeSpaceID = getActiveSpaceID() + guard + let unmanaged = CGSCopyManagedDisplaySpaces(getMainConnection()), + let displays = unmanaged.takeRetainedValue() as? [[String: Any]] + else { + return nil + } + for display in displays { + guard let spaces = display["Spaces"] as? [[String: Any]] else { + continue + } + for space in spaces { + let spaceID = (space["ManagedSpaceID"] as? Int) ?? (space["id64"] as? Int) + guard spaceID == activeSpaceID else { + continue + } + if let uuid = space["uuid"] as? String, !uuid.isEmpty { + return uuid + } + guard let displayIdentifier = display["Display Identifier"] as? String else { + return nil + } + return "FirstSpace:" + displayIdentifier + } + } + return nil + } + /// Returns the identifier for the current space on the given /// display. /// diff --git a/Shared/Bridging/Shims.swift b/Shared/Bridging/Shims.swift index d70c3d3d..2f160413 100644 --- a/Shared/Bridging/Shims.swift +++ b/Shared/Bridging/Shims.swift @@ -93,6 +93,11 @@ nonisolated func CGSManagedDisplayGetCurrentSpace( _ displayUUID: CFString ) -> CGSSpaceID +/// Returns one dictionary per display with its spaces ("Display Identifier", "Current +/// Space", "Spaces"; each space with "ManagedSpaceID" and "uuid"). Needs no permission. +@_silgen_name("CGSCopyManagedDisplaySpaces") +nonisolated func CGSCopyManagedDisplaySpaces(_ cid: CGSConnectionID) -> Unmanaged? + @_silgen_name("CGSSpaceGetType") nonisolated func CGSSpaceGetType( _ cid: CGSConnectionID, diff --git a/Shared/Services/SourcePIDCache.swift b/Shared/Services/SourcePIDCache.swift index 8e45f753..bd61f051 100644 --- a/Shared/Services/SourcePIDCache.swift +++ b/Shared/Services/SourcePIDCache.swift @@ -84,6 +84,11 @@ nonisolated final class SourcePIDCache: Sendable { nonisolated private struct State { var apps = [CachedApplication]() var pids = [CGWindowID: pid_t]() + /// Windows whose source process was not found, with when the scan failed. A miss is + /// not scanned again for ``SourcePIDCache/failedLookupInterval``: every scan asks every + /// running app through Accessibility (jordanbaird/Ice#911). Starts empty whenever + /// the running applications change, as a new app may own the window. + var failedLookups = [CGWindowID: ContinuousClock.Instant]() /// Observer for running applications. var observation: NSKeyValueObservation? @@ -164,6 +169,9 @@ nonisolated final class SourcePIDCache: Sendable { /// The shared cache. static let shared = SourcePIDCache() + /// How long a window whose source process was not found is not scanned again. + static let failedLookupInterval = Duration.seconds(30) + /// The cache's protected state. It holds Accessibility elements and running /// applications, which are not marked `Sendable`, so it is only touched /// inside the lock. @@ -233,8 +241,17 @@ nonisolated final class SourcePIDCache: Sendable { if let pid = state.pids[window.windowID] { return pid } + let now = ContinuousClock.now + if let failedAt = state.failedLookups[window.windowID], failedAt.duration(to: now) < Self.failedLookupInterval { + return nil + } state.updatePID(for: window) - return state.pids[window.windowID] + guard let pid = state.pids[window.windowID] else { + state.failedLookups[window.windowID] = now + return nil + } + state.failedLookups[window.windowID] = nil + return pid } } } diff --git a/Shared/Utilities/AXHelpers.swift b/Shared/Utilities/AXHelpers.swift index 4c9b327d..a93fbe45 100644 --- a/Shared/Utilities/AXHelpers.swift +++ b/Shared/Utilities/AXHelpers.swift @@ -49,15 +49,42 @@ nonisolated enum AXHelpers { } } - static func application(for runningApp: NSRunningApplication) -> AXUIElement? { + /// How long the reads of the application menu wait for an application to answer. + /// + /// The default timeout is 6 s. The application menu is read on every change of the + /// frontmost application, and an application that hangs must not hold up the next read + /// for that long. + static let applicationMenuTimeout: Float = 0.25 + + /// Returns the element of the given application. + /// + /// - Parameters: + /// - runningApp: The application. + /// - messagingTimeout: How long calls to the element wait for the application to + /// answer, or `nil` for the default timeout (6 s). The XPC service keeps the + /// default: it runs off the app's main thread and must not lose slow applications. + static func application(for runningApp: NSRunningApplication, messagingTimeout: Float? = nil) -> AXUIElement? { queue.sync { guard !runningApp.isTerminated else { return nil } - return AXUIElementCreateApplication(runningApp.processIdentifier) + let element = AXUIElementCreateApplication(runningApp.processIdentifier) + if let messagingTimeout { + AXUIElementSetMessagingTimeout(element, messagingTimeout) + } + return element } } + /// Sets how long calls to the given element wait for its application to answer. + /// + /// The timeout belongs to this element only, not to other elements of the same + /// application. It is never set on the system-wide element: there it would become the + /// timeout of every Accessibility call holzBar makes. + static func setMessagingTimeout(_ timeout: Float, for element: AXUIElement) { + AXUIElementSetMessagingTimeout(element, timeout) + } + static func extrasMenuBar(for app: AXUIElement) -> AXUIElement? { queue.sync { Self.element(from: value(app, kAXExtrasMenuBarAttribute)) } } @@ -115,28 +142,64 @@ nonisolated enum AXHelpers { } } - /// Returns the menu bar that holds the application menus, found at the - /// given point before macOS 27. + /// Returns the menu bar that holds the application menus, found on the given display + /// before macOS 27. + /// + /// Before macOS 27 the menu bar is the element at a point of the bar. The display's + /// top-left pixel alone missed it on notched Macs and with Liquid Glass, so a click in + /// the Shelf found no application menu and opened nothing (jordanbaird/Ice#911). After + /// that pixel, points 8, 20 and 40 points right of the display's left edge, halfway + /// down the bar, are tried in turn; a menu (the Apple menu) found there leads to its + /// menu bar. /// /// On macOS 27 the element at a display's origin is MenuBarAgent's window, /// so the menu bar comes from the application that owns it. holzBar's own menus /// are skipped there: asking our own process from the main thread would wait /// for the main thread itself. - static func applicationMenuBar(at point: CGPoint) -> AXUIElement? { + /// + /// The returned menu bar answers within ``applicationMenuTimeout``. This blocks, so it + /// is called off the main thread only (see `ApplicationMenuQuery`). + static func applicationMenuBar(in displayBounds: CGRect, menuBarHeight: CGFloat) -> AXUIElement? { if #available(macOS 27.0, *) { guard let owner = NSWorkspace.shared.menuBarOwningApplication, owner.processIdentifier != ProcessInfo.processInfo.processIdentifier, - let app = application(for: owner) + let app = application(for: owner, messagingTimeout: applicationMenuTimeout) else { return nil } - return queue.sync { Self.element(from: value(app, kAXMenuBarAttribute)) } + guard let menuBar = queue.sync(execute: { Self.element(from: value(app, kAXMenuBarAttribute)) }) else { + return nil + } + setMessagingTimeout(applicationMenuTimeout, for: menuBar) + return menuBar } - guard let element = element(at: point), role(for: element) == kAXMenuBarRole else { - return nil + let points = [displayBounds.origin] + [8, 20, 40].map { (offset: CGFloat) in + CGPoint(x: displayBounds.minX + offset, y: displayBounds.minY + menuBarHeight / 2) } - return element + for point in points { + guard let element = element(at: point) else { + continue + } + setMessagingTimeout(applicationMenuTimeout, for: element) + switch role(for: element) { + case kAXMenuBarRole: + return element + case kAXMenuBarItemRole: + if let menuBar = parent(of: element), role(for: menuBar) == kAXMenuBarRole { + setMessagingTimeout(applicationMenuTimeout, for: menuBar) + return menuBar + } + default: + break + } + } + return nil + } + + /// The parent of the given element, or `nil` when it has none. + private static func parent(of element: AXUIElement) -> AXUIElement? { + queue.sync { Self.element(from: value(element, kAXParentAttribute)) } } // MARK: Private diff --git a/Shared/Utilities/WindowInfo.swift b/Shared/Utilities/WindowInfo.swift index 9c41031d..a52e44fe 100644 --- a/Shared/Utilities/WindowInfo.swift +++ b/Shared/Utilities/WindowInfo.swift @@ -120,9 +120,11 @@ nonisolated extension WindowInfo { /// given list of windows. static func wallpaperWindow(from windows: [WindowInfo], for display: CGDirectDisplayID) -> WindowInfo? { let displayBounds = CGDisplayBounds(display) + // The Dock owns the wallpaper window; on macOS 27 WindowManager does + // (jordanbaird/Ice#1001, measured). + let owners: Set = ["com.apple.dock", "com.apple.WindowManager"] return windows.first { window in - // Wallpaper window belongs to the Dock process. - window.owningApplication?.bundleIdentifier == "com.apple.dock" && + window.owningApplication?.bundleIdentifier.map(owners.contains) == true && window.title?.hasPrefix("Wallpaper") == true && displayBounds.contains(window.bounds) } diff --git a/Tests/HolzBarCoreTests/BorderPatternTests.swift b/Tests/HolzBarCoreTests/BorderPatternTests.swift new file mode 100644 index 00000000..3b5a6096 --- /dev/null +++ b/Tests/HolzBarCoreTests/BorderPatternTests.swift @@ -0,0 +1,21 @@ +import Testing +@testable import HolzBarCore + +@Suite("BorderPattern") +struct BorderPatternTests { + @Test("Solid has no dashes; dashed and dotted scale with the width") + func patternsScaleWithWidth() { + #expect(BorderPattern.dashes(for: .solid, width: 2) == nil) + #expect(BorderPattern.dashes(for: .dashed, width: 1) == [6, 3]) + #expect(BorderPattern.dashes(for: .dashed, width: 3) == [18, 9]) + #expect(BorderPattern.dashes(for: .dotted, width: 2) == [0, 4]) + #expect(BorderPattern.usesRoundCaps(.dotted)) + #expect(!BorderPattern.usesRoundCaps(.dashed)) + #expect(!BorderPattern.usesRoundCaps(.solid)) + } + + @Test("Stored styles never change") + func storedStylesNeverChange() { + #expect(BorderStyle.allCases.map(\.rawValue) == [0, 1, 2]) + } +} diff --git a/Tests/HolzBarCoreTests/ChangeRevealTests.swift b/Tests/HolzBarCoreTests/ChangeRevealTests.swift new file mode 100644 index 00000000..e8ebcd6d --- /dev/null +++ b/Tests/HolzBarCoreTests/ChangeRevealTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import HolzBarCore + +@Suite("ChangeReveal") +struct ChangeRevealTests { + @Test("Changes within a second count once") + func changesWithinASecondCountOnce() { + var reveal = ChangeReveal() + for time in [10.0, 10.25, 10.75] { + reveal.noteChange(key: "timer", isZenActive: false, isVisible: false, at: time) + } + #expect(reveal.nextDueTime == 11.75) + #expect(reveal.due(at: 11.5).isEmpty) + #expect(reveal.due(at: 11.75) == ["timer"]) + #expect(reveal.due(at: 12.5).isEmpty) + #expect(reveal.nextDueTime == nil) + } + + @Test("An item is revealed at most every 30 s") + func revealedAtMostEveryThirtySeconds() { + var reveal = ChangeReveal() + reveal.noteChange(key: "timer", isZenActive: false, isVisible: false, at: 0) + #expect(reveal.due(at: 1) == ["timer"]) + reveal.noteChange(key: "timer", isZenActive: false, isVisible: false, at: 21) + #expect(reveal.due(at: 23).isEmpty) + reveal.noteChange(key: "timer", isZenActive: false, isVisible: false, at: 32) + #expect(reveal.due(at: 33) == ["timer"]) + // Another item has its own limit. + reveal.noteChange(key: "mail", isZenActive: false, isVisible: false, at: 33.5) + #expect(reveal.due(at: 34.5) == ["mail"]) + } + + @Test("Nothing is revealed in Zen mode or for a visible item") + func nothingInZenModeOrForVisibleItems() { + var reveal = ChangeReveal() + reveal.noteChange(key: "timer", isZenActive: true, isVisible: false, at: 0) + reveal.noteChange(key: "clock", isZenActive: false, isVisible: true, at: 0) + #expect(reveal.due(at: 5).isEmpty) + #expect(reveal.nextDueTime == nil) + } +} diff --git a/Tests/HolzBarCoreTests/ColorComponentsTests.swift b/Tests/HolzBarCoreTests/ColorComponentsTests.swift new file mode 100644 index 00000000..be629a3f --- /dev/null +++ b/Tests/HolzBarCoreTests/ColorComponentsTests.swift @@ -0,0 +1,35 @@ +import CoreGraphics +import Testing +@testable import HolzBarCore + +@Suite("ColorComponents") +struct ColorComponentsTests { + @Test("The count must match the colour space") + func countMatchesColorSpace() { + #expect(ColorComponents.isValid([0.1, 0.2, 0.3, 1], colorSpaceComponents: 3)) + #expect(ColorComponents.isValid([0.5, 1], colorSpaceComponents: 1)) + #expect(!ColorComponents.isValid([0.1, 0.2, 0.3], colorSpaceComponents: 3)) + #expect(!ColorComponents.isValid([0.1, 0.2, 0.3, 0.4, 1], colorSpaceComponents: 3)) + #expect(!ColorComponents.isValid([], colorSpaceComponents: 3)) + #expect(!ColorComponents.isValid([1], colorSpaceComponents: 0)) + } + + @Test("Components are finite and within 0 to 1") + func componentsAreFiniteAndInRange() { + #expect(!ColorComponents.isValid([.nan, 0.2, 0.3, 1], colorSpaceComponents: 3)) + #expect(!ColorComponents.isValid([0.1, .infinity, 0.3, 1], colorSpaceComponents: 3)) + #expect(!ColorComponents.isValid([0.1, 0.2, -0.1, 1], colorSpaceComponents: 3)) + #expect(!ColorComponents.isValid([0.1, 0.2, 0.3, 1.5], colorSpaceComponents: 3)) + #expect(ColorComponents.isValid([0, 0, 0, 0], colorSpaceComponents: 3)) + #expect(ColorComponents.isValid([1, 1, 1, 1], colorSpaceComponents: 3)) + } + + @Test("A stored colour slightly out of range is clamped, not lost") + func outOfRangeValuesAreClamped() { + let clamped = ColorComponents.clamped([-0.02, 1.05, 0.5, 1]) + #expect(clamped == [0, 1, 0.5, 1]) + #expect(ColorComponents.isValid(clamped, colorSpaceComponents: 3)) + let notANumber = ColorComponents.clamped([.nan, 0.5, 0.5, 1]) + #expect(!ColorComponents.isValid(notANumber, colorSpaceComponents: 3)) + } +} diff --git a/Tests/HolzBarCoreTests/DockIconPolicyTests.swift b/Tests/HolzBarCoreTests/DockIconPolicyTests.swift new file mode 100644 index 00000000..749d0ea1 --- /dev/null +++ b/Tests/HolzBarCoreTests/DockIconPolicyTests.swift @@ -0,0 +1,31 @@ +import Testing +@testable import HolzBarCore + +@Suite("DockIconPolicy") +struct DockIconPolicyTests { + @Test("Settings and search stay out of the Dock") + func settingsAndSearchStayOutOfTheDock() { + for keepsHidden in [true, false] { + #expect(DockIconPolicy.policy(for: .settings, keepsDockIconHidden: keepsHidden) == .accessory) + #expect(DockIconPolicy.policy(for: .search, keepsDockIconHidden: keepsHidden) == .accessory) + } + } + + @Test("The permissions window is in the Dock") + func permissionsWindowIsInTheDock() { + #expect(DockIconPolicy.policy(for: .permissions, keepsDockIconHidden: true) == .regular) + #expect(DockIconPolicy.policy(for: .permissions, keepsDockIconHidden: false) == .regular) + } + + @Test("Hiding application menus needs the Dock icon, so it waits for the setting") + func hidingApplicationMenusFollowsTheSetting() { + #expect(DockIconPolicy.policy(for: .hideApplicationMenus, keepsDockIconHidden: false) == .regular) + #expect(DockIconPolicy.policy(for: .hideApplicationMenus, keepsDockIconHidden: true) == nil) + } + + @Test("Hiding the menus on request brings the Dock icon") + func hidingMenusOnRequestBringsTheDockIcon() { + #expect(DockIconPolicy.policy(for: .toggleApplicationMenus, keepsDockIconHidden: true) == .regular) + #expect(DockIconPolicy.policy(for: .toggleApplicationMenus, keepsDockIconHidden: false) == .regular) + } +} diff --git a/Tests/HolzBarCoreTests/HotkeyStorageTests.swift b/Tests/HolzBarCoreTests/HotkeyStorageTests.swift index 175ce72c..22426a9c 100644 --- a/Tests/HolzBarCoreTests/HotkeyStorageTests.swift +++ b/Tests/HolzBarCoreTests/HotkeyStorageTests.swift @@ -19,6 +19,8 @@ struct HotkeyStorageTests { "EnableIceBar", "ToggleApplicationMenus", "ToggleAutoRehide", + "ToggleZenMode", + "ShowItemHints", ]) } @@ -39,6 +41,24 @@ struct HotkeyStorageTests { #expect(HotkeyStorage.decode(Data()) == nil) } + @Test("A key code outside 0 to 127 is ignored") + func keyCodeOutOfRangeIgnored() { + #expect(HotkeyStorage.decode(Data("[128,8]".utf8)) == nil) + #expect(HotkeyStorage.decode(Data("[-1,8]".utf8)) == nil) + #expect(HotkeyStorage.decode(Data("[99999,8]".utf8)) == nil) + #expect(HotkeyStorage.decode(Data("[0,8]".utf8))?.key == 0) + #expect(HotkeyStorage.decode(Data("[127,8]".utf8))?.key == 127) + } + + @Test("Unknown modifier bits are ignored") + func unknownModifierBitsIgnored() { + #expect(HotkeyStorage.decode(Data("[49,256]".utf8)) == nil) + #expect(HotkeyStorage.decode(Data("[49,16]".utf8)) == nil) + #expect(HotkeyStorage.decode(Data("[49,-1]".utf8)) == nil) + #expect(HotkeyStorage.decode(Data("[49,15]".utf8))?.modifiers == 15) + #expect(HotkeyStorage.decode(Data("[49,0]".utf8))?.modifiers == 0) + } + @Test("An action is stored by its raw value") func actionStoredByRawValue() throws { let data = try JSONEncoder().encode(HotkeyAction.enableShelf) diff --git a/Tests/HolzBarCoreTests/HotkeyTargetTests.swift b/Tests/HolzBarCoreTests/HotkeyTargetTests.swift new file mode 100644 index 00000000..5516c8c9 --- /dev/null +++ b/Tests/HolzBarCoreTests/HotkeyTargetTests.swift @@ -0,0 +1,44 @@ +import Testing +@testable import HolzBarCore + +@Suite("HotkeyTarget") +struct HotkeyTargetTests { + @Test("Actions keep their stored names") + func actionsKeepTheirStoredNames() { + #expect(HotkeyTarget.action(.toggleHiddenSection).storageKey == "ToggleHiddenSection") + #expect(HotkeyTarget.action(.enableShelf).storageKey == "EnableIceBar") + for action in HotkeyAction.allCases { + let target = HotkeyTarget.action(action) + #expect(HotkeyTarget(storageKey: target.storageKey) == target) + #expect(!target.isDynamic) + } + } + + @Test("Profiles and items have prefixed keys") + func profilesAndItemsHavePrefixedKeys() { + let profile = HotkeyTarget.applyProfile("Work") + let item = HotkeyTarget.openItem("com.example:#1") + #expect(profile.storageKey == "ApplyProfile:Work") + #expect(item.storageKey == "OpenItem:com.example:#1") + #expect(HotkeyTarget(storageKey: "ApplyProfile:Work") == profile) + #expect(HotkeyTarget(storageKey: "OpenItem:com.example:#1") == item) + #expect(HotkeyTarget(storageKey: "ApplyProfile:My Work: Home") == .applyProfile("My Work: Home")) + #expect(profile.isDynamic) + #expect(item.isDynamic) + } + + @Test("Unknown and empty keys name nothing") + func unknownKeysNameNothing() { + #expect(HotkeyTarget(storageKey: "LaunchRockets") == nil) + #expect(HotkeyTarget(storageKey: "ApplyProfile:") == nil) + #expect(HotkeyTarget(storageKey: "OpenItem:") == nil) + #expect(HotkeyTarget(storageKey: "") == nil) + } + + @Test("The log names no profile and no item") + func logNamesNoProfileOrItem() { + #expect(!HotkeyTarget.applyProfile("Secret").logDescription.contains("Secret")) + #expect(!HotkeyTarget.openItem("com.example:#1").logDescription.contains("example")) + #expect(HotkeyTarget.action(.toggleZenMode).logDescription == "ToggleZenMode") + } +} diff --git a/Tests/HolzBarCoreTests/InputMonitorsTests.swift b/Tests/HolzBarCoreTests/InputMonitorsTests.swift index e8382ce1..d9f052db 100644 --- a/Tests/HolzBarCoreTests/InputMonitorsTests.swift +++ b/Tests/HolzBarCoreTests/InputMonitorsTests.swift @@ -85,6 +85,17 @@ struct InputMonitorsTests { #expect(appearance.isSuperset(of: [.mouseDragged, .mouseUp])) } + @Test("Saving the user's arrangement needs only the mouse-up monitor") + func savingTheArrangementNeedsOnlyMouseUp() { + var settings = Self.defaults + settings.showAllSectionsOnUserDrag = false + settings.hasCustomAppearance = false + settings.savesUserArrangement = true + let kinds = InputMonitors.needed(for: settings) + #expect(kinds.contains(.mouseUp)) + #expect(!kinds.contains(.mouseDragged)) + } + @Test("The space click monitor runs only where a click can change the space") func spaceClickMonitorRunsOnlyWhereNeeded() { #expect(!InputMonitors.needsSpaceClickMonitor(isFullscreenSpace: false, screenCount: 1)) diff --git a/Tests/HolzBarCoreTests/InterfaceWindowRuleTests.swift b/Tests/HolzBarCoreTests/InterfaceWindowRuleTests.swift new file mode 100644 index 00000000..a104386a --- /dev/null +++ b/Tests/HolzBarCoreTests/InterfaceWindowRuleTests.swift @@ -0,0 +1,21 @@ +import Testing +@testable import HolzBarCore + +@Suite("InterfaceWindowRule") +struct InterfaceWindowRuleTests { + @Test("Only menus count") + func onlyMenusCount() { + for layer in [24, 25, 100, 101] { + #expect(InterfaceWindowRule.counts(layer: layer, isOwnersWindow: true, appearedAfterClick: true)) + } + // A floating window and a normal window never count. + #expect(!InterfaceWindowRule.counts(layer: 3, isOwnersWindow: true, appearedAfterClick: true)) + #expect(!InterfaceWindowRule.counts(layer: 0, isOwnersWindow: true, appearedAfterClick: true)) + } + + @Test("A menu counts only for the item's app and only when it is new") + func onlyTheOwnersNewMenuCounts() { + #expect(!InterfaceWindowRule.counts(layer: 101, isOwnersWindow: false, appearedAfterClick: true)) + #expect(!InterfaceWindowRule.counts(layer: 101, isOwnersWindow: true, appearedAfterClick: false)) + } +} diff --git a/Tests/HolzBarCoreTests/ItemHintsTests.swift b/Tests/HolzBarCoreTests/ItemHintsTests.swift new file mode 100644 index 00000000..494b7a65 --- /dev/null +++ b/Tests/HolzBarCoreTests/ItemHintsTests.swift @@ -0,0 +1,50 @@ +import Testing +@testable import HolzBarCore + +@Suite("ItemHints") +struct ItemHintsTests { + @Test("Letters are unique and stable") + func lettersAreUniqueAndStable() { + #expect(ItemHints.letters(count: 3) == ["a", "s", "d"]) + #expect(ItemHints.letters(count: 9) == ["a", "s", "d", "f", "j", "k", "l", "g", "h"]) + #expect(ItemHints.letters(count: 0).isEmpty) + #expect(ItemHints.letters(count: 26).allSatisfy { $0.count == 1 }) + #expect(Set(ItemHints.letters(count: 26)).count == 26) + + let thirty = ItemHints.letters(count: 30) + #expect(thirty.count == 30) + #expect(Set(thirty).count == 30) + // Two letters only after the single ones run out. + let firstDouble = thirty.firstIndex { $0.count == 2 } + #expect(firstDouble == 25) + #expect(thirty[25...].allSatisfy { $0.count == 2 }) + #expect(ItemHints.letters(count: 30) == thirty) + } + + @Test("No hint is the start of another") + func noHintIsAPrefixOfAnother() { + for count in [27, 30, 60, 200, 676] { + let hints = ItemHints.letters(count: count) + #expect(hints.count == count) + #expect(Set(hints).count == count) + for hint in hints { + #expect(!hints.contains { $0 != hint && $0.hasPrefix(hint) }) + } + } + #expect(ItemHints.letters(count: 1000).count == 676) + } + + @Test("Typing narrows and picks") + func typingNarrowsAndPicks() { + let three = ItemHints.letters(count: 3) + #expect(ItemHints.match(typed: "a", hints: three) == .item(0)) + #expect(ItemHints.match(typed: "D", hints: three) == .item(2)) + #expect(ItemHints.match(typed: "q", hints: three) == .none) + + let thirty = ItemHints.letters(count: 30) + let prefix = String(thirty[25].prefix(1)) + #expect(ItemHints.match(typed: prefix, hints: thirty) == .pending) + #expect(ItemHints.match(typed: thirty[26], hints: thirty) == .item(26)) + #expect(ItemHints.match(typed: "1", hints: thirty) == .none) + } +} diff --git a/Tests/HolzBarCoreTests/ItemIconChoiceTests.swift b/Tests/HolzBarCoreTests/ItemIconChoiceTests.swift new file mode 100644 index 00000000..fe15238f --- /dev/null +++ b/Tests/HolzBarCoreTests/ItemIconChoiceTests.swift @@ -0,0 +1,51 @@ +import Testing +@testable import HolzBarCore + +@Suite("ItemIconChoice") +struct ItemIconChoiceTests { + @Test("A chosen image wins") + func chosenImageWins() { + let file = ItemIconChoice.Stored.file("A1.png") + #expect(ItemIconChoice.source(stored: file, hasCustomImage: true, hasCapture: true, hasAppIcon: true) == .custom) + // A missing file falls back to the next choice. + #expect(ItemIconChoice.source(stored: file, hasCustomImage: false, hasCapture: true, hasAppIcon: true) == .captured) + #expect(ItemIconChoice.source(stored: nil, hasCustomImage: false, hasCapture: true, hasAppIcon: true) == .captured) + #expect(ItemIconChoice.source(stored: nil, hasCustomImage: false, hasCapture: false, hasAppIcon: true) == .appIcon) + #expect(ItemIconChoice.source(stored: nil, hasCustomImage: false, hasCapture: false, hasAppIcon: false) == .name) + } + + @Test("Use App Icon wins over a capture") + func useAppIconWinsOverCapture() { + #expect(ItemIconChoice.source(stored: .appIcon, hasCustomImage: false, hasCapture: true, hasAppIcon: true) == .appIcon) + #expect(ItemIconChoice.source(stored: .appIcon, hasCustomImage: false, hasCapture: true, hasAppIcon: false) == .captured) + } + + @Test("Stored choices parse") + func storedChoicesParse() { + #expect(ItemIconChoice.parse("app") == .appIcon) + #expect(ItemIconChoice.parse(nil) == nil) + let name = "6F1C2B4A-0D3E-4F5A-9B8C-7D6E5F4A3B2C.png" + #expect(ItemIconChoice.parse("file:" + name) == .file(name)) + for stored in [ItemIconChoice.Stored.appIcon, .file(name)] { + #expect(ItemIconChoice.parse(ItemIconChoice.storedValue(stored)) == stored) + } + #expect(ItemIconChoice.parse("file:../Preferences/x.png") == nil) + #expect(ItemIconChoice.parse("file:a/b.png") == nil) + #expect(ItemIconChoice.parse("file:..png") == nil) + #expect(ItemIconChoice.parse("file:icon.jpg") == nil) + #expect(ItemIconChoice.parse("file:.png") == nil) + #expect(ItemIconChoice.parse("icon.png") == nil) + #expect(ItemIconChoice.parse("App") == nil) + } + + @Test("Hex colours round-trip") + func hexColoursRoundTrip() throws { + let components = try #require(HexColor.components("#FF8000")) + #expect(components.red == 1) + #expect(components.blue == 0) + #expect(HexColor.string(red: components.red, green: components.green, blue: components.blue) == "#FF8000") + #expect(HexColor.components("#FF80") == nil) + #expect(HexColor.components("#GG0000") == nil) + #expect(HexColor.string(red: 2, green: -1, blue: 0.5) == "#FF0080") + } +} diff --git a/Tests/HolzBarCoreTests/ItemIdentityTests.swift b/Tests/HolzBarCoreTests/ItemIdentityTests.swift new file mode 100644 index 00000000..1876da6d --- /dev/null +++ b/Tests/HolzBarCoreTests/ItemIdentityTests.swift @@ -0,0 +1,80 @@ +import Testing +@testable import HolzBarCore + +@Suite("ItemIdentity") +struct ItemIdentityTests { + @Test("Numbers do not change an identity") + func numbersDoNotChangeAnIdentity() { + #expect(ItemIdentity.canonicalTitle("Mail 3") == ItemIdentity.canonicalTitle("Mail 12")) + #expect(ItemIdentity.canonicalTitle("CPU 12%") == ItemIdentity.canonicalTitle("CPU 7%")) + #expect(ItemIdentity.canonicalTitle("CPU 12%") != ItemIdentity.canonicalTitle("Network 3 KB/s")) + #expect(ItemIdentity.canonicalTitle("Network 3 KB/s") == ItemIdentity.canonicalTitle("Network 1.5 MB/s")) + // Identifiers keep their digits: they tell an app's items apart. + #expect(ItemIdentity.canonicalTitle("Item-0") == "Item-0") + #expect(ItemIdentity.canonicalTitle("Item-0") != ItemIdentity.canonicalTitle("Item-1")) + #expect(ItemIdentity.canonicalTitle("com.apple.menuextra.clock") == "com.apple.menuextra.clock") + } + + @Test("An app whose title changes is learned") + func titleChangingAppIsLearned() { + let holzBar = "com.holzcloud.holzBar" + let uuid = "6F9619FF-8B86-D011-B42D-00C04FC964FF" + let previous: [ItemIdentity.Item] = [ + (namespace: "a", title: "Mon 3 Oct"), + (namespace: "b", title: "Mail 3"), + (namespace: "c", title: "One"), + (namespace: "d", title: "Same"), + (namespace: holzBar, title: "Old"), + (namespace: uuid, title: "Old"), + ] + let current: [ItemIdentity.Item] = [ + (namespace: "a", title: "Tue 4 Oct"), + (namespace: "b", title: "Mail 12"), + (namespace: "c", title: "One"), + (namespace: "c", title: "Two"), + (namespace: "d", title: "Same"), + (namespace: holzBar, title: "New"), + (namespace: uuid, title: "New"), + ] + let learned = ItemIdentity.learnTitleChangingOwners(previous: previous, current: current, excluding: [holzBar]) + #expect(learned == ["a"]) + } + + @Test("Items of a learned app are keyed by position") + func learnedAppKeyedByPosition() { + let items: [ItemIdentity.Item] = [ + (namespace: "a", title: "Mon 3 Oct"), + (namespace: "x", title: "Other"), + (namespace: "a", title: "12:41"), + ] + let keys = ItemIdentity.keys(for: items, titleChangingOwners: ["a"]) + #expect(keys == ["a:#1", "x:Other", "a:#2"]) + } + + @Test("Several items of one app stay apart") + func severalItemsOfOneAppStayApart() { + let items: [ItemIdentity.Item] = [ + (namespace: "b", title: "Sync 3"), + (namespace: "b", title: "Sync 5"), + (namespace: "b", title: "Item-0"), + (namespace: "b", title: "Item-1"), + ] + let keys = ItemIdentity.keys(for: items, titleChangingOwners: []) + #expect(keys == ["b:Sync #", "b:Sync #:2", "b:Item-0", "b:Item-1"]) + } + + @Test("Stored keys from earlier versions still match") + func storedKeysStillMatch() { + let current = ItemIdentity.keys(for: [(namespace: "a", title: "Mail 12")], titleChangingOwners: []) + #expect(ItemIdentity.storedKey("a:Mail 3", titleChangingOwners: []) == current[0]) + #expect(ItemIdentity.storedKey("a:anything", titleChangingOwners: ["a"]) == "a:#1") + // Keys of this version are kept as they are. + #expect(ItemIdentity.storedKey("a:#2", titleChangingOwners: ["a"]) == "a:#2") + #expect(ItemIdentity.storedKey("b:Sync #:2", titleChangingOwners: []) == "b:Sync #:2") + #expect(ItemIdentity.storedKey("b:Item-0", titleChangingOwners: []) == "b:Item-0") + #expect(ItemIdentity.storedKey("b:Item-0:2", titleChangingOwners: []) == "b:Item-0:2") + // A clock-like raw title matches the canonical one. + let clock = ItemIdentity.keys(for: [(namespace: "c", title: "10:45")], titleChangingOwners: []) + #expect(ItemIdentity.storedKey("c:10:42", titleChangingOwners: []) == clock[0]) + } +} diff --git a/Tests/HolzBarCoreTests/LayoutKeysTests.swift b/Tests/HolzBarCoreTests/LayoutKeysTests.swift new file mode 100644 index 00000000..bbd78569 --- /dev/null +++ b/Tests/HolzBarCoreTests/LayoutKeysTests.swift @@ -0,0 +1,57 @@ +import Testing +@testable import HolzBarCore + +@Suite("LayoutKeys") +struct LayoutKeysTests { + private typealias Key = LayoutKeys.Key + private typealias Position = LayoutKeys.Position + + @Test("Arrows move the focus") + func arrowsMoveTheFocus() { + let right = LayoutKeys.command(keyCode: Key.rightArrow, modifiers: []) + let down = LayoutKeys.command(keyCode: Key.downArrow, modifiers: []) + let up = LayoutKeys.command(keyCode: Key.upArrow, modifiers: []) + #expect(right == .focus(dx: 1, dy: 0)) + let counts = [4, 2, 3] + #expect(LayoutKeys.focusTarget(from: Position(row: 0, index: 2), command: .focus(dx: 1, dy: 0), rowCounts: counts) == Position(row: 0, index: 3)) + #expect(LayoutKeys.focusTarget(from: Position(row: 0, index: 3), command: .focus(dx: 1, dy: 0), rowCounts: counts) == Position(row: 0, index: 3)) + #expect(LayoutKeys.focusTarget(from: Position(row: 0, index: 0), command: .focus(dx: -1, dy: 0), rowCounts: counts) == Position(row: 0, index: 0)) + // Down from the first section lands on the nearest item of the next one. + #expect(down == .focus(dx: 0, dy: 1)) + #expect(LayoutKeys.focusTarget(from: Position(row: 0, index: 3), command: .focus(dx: 0, dy: 1), rowCounts: counts) == Position(row: 1, index: 1)) + #expect(up == .focus(dx: 0, dy: -1)) + #expect(LayoutKeys.focusTarget(from: Position(row: 0, index: 1), command: .focus(dx: 0, dy: -1), rowCounts: counts) == Position(row: 0, index: 1)) + // Empty sections are skipped. + #expect(LayoutKeys.focusTarget(from: Position(row: 0, index: 1), command: .focus(dx: 0, dy: 1), rowCounts: [2, 0, 5]) == Position(row: 2, index: 1)) + } + + @Test("Option-arrows and Command-digits move the item") + func optionArrowsAndCommandDigitsMoveTheItem() { + #expect(LayoutKeys.command(keyCode: Key.leftArrow, modifiers: .option) == .moveWithinSection(-1)) + #expect(LayoutKeys.command(keyCode: Key.rightArrow, modifiers: .option) == .moveWithinSection(1)) + #expect(LayoutKeys.command(keyCode: Key.downArrow, modifiers: .option) == .moveToSection(.next)) + #expect(LayoutKeys.command(keyCode: Key.upArrow, modifiers: .option) == .moveToSection(.previous)) + #expect(LayoutKeys.command(keyCode: Key.one, modifiers: .command) == .moveToSection(.visible)) + #expect(LayoutKeys.command(keyCode: Key.two, modifiers: .command) == .moveToSection(.hidden)) + #expect(LayoutKeys.command(keyCode: Key.three, modifiers: .command) == .moveToSection(.alwaysHidden)) + #expect(LayoutKeys.command(keyCode: Key.two, modifiers: []) == nil) + #expect(LayoutKeys.sectionIndex(for: .next, from: 0, sectionCount: 3) == 1) + #expect(LayoutKeys.sectionIndex(for: .previous, from: 0, sectionCount: 3) == nil) + #expect(LayoutKeys.sectionIndex(for: .alwaysHidden, from: 0, sectionCount: 2) == nil) + #expect(LayoutKeys.sectionIndex(for: .hidden, from: 1, sectionCount: 3) == nil) + } + + @Test("Moving within a section is refused where macOS orders items") + func movingWithinASectionIsRefused() { + #expect(LayoutKeys.command(keyCode: Key.leftArrow, modifiers: .option, itemsKeepOrder: false) == .refused(LayoutKeys.macOSOrdersItems)) + #expect(LayoutKeys.command(keyCode: Key.downArrow, modifiers: .option, itemsKeepOrder: false) == .moveToSection(.next)) + } + + @Test("Return and Space open the item's menu") + func returnAndSpaceOpenTheMenu() { + #expect(LayoutKeys.command(keyCode: Key.returnKey, modifiers: []) == .openMenu) + #expect(LayoutKeys.command(keyCode: Key.space, modifiers: []) == .openMenu) + #expect(LayoutKeys.command(keyCode: Key.keypadEnter, modifiers: []) == .openMenu) + #expect(LayoutKeys.command(keyCode: Key.returnKey, modifiers: .command) == nil) + } +} diff --git a/Tests/HolzBarCoreTests/MenuBarOcclusionTests.swift b/Tests/HolzBarCoreTests/MenuBarOcclusionTests.swift new file mode 100644 index 00000000..3665b0cb --- /dev/null +++ b/Tests/HolzBarCoreTests/MenuBarOcclusionTests.swift @@ -0,0 +1,44 @@ +import CoreGraphics +import Testing +@testable import HolzBarCore + +@Suite("MenuBarOcclusion") +struct MenuBarOcclusionTests { + private let ownPID: pid_t = 100 + private let click = CGPoint(x: 760, y: 12) + private let notchFrame = CGRect(x: 640, y: 0, width: 240, height: 38) + + private func window( + layer: Int, + ownerPID: pid_t = 200, + bundleID: String? = "theboringteam.boringnotch", + alpha: Double = 1 + ) -> MenuBarOcclusion.Window { + (layer: layer, bounds: notchFrame, ownerPID: ownerPID, ownerBundleID: bundleID, alpha: alpha) + } + + @Test("A notch app above the bar covers it") + func notchAppCoversTheBar() { + #expect(MenuBarOcclusion.isCovered(point: click, windows: [window(layer: 27)], ownPID: ownPID)) + } + + @Test("holzBar's own windows and windows at the bar's level or lower do not cover it") + func ownAndLowWindowsDoNotCover() { + #expect(!MenuBarOcclusion.isCovered(point: click, windows: [window(layer: 27, ownerPID: ownPID)], ownPID: ownPID)) + #expect(!MenuBarOcclusion.isCovered(point: click, windows: [window(layer: 25)], ownPID: ownPID)) + #expect(!MenuBarOcclusion.isCovered(point: click, windows: [window(layer: 0)], ownPID: ownPID)) + } + + @Test("Transparent windows and the system's own processes do not cover it") + func transparentAndSystemWindowsDoNotCover() { + #expect(!MenuBarOcclusion.isCovered(point: click, windows: [window(layer: 27, alpha: 0)], ownPID: ownPID)) + #expect(!MenuBarOcclusion.isCovered(point: click, windows: [window(layer: 27, bundleID: "com.apple.controlcenter")], ownPID: ownPID)) + #expect(!MenuBarOcclusion.isCovered(point: click, windows: [window(layer: 27, bundleID: "com.apple.WindowServer")], ownPID: ownPID)) + } + + @Test("A window elsewhere does not cover the point") + func windowElsewhereDoesNotCover() { + let away = CGPoint(x: 200, y: 12) + #expect(!MenuBarOcclusion.isCovered(point: away, windows: [window(layer: 27)], ownPID: ownPID)) + } +} diff --git a/Tests/HolzBarCoreTests/MoveBackoffTests.swift b/Tests/HolzBarCoreTests/MoveBackoffTests.swift new file mode 100644 index 00000000..65353f68 --- /dev/null +++ b/Tests/HolzBarCoreTests/MoveBackoffTests.swift @@ -0,0 +1,98 @@ +import Testing +@testable import HolzBarCore + +@Suite("MoveBackoff") +struct MoveBackoffTests { + // The back-off is changed outside `#expect`, whose expansion cannot call a mutating + // method. + + private let start = ContinuousClock.now + + /// Records the given number of failed automatic moves at the given time. + private func fail(_ count: Int, _ backoff: inout MoveBackoff, at now: ContinuousClock.Instant) { + for _ in 0.. = 662...850 + let own = "com.holzcloud.holzBar" + + @Test("The icon under the notch frees itself") + func iconUnderTheNotchFreesItself() { + let icon = CGRect(x: 760, y: 0, width: 28, height: 37) + let a = (bundleID: "a", frame: CGRect(x: 790, y: 0, width: 100, height: 37)) + let b = (bundleID: "b", frame: CGRect(x: 892, y: 0, width: 30, height: 37)) + // 90 points are needed; a alone frees 100. + #expect(NotchCover27.appsToConceal(iconFrame: icon, visibleApps: [b, a], notchSpan: notch, concealed: [], ownBundleID: own) == ["a"]) + // Narrower apps: a frees 60, so b is needed too. + let narrowA = (bundleID: "a", frame: CGRect(x: 790, y: 0, width: 60, height: 37)) + #expect(NotchCover27.appsToConceal(iconFrame: icon, visibleApps: [narrowA, b], notchSpan: notch, concealed: [], ownBundleID: own) == ["a", "b"]) + // The icon is clear of the notch. + let clearIcon = CGRect(x: 860, y: 0, width: 28, height: 37) + #expect(NotchCover27.appsToConceal(iconFrame: clearIcon, visibleApps: [a, b], notchSpan: notch, concealed: [], ownBundleID: own).isEmpty) + } + + @Test("Only visible apps are concealed") + func onlyVisibleAppsAreConcealed() { + let icon = CGRect(x: 760, y: 0, width: 28, height: 37) + let ownItem = (bundleID: own, frame: CGRect(x: 790, y: 0, width: 200, height: 37)) + let concealedApp = (bundleID: "c", frame: CGRect(x: 990, y: 0, width: 200, height: 37)) + let a = (bundleID: "a", frame: CGRect(x: 1190, y: 0, width: 100, height: 37)) + let answer = NotchCover27.appsToConceal(iconFrame: icon, visibleApps: [ownItem, concealedApp, a], notchSpan: notch, concealed: ["c"], ownBundleID: own) + #expect(answer == ["a"]) + } + + @Test("Apps concealed for the notch join every assertion") + func notchAppsJoinEveryAssertion() { + #expect(NotchCover27.adding([], to: [["x"]]) == [["x"]]) + #expect(NotchCover27.adding(["a"], to: []) == [["a"]]) + #expect(NotchCover27.adding(["a"], to: [["x"], ["x", "y"]]) == [["x", "a"], ["x", "y", "a"]]) + } +} + +@Suite("LaunchGrace27") +struct LaunchGrace27Tests { + // The grace is changed outside `#expect`, whose expansion cannot call a mutating method. + + private let start = ContinuousClock.now + + @Test("A launching app is allowed until its item appears") + func allowedUntilItsItemAppears() { + var grace = LaunchGrace27() + grace.begin("a", isConcealed: true, at: start) + #expect(grace.allowed == ["a"]) + let ended = grace.itemsAppeared(["a", "b"]) + #expect(ended == ["a"]) + #expect(grace.allowed.isEmpty) + // A grace ends once. + let endedAgain = grace.itemsAppeared(["a"]) + #expect(endedAgain.isEmpty) + } + + @Test("A grace ends after 10 s without an item") + func endsAfterTenSeconds() { + var grace = LaunchGrace27() + grace.begin("a", isConcealed: true, at: start) + let early = grace.expired(at: start + .seconds(9)) + #expect(early.isEmpty) + let late = grace.expired(at: start + .seconds(10)) + #expect(late == ["a"]) + #expect(grace.allowed.isEmpty) + } + + @Test("Visible apps get no grace") + func visibleAppsGetNoGrace() { + var grace = LaunchGrace27() + let began = grace.begin("v", isConcealed: false, at: start) + #expect(!began) + #expect(grace.allowed.isEmpty) + } +} diff --git a/Tests/HolzBarMacOS27CoreTests/MenuBarGeometry27Tests.swift b/Tests/HolzBarMacOS27CoreTests/MenuBarGeometry27Tests.swift index f0004a34..6bd6c9c9 100644 --- a/Tests/HolzBarMacOS27CoreTests/MenuBarGeometry27Tests.swift +++ b/Tests/HolzBarMacOS27CoreTests/MenuBarGeometry27Tests.swift @@ -58,25 +58,46 @@ struct OverflowDetection27Tests { @Suite("ClockBridgeZone27") struct ClockBridgeZone27Tests { let clock = CGRect(x: 1787, y: 0, width: 113, height: 30) + let menuBar = CGRect(x: 0, y: 0, width: 1920, height: 30) @Test("A click on a system item is bridged while concealing") func bridged() { - #expect(ClockBridgeZone27.shouldBridge(click: CGPoint(x: 1840, y: 15), systemItemFrames: [clock], isConcealing: true)) + #expect(ClockBridgeZone27.shouldBridge(click: CGPoint(x: 1840, y: 15), systemItemFrames: [clock], isConcealing: true, menuBarRect: menuBar)) } @Test("A click one point outside the frame still counts") func edgeTolerance() { - #expect(ClockBridgeZone27.shouldBridge(click: CGPoint(x: 1786.5, y: 15), systemItemFrames: [clock], isConcealing: true)) + #expect(ClockBridgeZone27.shouldBridge(click: CGPoint(x: 1786.5, y: 15), systemItemFrames: [clock], isConcealing: true, menuBarRect: menuBar)) } @Test("A click elsewhere is not bridged") func elsewhere() { - #expect(!ClockBridgeZone27.shouldBridge(click: CGPoint(x: 900, y: 12), systemItemFrames: [clock], isConcealing: true)) + #expect(!ClockBridgeZone27.shouldBridge(click: CGPoint(x: 900, y: 12), systemItemFrames: [clock], isConcealing: true, menuBarRect: menuBar)) } @Test("Nothing is bridged while nothing is concealed") func notConcealing() { - #expect(!ClockBridgeZone27.shouldBridge(click: CGPoint(x: 1840, y: 15), systemItemFrames: [clock], isConcealing: false)) + #expect(!ClockBridgeZone27.shouldBridge(click: CGPoint(x: 1840, y: 15), systemItemFrames: [clock], isConcealing: false, menuBarRect: menuBar)) + } + + @Test("A click just below the bar is not bridged") + func belowTheBar() { + // One point under the bar's bottom edge, still inside the clock's frame grown by a point. + #expect(!ClockBridgeZone27.shouldBridge(click: CGPoint(x: 1840, y: 30.5), systemItemFrames: [clock], isConcealing: true, menuBarRect: menuBar)) + let lowerBar = CGRect(x: 0, y: 0, width: 1920, height: 24) + #expect(!ClockBridgeZone27.shouldBridge(click: CGPoint(x: 1840, y: 25), systemItemFrames: [clock], isConcealing: true, menuBarRect: lowerBar)) + } + + @Test("A click on the overflow button is bridged while concealing") + func overflowButtonBridged() { + let overflowButton = CGRect(x: 1700, y: 0, width: 18, height: 30) + #expect(ClockBridgeZone27.shouldBridge(click: CGPoint(x: 1708, y: 15), systemItemFrames: [clock, overflowButton], isConcealing: true, menuBarRect: menuBar)) + #expect(!ClockBridgeZone27.shouldBridge(click: CGPoint(x: 1708, y: 15), systemItemFrames: [clock], isConcealing: true, menuBarRect: menuBar)) + } + + @Test("Nothing is bridged while the bar is hidden") + func barHidden() { + #expect(!ClockBridgeZone27.shouldBridge(click: CGPoint(x: 1840, y: 15), systemItemFrames: [clock], isConcealing: true, menuBarRect: nil)) } } @@ -110,6 +131,34 @@ struct ItemHitTest27Tests { #expect(ItemHitTest27.isInsideItem(point: CGPoint(x: -611, y: 114), items: [folded], concealedPIDs: [], systemFrames: [overflowButton])) } + @Test("On the inactive display drawn frames count") + func drawnFramesOnTheInactiveDisplay() { + // The built-in display's bar is not active: no cached item lies on it, but + // MenuBarAgent draws an item there. + let display = CGRect(x: -1512, y: 0, width: 1512, height: 982) + let drawn = CGRect(x: -400, y: 6, width: 30, height: 24) + #expect(ItemHitTest27.isInsideItem(point: CGPoint(x: -385, y: 18), items: [stats], concealedPIDs: [], systemFrames: [], drawnFramesOnDisplay: [drawn])) + #expect(!ItemHitTest27.isInsideItem(point: CGPoint(x: -700, y: 18), items: [stats], concealedPIDs: [], systemFrames: [], drawnFramesOnDisplay: [drawn])) + #expect(ItemHitTest27.isInsideItemsArea( + point: CGPoint(x: -390, y: 18), + displayBounds: display, + items: [stats], + concealedPIDs: [], + systemFrames: [], + rememberedLeftEdge: nil, + drawnFramesOnDisplay: [drawn] + )) + #expect(!ItemHitTest27.isInsideItemsArea( + point: CGPoint(x: -700, y: 18), + displayBounds: display, + items: [stats], + concealedPIDs: [], + systemFrames: [], + rememberedLeftEdge: nil, + drawnFramesOnDisplay: [drawn] + )) + } + @Test("System items count on either display") func systemItems() { #expect(ItemHitTest27.isInsideItem(point: CGPoint(x: 1843, y: 12), items: [], concealedPIDs: [], systemFrames: [clock])) diff --git a/docs/upstream-bugs.md b/docs/upstream-bugs.md index 0c943695..5fcb3344 100644 --- a/docs/upstream-bugs.md +++ b/docs/upstream-bugs.md @@ -8,7 +8,13 @@ Issue numbers below refer to [jordanbaird/Ice](https://github.com/jordanbaird/Ic | Group | Issues | Fix | |---|---|---| -| macOS 27: nothing hides, "Loading menu bar items", layout editor spins | #954, #965, #970, #991, #992, #996, #999, #1003, #1006 | macOS 27 backend from [#995](https://github.com/jordanbaird/Ice/pull/995) | +| macOS 27: nothing hides, "Loading menu bar items", layout editor spins | #954, #965, #970, #991, #992, #996, #999, #1003, #1005, #1006 | macOS 27 backend from [#995](https://github.com/jordanbaird/Ice/pull/995) | +| macOS 27: an app launched while items are concealed gets a squashed 3-point item | #1007 | The app is shown until its item exists, then concealed | +| Items lose their section after a restart, an app update or a width change | #661, #666, #675, #702, #723, #732, #748, #769, #771, #806, #844, #853, #887, #909, #979 | Items keep an identity when their titles change; their sections are saved and restored on launch, app launch and display changes | +| Several items of one app (OneDrive, …) are mixed up | #300, #404, #739, #857 | Several items of one app keep separate identities | +| Auto-rehide fires while a menu is open, or waits forever behind another window | #403, #452, #526, #621, #914, #974, #968 | Only a real menu delays rehiding; other windows of the app never do | +| Dock icon appears while showing items | #397, #590, #768, #808, #906, #961 | Settings and the search open without a Dock icon; "Keep the Dock icon hidden" (on by default) leaves the application menus alone instead | +| Styling lost after sleep or launch at login | #53, #409, #525 | The appearance is restored once the bar settles after sleep, wake and display changes, and retried after login | | Stuck on the permissions window although permissions are granted | #1004, #834 | "Reset and Grant Again" in the permissions window; `Scripts/install.sh` resets them for ad hoc builds | | Crash on clicking the icon or with the Ice Bar on macOS 26 (`EXC_BREAKPOINT`, `NSStatusBarWindow.windowNumber`) | #786, #796, #810, #821, #855, #867, #880, #905, #925, #947, #977, #779, #742, #669 | The `macos-26` base no longer casts window numbers with `CGWindowID(_:)`; see also [#989](https://github.com/jordanbaird/Ice/pull/989) | | "Check for updates automatically?" dialog that cannot be closed | #681, #688, #699, #837, #882, #912, #926, #931, #932, #937, #957, #969 | Sparkle is not started; holzBar updates through Homebrew | @@ -26,16 +32,11 @@ Issue numbers below refer to [jordanbaird/Ice](https://github.com/jordanbaird/Ic | Group | Issues | Notes | |---|---|---| | Layout editor or Ice Bar empty/white on macOS 26 ("Unable to display menu bar items") | #635, #664, #673, #677, #679, #682, #685, #687, #696, #710, #711, #716, #730, #741, #743, #744, #753, #758, #762, #773, #818, #833, #846, #891, #913, #916, #921, #973 | Item discovery or capture fails; #710 says 0.11.13-dev.1 mostly worked. Needs logs from an affected Mac | -| Items lose their section after a restart, an app update or a width change | #661, #666, #675, #702, #723, #732, #748, #769, #771, #806, #844, #853, #887, #909 | Item identity is not stable across launches | -| Several items of one app (OneDrive, …) are mixed up | #300, #404, #739, #857 | Same root as above | | Moving an item times out (Live Activities, iPhone items) | #656, #704, #729, #746, #861, #918 | | | Pointer gone or stuck after clicking an item in the Ice Bar | #640, #751, #757 | Hide and show calls are balanced; may be the enlarged accessibility pointer (#757) | -| Auto-rehide fires while a menu is open or a control is in use | #403, #452, #526, #621, #914 | | -| Bar, tint or shape drawn in the wrong place (middle of the screen, rotated or secondary displays, fullscreen) | #445, #517, #550, #609, #750, #780, #858, #863, #986 | | +| Bar, tint or shape drawn in the wrong place (middle of the screen, rotated or secondary displays, fullscreen) | #445, #517, #550, #609, #750, #780, #858, #863, #986, #988, #959 | The tint and shape now stand on every desktop at once and step aside on a fullscreen space; rotated and ultrawide displays still need a check | | Split shape on ultrawide and external displays | #94, #529, #573, #608 | | -| Styling lost after sleep or launch at login | #53, #409, #525 | Appearance is not re-applied | | Volume and brightness HUD hidden with its item on macOS 26 | #701, #719 | macOS attaches the HUD to the item; may be unfixable | | High CPU, energy or memory | #334, #479, #530, #578, #819 | holzBar keeps one hover task instead of one per mouse move, stops permission polling once granted and reacts to power events instead of a 60 s timer. Input taps (the mouse-moved tap above all) and refresh timers run only while a setting or an open view needs them, item images are released when nothing shows them, and no Swift package is linked; needs profiling on an affected Mac | -| Dock icon appears while showing items | #397, #590, #768, #808, #906 | Activation policy switching | -Not bugs, or not in the app: #745 (thanks), #752, #774, #935 (website), #939 (project status), #966 (another fork). +Not bugs, or not in the app: #745 (thanks), #752, #774, #935 (website), #939 (project status), #966 (another fork), #976. diff --git a/holzBar.xcodeproj/project.pbxproj b/holzBar.xcodeproj/project.pbxproj index cf5ed4a1..749da747 100644 --- a/holzBar.xcodeproj/project.pbxproj +++ b/holzBar.xcodeproj/project.pbxproj @@ -176,6 +176,10 @@ knownRegions = ( en, Base, + de, + fr, + it, + rm, ); mainGroup = 716683212A767E6A006ABF84; packageReferences = ( diff --git a/holzBar/Core/BorderPattern.swift b/holzBar/Core/BorderPattern.swift new file mode 100644 index 00000000..2a61b4db --- /dev/null +++ b/holzBar/Core/BorderPattern.swift @@ -0,0 +1,39 @@ +// +// BorderPattern.swift +// holzBar +// + +import Foundation + +/// How the menu bar's border is drawn (THAW-17). +/// +/// The raw values are stored in the appearance configuration; new styles go at the end. +nonisolated enum BorderStyle: Int, Codable, CaseIterable, Sendable { + case solid = 0 + case dashed = 1 + case dotted = 2 +} + +/// The dash pattern of a border style, scaled with the border's width. +nonisolated enum BorderPattern { + /// The lengths of the dashes and gaps, in points, or `nil` for a solid line. + /// + /// Dashed: dashes six widths long with gaps of three. Dotted: dots of zero length with + /// round caps (``usesRoundCaps(_:)``), one width wide, every two widths. + static func dashes(for style: BorderStyle, width: Double) -> [Double]? { + let width = max(width, 0.5) + switch style { + case .solid: + return nil + case .dashed: + return [6 * width, 3 * width] + case .dotted: + return [0, 2 * width] + } + } + + /// Whether the line ends are round, which turns zero-length dashes into dots. + static func usesRoundCaps(_ style: BorderStyle) -> Bool { + style == .dotted + } +} diff --git a/holzBar/Core/ChangeReveal.swift b/holzBar/Core/ChangeReveal.swift new file mode 100644 index 00000000..8373900c --- /dev/null +++ b/holzBar/Core/ChangeReveal.swift @@ -0,0 +1,68 @@ +// +// ChangeReveal.swift +// holzBar +// + +import Foundation + +/// When a hidden item that changed is shown for a moment (THAW-12). +/// +/// The user marks items "Show When It Changes"; Accessibility tells holzBar when such an +/// item's title or value changes. Changes are counted once they have been quiet for +/// ``debounce`` (a counter that ticks several times in a row is one change), an item is +/// shown at most every ``minimumGap``, and nothing is shown in Zen mode or for an item that +/// is visible anyway. An app cannot keep its item on screen by changing it all the time. +/// +/// Times are seconds of a monotonic clock, passed in, so the rules can be tested. +nonisolated struct ChangeReveal: Sendable { + /// How long changes of one item must be quiet before it is shown. + static let debounce: TimeInterval = 1 + + /// The shortest time between two reveals of one item. + static let minimumGap: TimeInterval = 30 + + /// The last change of each item that waits to be shown, by item key. + private var pending = [String: TimeInterval]() + + /// When each item was last shown, by item key. + private var lastReveals = [String: TimeInterval]() + + /// Notes a change of an item. + /// + /// - Parameters: + /// - key: The item's identity key. + /// - isZenActive: Whether Zen mode is on, which shows nothing. + /// - isVisible: Whether the item is visible anyway. + /// - time: When the change happened. + mutating func noteChange(key: String, isZenActive: Bool, isVisible: Bool, at time: TimeInterval) { + guard !isZenActive, !isVisible else { + pending[key] = nil + return + } + if let lastReveal = lastReveals[key], time - lastReveal < Self.minimumGap { + return + } + pending[key] = time + } + + /// The items to show now, whose changes have been quiet long enough; they count as + /// shown at `time`. + mutating func due(at time: TimeInterval) -> [String] { + var keys = [String]() + for (key, lastChange) in pending where time - lastChange >= Self.debounce { + pending[key] = nil + if let lastReveal = lastReveals[key], time - lastReveal < Self.minimumGap { + continue + } + lastReveals[key] = time + keys.append(key) + } + return keys.sorted() + } + + /// When the next waiting change will be due, for one bounded wait; `nil` when nothing + /// waits. + var nextDueTime: TimeInterval? { + pending.values.min().map { $0 + Self.debounce } + } +} diff --git a/holzBar/Core/ColorComponents.swift b/holzBar/Core/ColorComponents.swift new file mode 100644 index 00000000..5ba23b33 --- /dev/null +++ b/holzBar/Core/ColorComponents.swift @@ -0,0 +1,35 @@ +// +// ColorComponents.swift +// holzBar +// + +import CoreGraphics + +/// Validation of the color components holzBar stores with its appearance settings. +/// +/// `CGColor(colorSpace:components:)` reads one component for each channel of the color +/// space and one for alpha, whatever the array holds. Anything can write holzBar's +/// preferences, so a stored array that is too short made it read past its end +/// (jordanbaird/Ice#985). +nonisolated enum ColorComponents { + /// Returns whether the components fit a color space with the given number of channels: + /// one value per channel plus alpha, each finite and between 0 and 1. + static func isValid(_ components: [CGFloat], colorSpaceComponents: Int) -> Bool { + guard colorSpaceComponents > 0, components.count == colorSpaceComponents + 1 else { + return false + } + return components.allSatisfy { $0.isFinite && (0...1).contains($0) } + } + + /// Returns the components with every finite value clamped to 0 through 1; values that + /// are not finite stay as they are, so ``isValid(_:colorSpaceComponents:)`` rejects them. + /// + /// A color picked in an extended color space can be stored slightly outside that range, + /// and the color space read back from its ICC profile is not extended; drawing clamped + /// such a value before, so clamping it keeps the color the user saw. + static func clamped(_ components: [CGFloat]) -> [CGFloat] { + components.map { value in + value.isFinite ? min(max(value, 0), 1) : value + } + } +} diff --git a/holzBar/Core/Defaults.swift b/holzBar/Core/Defaults.swift index 4f437d1d..2b02d64b 100644 --- a/holzBar/Core/Defaults.swift +++ b/holzBar/Core/Defaults.swift @@ -169,11 +169,18 @@ nonisolated extension Defaults { case showAllSectionsOnUserDrag = "ShowAllSectionsOnUserDrag" case sectionDividerStyle = "SectionDividerStyle" case hideApplicationMenus = "HideApplicationMenus" + case keepsDockIconHidden = "KeepsDockIconHidden" case enableSecondaryContextMenu = "EnableSecondaryContextMenu" case showOnHoverDelay = "ShowOnHoverDelay" case tempShowInterval = "TempShowInterval" case newItemsPlacement = "NewItemsPlacement" case keepLiveActivitiesVisible = "KeepLiveActivitiesVisible" + case autoZenWhileSharingScreen = "AutoZenWhileSharingScreen" + case openHiddenItemsInMenuBar = "OpenHiddenItemsInMenuBar" + /// The identity keys of the items shown for a moment when they change. + case revealOnChangeItems = "RevealOnChangeItems" + /// The image each item shows, by identity: "app" or "file:.png". + case itemIcons = "ItemIcons" case layoutProfiles = "LayoutProfiles" case currentLayoutProfile = "CurrentLayoutProfile" case syncsSettingsWithICloud = "SyncsSettingsWithICloud" @@ -183,6 +190,11 @@ nonisolated extension Defaults { case revealRules = "RevealRules" case knownItemTags = "KnownItemTags" case knownApplications27 = "KnownApplications27" + /// The section of each item before macOS 27, keyed by its identity (`ItemIdentity`): + /// 0 visible, 1 hidden, 2 always hidden. + case itemSections = "ItemSections" + /// The namespaces whose item titles change beyond their numbers (`ItemIdentity`). + case titleChangingItemOwners = "TitleChangingItemOwners" // MARK: Appearance Settings case menuBarAppearanceConfigurationV2 = "MenuBarAppearanceConfigurationV2" @@ -246,8 +258,11 @@ nonisolated extension Defaults.Key { .enableAlwaysHiddenSection, .showAllSectionsOnUserDrag, .hideApplicationMenus, + .keepsDockIconHidden, .enableSecondaryContextMenu, .keepLiveActivitiesVisible, + .autoZenWhileSharingScreen, + .openHiddenItemsInMenuBar, .syncsSettingsWithICloud, .macOS27LayoutSeeded, .macOS27ShelfWaitsForRefresh, @@ -294,11 +309,15 @@ nonisolated extension Defaults.Key { case .currentLayoutProfile: .string case .knownItemTags, - .knownApplications27: + .knownApplications27, + .titleChangingItemOwners, + .revealOnChangeItems: .stringArray case .hotkeys, .revealRules, - .macOS27Layout: + .macOS27Layout, + .itemSections, + .itemIcons: // Their readers cast the contents themselves, so the kind is checked // only at the top level. .dictionary diff --git a/holzBar/Core/DockIconPolicy.swift b/holzBar/Core/DockIconPolicy.swift new file mode 100644 index 00000000..08ac71ae --- /dev/null +++ b/holzBar/Core/DockIconPolicy.swift @@ -0,0 +1,56 @@ +// +// DockIconPolicy.swift +// holzBar +// + +import Foundation + +/// Whether holzBar shows a Dock icon when it comes to the front. +/// +/// holzBar used to switch to the regular activation policy — with a Dock icon — for +/// Settings, the search and hiding application menus, so a Dock icon appeared while items +/// were shown (jordanbaird/Ice#906, Thaw #1197). Settings and the search work without one. +/// macOS hides another app's menus only for an active regular app, so hiding application +/// menus needs the Dock icon; with "Keep the Dock icon hidden" (on by default) they are not +/// hidden instead. +nonisolated enum DockIconPolicy { + /// Why holzBar comes to the front. + nonisolated enum ActivationReason: Equatable { + /// The Settings window opens. + case settings + /// The search panel opens. + case search + /// The permissions window opens at the first launch. + case permissions + /// Shown items reach the application menus, which holzBar hides by coming to the front. + case hideApplicationMenus + /// The user hides the application menus with the hotkey or URL command. + case toggleApplicationMenus + } + + /// An activation policy, matching `NSApplication.ActivationPolicy`. + nonisolated enum ActivationPolicyChoice: Equatable { + /// With a Dock icon and a menu bar of holzBar's own. + case regular + /// Without a Dock icon. + case accessory + } + + /// The activation policy for the given reason, or `nil` when holzBar should not come to + /// the front at all. + static func policy(for reason: ActivationReason, keepsDockIconHidden: Bool) -> ActivationPolicyChoice? { + switch reason { + case .settings, .search: + .accessory + case .permissions: + // The first window of a fresh install, which must be found in the Dock and in + // the app switcher until the permissions are granted. + .regular + case .hideApplicationMenus: + keepsDockIconHidden ? nil : .regular + case .toggleApplicationMenus: + // The user asked for it, which the setting does not overrule. + .regular + } + } +} diff --git a/holzBar/Core/HotkeyAction.swift b/holzBar/Core/HotkeyAction.swift index e2a60f7c..f291cbbe 100644 --- a/holzBar/Core/HotkeyAction.swift +++ b/holzBar/Core/HotkeyAction.swift @@ -27,4 +27,8 @@ nonisolated enum HotkeyAction: String, Codable, CaseIterable { case enableShelf = "EnableIceBar" case toggleApplicationMenus = "ToggleApplicationMenus" case toggleAutoRehide = "ToggleAutoRehide" + + // Added by holzBar; new actions go at the end. + case toggleZenMode = "ToggleZenMode" + case showItemHints = "ShowItemHints" } diff --git a/holzBar/Core/HotkeyStorage.swift b/holzBar/Core/HotkeyStorage.swift index 6d4a517a..74dd6b33 100644 --- a/holzBar/Core/HotkeyStorage.swift +++ b/holzBar/Core/HotkeyStorage.swift @@ -22,14 +22,30 @@ nonisolated enum HotkeyStorage { (try? JSONEncoder().encode([key, modifiers])) ?? Data() } + /// The virtual key codes of macOS keyboards. + static let validKeyCodes = 0...127 + + /// The modifier bits holzBar knows (``Modifiers``). + static let knownModifierBits = Modifiers.canonicalOrder.reduce(0) { $0 | $1.rawValue } + + /// Returns whether a key code and modifier flags can be a hotkey: the key code is a + /// virtual key code (0 through 127) and the flags hold no bit ``Modifiers`` does not know. + /// + /// Anything can write holzBar's preferences, and a value out of range trapped in the + /// conversions for the system at every launch (jordanbaird/Ice#985). + static func isValid(key: Int, modifiers: Int) -> Bool { + validKeyCodes.contains(key) && modifiers & ~knownModifierBits == 0 + } + /// Reads a stored key combination. /// /// - Returns: The key code and modifier flags, or `nil` when the data is not a JSON - /// array of exactly two integers. + /// array of exactly two integers, or they cannot be a hotkey (``isValid(key:modifiers:)``). static func decode(_ data: Data) -> (key: Int, modifiers: Int)? { guard let values = try? JSONDecoder().decode([Int].self, from: data), - values.count == 2 + values.count == 2, + isValid(key: values[0], modifiers: values[1]) else { return nil } diff --git a/holzBar/Core/HotkeyTarget.swift b/holzBar/Core/HotkeyTarget.swift new file mode 100644 index 00000000..53a4d09d --- /dev/null +++ b/holzBar/Core/HotkeyTarget.swift @@ -0,0 +1,82 @@ +// +// HotkeyTarget.swift +// holzBar +// + +import Foundation + +/// What a hotkey does: one of holzBar's actions, applying a layout profile or opening a +/// menu bar item's menu (THAW-15). +/// +/// Every hotkey is stored in the `Hotkeys` setting under its ``storageKey``. An action +/// keeps its raw value, which was Ice's (see ``HotkeyAction``), so imported and synced +/// hotkeys keep working; profiles and items get keys with a prefix that no action uses. +nonisolated enum HotkeyTarget: Hashable, Sendable { + /// One of holzBar's actions. + case action(HotkeyAction) + /// Applies the layout profile with this name. + case applyProfile(String) + /// Opens the menu of the item stored under this identity key. + case openItem(String) + + /// The prefix of a profile's stored key. + static let profilePrefix = "ApplyProfile:" + + /// The prefix of an item's stored key. + static let itemPrefix = "OpenItem:" + + /// The key the hotkey is stored under. + var storageKey: String { + switch self { + case .action(let action): + action.rawValue + case .applyProfile(let name): + Self.profilePrefix + name + case .openItem(let key): + Self.itemPrefix + key + } + } + + /// Reads a stored key; `nil` for a key that names nothing holzBar knows. + init?(storageKey: String) { + if let action = HotkeyAction(rawValue: storageKey) { + self = .action(action) + } else if storageKey.hasPrefix(Self.profilePrefix) { + let name = String(storageKey.dropFirst(Self.profilePrefix.count)) + guard !name.isEmpty else { + return nil + } + self = .applyProfile(name) + } else if storageKey.hasPrefix(Self.itemPrefix) { + let key = String(storageKey.dropFirst(Self.itemPrefix.count)) + guard !key.isEmpty else { + return nil + } + self = .openItem(key) + } else { + return nil + } + } + + /// Whether the target is a profile or an item, whose hotkeys come and go. + var isDynamic: Bool { + switch self { + case .action: + false + case .applyProfile, .openItem: + true + } + } + + /// A description for the log that names no profile and no item. + var logDescription: String { + switch self { + case .action(let action): + action.rawValue + case .applyProfile: + "a layout profile" + case .openItem: + "a menu bar item" + } + } +} diff --git a/holzBar/Core/InputMonitors.swift b/holzBar/Core/InputMonitors.swift index 920ad406..cc06727d 100644 --- a/holzBar/Core/InputMonitors.swift +++ b/holzBar/Core/InputMonitors.swift @@ -16,7 +16,8 @@ nonisolated enum InputMonitors { /// Left and right mouse-down: show on click, smart rehide, the secondary /// context menu, and the click that pauses show on hover. case mouseDown - /// Left mouse-up: ends a Command-drag of a menu bar item. + /// Left mouse-up: ends a Command-drag of a menu bar item, which also records the + /// sections the user arranged. case mouseUp /// Left mouse-dragged: starts a Command-drag of a menu bar item. case mouseDragged @@ -38,6 +39,9 @@ nonisolated enum InputMonitors { /// Whether the holzBar Shelf is used, which lets clicks keep show on hover going. var usesShelf = false var showAllSectionsOnUserDrag = false + /// Whether holzBar saves and restores the section of each item (before macOS 27), + /// so the end of a Command-drag on the bar must be noticed. + var savesUserArrangement = false /// Whether a custom menu bar appearance draws overlay panels, which fade while /// an item is dragged. var hasCustomAppearance = false @@ -62,6 +66,10 @@ nonisolated enum InputMonitors { kinds.insert(.mouseUp) kinds.insert(.mouseDragged) } + // Only the mouse-up, one event per click: a Command-drag on the bar ends with it. + if settings.savesUserArrangement { + kinds.insert(.mouseUp) + } return kinds } diff --git a/holzBar/Core/InterfaceWindowRule.swift b/holzBar/Core/InterfaceWindowRule.swift new file mode 100644 index 00000000..99d0a162 --- /dev/null +++ b/holzBar/Core/InterfaceWindowRule.swift @@ -0,0 +1,24 @@ +// +// InterfaceWindowRule.swift +// holzBar +// + +import Foundation + +/// Which windows count as the open menu of an item that holzBar showed for a moment. +/// +/// holzBar waits to hide such an item again while its menu is open. Any new window of the +/// item's app used to count, so a small floating window of another kind — a Dock preview, +/// a picture-in-picture window, a HUD — kept holzBar waiting, and the item never went back +/// (Thaw #1158). Only windows at the levels of menus count now. +nonisolated enum InterfaceWindowRule { + /// The window levels of menus: the main menu (24), the status bar (25), and pop-up + /// menus (101, and 100 just below it). + static let menuLayers: Set = [24, 25, 100, 101] + + /// Whether a window counts as the item's open menu: a window of the item's app that + /// appeared after the click, at the level of a menu. + static func counts(layer: Int, isOwnersWindow: Bool, appearedAfterClick: Bool) -> Bool { + isOwnersWindow && appearedAfterClick && menuLayers.contains(layer) + } +} diff --git a/holzBar/Core/ItemHints.swift b/holzBar/Core/ItemHints.swift new file mode 100644 index 00000000..f826d574 --- /dev/null +++ b/holzBar/Core/ItemHints.swift @@ -0,0 +1,70 @@ +// +// ItemHints.swift +// holzBar +// + +import Foundation + +/// The letters of the item hints: a hotkey shows every menu bar item with a letter, and +/// typing the letter opens that item (THAW-14). +/// +/// Letters start on the home row (a s d f j k l g h), then follow the rest of the alphabet. +/// With more items than letters, the last letters become prefixes of two-letter hints, so +/// no hint is the start of another: typing never picks an item too early. +nonisolated enum ItemHints { + /// The letters in the order they are handed out. + static let alphabet: [Character] = Array("asdfjklgh") + Array("bceimnopqrtuvwxyz") + + /// What a typed sequence picks. + nonisolated enum Match: Equatable, Sendable { + /// The item at this index of the hints. + case item(Int) + /// The typed letters start a hint; wait for the next one. + case pending + /// No hint starts with the typed letters. + case none + } + + /// Unique hints for the given number of items, in item order. + /// + /// Single letters come first; two letters only once the single ones run out. At most + /// 26 × 26 items get a hint. + static func letters(count: Int) -> [String] { + let size = alphabet.count + guard count > 0 else { + return [] + } + guard count > size else { + return alphabet.prefix(count).map { String($0) } + } + let count = min(count, size * size) + // k single letters and (size - k) prefixes of `size` hints each must cover `count`: + // k + (size - k) × size >= count, with k as large as possible. + let singles = (size * size - count) / (size - 1) + var hints = alphabet.prefix(singles).map { String($0) } + for prefix in alphabet.dropFirst(singles) { + for letter in alphabet { + guard hints.count < count else { + return hints + } + hints.append(String(prefix) + String(letter)) + } + } + return hints + } + + /// What the typed letters pick among the hints. + static func match(typed: String, hints: [String]) -> Match { + let typed = typed.lowercased() + guard !typed.isEmpty else { + return .pending + } + if let index = hints.firstIndex(of: typed) { + return .item(index) + } + if hints.contains(where: { $0.hasPrefix(typed) }) { + return .pending + } + return .none + } +} diff --git a/holzBar/Core/ItemIconChoice.swift b/holzBar/Core/ItemIconChoice.swift new file mode 100644 index 00000000..1c6cec35 --- /dev/null +++ b/holzBar/Core/ItemIconChoice.swift @@ -0,0 +1,127 @@ +// +// ItemIconChoice.swift +// holzBar +// + +import Foundation + +/// What picture a menu bar item shows in the holzBar Shelf, the search, the Layout pane and +/// the item hints (THAW-16). +/// +/// The order: an image the user chose, the item's own picture (a capture, which needs +/// Screen Recording), the icon of the item's app, and its name when there is nothing else. +/// "Use App Icon" puts the app icon before the capture. +nonisolated enum ItemIconChoice { + /// A choice stored in the `ItemIcons` setting, by item identity. + nonisolated enum Stored: Equatable, Sendable { + /// The icon of the item's app. + case appIcon + /// An image file in holzBar's `ItemIcons` folder. + case file(String) + } + + /// What the item shows. + nonisolated enum Source: Equatable, Sendable { + case custom + case captured + case appIcon + case name + } + + /// The stored value of "Use App Icon". + static let appIconValue = "app" + + /// The prefix of a stored image file. + static let filePrefix = "file:" + + /// Reads a stored choice; anything else, and any file name that could reach outside + /// holzBar's folder, reads as no choice. + static func parse(_ value: String?) -> Stored? { + guard let value else { + return nil + } + if value == appIconValue { + return .appIcon + } + guard value.hasPrefix(filePrefix) else { + return nil + } + let name = String(value.dropFirst(filePrefix.count)) + return isValidFileName(name) ? .file(name) : nil + } + + /// The value a choice is stored as. + static func storedValue(_ stored: Stored) -> String { + switch stored { + case .appIcon: + appIconValue + case .file(let name): + filePrefix + name + } + } + + /// Whether a name can be a file in holzBar's image folder: a PNG with letters, digits, + /// hyphens and underscores only, so never a path. + static func isValidFileName(_ name: String) -> Bool { + guard name.hasSuffix(".png") else { + return false + } + let base = name.dropLast(4) + guard !base.isEmpty, base.count <= 64 else { + return false + } + return base.unicodeScalars.allSatisfy { scalar in + scalar.isASCII && (CharacterSet.alphanumerics.contains(scalar) || scalar == "-" || scalar == "_") + } + } + + /// What an item shows. + /// + /// - Parameters: + /// - stored: The user's choice for the item. + /// - hasCustomImage: Whether the chosen image file could be read. + /// - hasCapture: Whether a picture of the item was taken. + /// - hasAppIcon: Whether the item's app has an icon. + static func source(stored: Stored?, hasCustomImage: Bool, hasCapture: Bool, hasAppIcon: Bool) -> Source { + switch stored { + case .file: + if hasCustomImage { + return .custom + } + case .appIcon: + if hasAppIcon { + return .appIcon + } + case nil: + break + } + if hasCapture { + return .captured + } + return hasAppIcon ? .appIcon : .name + } +} + +/// Colours stored as `#RRGGBB` strings, as a group's colour is. +nonisolated enum HexColor { + /// The red, green and blue components (0 to 1) of a `#RRGGBB` string, or `nil`. + static func components(_ hex: String) -> (red: Double, green: Double, blue: Double)? { + let digits = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex + guard digits.count == 6, let value = UInt32(digits, radix: 16) else { + return nil + } + return ( + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255 + ) + } + + /// The `#RRGGBB` string of the given components, each clamped to 0 to 1. + static func string(red: Double, green: Double, blue: Double) -> String { + func byte(_ component: Double) -> Int { + Int((min(max(component, 0), 1) * 255).rounded()) + } + return String(format: "#%02X%02X%02X", byte(red), byte(green), byte(blue)) + } +} diff --git a/holzBar/Core/ItemIdentity.swift b/holzBar/Core/ItemIdentity.swift new file mode 100644 index 00000000..3e88bd44 --- /dev/null +++ b/holzBar/Core/ItemIdentity.swift @@ -0,0 +1,141 @@ +// +// ItemIdentity.swift +// holzBar +// + +import Foundation + +/// The identity holzBar stores for a menu bar item: a key that stays the same when the +/// item's title changes. +/// +/// An item is known by its namespace (the bundle identifier of its app) and its title. Many +/// apps put a live value in the title — a count of unread mail, the date, a CPU load — so a +/// key built from the raw title changed with every update: the item counted as new and was +/// moved into the new-items section, and profiles and groups lost it. The key is built from +/// the title with its numbers replaced (``canonicalTitle(_:)``); apps whose titles change +/// beyond that are learned (``learnTitleChangingOwners(previous:current:excluding:)``) and +/// their items are keyed by their position among the app's items. Several items of one app +/// with the same canonical title are told apart by a number. +/// +/// Keys stored by earlier versions (`namespace:title`) still match through +/// ``storedKey(_:titleChangingOwners:)``. The canonical form of a title is adapted from +/// Thaw's `canonicalMetricTitle` (GPL-3.0, see NOTICE). +nonisolated enum ItemIdentity { + /// One item, as read from the bar: its namespace and its title. + typealias Item = (namespace: String, title: String) + + /// The title with its live values replaced, so "Mail 3" and "Mail 12" are one title. + /// + /// Numbers become `#` and byte units are normalised ("CPU 12%" becomes "CPU #%", + /// "Network 3 KB/s" becomes "Network # B/s"). Titles that are identifiers, such as + /// `Item-0` or `com.apple.menuextra.clock` (a letter followed by letters, digits, dots, + /// hyphens and underscores only), are kept as they are: their digits tell the items of + /// one app apart and never change. + static func canonicalTitle(_ title: String) -> String { + if isIdentifier(title) { + return title + } + return title + .replacing(/[-+]?\d+(?:[.,]\d+)?/, with: "#") + .replacing(/#\s*[KMGTPE]?[Bb]\/s/, with: "# B/s") + .replacing(/#\s*[KMGTPE]?[Bb]/, with: "# B") + } + + /// Returns the namespaces whose item titles changed beyond their numbers between two + /// reads of the bar. + /// + /// A namespace is learned when it has the same, non-zero number of items in both reads + /// and the canonical titles of those items differ. Namespaces in `excluding` (holzBar's + /// own) and UUID namespaces (system items whose namespace changes on every launch) are + /// never learned. + static func learnTitleChangingOwners(previous: [Item], current: [Item], excluding: Set) -> Set { + let previousTitles = canonicalTitlesByNamespace(previous) + let currentTitles = canonicalTitlesByNamespace(current) + var learned = Set() + for (namespace, titles) in currentTitles { + guard + !excluding.contains(namespace), + UUID(uuidString: namespace) == nil, + let previousTitles = previousTitles[namespace], + !titles.isEmpty, + previousTitles.count == titles.count, + previousTitles.sorted() != titles.sorted() + else { + continue + } + learned.insert(namespace) + } + return learned + } + + /// Returns the key of each item, in the order of `items` (the bar's order, left to right). + /// + /// - The items of a namespace in `titleChangingOwners` are keyed by their position among + /// that namespace's items: `namespace:#1`, `namespace:#2`, … + /// - Every other item is keyed `namespace:canonical title`; a second item of the same + /// namespace with the same canonical title gets `:2`, a third `:3`, and so on. + static func keys(for items: [Item], titleChangingOwners: Set) -> [String] { + var positions = [String: Int]() + var occurrences = [String: Int]() + return items.map { item in + if titleChangingOwners.contains(item.namespace) { + let position = positions[item.namespace, default: 0] + 1 + positions[item.namespace] = position + return "\(item.namespace):#\(position)" + } + let base = baseKey(namespace: item.namespace, canonicalTitle: canonicalTitle(item.title)) + let occurrence = occurrences[base, default: 0] + 1 + occurrences[base] = occurrence + return occurrence == 1 ? base : "\(base):\(occurrence)" + } + } + + /// Returns the key a stored key matches today. + /// + /// Earlier versions stored `namespace:raw title`; such a key is canonicalised, and for a + /// namespace in `titleChangingOwners` it matches the app's first item. Keys this version + /// stored are returned unchanged. + static func storedKey(_ stored: String, titleChangingOwners: Set) -> String { + guard let separator = stored.firstIndex(of: ":") else { + // A namespace without a title. + return titleChangingOwners.contains(stored) ? "\(stored):#1" : stored + } + let namespace = String(stored[..`. + if let match = title.wholeMatch(of: /(.*):(\d+)/) { + let base = String(match.1) + if canonicalTitle(base) == base, !base.contains(where: \.isNumber) || isIdentifier(base) { + return stored + } + } + return baseKey(namespace: namespace, canonicalTitle: canonicalTitle(title)) + } + + // MARK: Private + + /// Whether the title is an identifier rather than text: a letter followed by letters, + /// digits, dots, hyphens and underscores only. + private static func isIdentifier(_ title: String) -> Bool { + title.wholeMatch(of: /[A-Za-z][A-Za-z0-9._\-]*/) != nil + } + + /// The key of an item without an occurrence number. + private static func baseKey(namespace: String, canonicalTitle: String) -> String { + canonicalTitle.isEmpty ? namespace : "\(namespace):\(canonicalTitle)" + } + + /// The canonical titles of each namespace's items. + private static func canonicalTitlesByNamespace(_ items: [Item]) -> [String: [String]] { + items.reduce(into: [String: [String]]()) { result, item in + result[item.namespace, default: []].append(canonicalTitle(item.title)) + } + } +} diff --git a/holzBar/Core/LayoutKeys.swift b/holzBar/Core/LayoutKeys.swift new file mode 100644 index 00000000..ef563372 --- /dev/null +++ b/holzBar/Core/LayoutKeys.swift @@ -0,0 +1,155 @@ +// +// LayoutKeys.swift +// holzBar +// + +import Foundation + +/// What the keys do in the Menu Bar Layout pane (THAW-14). +/// +/// The arrows move the focus: Left and Right between the items of a section, Up and Down +/// between the sections. Option with an arrow moves the focused item: Left and Right one +/// place within its section (refused where macOS orders the items, on macOS 27), Up and +/// Down to the neighbouring section. Command-1, 2 and 3 move it to the visible, hidden +/// and always-hidden section. Return or Space opens the item's menu. +nonisolated enum LayoutKeys { + /// Virtual key codes (`kVK_*` of Carbon's `Events.h`). + nonisolated enum Key { + static let returnKey = 36 + static let keypadEnter = 76 + static let space = 49 + static let leftArrow = 123 + static let rightArrow = 124 + static let downArrow = 125 + static let upArrow = 126 + static let one = 18 + static let two = 19 + static let three = 20 + } + + /// The section an item moves to. + nonisolated enum SectionTarget: Equatable, Sendable { + /// The section on the right, toward the visible one. + case previous + /// The section on the left, toward the always-hidden one. + case next + case visible + case hidden + case alwaysHidden + } + + /// What a key does. + nonisolated enum Command: Equatable, Sendable { + /// Moves the focus by the given steps: `dx` within a section, `dy` between sections. + case focus(dx: Int, dy: Int) + /// Moves the focused item by one place within its section. + case moveWithinSection(Int) + /// Moves the focused item to another section. + case moveToSection(SectionTarget) + /// Opens the focused item's menu. + case openMenu + /// Says why the key does nothing here. + case refused(String) + } + + /// Why an item cannot move within its section on macOS 27. + static let macOSOrdersItems = "macOS orders the items within each section." + + /// The command for a key, or `nil` for a key the Layout pane leaves alone. + /// + /// - Parameters: + /// - keyCode: The key's virtual key code. + /// - modifiers: The modifier keys held. + /// - itemsKeepOrder: Whether items can be ordered within a section (before macOS 27). + static func command(keyCode: Int, modifiers: Modifiers, itemsKeepOrder: Bool = true) -> Command? { + let modifiers = modifiers.subtracting(.shift) + switch (keyCode, modifiers) { + case (Key.leftArrow, []): + return .focus(dx: -1, dy: 0) + case (Key.rightArrow, []): + return .focus(dx: 1, dy: 0) + case (Key.upArrow, []): + return .focus(dx: 0, dy: -1) + case (Key.downArrow, []): + return .focus(dx: 0, dy: 1) + case (Key.leftArrow, .option): + return itemsKeepOrder ? .moveWithinSection(-1) : .refused(macOSOrdersItems) + case (Key.rightArrow, .option): + return itemsKeepOrder ? .moveWithinSection(1) : .refused(macOSOrdersItems) + case (Key.upArrow, .option): + return .moveToSection(.previous) + case (Key.downArrow, .option): + return .moveToSection(.next) + case (Key.one, .command): + return .moveToSection(.visible) + case (Key.two, .command): + return .moveToSection(.hidden) + case (Key.three, .command): + return .moveToSection(.alwaysHidden) + case (Key.returnKey, []), (Key.keypadEnter, []), (Key.space, []): + return .openMenu + default: + return nil + } + } + + /// A place in the Layout pane: a section's row and an item's index in it. + nonisolated struct Position: Equatable, Sendable { + var row: Int + var index: Int + } + + /// Where the focus goes for a focus command. + /// + /// Within a row the focus stops at either end. Between rows it skips empty rows and + /// lands on the item nearest the same index; at the first or last row it stays. + /// + /// - Parameters: + /// - position: The focused place. + /// - command: The command; anything but `.focus` keeps the position. + /// - rowCounts: The number of items in each row, top to bottom. + static func focusTarget(from position: Position, command: Command, rowCounts: [Int]) -> Position { + guard + case .focus(let dx, let dy) = command, + rowCounts.indices.contains(position.row) + else { + return position + } + if dy == 0 { + let count = rowCounts[position.row] + guard count > 0 else { + return position + } + return Position(row: position.row, index: min(max(position.index + dx, 0), count - 1)) + } + var row = position.row + (dy > 0 ? 1 : -1) + while rowCounts.indices.contains(row) { + let count = rowCounts[row] + if count > 0 { + return Position(row: row, index: min(max(position.index, 0), count - 1)) + } + row += dy > 0 ? 1 : -1 + } + return position + } + + /// The index of the section an item moves to, or `nil` when it stays. + /// + /// - Parameters: + /// - target: Where the command moves it. + /// - current: The index of the item's section (0 visible, 1 hidden, 2 always hidden). + /// - sectionCount: The number of enabled sections. + static func sectionIndex(for target: SectionTarget, from current: Int, sectionCount: Int) -> Int? { + let index = switch target { + case .previous: current - 1 + case .next: current + 1 + case .visible: 0 + case .hidden: 1 + case .alwaysHidden: 2 + } + guard index != current, index >= 0, index < sectionCount else { + return nil + } + return index + } +} diff --git a/holzBar/Core/MenuBarOcclusion.swift b/holzBar/Core/MenuBarOcclusion.swift new file mode 100644 index 00000000..6f26a6ce --- /dev/null +++ b/holzBar/Core/MenuBarOcclusion.swift @@ -0,0 +1,49 @@ +// +// MenuBarOcclusion.swift +// holzBar +// + +import CoreGraphics +import Foundation + +/// Whether another app's window stands above the menu bar at a point. +/// +/// Notch apps (boring.notch, NotchNook and others) draw their controls in windows above +/// the menu bar, at the level of the main menu plus a few. A click on such a control is +/// a click on that app, not on empty menu bar space, so show on click must not toggle +/// the hidden items (jordanbaird/Ice#933). +nonisolated enum MenuBarOcclusion { + /// A window on screen, as the window server lists it. + typealias Window = (layer: Int, bounds: CGRect, ownerPID: pid_t, ownerBundleID: String?, alpha: Double) + + /// The level of the status bar (25); a window above it stands over the menu bar. + static let statusBarLayer = 25 + + /// The system's own processes, whose windows above the bar belong to the bar itself. + static let systemOwners: Set = [ + "com.apple.WindowServer", + "com.apple.dock", + "com.apple.controlcenter", + "com.apple.MenuBarAgent", + "com.apple.systemuiserver", + "com.apple.notificationcenterui", + ] + + /// Whether a window of another, non-system app above the status bar level, visible and + /// containing the point, covers the menu bar there. + /// + /// - Parameters: + /// - point: The point, in the window server's coordinates. + /// - windows: The windows on screen. + /// - ownPID: holzBar's process identifier. + /// - systemOwners: The bundle identifiers of the system's own processes. + static func isCovered(point: CGPoint, windows: [Window], ownPID: pid_t, systemOwners: Set = MenuBarOcclusion.systemOwners) -> Bool { + windows.contains { window in + window.layer > statusBarLayer && + window.alpha > 0 && + window.ownerPID != ownPID && + !(window.ownerBundleID.map(systemOwners.contains) ?? false) && + window.bounds.contains(point) + } + } +} diff --git a/holzBar/Core/MoveBackoff.swift b/holzBar/Core/MoveBackoff.swift new file mode 100644 index 00000000..0a3863c7 --- /dev/null +++ b/holzBar/Core/MoveBackoff.swift @@ -0,0 +1,131 @@ +// +// MoveBackoff.swift +// holzBar +// + +import Foundation + +/// Pauses holzBar's automatic item moves when they run away. +/// +/// holzBar moves items on its own: new items into their section, Live Activities into +/// view, temporarily shown items back, the dividers into order, a profile's layout. When +/// macOS keeps putting an item back, the same item is dragged again and again; when the +/// bar refuses moves, every failed one holds the hidden pointer for its whole budget. So +/// both are counted in a sliding window (``window``), and past a limit automatic moves +/// are refused for a pause that doubles with each trip (``initialPause`` up to +/// ``maximumPause``) and starts over when ``resetInterval`` passed after the last pause +/// without a trip. +/// +/// Moves the user makes are never counted and are always allowed; one ends a pause. +/// +/// Adapted from Thaw's `MoveCircuitBreaker` (GPL-3.0, see NOTICE). Every method takes the +/// current time, so the type can be tested without a clock. +nonisolated struct MoveBackoff { + /// How far back the counts look. + static let window = Duration.seconds(60) + + /// Automatic moves of one item allowed in the window; one more trips the back-off. + static let sameItemLimit = 4 + + /// Failed automatic moves in the window that trip the back-off. + static let failureLimit = 8 + + /// The first pause after a trip. + static let initialPause = Duration.seconds(60) + + /// The longest pause. + static let maximumPause = Duration.seconds(600) + + /// How long after the last pause ended without a trip before the pause starts over at + /// ``initialPause``. + static let resetInterval = Duration.seconds(300) + + /// What is counted. + private nonisolated enum Signal: Equatable { + case move(identifier: String) + case failure + } + + /// A counted signal and when it happened. + private nonisolated struct Record { + let at: ContinuousClock.Instant + let signal: Signal + } + + /// The signals in the window. + private var records = [Record]() + + /// When the current pause ends, or `nil` when automatic moves are not paused. + private(set) var pausedUntil: ContinuousClock.Instant? + + /// When the last pause ended (or was to end, if a user move ended it early). + private var lastPauseEnd: ContinuousClock.Instant? + + /// The pause the next trip starts. + private(set) var nextPause = MoveBackoff.initialPause + + /// Whether an automatic move may run at the given time. + func allowsAutomaticMove(at now: ContinuousClock.Instant) -> Bool { + guard let pausedUntil else { + return true + } + return now >= pausedUntil + } + + /// Counts an automatic move of the item with the given identifier. + /// + /// - Returns: `true` when this move tripped the back-off; the move should then not run. + @discardableResult + mutating func recordAutomaticMove(identifier: String, at now: ContinuousClock.Instant) -> Bool { + record(.move(identifier: identifier), at: now) + let moves = records.count { $0.signal == .move(identifier: identifier) } + guard moves > Self.sameItemLimit else { + return false + } + trip(at: now) + return true + } + + /// Counts a failed automatic move. + /// + /// - Returns: `true` when this failure tripped the back-off. + @discardableResult + mutating func recordFailure(at now: ContinuousClock.Instant) -> Bool { + record(.failure, at: now) + let failures = records.count { $0.signal == .failure } + guard failures >= Self.failureLimit else { + return false + } + trip(at: now) + return true + } + + /// Notes a move the user made: it ends a pause and clears the counts. The grown pause is + /// kept for the next trip. + mutating func recordUserMove() { + records.removeAll() + pausedUntil = nil + } + + /// Adds a signal and drops the ones that left the window. + private mutating func record(_ signal: Signal, at now: ContinuousClock.Instant) { + records.removeAll { $0.at.duration(to: now) > Self.window } + records.append(Record(at: now, signal: signal)) + } + + /// Pauses automatic moves and doubles the next pause. + private mutating func trip(at now: ContinuousClock.Instant) { + // A trip long after the last pause means the bar had settled in between, so the + // pause starts over rather than punishing a one-off. Measured from the end of the + // pause, as no trip can happen during one. + if let lastPauseEnd, lastPauseEnd.duration(to: now) >= Self.resetInterval { + nextPause = Self.initialPause + } + let pauseEnd = now + nextPause + lastPauseEnd = pauseEnd + pausedUntil = pauseEnd + nextPause = min(nextPause * 2, Self.maximumPause) + // The pause stands for what was counted; afterwards the counts start over. + records.removeAll() + } +} diff --git a/holzBar/Core/PresentationSignals.swift b/holzBar/Core/PresentationSignals.swift new file mode 100644 index 00000000..8e243ac4 --- /dev/null +++ b/holzBar/Core/PresentationSignals.swift @@ -0,0 +1,27 @@ +// +// PresentationSignals.swift +// holzBar +// + +import Foundation + +/// Whether the screen is shown to someone else, for Zen mode while presenting (THAW-10). +/// +/// Only signals that need no permission and no polling: a display in a mirror set (read +/// when the displays change) and macOS's screen sharing agent among the running +/// applications (read when an application launches or quits). Thaw read the process table +/// every 5 seconds for `screensharingd`; holzBar never polls. +nonisolated enum PresentationSignals { + /// The bundle identifier of macOS's per-user screen sharing agent, which runs while + /// another Mac views or controls this one. + static let screenSharingAgentBundleID = "com.apple.screensharing.agent" + + /// Whether the screen is presented: a display is mirrored or the screen is shared. + /// + /// - Parameters: + /// - mirroredDisplays: The number of active displays in a mirror set. + /// - runningBundleIDs: The bundle identifiers of the running applications. + static func isPresenting(mirroredDisplays: Int, runningBundleIDs: Set) -> Bool { + mirroredDisplays > 0 || runningBundleIDs.contains(screenSharingAgentBundleID) + } +} diff --git a/holzBar/Core/ProfileBinding.swift b/holzBar/Core/ProfileBinding.swift new file mode 100644 index 00000000..2c3ffdf9 --- /dev/null +++ b/holzBar/Core/ProfileBinding.swift @@ -0,0 +1,69 @@ +// +// ProfileBinding.swift +// holzBar +// + +import Foundation + +/// Which layout profile to apply when a display is connected or the Space changes +/// (THAW-11). +/// +/// A profile can be bound to a display (by the display's UUID) and to a Space (by the +/// Space's UUID). Connecting a bound display, or switching to a bound Space, applies the +/// profile; when both match, the Space wins, because it is the more specific choice. The +/// profile that is already applied is never applied again, so a flickering display or a +/// Space switch back and forth does not move items in a loop. +nonisolated enum ProfileBinding { + /// The bindings of one profile. + nonisolated struct Profile: Equatable, Sendable { + /// The profile's name. + var name: String + /// The UUID of the display the profile is bound to. + var displayUUID: String? + /// The UUID of the Space the profile is bound to. + var spaceUUID: String? + } + + /// What happened. + nonisolated enum Event: Equatable, Sendable { + /// Displays were connected (only the new ones), while the given Space is active. + case displaysConnected(Set, activeSpace: String?) + /// The given Space became active. + case spaceChanged(String) + } + + /// The name of the profile to apply after the event, or `nil` for none. + /// + /// - Parameters: + /// - profiles: The saved profiles with their bindings, in their order. + /// - event: What happened. + /// - currentProfile: The name of the profile that was applied last. + static func profileToApply(profiles: [Profile], event: Event, currentProfile: String?) -> String? { + let match: Profile? + switch event { + case .displaysConnected(let displays, let activeSpace): + guard !displays.isEmpty else { + return nil + } + let spaceMatch = activeSpace.flatMap { space in + profiles.first { $0.spaceUUID == space } + } + let displayMatch = profiles.first { profile in + profile.displayUUID.map { displays.contains($0) } ?? false + } + // A Space-bound profile only wins over a display that is bound as well. + match = displayMatch == nil ? nil : (spaceMatch ?? displayMatch) + case .spaceChanged(let space): + match = profiles.first { $0.spaceUUID == space } + } + guard let name = match?.name, name != currentProfile else { + return nil + } + return name + } + + /// The displays in `current` that were not in `previous`. + static func newlyConnected(previous: Set, current: Set) -> Set { + current.subtracting(previous) + } +} diff --git a/holzBar/Core/ScreenRecordingFeature.swift b/holzBar/Core/ScreenRecordingFeature.swift index 1b154630..91958aa8 100644 --- a/holzBar/Core/ScreenRecordingFeature.swift +++ b/holzBar/Core/ScreenRecordingFeature.swift @@ -47,7 +47,7 @@ nonisolated enum ScreenRecordingFeature: CaseIterable, Sendable { case .layoutPane: "The Menu Bar Layout pane shows pictures of your menu bar items, which macOS lets an app take only with Screen Recording." case .menuBarShape: - "A menu bar shape draws the wallpaper beside the shape, which macOS lets an app read only with Screen Recording." + "A menu bar shape draws the wallpaper beside the shape. holzBar reads most wallpapers from their file; a moving wallpaper can be read only with Screen Recording." } } @@ -55,7 +55,7 @@ nonisolated enum ScreenRecordingFeature: CaseIterable, Sendable { static var summary: String { """ Pictures of your menu bar items in the holzBar Shelf, the search and the Menu Bar Layout pane \ - (on macOS 27 taken once per item), and the wallpaper beside a menu bar shape. + (on macOS 27 taken once per item), and a moving wallpaper beside a menu bar shape (before macOS 27). """ } } diff --git a/holzBar/Core/SettingsSyncLocation.swift b/holzBar/Core/SettingsSyncLocation.swift new file mode 100644 index 00000000..dfee7d5b --- /dev/null +++ b/holzBar/Core/SettingsSyncLocation.swift @@ -0,0 +1,88 @@ +// +// SettingsSyncLocation.swift +// holzBar +// + +import Foundation + +/// Where holzBar syncs its settings: iCloud Drive or any folder the user's Macs keep in +/// step, such as a Nextcloud, Dropbox, OneDrive or Syncthing folder or a network share +/// (SYNC-01). +/// +/// The folder is stored as a bookmark, so it is found again after it was moved or renamed. +/// The settings travel as `holzBar/Settings.plist` inside it; the folder's own app syncs +/// the file, holzBar never connects to the network. +nonisolated enum SettingsSyncLocation { + /// The sync file inside the chosen folder. + static let fileComponents = ["holzBar", "Settings.plist"] + + /// What the stored bookmark gave. + nonisolated enum Resolution: Equatable, Sendable { + /// No folder was chosen yet. + case none + /// The bookmark gave this folder; a stale bookmark is to be stored again. + case resolved(path: String, isStale: Bool) + /// The bookmark could not be resolved: the folder is gone or its volume is not + /// mounted. + case failed + } + + /// Where to sync and what to store. + nonisolated struct Decision: Equatable, Sendable { + /// The folder to sync through, or `nil` when there is none now. + let folderPath: String? + /// Whether to store a new bookmark of ``folderPath``: it was stale, or it is + /// iCloud Drive, used before folders could be chosen. + let storesBookmark: Bool + } + + /// Decides the folder. + /// + /// Without a chosen folder, iCloud Drive is used, as it was before folders could be + /// chosen, and stored as the choice, so existing users keep syncing without a question. + /// A folder that cannot be found is not replaced by iCloud Drive: that would mix the + /// settings of two places. + /// + /// - Parameters: + /// - resolution: What the stored bookmark gave. + /// - iCloudDrivePath: iCloud Drive's folder, if iCloud Drive is turned on. + static func decide(resolution: Resolution, iCloudDrivePath: String?) -> Decision { + switch resolution { + case .none: + Decision(folderPath: iCloudDrivePath, storesBookmark: iCloudDrivePath != nil) + case .resolved(let path, let isStale): + Decision(folderPath: path, storesBookmark: isStale) + case .failed: + Decision(folderPath: nil, storesBookmark: false) + } + } + + /// The path of the sync file in the given folder. + static func syncFilePath(inFolder folderPath: String) -> String { + fileComponents.reduce(URL(fileURLWithPath: folderPath, isDirectory: true)) { url, component in + url.appendingPathComponent(component) + }.path + } + + /// The name of the folder to show: "iCloud Drive" for iCloud Drive, a path starting + /// with "~" inside the home folder, the full path otherwise. + static func displayName(forFolder folderPath: String, homePath: String, iCloudDrivePath: String?) -> String { + let folder = normalized(folderPath) + if let iCloudDrivePath, folder == normalized(iCloudDrivePath) { + return "iCloud Drive" + } + let home = normalized(homePath) + if folder == home { + return "~" + } + if folder.hasPrefix(home + "/") { + return "~" + folder.dropFirst(home.count) + } + return folder + } + + /// The path without a trailing slash. + private static func normalized(_ path: String) -> String { + path.count > 1 && path.hasSuffix("/") ? String(path.dropLast()) : path + } +} diff --git a/holzBar/Core/SpacingRelaunch.swift b/holzBar/Core/SpacingRelaunch.swift index a9d9cd74..c8d5e3a6 100644 --- a/holzBar/Core/SpacingRelaunch.swift +++ b/holzBar/Core/SpacingRelaunch.swift @@ -83,6 +83,26 @@ nonisolated enum SpacingRelaunch { return defaultSpacing + offset } + /// A running process, as `NSWorkspace` lists it. + typealias RunningProcess = (pid: pid_t, bundleID: String?, isTerminated: Bool) + + /// Whether the app that was quit is running again on its own, so it must not be launched + /// a second time. + /// + /// An app that respawns (a helper relaunched by launchd, an app that restarts itself) + /// runs under a new process. The old process can still be listed while it quits; it + /// does not count, so the app is launched when nothing else runs (jordanbaird/Ice#923). + /// + /// - Parameters: + /// - oldPID: The process that was quit. + /// - bundleID: The app's bundle identifier. + /// - running: The running processes. + static func isRelaunched(oldPID: pid_t, bundleID: String, running: [RunningProcess]) -> Bool { + running.contains { process in + process.pid != oldPID && process.bundleID == bundleID && !process.isTerminated + } + } + /// How long an app gets to quit after being asked. /// /// Long enough for an app that saves or syncs on quit. An app that is still running diff --git a/holzBar/Core/SystemActivity.swift b/holzBar/Core/SystemActivity.swift new file mode 100644 index 00000000..69392a79 --- /dev/null +++ b/holzBar/Core/SystemActivity.swift @@ -0,0 +1,86 @@ +// +// SystemActivity.swift +// holzBar +// + +import Foundation + +/// Whether the Mac is in use: the screen unlocked, the Mac awake and holzBar's login +/// session in front. +/// +/// While the screen is locked, the Mac or its displays sleep, or another user's session is +/// in front, holzBar has nothing to do: the menu bar is not seen, and reading, moving or +/// capturing items then only fights macOS as it rearranges the bar. When that ends, and +/// when displays are connected or disconnected, the bar needs a moment to settle before +/// holzBar reads it again (``settleDelay``). +/// +/// The state is fed with events (`SystemActivityMonitor`) and with the time of each, so it +/// can be tested without a clock. +nonisolated struct SystemActivity: Equatable { + /// Something that changes whether the Mac is in use. + nonisolated enum Event: Equatable, Sendable { + case screenLocked + case screenUnlocked + case willSleep + case didWake + case sessionResigned + case sessionBecameActive + case displaysChanged + } + + /// How long after the Mac is in use again, or the displays change, the menu bar is left + /// to settle before holzBar reads it. + static let settleDelay = Duration.seconds(2) + + /// Whether the screen is locked. + private(set) var isLocked = false + + /// Whether the Mac or its displays sleep. + private(set) var isAsleep = false + + /// Whether another login session is in front. + private(set) var isSessionAway = false + + /// When the menu bar has settled after the last event that resumed or changed it. + private(set) var settlesAt: ContinuousClock.Instant? + + /// Whether holzBar rests: the screen is locked, the Mac sleeps or the session is away. + var isPaused: Bool { + isLocked || isAsleep || isSessionAway + } + + /// Records an event that happened at the given time. + mutating func handle(_ event: Event, at now: ContinuousClock.Instant) { + switch event { + case .screenLocked: + isLocked = true + case .screenUnlocked: + isLocked = false + settlesAt = now + Self.settleDelay + case .willSleep: + isAsleep = true + case .didWake: + isAsleep = false + settlesAt = now + Self.settleDelay + case .sessionResigned: + isSessionAway = true + case .sessionBecameActive: + isSessionAway = false + settlesAt = now + Self.settleDelay + case .displaysChanged: + settlesAt = now + Self.settleDelay + } + } + + /// Whether holzBar may read the menu bar again at the given time: it does not rest, and + /// the bar has settled after the last event that resumed or changed it. + func settled(at now: ContinuousClock.Instant) -> Bool { + guard !isPaused else { + return false + } + guard let settlesAt else { + return true + } + return now >= settlesAt + } +} diff --git a/holzBar/Core/URLCommand.swift b/holzBar/Core/URLCommand.swift index 7af3bb45..c32f0646 100644 --- a/holzBar/Core/URLCommand.swift +++ b/holzBar/Core/URLCommand.swift @@ -26,9 +26,26 @@ nonisolated struct URLCommand: Equatable, Sendable { case toggleShelf case toggleAutoRehide case toggleApplicationMenus + /// Turn Zen mode on or off. + case toggleZenMode /// Apply the layout profile with the given name. case applyProfile(String) case unknown + + /// Whether holzBar asks the user before it performs the action. + /// + /// Any app can open a `holzbar://` URL without the user knowing. Applying a profile + /// rearranges the menu bar, so it asks first; every other action changes nothing + /// lasting (show, hide, search, settings) or flips a setting the same command flips + /// back (the toggles). Zen mode only hides. + var needsConfirmation: Bool { + switch self { + case .applyProfile: + true + case .toggle, .show, .hide, .search, .settings, .toggleShelf, .toggleAutoRehide, .toggleApplicationMenus, .toggleZenMode, .unknown: + false + } + } } /// The command, lower-cased. @@ -83,6 +100,8 @@ nonisolated struct URLCommand: Equatable, Sendable { .toggleAutoRehide case "application-menus": .toggleApplicationMenus + case "zen": + .toggleZenMode case "profile": arguments.first.map(Action.applyProfile) ?? .unknown default: diff --git a/holzBar/Core/WallpaperPalette.swift b/holzBar/Core/WallpaperPalette.swift new file mode 100644 index 00000000..de85499e --- /dev/null +++ b/holzBar/Core/WallpaperPalette.swift @@ -0,0 +1,138 @@ +// +// WallpaperPalette.swift +// holzBar +// + +import Foundation + +/// The dominant colors of the desktop picture under the menu bar, ordered by how much of +/// it they cover, for the "Follow Wallpaper" tint (THAW-17). +/// +/// An average turns a sunset into brown; a palette keeps the colors that actually cover +/// the picture. Pure and deterministic, so it can be tested without a screen; the pixels +/// come from the desktop picture file (`DesktopPicture.palette(for:height:)`), never from a +/// capture. +/// +/// Adapted from Thaw's `WallpaperPalette` (see NOTICE). +nonisolated struct WallpaperPalette: Equatable, Sendable { + /// One dominant color. + nonisolated struct Swatch: Equatable, Sendable { + /// Components in the 0...1 range. + let red: Double + let green: Double + let blue: Double + + /// The fraction of the samples this swatch covers, 0...1. + let weight: Double + + /// RGB distance. Not perceptually uniform, but good enough to reject + /// near-duplicates and easy to test. + func distance(to other: Swatch) -> Double { + let dr = red - other.red + let dg = green - other.green + let db = blue - other.blue + return (dr * dr + dg * dg + db * db).squareRoot() + } + } + + /// A single observed pixel, components in the 0...1 range. + nonisolated struct Sample: Equatable, Sendable { + let red: Double + let green: Double + let blue: Double + } + + /// Most-covering first. May be empty. + let swatches: [Swatch] + + /// The most-covering swatch. + var primary: Swatch? { + swatches.first + } + + /// The second most-covering swatch, falling back to ``primary`` so a single-color + /// wallpaper still yields a pair. + var secondary: Swatch? { + swatches.count > 1 ? swatches[1] : primary + } + + /// Buckets per channel. Finer splits smooth gradients into near-identical buckets; + /// coarser merges colors a viewer would call different. + private static let levelsPerChannel = 32 + + /// Derives a palette from raw samples. + /// + /// Takes buckets most-populous first, skipping any too close to one already taken, so + /// a sky photo doesn't return five blues. + /// + /// - Parameters: + /// - samples: The observed pixels. Order does not matter. + /// - maximumCount: The most swatches to return. + /// - minimumSeparation: How far apart two swatches must be, as an RGB distance. + static func derive( + from samples: [Sample], + maximumCount: Int = 5, + minimumSeparation: Double = 0.25 + ) -> WallpaperPalette { + guard maximumCount > 0, !samples.isEmpty else { + return WallpaperPalette(swatches: []) + } + + let levels = levelsPerChannel + var counts = [Int: Int]() + var totals = [Int: (red: Double, green: Double, blue: Double)]() + + for sample in samples { + let key = bucketKey(for: sample, levels: levels) + counts[key, default: 0] += 1 + var total = totals[key] ?? (0, 0, 0) + total.red += sample.red + total.green += sample.green + total.blue += sample.blue + totals[key] = total + } + + let sampleCount = Double(samples.count) + // Sorted by population, ties broken on the bucket key, so the result does not + // depend on dictionary order. + let ranked = counts.sorted { lhs, rhs in + lhs.value == rhs.value ? lhs.key < rhs.key : lhs.value > rhs.value + } + + var result = [Swatch]() + for (key, count) in ranked { + guard result.count < maximumCount else { + break + } + guard let total = totals[key] else { + continue + } + // The average within the bucket rather than its center, so the swatch is a + // color that actually appears in the picture. + let population = Double(count) + let swatch = Swatch( + red: total.red / population, + green: total.green / population, + blue: total.blue / population, + weight: population / sampleCount + ) + if result.contains(where: { $0.distance(to: swatch) < minimumSeparation }) { + continue + } + result.append(swatch) + } + + return WallpaperPalette(swatches: result) + } + + private static func bucketKey(for sample: Sample, levels: Int) -> Int { + func level(_ value: Double) -> Int { + let scaled = Int(min(max(value, 0), 1) * Double(levels)) + // A component of exactly 1 would land one past the top bucket. + return min(scaled, levels - 1) + } + return (level(sample.red) * levels * levels) + + (level(sample.green) * levels) + + level(sample.blue) + } +} diff --git a/holzBar/Core/ZenMode.swift b/holzBar/Core/ZenMode.swift new file mode 100644 index 00000000..83d5406b --- /dev/null +++ b/holzBar/Core/ZenMode.swift @@ -0,0 +1,75 @@ +// +// ZenMode.swift +// holzBar +// + +import Foundation + +/// Zen mode keeps every hidden menu bar item hidden until it is turned off (THAW-10). +/// +/// While it is active, the gestures and rules that would reveal items on their own are +/// refused: hovering, a click on empty menu bar space, scrolling, the reveal rules, URL +/// commands from other apps and items that show when they change. What the user asks for +/// directly still works: clicking holzBar's icon and the section hotkeys. +/// +/// The user turns it on and off (``isManual``); holzBar turns it on while the screen is +/// mirrored or shared (``isAutomatic``). It is active while either is on, so the end of a +/// presentation never turns off what the user turned on. +nonisolated struct ZenMode: Equatable, Sendable { + /// What can reveal hidden items. + nonisolated enum Trigger: CaseIterable, Sendable { + /// The pointer rests on empty menu bar space ("Show on hover"). + case hover + /// A click on empty menu bar space ("Show on click"). + case clickOnEmptyBar + /// Scrolling in the menu bar ("Show on scroll"). + case scroll + /// A rule such as "When the battery is low". + case revealRule + /// A `holzbar://` URL from another app. + case urlCommand + /// An item that shows when it changes. + case changeReveal + /// A click on holzBar's icon. + case holzBarIcon + /// The hotkey of a section. + case sectionHotkey + } + + /// Whether the user turned Zen mode on. + var isManual = false + + /// Whether holzBar turned Zen mode on because the screen is mirrored or shared. + var isAutomatic = false + + /// Whether Zen mode is on. + var isActive: Bool { + isManual || isAutomatic + } + + /// Whether the given trigger may reveal hidden items now. + func allows(_ trigger: Trigger) -> Bool { + guard isActive else { + return true + } + switch trigger { + case .holzBarIcon, .sectionHotkey: + // The user's own request. + return true + case .hover, .clickOnEmptyBar, .scroll, .revealRule, .urlCommand, .changeReveal: + return false + } + } + + /// Zen mode after the user toggled it. + /// + /// Turning it off ends the automatic part as well, so it goes off even while the screen + /// is still shared; holzBar turns it on again only when a presentation starts anew. + func toggled() -> ZenMode { + if isActive { + ZenMode(isManual: false, isAutomatic: false) + } else { + ZenMode(isManual: true, isAutomatic: isAutomatic) + } + } +} diff --git a/holzBar/Events/EventMonitor.swift b/holzBar/Events/EventMonitor.swift index 872cc247..5c1891fe 100644 --- a/holzBar/Events/EventMonitor.swift +++ b/holzBar/Events/EventMonitor.swift @@ -91,6 +91,18 @@ final class EventMonitor { monitors.withLockUnchecked { $0 = installed } } + /// A Boolean value that indicates whether the monitor is installed. + var isRunning: Bool { + monitors.withLockUnchecked { !$0.isEmpty } + } + + /// Installs the monitor anew: AppKit may have dropped it (after the Mac woke or + /// Accessibility was granted), and a monitor that could not be installed is tried again. + func restart() { + stop() + start() + } + /// Uninstalls the monitor and stops listening for events. func stop() { let installed = monitors.withLockUnchecked { monitors in diff --git a/holzBar/Events/EventTap.swift b/holzBar/Events/EventTap.swift index 27d68864..d7e44257 100644 --- a/holzBar/Events/EventTap.swift +++ b/holzBar/Events/EventTap.swift @@ -66,6 +66,18 @@ final class EventTap { private var resources: Resources? private let callback: (EventTap, CGEvent) -> CGEvent? + /// The events the tap receives. + private let mask: CGEventMask + + /// Where the tap is inserted into the event stream. + private let location: Location + + /// The tap's placement, relative to other taps at its location. + private let placement: CGEventTapPlacement + + /// Whether the tap filters events or only listens. + private let option: CGEventTapOptions + /// A string label that identifies the tap. let label: String @@ -117,22 +129,11 @@ final class EventTap { ) { self.label = label self.callback = callback - - guard - let machPort = EventTap.createMachPort( - mask: types.reduce(0) { $0 | (1 << $1.rawValue) }, - location: location, - place: placement, - options: option, - userInfo: Unmanaged.passUnretained(self).toOpaque() - ), - let source = CFMachPortCreateRunLoopSource(nil, machPort, 0) - else { - EventTap.logger.error(#"Error creating event tap "\#(label, privacy: .public)""#) - return - } - - self.resources = Resources(machPort: machPort, source: source, runLoop: CFRunLoopGetMain()) + self.mask = types.reduce(0) { $0 | (1 << $1.rawValue) } + self.location = location + self.placement = placement + self.option = option + self.resources = makeResources() } /// Creates a new event tap for the specified event type. @@ -177,6 +178,47 @@ final class EventTap { ) } + /// Creates the tap's mach port and run loop source, or returns `nil` when macOS refuses + /// the tap (before Accessibility is granted, for one). + private func makeResources() -> Resources? { + guard + let machPort = EventTap.createMachPort( + mask: mask, + location: location, + place: placement, + options: option, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ), + let source = CFMachPortCreateRunLoopSource(nil, machPort, 0) + else { + EventTap.logger.error(#"Error creating event tap "\#(self.label, privacy: .public)""#) + return nil + } + return Resources(machPort: machPort, source: source, runLoop: CFRunLoopGetMain()) + } + + /// Creates the tap's mach port again when it is missing or no longer valid. + /// + /// A tap created before Accessibility was granted has no port for the rest of the + /// session otherwise, and macOS can invalidate a port (after the permission changes, + /// for one). + /// + /// - Returns: `true` when a new port was created. + @discardableResult + func recreateIfInvalid() -> Bool { + if let resources, CFMachPortIsValid(resources.machPort) { + return false + } + // Releasing the old resources takes the old port out of the event stream. + resources = nil + resources = makeResources() + guard resources != nil else { + return false + } + EventTap.logger.info(#"Recreated event tap "\#(self.label, privacy: .public)""#) + return true + } + /// Creates an event tap mach port. private static func createMachPort( mask: CGEventMask, @@ -220,10 +262,15 @@ final class EventTap { } /// Enables the tap. + /// + /// The port is created again first if it is missing or invalid, and the run loop + /// source is added before the tap is enabled, so the first event after enabling is + /// delivered. func enable() { + recreateIfInvalid() guard let resources else { return } - CGEvent.tapEnable(tap: resources.machPort, enable: true) CFRunLoopAddSource(resources.runLoop, resources.source, .commonModes) + CGEvent.tapEnable(tap: resources.machPort, enable: true) } /// Disables the tap. diff --git a/holzBar/Events/HIDEventManager.swift b/holzBar/Events/HIDEventManager.swift index 23918607..5183fc2d 100644 --- a/holzBar/Events/HIDEventManager.swift +++ b/holzBar/Events/HIDEventManager.swift @@ -29,6 +29,10 @@ final class HIDEventManager { /// History of the manager's enabled states. @ObservationIgnored private var enabledStateStack = [Bool]() + /// The windows above the menu bar's level from the last read, reused for a moment so + /// mouse moves do not read the window list each (see `MenuBarHitTesting.swift`). + @ObservationIgnored var windowsAboveMenuBar: (readAt: TimeInterval, windows: [MenuBarOcclusion.Window])? + /// The last empty menu bar spot hovered on each display (see `ItemClicker27`). @ObservationIgnored private var lastEmptyMenuBarPoints = [CGDirectDisplayID: CGPoint]() @@ -73,6 +77,8 @@ final class HIDEventManager { @ObservationIgnored private(set) lazy var mouseDownMonitor = EventMonitor.universal( for: [.leftMouseDown, .rightMouseDown] ) { [weak self] event in + // When the click arrived, for the latency of show on click. + let clickTime = ProcessInfo.processInfo.systemUptime guard let self, isEnabled, let appState, let screen = bestScreen(appState: appState) else { return event } @@ -82,7 +88,7 @@ final class HIDEventManager { } switch event.type { case .leftMouseDown: - handleShowOnClick(appState: appState, screen: screen) + handleShowOnClick(appState: appState, screen: screen, clickTime: clickTime) handleSmartRehide(with: event, appState: appState, screen: screen) case .rightMouseDown: handleSecondaryContextMenu(appState: appState, screen: screen) @@ -101,6 +107,7 @@ final class HIDEventManager { return event } handleMenuBarItemDragStop() + handleArrangementEnd(with: event) return event } @@ -137,6 +144,9 @@ final class HIDEventManager { return event } + /// Logger for the event manager. + @ObservationIgnored private let logger = Logger(category: "HIDEventManager") + /// The tap that lets clicks reach the system items while items are concealed, where /// the backend needs one (`SystemItemClickBridge27`). It runs while the manager is enabled. @ObservationIgnored private var systemItemClickBridge: (any SystemItemClickBridge)? @@ -177,6 +187,7 @@ final class HIDEventManager { secondaryContextMenu: advanced.enableSecondaryContextMenu, usesShelf: usesShelfEverywhere, showAllSectionsOnUserDrag: advanced.showAllSectionsOnUserDrag, + savesUserArrangement: MenuBarBackends.current.canMoveItems, hasCustomAppearance: appState.appearanceManager.needsOverlayPanels(for: appState.appearanceManager.configuration) ) } @@ -249,6 +260,17 @@ final class HIDEventManager { } } + // A tap created before Accessibility was granted has no port; check the monitors + // once the permission is there. + let accessibility = appState.permissions.accessibility + observers.append( + ObservationLoop.observe { accessibility.hasPermission } onChange: { [weak self] hasPermission in + if hasPermission { + self?.healthCheck() + } + } + ) + if let hiddenSection = appState.menuBarManager.section(withName: .hidden) { // In fullscreen mode, the menu bar slides down from the top on hover. Observe the // frame of the hidden section's control item, which we know will always be in the @@ -282,6 +304,29 @@ final class HIDEventManager { enabledStateStack.append(isEnabled) isEnabled = false } + + // MARK: Health Check + + /// Repairs the running monitors: taps whose port is missing or invalid are created + /// again, AppKit's monitors are installed anew and the system item click tap is + /// restarted. + /// + /// Runs once the bar has settled after the screen was locked, the Mac slept, the + /// session was away or the displays changed, and when Accessibility is granted. + func healthCheck() { + var repaired = [String]() + for kind in InputMonitors.Kind.allCases where runningKinds.contains(kind) { + if monitor(for: kind).repair() { + repaired.append(String(describing: kind)) + } + } + if let systemItemClickBridge, isSystemItemClickBridgeRunning { + systemItemClickBridge.stop() + systemItemClickBridge.start() + repaired.append("system item click tap") + } + logger.info("Health check: restarted \(repaired.joined(separator: ", "), privacy: .public)") + } } // MARK: - Handler Methods @@ -290,7 +335,13 @@ extension HIDEventManager { // MARK: Handle Show On Click - private func handleShowOnClick(appState: AppState, screen: NSScreen) { + /// Shows or hides a section after a click on empty menu bar space. + /// + /// Nothing here asks another application: the hit test reads the application menu + /// from `ApplicationMenuFrames`, so the reveal follows the click at once. + /// + /// - Parameter clickTime: The system uptime when the click arrived. + private func handleShowOnClick(appState: AppState, screen: NSScreen, clickTime: TimeInterval) { guard appState.settings.general.showOnClick, isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen) @@ -321,7 +372,16 @@ extension HIDEventManager { return } + // Zen mode refuses to reveal; hiding what the user showed stays possible. + guard !targetSection.isHidden || appState.menuBarManager.zenMode.allows(.clickOnEmptyBar) else { + logger.debug("Show on click: ignored in Zen mode") + return + } + + // On macOS 27 this also applies the concealment (`Concealer27.update()`). targetSection.toggle() + let milliseconds = Int((ProcessInfo.processInfo.systemUptime - clickTime) * 1000) + logger.debug("Show on click: revealed \(milliseconds, privacy: .public) ms after the click") } } @@ -420,7 +480,28 @@ extension HIDEventManager { private func handleMenuBarItemDragStop() { if isDraggingMenuBarItem { isDraggingMenuBarItem = false + // The user arranged items on the bar: their sections are saved once the bar + // shows the result (see `SectionRestore.swift`). + appState?.itemManager.saveSectionsSoon() + } + } + + // MARK: Handle Arrangement End + + /// Notes the end of a Command-drag on the menu bar when the drag monitors do not run + /// (they run only for "Show all sections on drag" or a custom appearance), so the + /// sections the user arranged are saved before anything restores the old ones. + private func handleArrangementEnd(with event: NSEvent) { + guard + !runningKinds.contains(.mouseDragged), + event.modifierFlags.contains(.command), + let appState, + let screen = bestScreen(appState: appState), + isMouseInsideMenuBar(appState: appState, screen: screen) + else { + return } + appState.itemManager.saveSectionsSoon() } // MARK: Handle Menu Bar Item Drag Start @@ -481,6 +562,7 @@ extension HIDEventManager { if hiddenSection.isHidden { guard appState.menuBarManager.showOnHoverAllowed, + appState.menuBarManager.zenMode.allows(.hover), isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen) else { return @@ -598,6 +680,9 @@ extension HIDEventManager { } if averageDelta > 5 { + guard appState.menuBarManager.zenMode.allows(.scroll) else { + return + } hiddenSection.show() } else if averageDelta < -5 { hiddenSection.hide() @@ -613,9 +698,17 @@ extension HIDEventManager { private protocol EventMonitorProtocol { func start() func stop() + + /// Repairs the running monitor and returns whether it did anything. + func repair() -> Bool } -extension EventMonitor: EventMonitorProtocol { } +extension EventMonitor: EventMonitorProtocol { + fileprivate func repair() -> Bool { + restart() + return true + } +} extension EventTap: EventMonitorProtocol { fileprivate func start() { @@ -625,4 +718,10 @@ extension EventTap: EventMonitorProtocol { fileprivate func stop() { disable() } + + fileprivate func repair() -> Bool { + let wasHealthy = isValid && isEnabled + enable() + return !wasHealthy + } } diff --git a/holzBar/Events/MenuBarHitTesting.swift b/holzBar/Events/MenuBarHitTesting.swift index 018e3b3d..63d7ca0e 100644 --- a/holzBar/Events/MenuBarHitTesting.swift +++ b/holzBar/Events/MenuBarHitTesting.swift @@ -58,10 +58,14 @@ extension HIDEventManager { /// A Boolean value that indicates whether the mouse pointer is within /// the bounds of the current application menu. + /// + /// The frame comes from `ApplicationMenuFrames`, which reads it off the main thread: + /// this runs for every click and mouse move, and asking an application that hangs + /// would hold up every click on the Mac. func isMouseInsideApplicationMenu(appState: AppState, screen: NSScreen) -> Bool { guard let mouseLocation = MouseHelpers.locationCoreGraphics, - var applicationMenuFrame = screen.getApplicationMenuFrame() + var applicationMenuFrame = appState.applicationMenuFrames.frame(for: screen) else { return false } @@ -107,7 +111,62 @@ extension HIDEventManager { } // Where the backend draws the items without windows (macOS 27), the gaps between // items are part of the items' own run of the bar. - return !isMouseInsideItemsArea(appState: appState, screen: screen) + guard !isMouseInsideItemsArea(appState: appState, screen: screen) else { + return false + } + // Last, as it reads the window list: a notch app's window above the bar is not + // empty space (jordanbaird/Ice#933). + return !isMouseInsideWindowAboveMenuBar(screen: screen) + } + + /// A Boolean value that indicates whether another app's window stands above the menu + /// bar at the mouse pointer, such as the controls of a notch app. + /// + /// Reads the window server's list (no Accessibility), at most every quarter second. + func isMouseInsideWindowAboveMenuBar(screen: NSScreen) -> Bool { + guard let mouseLocation = MouseHelpers.locationCoreGraphics else { + return false + } + let now = ProcessInfo.processInfo.systemUptime + let windows: [MenuBarOcclusion.Window] + if let cached = windowsAboveMenuBar, now - cached.readAt < 0.25 { + windows = cached.windows + } else { + windows = Self.readWindowsAboveMenuBar() + windowsAboveMenuBar = (now, windows) + } + // A window as wide as the display is an overlay (dimming, screen annotation), not a + // control standing over the bar. + let displayWidth = CGDisplayBounds(screen.displayID).width + return MenuBarOcclusion.isCovered( + point: mouseLocation, + windows: windows.filter { $0.bounds.width < displayWidth }, + ownPID: ProcessInfo.processInfo.processIdentifier + ) + } + + /// The windows on screen above the status bar's level. + private static func readWindowsAboveMenuBar() -> [MenuBarOcclusion.Window] { + guard + let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[CFString: Any]] + else { + return [] + } + return list.compactMap { info in + guard + let layer = info[kCGWindowLayer] as? Int, + layer > MenuBarOcclusion.statusBarLayer, + let boundsDictionary = info[kCGWindowBounds] as? NSDictionary, + let bounds = CGRect(dictionaryRepresentation: boundsDictionary), + let ownerPID = info[kCGWindowOwnerPID] as? pid_t + else { + return nil + } + let alpha = info[kCGWindowAlpha] as? Double ?? 1 + let bundleID = NSRunningApplication(processIdentifier: ownerPID)?.bundleIdentifier + ?? (info[kCGWindowOwnerName] as? String == "Window Server" ? "com.apple.WindowServer" : nil) + return (layer: layer, bounds: bounds, ownerPID: ownerPID, ownerBundleID: bundleID, alpha: alpha) + } } /// A Boolean value that indicates whether the mouse pointer rests in the part of the diff --git a/holzBar/Events/SystemActivityMonitor.swift b/holzBar/Events/SystemActivityMonitor.swift new file mode 100644 index 00000000..f2ff0ca0 --- /dev/null +++ b/holzBar/Events/SystemActivityMonitor.swift @@ -0,0 +1,119 @@ +// +// SystemActivityMonitor.swift +// holzBar +// + +import Cocoa +import Observation +import OSLog + +/// Follows whether the Mac is in use, and tells holzBar when the menu bar has settled after +/// it was not or after the displays changed (see `SystemActivity`). +/// +/// It listens to notifications only: the workspace's sleep, wake and session notifications, +/// the screen lock's distributed notifications and the screen parameters. After an event +/// that resumes or changes the bar it waits once for ``SystemActivity/settleDelay`` and then +/// runs the settle handlers; nothing repeats. +@MainActor +@Observable +final class SystemActivityMonitor { + /// Whether holzBar rests: the screen is locked, the Mac sleeps or the session is away. + /// Item reads, moves and captures wait while it is `true`. + private(set) var isPaused = false + + /// The state fed by the notifications. + @ObservationIgnored private var activity = SystemActivity() + + /// The handlers that run once the bar has settled. + @ObservationIgnored private var settleHandlers = [@MainActor () -> Void]() + + /// Tasks that observe the notifications. + @ObservationIgnored private var observerTasks = [Task]() + + /// The one-shot wait for the bar to settle. + @ObservationIgnored private var settleTask: Task? + + @ObservationIgnored private let logger = Logger(category: "SystemActivityMonitor") + + deinit { + for task in observerTasks { + task.cancel() + } + settleTask?.cancel() + } + + /// Starts listening to the notifications. + func performSetup() { + let workspaceEvents: [(Notification.Name, SystemActivity.Event)] = [ + (NSWorkspace.willSleepNotification, .willSleep), + (NSWorkspace.didWakeNotification, .didWake), + (NSWorkspace.screensDidSleepNotification, .willSleep), + (NSWorkspace.screensDidWakeNotification, .didWake), + (NSWorkspace.sessionDidResignActiveNotification, .sessionResigned), + (NSWorkspace.sessionDidBecomeActiveNotification, .sessionBecameActive), + ] + for (name, event) in workspaceEvents { + observe(name, in: NSWorkspace.shared.notificationCenter, as: event) + } + // macOS posts no public notification for the screen lock; these distributed ones + // are what the system's own clients listen to. + let screenLockEvents: [(Notification.Name, SystemActivity.Event)] = [ + (Notification.Name("com.apple.screenIsLocked"), .screenLocked), + (Notification.Name("com.apple.screenIsUnlocked"), .screenUnlocked), + ] + for (name, event) in screenLockEvents { + observe(name, in: DistributedNotificationCenter.default(), as: event) + } + observe(NSApplication.didChangeScreenParametersNotification, in: NotificationCenter.default, as: .displaysChanged) + } + + /// Adds a handler that runs each time the bar has settled after the Mac was not in use + /// or the displays changed. + func onSettled(_ handler: @escaping @MainActor () -> Void) { + settleHandlers.append(handler) + } + + /// Feeds each notification with the given name to the state as the given event. + private func observe(_ name: Notification.Name, in center: NotificationCenter, as event: SystemActivity.Event) { + observerTasks.append(Task { [weak self] in + for await _ in center.notifications(named: name) { + self?.handle(event) + } + }) + } + + /// Records an event, publishes the pause and waits for the bar to settle. + private func handle(_ event: SystemActivity.Event) { + activity.handle(event, at: .now) + logger.info("System activity: \(String(describing: event), privacy: .public), paused: \(self.activity.isPaused, privacy: .public)") + if isPaused != activity.isPaused { + isPaused = activity.isPaused + } + switch event { + case .screenUnlocked, .didWake, .sessionBecameActive, .displaysChanged: + scheduleSettle() + case .screenLocked, .willSleep, .sessionResigned: + settleTask?.cancel() + settleTask = nil + } + } + + /// Runs the settle handlers once the bar has settled, unless holzBar rests again first. + private func scheduleSettle() { + settleTask?.cancel() + settleTask = Task { [weak self] in + do { + try await Task.sleep(for: SystemActivity.settleDelay) + } catch { + return + } + guard let self, activity.settled(at: .now) else { + return + } + logger.info("System activity: the menu bar has settled") + for handler in settleHandlers { + handler() + } + } + } +} diff --git a/holzBar/Hotkeys/Hotkey.swift b/holzBar/Hotkeys/Hotkey.swift index fb4f4893..f10566e4 100644 --- a/holzBar/Hotkeys/Hotkey.swift +++ b/holzBar/Hotkeys/Hotkey.swift @@ -26,17 +26,27 @@ final class Hotkey { /// Manages the lifetime of the hotkey observation. private var listener: Listener? - /// The hotkey's action. - let action: HotkeyAction + /// What the hotkey does: an action, applying a profile or opening an item. + let target: HotkeyTarget + + /// The hotkey's action, for a hotkey of one of holzBar's actions. + var action: HotkeyAction? { + switch target { + case .action(let action): + action + case .applyProfile, .openItem: + nil + } + } /// A Boolean value that indicates whether the hotkey is enabled. var isEnabled: Bool { listener != nil } - /// Creates a hotkey with the given action and key combination. - init(action: HotkeyAction, keyCombination: KeyCombination? = nil) { - self.action = action + /// Creates a hotkey with the given target and key combination. + init(target: HotkeyTarget, keyCombination: KeyCombination? = nil) { + self.target = target self.keyCombination = keyCombination } @@ -80,7 +90,7 @@ extension Hotkey { guard let hotkey, let appState else { return } - hotkey.action.perform(appState: appState) + hotkey.target.perform(appState: appState) } guard let id else { return nil @@ -120,7 +130,7 @@ extension Hotkey { extension Hotkey: @MainActor Equatable { static func == (lhs: Hotkey, rhs: Hotkey) -> Bool { lhs.keyCombination == rhs.keyCombination && - lhs.action == rhs.action + lhs.target == rhs.target } } @@ -128,6 +138,6 @@ extension Hotkey: @MainActor Equatable { extension Hotkey: @MainActor Hashable { func hash(into hasher: inout Hasher) { hasher.combine(keyCombination) - hasher.combine(action) + hasher.combine(target) } } diff --git a/holzBar/Hotkeys/HotkeyActionPerform.swift b/holzBar/Hotkeys/HotkeyActionPerform.swift index 7a8148fa..d75d89e9 100644 --- a/holzBar/Hotkeys/HotkeyActionPerform.swift +++ b/holzBar/Hotkeys/HotkeyActionPerform.swift @@ -3,9 +3,36 @@ // holzBar // -// What each hotkey action does. The actions and their stored names are in -// `holzBar/Core/HotkeyAction.swift`. +// What each hotkey does. The actions and their stored names are in +// `holzBar/Core/HotkeyAction.swift`, the targets in `holzBar/Core/HotkeyTarget.swift`. + +extension HotkeyTarget { + /// Performs what the hotkey is for. + @MainActor + func perform(appState: AppState) { + switch self { + case .action(let action): + action.perform(appState: appState) + case .applyProfile(let name): + // No question, unlike `holzbar://profile/`: the user gave the profile + // this hotkey. + appState.profiles.apply(named: name) + case .openItem(let key): + appState.itemManager.openItem(withIdentityKey: key) + } + } +} + extension HotkeyAction { + /// Lets the holzBar Shelf take keys when a hotkey opens it, so the arrows, Return and + /// Escape work in it. + @MainActor + private func prepareShelfForKeyboard(opening section: MenuBarSection, appState: AppState) { + if appState.settings.general.usesShelf, section.isHidden { + appState.menuBarManager.shelfPanel.acceptsKeyboard = true + } + } + /// Performs the action. @MainActor func perform(appState: AppState) { @@ -14,6 +41,7 @@ extension HotkeyAction { guard let section = appState.menuBarManager.section(withName: .hidden) else { return } + prepareShelfForKeyboard(opening: section, appState: appState) section.toggle() // Prevent the section from automatically rehiding after mouse movement. if !section.isHidden { @@ -23,6 +51,7 @@ extension HotkeyAction { guard let section = appState.menuBarManager.section(withName: .alwaysHidden) else { return } + prepareShelfForKeyboard(opening: section, appState: appState) section.toggle() // Prevent the section from automatically rehiding after mouse movement. if !section.isHidden { @@ -51,6 +80,10 @@ extension HotkeyAction { appState.menuBarManager.toggleApplicationMenus() case .toggleAutoRehide: appState.settings.general.autoRehide.toggle() + case .toggleZenMode: + appState.menuBarManager.toggleZenMode() + case .showItemHints: + appState.menuBarManager.itemHintsPanel.toggle() } } } diff --git a/holzBar/Hotkeys/HotkeyRegistry.swift b/holzBar/Hotkeys/HotkeyRegistry.swift index 86399ae2..caff6cf1 100644 --- a/holzBar/Hotkeys/HotkeyRegistry.swift +++ b/holzBar/Hotkeys/HotkeyRegistry.swift @@ -139,6 +139,15 @@ final class HotkeyRegistry { return nil } + // A value out of range must not trap (jordanbaird/Ice#985). + guard + let keyCode = UInt32(exactly: keyCombination.key.rawValue), + let modifierFlags = UInt32(exactly: keyCombination.modifiers.carbonFlags) + else { + Logger.hotkeys.error("Hotkey not registered: key code \(keyCombination.key.rawValue, privacy: .public) cannot be registered") + return nil + } + var status = installIfNeeded() guard status == noErr else { @@ -156,8 +165,8 @@ final class HotkeyRegistry { let hotKeyID = EventHotKeyID(signature: signature, id: id) var hotKeyRef: EventHotKeyRef? status = RegisterEventHotKey( - UInt32(keyCombination.key.rawValue), - UInt32(keyCombination.modifiers.carbonFlags), + keyCode, + modifierFlags, hotKeyID, GetEventDispatcherTarget(), 0, @@ -227,10 +236,19 @@ final class HotkeyRegistry { continue } + guard + let keyCode = UInt32(exactly: registration.key.rawValue), + let modifierFlags = UInt32(exactly: registration.modifiers.carbonFlags) + else { + registrations.removeValue(forKey: registration.hotKeyID.id) + Logger.hotkeys.error("Hotkey not registered: key code \(registration.key.rawValue, privacy: .public) cannot be registered") + continue + } + var hotKeyRef: EventHotKeyRef? let status = RegisterEventHotKey( - UInt32(registration.key.rawValue), - UInt32(registration.modifiers.carbonFlags), + keyCode, + modifierFlags, registration.hotKeyID, GetEventDispatcherTarget(), 0, diff --git a/holzBar/Hotkeys/KeyCode.swift b/holzBar/Hotkeys/KeyCode.swift index 5498a41b..1546eabf 100644 --- a/holzBar/Hotkeys/KeyCode.swift +++ b/holzBar/Hotkeys/KeyCode.swift @@ -155,6 +155,7 @@ extension KeyCode { /// System representation. var keyEquivalent: String { guard + let virtualKeyCode = UInt16(exactly: rawValue), let inputSource = TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue(), let layoutData = TISGetInputSourceProperty(inputSource, kTISPropertyUnicodeKeyLayoutData) else { @@ -172,7 +173,7 @@ extension KeyCode { let status = UCKeyTranslate( layoutPtr, - UInt16(rawValue), + virtualKeyCode, UInt16(kUCKeyActionDisplay), modifierKeyState, UInt32(LMGetKbdType()), @@ -269,6 +270,9 @@ private let customStringMappings = [ extension KeyCode { /// A custom string representation for the key. var stringValue: String { - customStringMappings[self, default: keyEquivalent] + if self == .space { + return String(localized: "Space") + } + return customStringMappings[self, default: keyEquivalent] } } diff --git a/holzBar/Hotkeys/KeyCombination.swift b/holzBar/Hotkeys/KeyCombination.swift index 153e5b94..fa93123e 100644 --- a/holzBar/Hotkeys/KeyCombination.swift +++ b/holzBar/Hotkeys/KeyCombination.swift @@ -71,8 +71,18 @@ extension KeyCombination: Codable { let description = "Expected 2 encoded values, found \(container.count ?? 0)" throw DecodingError.dataCorruptedError(in: container, debugDescription: description) } - self.key = try KeyCode(rawValue: container.decode(Int.self)) - self.modifiers = try Modifiers(rawValue: container.decode(Int.self)) + let key = try container.decode(Int.self) + let modifiers = try container.decode(Int.self) + // Anything can write holzBar's preferences; a key code out of range trapped in the + // conversions for the system at every launch (jordanbaird/Ice#985). + guard HotkeyStorage.isValid(key: key, modifiers: modifiers) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Key code \(key) or modifiers \(modifiers) cannot be a hotkey" + ) + } + self.key = KeyCode(rawValue: key) + self.modifiers = Modifiers(rawValue: modifiers) } func encode(to encoder: any Encoder) throws { diff --git a/holzBar/Main/AppDelegate.swift b/holzBar/Main/AppDelegate.swift index 1853674e..d5350d1b 100644 --- a/holzBar/Main/AppDelegate.swift +++ b/holzBar/Main/AppDelegate.swift @@ -16,7 +16,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // and before the app state, which reads the settings. MigrationManager.importPreviousSettingsIfNeeded() SettingsSync.pullIfNeeded() - self.appState = AppState() + let appState = AppState() + AppState.current = appState + self.appState = appState super.init() } @@ -35,6 +37,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // menu bar when we are the focused app. for item in NSApp.mainMenu?.items ?? [] { item.isHidden = true + // The Edit menu keeps its key equivalents while hidden, so Undo and Redo (the + // Layout pane and the profiles), Cut, Copy and Paste work in holzBar's windows. + let isEditMenu = item.submenu?.items.contains { $0.action == Selector(("undo:")) } ?? false + if isEditMenu { + item.allowsKeyEquivalentWhenHidden = true + for subitem in item.submenu?.items ?? [] { + subitem.allowsKeyEquivalentWhenHidden = true + } + } } // Allow hiding the mouse while the app is in the background @@ -148,7 +159,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Delay makes this more reliable for some reason. Task { [appState] in try? await Task.sleep(for: .milliseconds(100)) - appState.activate(withPolicy: .regular) + appState.activate(for: .settings) appState.openWindow(.settings) } } diff --git a/holzBar/Main/AppState.swift b/holzBar/Main/AppState.swift index 7e0dc6fc..76f70b76 100644 --- a/holzBar/Main/AppState.swift +++ b/holzBar/Main/AppState.swift @@ -11,6 +11,13 @@ import SwiftUI @MainActor @Observable final class AppState { + /// The app's state, for code macOS runs outside holzBar's views and delegate, such as + /// the Shortcuts actions (`HolzBarIntents.swift`). + /// + /// SwiftUI's application delegate adaptor puts its own object in `NSApp.delegate`, so + /// the app delegate cannot be reached from there. + static weak var current: AppState? + /// Information for the active space. private(set) var activeSpace = SpaceInfo.activeSpace() @@ -43,9 +50,18 @@ final class AppState { /// Global cache for menu bar item images. let imageCache = MenuBarItemImageCache() + /// Images of the user's choice for items, and app icons where there is no picture. + let itemIconStore = ItemIconStore() + /// Manager for input events received by the app. let hidEventManager = HIDEventManager() + /// The frame of the application menu on each display, read off the main thread. + let applicationMenuFrames = ApplicationMenuFrames() + + /// Whether the Mac is in use (screen lock, sleep, session) and when the bar has settled. + let systemActivityMonitor = SystemActivityMonitor() + /// Saved layout profiles. let profiles = LayoutProfiles() @@ -61,6 +77,12 @@ final class AppState { /// Rules that show hidden items when something happens. let revealRules = RevealRules() + /// Turns Zen mode on while the screen is mirrored or shared. + let presentationMonitor = PresentationMonitor() + + /// Shows the items marked "Show When It Changes" for a moment when they change. + let itemChangeWatcher = ItemChangeWatcher() + /// The action that opens holzBar's windows, handed over by its scenes. @ObservationIgnored private var openWindowAction: OpenWindowAction? @@ -118,6 +140,8 @@ final class AppState { permissions.stopAllChecks() settings.performSetup(with: self) + systemActivityMonitor.performSetup() + applicationMenuFrames.performSetup() menuBarManager.performSetup(with: self) // The item service on macOS 26, the synthetic bounds on macOS 27. @@ -132,15 +156,37 @@ final class AppState { } await itemManager.performSetup(with: self) imageCache.performSetup(with: self) + itemIconStore.performSetup(with: self) profiles.performSetup(with: self) settingsSync.performSetup(with: self) itemGroups.performSetup(with: self) spacers.performSetup() revealRules.performSetup(with: self) + presentationMonitor.performSetup(with: self) + itemChangeWatcher.performSetup(with: self) configureObservers() } + /// Brings holzBar up to date once the bar has settled after the screen was locked, the + /// Mac slept, the session was away or the displays changed: the input monitors are + /// checked, the items read again, the images refreshed where a view shows them, the + /// concealment of macOS 27 applied and the menu bar appearance restored. + private func systemActivityDidSettle() { + hidEventManager.healthCheck() + if #available(macOS 27.0, *) { + concealer27.update() + } + appearanceManager.systemActivityDidSettle() + Task { + await itemManager.cacheItemsRegardless() + // Items that macOS put elsewhere while the displays changed go back. + await itemManager.reconcileSections(trigger: .settle) + await itemManager.retryPausedRehide() + await imageCache.updateCache() + } + } + /// Performs app state setup. /// /// - Parameter hasPermissions: If `true`, continues with setup normally. @@ -156,7 +202,7 @@ final class AppState { Task { // Delay to prevent conflicts with the app delegate. try? await Task.sleep(for: .milliseconds(100)) - activate(withPolicy: .regular) + activate(for: .permissions) dismissWindow(.settings) // Shouldn't be open anyway. openWindow(.permissions) } @@ -165,6 +211,11 @@ final class AppState { /// Configures the internal observers for the app state. private func configureObservers() { + // Brings holzBar up to date once the bar has settled after the Mac was not in use. + systemActivityMonitor.onSettled { [weak self] in + self?.systemActivityDidSettle() + } + // Listen for changes to the active space. We need handle some special // cases that NSWorkspace.shared.notificationCenter seems to miss. // @@ -317,8 +368,27 @@ final class AppState { } } + /// Activates the app for the given reason, with or without a Dock icon as + /// `DockIconPolicy` decides. + /// + /// - Returns: `false` when the app does not come to the front for this reason (hiding + /// application menus while "Keep the Dock icon hidden" is on). + @discardableResult + func activate(for reason: DockIconPolicy.ActivationReason) -> Bool { + guard let choice = DockIconPolicy.policy(for: reason, keepsDockIconHidden: settings.advanced.keepsDockIconHidden) else { + return false + } + switch choice { + case .regular: + activate(withPolicy: .regular) + case .accessory: + activate(withPolicy: .accessory) + } + return true + } + /// Activates the app and sets its activation policy. - func activate(withPolicy policy: NSApplication.ActivationPolicy? = nil) { + private func activate(withPolicy policy: NSApplication.ActivationPolicy? = nil) { if let policy { NSApp.setActivationPolicy(policy) } diff --git a/holzBar/Main/ConflictingApps.swift b/holzBar/Main/ConflictingApps.swift index 33f99b15..d019c5fb 100644 --- a/holzBar/Main/ConflictingApps.swift +++ b/holzBar/Main/ConflictingApps.swift @@ -53,14 +53,10 @@ enum ConflictingApps { let alert = NSAlert() alert.alertStyle = .warning - alert.messageText = "Quit \(names) first?" - alert.informativeText = """ - holzBar and \(names) would both try to manage the menu bar. They get in \ - each other's way, and can leave the pointer unable to click anything. \ - holzBar can quit \(names) for you. - """ - alert.addButton(withTitle: "Quit \(names) and Continue") - alert.addButton(withTitle: "Quit holzBar") + alert.messageText = String(localized: "Quit \(names) first?") + alert.informativeText = String(localized: "holzBar and \(names) would both try to manage the menu bar. They get in each other's way, and can leave the pointer unable to click anything. holzBar can quit \(names) for you.") + alert.addButton(withTitle: String(localized: "Quit \(names) and Continue")) + alert.addButton(withTitle: String(localized: "Quit holzBar")) NSApp.activate() guard alert.runModal() == .alertFirstButtonReturn else { return false diff --git a/holzBar/Main/HolzBarIntents.swift b/holzBar/Main/HolzBarIntents.swift new file mode 100644 index 00000000..ba44cc6c --- /dev/null +++ b/holzBar/Main/HolzBarIntents.swift @@ -0,0 +1,240 @@ +// +// HolzBarIntents.swift +// holzBar +// + +import AppIntents + +// Shortcuts actions (App Intents). They run the same code as holzBar's hotkeys and menu, +// inside holzBar, without a script, a command line tool or a new permission. An action +// only does what the user put into a shortcut, so nothing here asks first (unlike +// `holzbar://profile/`, which any app can open). + +// MARK: - Errors + +/// Why a Shortcuts action could not run. +nonisolated enum HolzBarIntentError: Error, CustomLocalizedStringResourceConvertible { + /// holzBar has not finished starting, or waits for its permissions. + case notReady + /// The section is turned off in holzBar's settings. + case sectionUnavailable + /// No menu bar item's name matches what the shortcut asked for. + case noMatchingItem(String) + + var localizedStringResource: LocalizedStringResource { + switch self { + case .notReady: + "holzBar is not ready yet. Open holzBar and grant its permissions first." + case .sectionUnavailable: + "This section is turned off in holzBar's settings." + case .noMatchingItem(let name): + "No menu bar item matches \u{201C}\(name)\u{201D}." + } + } +} + +/// The app's state for an action, or an error when holzBar is not ready. +@MainActor +private func intentAppState() throws -> AppState { + guard let appState = AppState.current else { + throw HolzBarIntentError.notReady + } + return appState +} + +// MARK: - Zen Mode + +/// Turns Zen mode on or off. +nonisolated struct ToggleZenModeIntent: AppIntent { + static let title: LocalizedStringResource = "Toggle Zen Mode" + + @MainActor + func perform() async throws -> some IntentResult { + try intentAppState().menuBarManager.toggleZenMode() + return .result() + } +} + +// MARK: - Sections + +/// A menu bar section a shortcut can show, hide or toggle. +nonisolated enum MenuBarSectionAppEnum: String, AppEnum { + case hidden + case alwaysHidden + + static let typeDisplayRepresentation: TypeDisplayRepresentation = "Menu Bar Section" + + static let caseDisplayRepresentations: [MenuBarSectionAppEnum: DisplayRepresentation] = [ + .hidden: "Hidden", + .alwaysHidden: "Always Hidden", + ] +} + +/// What a shortcut does with a section. +nonisolated enum SectionActionAppEnum: String, AppEnum { + case show + case hide + case toggle + + static let typeDisplayRepresentation: TypeDisplayRepresentation = "Section Action" + + static let caseDisplayRepresentations: [SectionActionAppEnum: DisplayRepresentation] = [ + .show: "Show", + .hide: "Hide", + .toggle: "Toggle", + ] +} + +/// Shows, hides or toggles a menu bar section. +struct ChangeSectionIntent: AppIntent { + static let title: LocalizedStringResource = "Change Section" + + @Parameter(title: "Action", default: .toggle) + var sectionAction: SectionActionAppEnum + + @Parameter(title: "Section", default: .hidden) + var section: MenuBarSectionAppEnum + + @MainActor + func perform() async throws -> some IntentResult { + let manager = try intentAppState().menuBarManager + let name: MenuBarSection.Name = switch section { + case .hidden: .hidden + case .alwaysHidden: .alwaysHidden + } + guard let target = manager.section(withName: name), target.isEnabled else { + throw HolzBarIntentError.sectionUnavailable + } + switch sectionAction { + case .show: + target.show() + manager.showOnHoverAllowed = false + case .hide: + target.hide() + case .toggle: + target.toggle() + } + return .result() + } +} + +// MARK: - Layout Profiles + +/// A saved layout profile, identified by its name. +nonisolated struct LayoutProfileEntity: AppEntity { + static let typeDisplayRepresentation: TypeDisplayRepresentation = "Layout Profile" + + static let defaultQuery = LayoutProfileQuery() + + /// The profile's name. + let id: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(id)") + } +} + +/// Lists the saved layout profiles for Shortcuts. +nonisolated struct LayoutProfileQuery: EntityQuery { + @MainActor + func entities(for identifiers: [String]) async throws -> [LayoutProfileEntity] { + let names = Set(try intentAppState().profiles.profiles.map(\.name)) + return identifiers.filter { names.contains($0) }.map { LayoutProfileEntity(id: $0) } + } + + @MainActor + func suggestedEntities() async throws -> [LayoutProfileEntity] { + try intentAppState().profiles.profiles.map { LayoutProfileEntity(id: $0.name) } + } +} + +/// Applies a saved layout profile. +struct ApplyLayoutProfileIntent: AppIntent { + static let title: LocalizedStringResource = "Apply Layout Profile" + + @Parameter(title: "Profile") + var profile: LayoutProfileEntity + + @MainActor + func perform() async throws -> some IntentResult { + try intentAppState().profiles.apply(named: profile.id) + return .result() + } +} + +// MARK: - Menu Bar Items + +/// Opens the menu of the menu bar item whose name matches best. +struct OpenMenuBarItemIntent: AppIntent { + static let title: LocalizedStringResource = "Open Menu Bar Item" + + @Parameter(title: "Name") + var name: String + + @MainActor + func perform() async throws -> some IntentResult { + let itemManager = try intentAppState().itemManager + let items = itemManager.itemCache.managedItems.filter { !$0.isControlItem } + guard + name.contains(where: { !$0.isWhitespace }), + let item = FuzzyMatch.rank(items, query: name, key: { $0.displayName }).first + else { + throw HolzBarIntentError.noMatchingItem(name) + } + // The shortcut goes on while the menu is open; the item is hidden again later. + Task { + await itemManager.openItem(item, mouseButton: .left, shelfDisplayID: nil) + } + return .result() + } +} + +/// Opens the menu bar item search. +nonisolated struct SearchMenuBarItemsIntent: AppIntent { + static let title: LocalizedStringResource = "Search Menu Bar Items" + + @MainActor + func perform() async throws -> some IntentResult { + try intentAppState().menuBarManager.searchPanel.show() + return .result() + } +} + +// MARK: - App Shortcuts + +/// The actions Shortcuts and Spotlight offer without any setup. Main-actor isolated, like +/// the actions with parameters (whose parameter wrappers keep them off `nonisolated`). +struct HolzBarShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: ToggleZenModeIntent(), + phrases: ["Toggle Zen mode in \(.applicationName)"], + shortTitle: "Zen Mode", + systemImageName: "moon" + ) + AppShortcut( + intent: ChangeSectionIntent(), + phrases: ["Toggle hidden items in \(.applicationName)"], + shortTitle: "Change Section", + systemImageName: "eye" + ) + AppShortcut( + intent: ApplyLayoutProfileIntent(), + phrases: ["Apply a layout profile in \(.applicationName)"], + shortTitle: "Apply Layout Profile", + systemImageName: "rectangle.stack" + ) + AppShortcut( + intent: OpenMenuBarItemIntent(), + phrases: ["Open a menu bar item with \(.applicationName)"], + shortTitle: "Open Menu Bar Item", + systemImageName: "menubar.rectangle" + ) + AppShortcut( + intent: SearchMenuBarItemsIntent(), + phrases: ["Search menu bar items with \(.applicationName)"], + shortTitle: "Search Menu Bar Items", + systemImageName: "magnifyingglass" + ) + } +} diff --git a/holzBar/Main/URLCommands.swift b/holzBar/Main/URLCommands.swift index 4b3de41b..11ed3e71 100644 --- a/holzBar/Main/URLCommands.swift +++ b/holzBar/Main/URLCommands.swift @@ -10,13 +10,23 @@ import OSLog /// example from Raycast, Alfred, Shortcuts or a script (jordanbaird/Ice#501). /// /// - `holzbar://toggle/hidden`, `holzbar://show/hidden`, `holzbar://hide/hidden` -/// (also `always-hidden`) -/// - `holzbar://search` – the menu bar item search -/// - `holzbar://settings` – the settings window -/// - `holzbar://shelf/toggle` – turn the holzBar Shelf on or off -/// - `holzbar://auto-rehide/toggle` – turn auto-rehide on or off -/// - `holzbar://application-menus/toggle` -/// - `holzbar://profile/` – apply a saved layout profile +/// (also `always-hidden`) – changes nothing persistent +/// - `holzbar://search` – the menu bar item search; changes nothing persistent +/// - `holzbar://settings` – the settings window; changes nothing persistent +/// - `holzbar://shelf/toggle` – turn the holzBar Shelf on or off; a setting the same +/// command flips back +/// - `holzbar://auto-rehide/toggle` – turn auto-rehide on or off; a setting the same +/// command flips back +/// - `holzbar://application-menus/toggle` – hides or shows the application menus; +/// nothing persistent +/// - `holzbar://zen/toggle` – turns Zen mode on or off; it only hides, and while it is +/// on, `show` and `toggle` of a hidden section are ignored +/// - `holzbar://profile/` – apply a saved layout profile; rearranges items, so +/// holzBar asks first (`URLCommand.Action.needsConfirmation`) +/// +/// Any app can open these URLs without the user knowing, so the only command that +/// rearranges the menu bar needs the user's answer. Shortcuts actions (App Intents) run +/// without the question, because the user built them. /// /// `ice-bar` still works as another name for `shelf`. /// @@ -46,18 +56,33 @@ enum URLCommands { } } + // Zen mode keeps hidden items hidden; another app cannot reveal them. + func isRefusedByZenMode(_ target: MenuBarSection) -> Bool { + guard target.isHidden, !manager.zenMode.allows(.urlCommand) else { + return false + } + logger.notice("Ignored: Zen mode") + return true + } + switch command.action { case .toggle(let name): - section(name)?.toggle() + guard let target = section(name), !isRefusedByZenMode(target) else { + return + } + target.toggle() case .show(let name): - section(name)?.show() + guard let target = section(name), !isRefusedByZenMode(target) else { + return + } + target.show() manager.showOnHoverAllowed = false case .hide(let name): section(name)?.hide() case .search: manager.searchPanel.toggle() case .settings: - appState.activate(withPolicy: .regular) + appState.activate(for: .settings) appState.openWindow(.settings) case .toggleShelf: appState.settings.general.useShelf.toggle() @@ -65,10 +90,45 @@ enum URLCommands { appState.settings.general.autoRehide.toggle() case .toggleApplicationMenus: manager.toggleApplicationMenus() + case .toggleZenMode: + manager.toggleZenMode() case .applyProfile(let name): + guard !command.action.needsConfirmation || confirmProfile(named: name, appState: appState) else { + logger.notice("Applying a layout profile from a URL was cancelled") + return + } appState.profiles.apply(named: name) case .unknown: logger.warning("Unknown command \(command.name, privacy: .private)") } } + + /// Asks whether another app may apply the layout profile with the given name. + /// + /// holzBar comes to the front without a Dock icon for the question; Cancel is the + /// default answer and the answer to Escape. + /// + /// - Returns: Whether the user chose Apply. + private static func confirmProfile(named name: String, appState: AppState) -> Bool { + appState.activate(for: .settings) + let alert = NSAlert() + alert.messageText = String(localized: "Apply the layout profile \u{201C}\(name)\u{201D}?") + alert.informativeText = String(localized: "Another app asked holzBar to rearrange your menu bar.") + // The first button is the default one (Return). + alert.addButton(withTitle: String(localized: "Cancel")) + alert.addButton(withTitle: String(localized: "Apply")) + // Escape answers Cancel as well: it ends the alert without Apply. + let escapeMonitor = EventMonitor.local(for: .keyDown) { event in + guard event.keyCode == 53 else { + return event + } + NSApp.abortModal() + return nil + } + escapeMonitor.start() + defer { + escapeMonitor.stop() + } + return alert.runModal() == .alertSecondButtonReturn + } } diff --git a/holzBar/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV2.swift b/holzBar/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV2.swift index 26f82065..ab30fdf4 100644 --- a/holzBar/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV2.swift +++ b/holzBar/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV2.swift @@ -26,6 +26,12 @@ struct MenuBarAppearanceConfigurationV2: Hashable { } } + /// Whether a tint follows the wallpaper in any of the configurations, so its palette + /// is read. + var usesWallpaperTint: Bool { + [lightModeConfiguration, darkModeConfiguration, staticConfiguration].contains { $0.tintKind == .adaptive } + } + var current: MenuBarAppearancePartialConfiguration { if isDynamic { switch SystemAppearance.current { @@ -110,6 +116,10 @@ struct MenuBarAppearancePartialConfiguration: Hashable { var tintKind: MenuBarTintKind var tintColor: CGColor var tintGradient: HolzBarGradient + /// How the border is drawn: solid, dashed or dotted (THAW-17). + var borderStyle: BorderStyle = .solid + /// Whether the solid tint uses the system accent color instead of ``tintColor``. + var tintFollowsAccentColor = false } // MARK: Default Partial Configuration @@ -138,6 +148,8 @@ extension MenuBarAppearancePartialConfiguration: Codable { case tintKind case tintColor case tintGradient + case borderStyle + case tintFollowsAccentColor } init(from decoder: Decoder) throws { @@ -149,7 +161,10 @@ extension MenuBarAppearancePartialConfiguration: Codable { borderWidth: container.decodeIfPresent(Double.self, forKey: .borderWidth) ?? Self.defaultConfiguration.borderWidth, tintKind: container.decodeIfPresent(MenuBarTintKind.self, forKey: .tintKind) ?? Self.defaultConfiguration.tintKind, tintColor: container.decodeIfPresent(HolzBarColor.self, forKey: .tintColor)?.cgColor ?? Self.defaultConfiguration.tintColor, - tintGradient: container.decodeIfPresent(HolzBarGradient.self, forKey: .tintGradient) ?? Self.defaultConfiguration.tintGradient + tintGradient: container.decodeIfPresent(HolzBarGradient.self, forKey: .tintGradient) ?? Self.defaultConfiguration.tintGradient, + // An unknown style reads as solid, as a missing one does. + borderStyle: (try? container.decodeIfPresent(BorderStyle.self, forKey: .borderStyle)) ?? Self.defaultConfiguration.borderStyle, + tintFollowsAccentColor: container.decodeIfPresent(Bool.self, forKey: .tintFollowsAccentColor) ?? Self.defaultConfiguration.tintFollowsAccentColor ) } @@ -162,6 +177,8 @@ extension MenuBarAppearancePartialConfiguration: Codable { try container.encode(tintKind, forKey: .tintKind) try container.encode(HolzBarColor(cgColor: tintColor), forKey: .tintColor) try container.encode(tintGradient, forKey: .tintGradient) + try container.encode(borderStyle, forKey: .borderStyle) + try container.encode(tintFollowsAccentColor, forKey: .tintFollowsAccentColor) } } diff --git a/holzBar/MenuBar/Appearance/Configurations/MenuBarTintKind.swift b/holzBar/MenuBar/Appearance/Configurations/MenuBarTintKind.swift index 9755f5b8..863ec1e1 100644 --- a/holzBar/MenuBar/Appearance/Configurations/MenuBarTintKind.swift +++ b/holzBar/MenuBar/Appearance/Configurations/MenuBarTintKind.swift @@ -6,6 +6,8 @@ import SwiftUI /// A type that specifies how the menu bar is tinted. +/// +/// The raw values are stored in the appearance configuration; new kinds go at the end. enum MenuBarTintKind: Int, CaseIterable, Codable, Identifiable { /// The menu bar is not tinted. case noTint = 0 @@ -13,15 +15,39 @@ enum MenuBarTintKind: Int, CaseIterable, Codable, Identifiable { case solid = 1 /// The menu bar is tinted with a gradient. case gradient = 2 + /// The menu bar is tinted with the dominant colors of the wallpaper (THAW-17). + case adaptive = 3 + /// The menu bar shows the system's glass (macOS 26 and later; THAW-17). + case systemGlass = 4 var id: Int { rawValue } + /// Decodes a tint kind; one this version does not know (from a newer version or a + /// damaged file) reads as no tint instead of failing the whole appearance. + init(from decoder: any Decoder) throws { + let rawValue = try decoder.singleValueContainer().decode(Int.self) + self = MenuBarTintKind(rawValue: rawValue) ?? .noTint + } + + /// Whether the running macOS can draw the tint. + var isAvailable: Bool { + guard self == .systemGlass else { + return true + } + if #available(macOS 26.0, *) { + return true + } + return false + } + /// Localized string key representation. var localized: LocalizedStringKey { switch self { case .noTint: "None" case .solid: "Solid" case .gradient: "Gradient" + case .adaptive: "Follow Wallpaper" + case .systemGlass: "System Glass" } } } diff --git a/holzBar/MenuBar/Appearance/DesktopPicture.swift b/holzBar/MenuBar/Appearance/DesktopPicture.swift new file mode 100644 index 00000000..6964c868 --- /dev/null +++ b/holzBar/MenuBar/Appearance/DesktopPicture.swift @@ -0,0 +1,270 @@ +// +// DesktopPicture.swift +// holzBar +// + +import Cocoa +import ImageIO +import OSLog + +/// The desktop picture under a display's menu bar, read from its file. +/// +/// A menu bar shape draws the wallpaper beside the shape. holzBar used to capture the +/// wallpaper window for it, which needs Screen Recording and, on macOS 27, lights the +/// screen-recording indicator on every update. The picture is read from the file macOS +/// shows instead (`NSWorkspace.desktopImageURL(for:)`), laid out as the desktop lays it +/// out, and the strip under the menu bar is cut from it. A moving (aerial) wallpaper has no +/// such file; then there is no strip. +@MainActor +enum DesktopPicture { + /// How the desktop lays a picture out on a screen. + private nonisolated struct Layout: Sendable, Equatable { + /// The picture's file. + let url: URL + /// When the file was last changed, so a rewritten file is read again. + let modificationDate: Date? + /// The screen's size, in points. + let screenSize: CGSize + /// The screen's scale factor. + let scale: CGFloat + /// The height of the strip under the menu bar, in points. + let stripHeight: CGFloat + /// `NSImageScaling` raw value. + let scaling: UInt + /// Whether a proportionally scaled picture fills the screen and is clipped. + let allowsClipping: Bool + /// The colour around a picture that does not fill the screen: sRGB red, green, blue + /// and alpha. + let fillComponents: [CGFloat]? + } + + /// A strip handed from the decoding queue to the main actor. Nothing else holds the + /// image, so it crosses the boundary once. + private nonisolated struct DecodedStrip: @unchecked Sendable { + let image: CGImage? + } + + /// The decoding queue: a large picture takes a moment to decode, off the main thread. + private nonisolated static let queue = DispatchQueue(label: "com.holzcloud.holzBar.DesktopPicture", qos: .utility) + + /// The last strip of each display, with the layout it was made for. + private static var cache = [CGDirectDisplayID: (layout: Layout, strip: CGImage)]() + + private static let logger = Logger(category: "DesktopPicture") + + /// Returns the strip of the desktop picture under the menu bar of the given screen, or + /// `nil` when the picture cannot be read from a file (a moving wallpaper). + /// + /// - Parameters: + /// - screen: The screen. + /// - height: The height of the menu bar, in points. + static func strip(for screen: NSScreen, height: CGFloat) async -> CGImage? { + guard let layout = layout(for: screen, height: height) else { + return nil + } + let displayID = screen.displayID + if let cached = cache[displayID], cached.layout == layout { + return cached.strip + } + let strip = await withCheckedContinuation { (continuation: CheckedContinuation) in + queue.async { + continuation.resume(returning: DecodedStrip(image: makeStrip(layout: layout))) + } + }.image + if let strip { + cache[displayID] = (layout, strip) + } else { + cache[displayID] = nil + logger.debug("The desktop picture could not be read from its file") + } + return strip + } + + /// The dominant colors of the desktop picture under the menu bar of the given screen, + /// for the "Follow Wallpaper" tint, or `nil` when the picture cannot be read from a + /// file. Read from the same strip, with no capture (THAW-17). + static func palette(for screen: NSScreen, height: CGFloat) async -> WallpaperPalette? { + guard let strip = await strip(for: screen, height: height) else { + return nil + } + let decoded = DecodedStrip(image: strip) + return await withCheckedContinuation { (continuation: CheckedContinuation) in + queue.async { + continuation.resume(returning: decoded.image.map(samplePalette)) + } + } + } + + /// Samples the strip on a small grid and derives its palette. Runs on ``queue``. + private nonisolated static func samplePalette(_ image: CGImage) -> WallpaperPalette { + let width = 64 + let height = 4 + var pixels = [UInt8](repeating: 0, count: width * height * 4) + let samples = pixels.withUnsafeMutableBytes { buffer -> [WallpaperPalette.Sample] in + guard let context = CGContext( + data: buffer.baseAddress, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return [] + } + context.interpolationQuality = .medium + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + var result: [WallpaperPalette.Sample] = [] + result.reserveCapacity(width * height) + for index in stride(from: 0, to: buffer.count, by: 4) { + let red = Double(buffer[index]) / 255 + let green = Double(buffer[index + 1]) / 255 + let blue = Double(buffer[index + 2]) / 255 + result.append(WallpaperPalette.Sample(red: red, green: green, blue: blue)) + } + return result + } + return WallpaperPalette.derive(from: samples) + } + + /// The strip for the given screen from the last read, when the picture has not changed + /// since; it never decodes, so it can be used where no wait is allowed. + static func cachedStrip(for screen: NSScreen, height: CGFloat) -> CGImage? { + guard + let layout = layout(for: screen, height: height), + let cached = cache[screen.displayID], + cached.layout == layout + else { + return nil + } + return cached.strip + } + + /// Whether the desktop picture of the given screen can be read from its file. + /// + /// Reads only the file's header. + static func isReadable(on screen: NSScreen) -> Bool { + guard + let url = NSWorkspace.shared.desktopImageURL(for: screen), + let source = CGImageSourceCreateWithURL(url as CFURL, nil) + else { + return false + } + return CGImageSourceGetCount(source) > 0 + } + + /// Forgets the strips, so the next request reads the pictures again. + static func invalidate() { + cache.removeAll() + } + + // MARK: Private + + /// How the desktop shows its picture on the given screen. + private static func layout(for screen: NSScreen, height: CGFloat) -> Layout? { + let workspace = NSWorkspace.shared + guard let url = workspace.desktopImageURL(for: screen), url.isFileURL else { + return nil + } + let options = workspace.desktopImageOptions(for: screen) ?? [:] + let scaling = (options[.imageScaling] as? NSNumber)?.uintValue ?? NSImageScaling.scaleProportionallyUpOrDown.rawValue + let allowsClipping = (options[.allowClipping] as? NSNumber)?.boolValue ?? true + let fillComponents = (options[.fillColor] as? NSColor)?.usingColorSpace(.sRGB).map { color in + [color.redComponent, color.greenComponent, color.blueComponent, color.alphaComponent] + } + let modificationDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + return Layout( + url: url, + modificationDate: modificationDate, + screenSize: screen.frame.size, + scale: screen.backingScaleFactor, + stripHeight: height, + scaling: scaling, + allowsClipping: allowsClipping, + fillComponents: fillComponents + ) + } + + /// Decodes the picture and cuts the strip under the menu bar. Runs on ``queue``. + private nonisolated static func makeStrip(layout: Layout) -> CGImage? { + let pixelWidth = Int((layout.screenSize.width * layout.scale).rounded()) + let pixelHeight = Int((layout.stripHeight * layout.scale).rounded()) + guard + pixelWidth > 0, + pixelHeight > 0, + let source = CGImageSourceCreateWithURL(layout.url as CFURL, nil), + CGImageSourceGetCount(source) > 0 + else { + return nil + } + // A thumbnail no larger than the screen: a huge picture is never decoded in full. + // A dynamic HEIC wallpaper gives its first image. + let maxPixelSize = max(layout.screenSize.width, layout.screenSize.height) * layout.scale + let thumbnailOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: Int(maxPixelSize.rounded()), + ] + guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions as CFDictionary) else { + return nil + } + let imageRect = pictureRect(imageSize: CGSize(width: image.width, height: image.height), layout: layout) + + guard let context = CGContext( + data: nil, + width: pixelWidth, + height: pixelHeight, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue + ) else { + return nil + } + context.interpolationQuality = .medium + if let fill = layout.fillComponents, fill.count == 4 { + context.setFillColor(CGColor(srgbRed: fill[0], green: fill[1], blue: fill[2], alpha: fill[3])) + context.fill(CGRect(x: 0, y: 0, width: pixelWidth, height: pixelHeight)) + } + // The context covers the top of the screen; its origin is at the bottom left. + let scale = layout.scale + let drawRect = CGRect( + x: imageRect.minX * scale, + y: CGFloat(pixelHeight) - imageRect.maxY * scale, + width: imageRect.width * scale, + height: imageRect.height * scale + ) + context.draw(image, in: drawRect) + return context.makeImage() + } + + /// Where the desktop puts a picture of the given size, in points from the screen's + /// top-left corner. + private nonisolated static func pictureRect(imageSize: CGSize, layout: Layout) -> CGRect { + let screen = layout.screenSize + // The decoded pixels stand for the picture at its natural size in points. + let naturalSize = CGSize(width: imageSize.width / layout.scale, height: imageSize.height / layout.scale) + guard naturalSize.width > 0, naturalSize.height > 0 else { + return CGRect(origin: .zero, size: screen) + } + func centred(_ size: CGSize) -> CGRect { + CGRect(x: (screen.width - size.width) / 2, y: (screen.height - size.height) / 2, width: size.width, height: size.height) + } + let widthRatio = screen.width / naturalSize.width + let heightRatio = screen.height / naturalSize.height + switch NSImageScaling(rawValue: layout.scaling) { + case .scaleAxesIndependently: + return CGRect(origin: .zero, size: screen) + case .scaleNone: + return centred(naturalSize) + case .scaleProportionallyDown: + let ratio = min(1, min(widthRatio, heightRatio)) + return centred(CGSize(width: naturalSize.width * ratio, height: naturalSize.height * ratio)) + default: + // Fill the screen (clipping) or fit inside it. + let ratio = layout.allowsClipping ? max(widthRatio, heightRatio) : min(widthRatio, heightRatio) + return centred(CGSize(width: naturalSize.width * ratio, height: naturalSize.height * ratio)) + } + } +} diff --git a/holzBar/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift b/holzBar/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift index 67ca2268..e615c68b 100644 --- a/holzBar/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift +++ b/holzBar/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift @@ -155,13 +155,25 @@ struct MenuBarAppearanceEditor: View { .fixedSize(horizontal: false, vertical: true) } - /// Asks for Screen Recording when a shape is chosen without it: the shape draws the - /// wallpaper beside it. + /// Whether holzBar runs on macOS 27. + private var isMacOS27: Bool { + if #available(macOS 27.0, *) { + true + } else { + false + } + } + + /// Asks for Screen Recording when a shape is chosen without it and the wallpaper + /// beside it cannot be read from its file (a moving wallpaper). Before macOS 27 only: + /// on macOS 27 holzBar never captures the wallpaper. @ViewBuilder private var shapeScreenRecordingHint: some View { if + !isMacOS27, appearanceManager.configuration.shapeKind != .noShape, - !ScreenRecordingAccess.isGranted(appState) + !ScreenRecordingAccess.isGranted(appState), + !(NSScreen.main.map(DesktopPicture.isReadable(on:)) ?? false) { ScreenRecordingHint(feature: .menuBarShape, appState: appState) .frame(maxWidth: .infinity) @@ -185,12 +197,14 @@ private struct UnlabeledPartialEditor: View { var body: some View { HolzBarSection { tintPicker + accentColorToggle shadowToggle } HolzBarSection { borderToggle borderColor borderWidth + borderStyle } } @@ -199,14 +213,15 @@ private struct UnlabeledPartialEditor: View { LabeledContent("Tint") { HStack { HolzBarPicker("Tint", selection: $configuration.tintKind) { - ForEach(MenuBarTintKind.allCases) { tintKind in + // System Glass needs macOS 26; an older system shows no tint for it. + ForEach(MenuBarTintKind.allCases.filter(\.isAvailable)) { tintKind in Text(tintKind.localized).tag(tintKind) } } .labelsHidden() switch configuration.tintKind { - case .noTint: + case .noTint, .adaptive, .systemGlass: EmptyView() case .solid: ColorPicker( @@ -215,6 +230,7 @@ private struct UnlabeledPartialEditor: View { supportsOpacity: false ) .labelsHidden() + .disabled(configuration.tintFollowsAccentColor) case .gradient: HolzBarGradientPicker( configuration.tintKind.localized, @@ -228,6 +244,18 @@ private struct UnlabeledPartialEditor: View { } } + @ViewBuilder + private var accentColorToggle: some View { + if configuration.tintKind == .solid { + Toggle("Use Accent Color", isOn: $configuration.tintFollowsAccentColor) + .annotation("Follows the accent color chosen in System Settings, Appearance.") + } else if configuration.tintKind == .adaptive { + Text("Tints the menu bar with the main colors of your wallpaper, read from its file.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + @ViewBuilder private var shadowToggle: some View { Toggle("Shadow", isOn: $configuration.hasShadow) @@ -256,9 +284,20 @@ private struct UnlabeledPartialEditor: View { "Border Width", selection: $configuration.borderWidth ) { - Text("1").tag(1.0) - Text("2").tag(2.0) - Text("3").tag(3.0) + Text(verbatim: "1").tag(1.0) + Text(verbatim: "2").tag(2.0) + Text(verbatim: "3").tag(3.0) + } + } + } + + @ViewBuilder + private var borderStyle: some View { + if configuration.hasBorder { + HolzBarPicker("Border Style", selection: $configuration.borderStyle) { + Text("Solid").tag(BorderStyle.solid) + Text("Dashed").tag(BorderStyle.dashed) + Text("Dotted").tag(BorderStyle.dotted) } } } diff --git a/holzBar/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift b/holzBar/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift index 36721d8e..b8708067 100644 --- a/holzBar/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift +++ b/holzBar/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift @@ -30,7 +30,7 @@ final class MenuBarAppearanceEditorPanel: NSPanel { backing: .buffered, defer: false ) - self.title = "Menu Bar Appearance" + self.title = String(localized: "Menu Bar Appearance") self.titlebarAppearsTransparent = true self.allowsToolTipsWhenApplicationIsInactive = true self.isFloatingPanel = true diff --git a/holzBar/MenuBar/Appearance/MenuBarAppearanceManager.swift b/holzBar/MenuBar/Appearance/MenuBarAppearanceManager.swift index a61db624..eef996f1 100644 --- a/holzBar/MenuBar/Appearance/MenuBarAppearanceManager.swift +++ b/holzBar/MenuBar/Appearance/MenuBarAppearanceManager.swift @@ -59,6 +59,13 @@ final class MenuBarAppearanceManager { /// The rounded screen corners. @ObservationIgnored private let screenCorners = ScreenCorners() + /// The retries after launch for panels that found no menu bar yet. + @ObservationIgnored private var launchRetryTask: Task? + + /// How many times, and how far apart, panels that found no menu bar at launch are + /// tried again (at login the menu bar can appear after holzBar). + private static let launchRetries = (count: 3, interval: Duration.seconds(3)) + /// The amount to inset the menu bar if called for by the configuration. let menuBarInsetAmount: CGFloat = if #available(macOS 26.0, *) { 3.5 } else { 5 } @@ -71,6 +78,33 @@ final class MenuBarAppearanceManager { configureOverlayPanels(with: configuration) } screenCorners.performSetup(with: self) + scheduleLaunchRetries() + } + + /// Tries the panels that found no menu bar again, at most three times 3 s apart, then + /// stops; later the next settle or screen change tries them again. + private func scheduleLaunchRetries() { + launchRetryTask = Task { [weak self] in + for _ in 0.. Bool { diff --git a/holzBar/MenuBar/Appearance/MenuBarOverlayPanel.swift b/holzBar/MenuBar/Appearance/MenuBarOverlayPanel.swift index 13ace99e..20cd8aa9 100644 --- a/holzBar/MenuBar/Appearance/MenuBarOverlayPanel.swift +++ b/holzBar/MenuBar/Appearance/MenuBarOverlayPanel.swift @@ -93,6 +93,10 @@ final class MenuBarOverlayPanel: NSPanel { } } + /// Whether the panel found no menu bar to draw on (at login, before the bar exists), so + /// it needs another try (see `MenuBarAppearanceManager`). + private(set) var needsRetry = false + /// The frame of the application menu. private(set) var applicationMenuFrame: CGRect? { didSet { @@ -107,16 +111,27 @@ final class MenuBarOverlayPanel: NSPanel { } } + /// The dominant colors of the wallpaper under the menu bar, for the "Follow Wallpaper" + /// tint; read only while a configuration uses it. + private(set) var wallpaperPalette: WallpaperPalette? { + didSet { + contentView?.needsDisplay = true + } + } + + /// The read of the wallpaper's palette that is under way. + private var paletteTask: Task? + + /// Observes whether a tint follows the wallpaper. + private var wallpaperTintObserver: ObservationLoop? + /// Shows the panel once ``needsShow`` stops changing. private let needsShowDebouncer = Debouncer(delay: .milliseconds(50)) - /// Shows the panel 0.1 s after the active space stops changing. - private let spaceDebouncer = Debouncer(delay: .milliseconds(100)) - /// Updates the wallpaper 0.1 s after the appearance stops changing. private let themeDebouncer = Debouncer(delay: .milliseconds(100)) - /// Updates the application menu frame 0.05 s after the space or a click settles. + /// Reads the application menu frame again 0.05 s after a click settles. private let applicationMenuDebouncer = Debouncer(delay: .milliseconds(50)) /// Tasks that observe notifications and the wallpaper fallback. @@ -131,6 +146,49 @@ final class MenuBarOverlayPanel: NSPanel { /// Observes whether the system hides the menu bar. private var menuBarHiddenObserver: ObservationLoop? + /// Observes the application menu frames read by `ApplicationMenuFrames`. + private var applicationMenuFrameObserver: ObservationLoop? + + /// Observes whether the owning screen's menu bar is valid (see `ApplicationMenuFrames`). + private var menuBarValidityObserver: ObservationLoop? + + /// Observes the shape, which decides whether the wallpaper is needed. + private var shapeKindObserver: ObservationLoop? + + /// Notices a new wallpaper without a timer. + private let wallpaperChangeMonitor = WallpaperChangeMonitor() + + /// The read of the desktop picture under the bar that is under way. + private var wallpaperTask: Task? + + /// Captures the wallpaper again every 30 s while the capture fallback is in use (a + /// moving wallpaper before macOS 27, which changes without an event). + private var captureFallbackTask: Task? + + /// Whether the wallpaper comes from a capture (a moving wallpaper before macOS 27). + private var usesCaptureFallback = false { + didSet { + guard usesCaptureFallback != oldValue else { + return + } + captureFallbackTask?.cancel() + captureFallbackTask = nil + guard usesCaptureFallback else { + return + } + captureFallbackTask = Task { [weak self] in + while true { + do { + try await Task.sleep(for: .seconds(30), tolerance: .seconds(5)) + } catch { + return + } + self?.insertUpdateFlag(.desktopWallpaper) + } + } + } + } + /// The context that manages panel update tasks. private let updateTaskContext = UpdateTaskContext() @@ -160,7 +218,9 @@ final class MenuBarOverlayPanel: NSPanel { self.isMovable = false self.ignoresMouseEvents = true self.isExcludedFromWindowsMenu = true - self.collectionBehavior = [.fullScreenNone, .ignoresCycle, .moveToActiveSpace] + // One panel stands on every desktop at once, so the look is there the moment a + // desktop slides in instead of following it after the switch (Thaw #1139). + self.collectionBehavior = [.fullScreenNone, .ignoresCycle, .canJoinAllSpaces, .stationary] self.contentView = MenuBarOverlayPanelContentView() configureObservers() } @@ -169,16 +229,12 @@ final class MenuBarOverlayPanel: NSPanel { for task in observerTasks { task.cancel() } + wallpaperTask?.cancel() + paletteTask?.cancel() + captureFallbackTask?.cancel() } private func configureObservers() { - // Show the panel on the active space. - observeNotifications(named: NSWorkspace.activeSpaceDidChangeNotification, in: NSWorkspace.shared.notificationCenter) { panel in - panel.spaceDebouncer.schedule { [weak panel] in - panel?.needsShow = true - } - } - // Update when light/dark mode changes. observeNotifications( named: DistributedNotificationCenter.interfaceThemeChangedNotification, @@ -189,33 +245,33 @@ final class MenuBarOverlayPanel: NSPanel { } } - // Update application menu frame when the menu bar owning or frontmost app changes. - keyValueObservations.append( - NSWorkspace.shared.observe(\.menuBarOwningApplication, options: [.old, .new]) { [weak self] _, change in - let changed = change.oldValue != change.newValue - Task { @MainActor in - if changed { - self?.startApplicationMenuFrameUpdates() - } - } + // Redraw with the application menu frame. `ApplicationMenuFrames` reads it off the + // main thread when the frontmost application, the menu bar's owner, the space or + // the screens change; the panel used to read it itself, on the main thread, every + // millisecond until it changed and then every second for ten seconds. + if let applicationMenuFrames = appState?.applicationMenuFrames { + applicationMenuFrameObserver = ObservationLoop.observe { + applicationMenuFrames.frames + } onChange: { [weak self] _ in + self?.insertUpdateFlag(.applicationMenuFrame) } - ) - keyValueObservations.append( - NSWorkspace.shared.observe(\.frontmostApplication, options: [.old, .new]) { [weak self] _, change in - let changed = change.oldValue != change.newValue - Task { @MainActor in - if changed { - self?.startApplicationMenuFrameUpdates() - } + // The first read can finish after the panel first tried to update, and a menu bar + // can appear later (at login): update the panel once its menu bar is valid. + let displayID = owningScreen.displayID + menuBarValidityObserver = ObservationLoop.observe { + applicationMenuFrames.hasValidMenuBar(on: displayID) + } onChange: { [weak self] isValid in + if isValid { + self?.updateFlags = [.applicationMenuFrame, .desktopWallpaper] } } - ) + } // Special cases for when the user drags an app onto or clicks into another space. keyValueObservations.append( observe(\.isOnActiveSpace, options: [.initial, .new]) { [weak self] _, _ in Task { @MainActor in - self?.scheduleApplicationMenuFrameUpdate() + self?.insertUpdateFlag(.applicationMenuFrame) } } ) @@ -223,16 +279,17 @@ final class MenuBarOverlayPanel: NSPanel { guard let self, isOnActiveSpace else { return } - scheduleApplicationMenuFrameUpdate() + scheduleApplicationMenuFrameRefresh() } mouseUpMonitor.start() self.mouseUpMonitor = mouseUpMonitor - // Update the desktop wallpaper when the space or the screens change, and on the - // distributed notification "com.apple.desktop" (posted for some wallpaper changes). - // macOS posts no reliable wallpaper notification, so a slow, tolerant fallback - // catches the rest; it replaced a 5 s timer per screen. The application menu frame - // needs no timer: the frontmost-application and mouse-up observers above update it. + // Update the desktop wallpaper when the space or the screens change, on the + // distributed notification "com.apple.desktop" (posted for some wallpaper changes) + // and when the wallpaper store's index changes (`WallpaperChangeMonitor`). Only the + // capture fallback for a moving wallpaper before macOS 27 needs a slow, tolerant + // timer (`captureFallbackTask`). The application menu frame needs no timer: + // `ApplicationMenuFrames` follows it. observeNotifications(named: NSWorkspace.activeSpaceDidChangeNotification, in: NSWorkspace.shared.notificationCenter) { panel in panel.insertUpdateFlag(.desktopWallpaper) } @@ -240,28 +297,52 @@ final class MenuBarOverlayPanel: NSPanel { panel.insertUpdateFlag(.desktopWallpaper) } observeNotifications(named: Notification.Name("com.apple.desktop"), in: DistributedNotificationCenter.default()) { panel in + DesktopPicture.invalidate() panel.insertUpdateFlag(.desktopWallpaper) } - observerTasks.append(Task { [weak self] in - while true { - do { - try await Task.sleep(for: .seconds(30), tolerance: .seconds(5)) - } catch { - return - } + wallpaperChangeMonitor.onChange = { [weak self] in + DesktopPicture.invalidate() + self?.insertUpdateFlag(.desktopWallpaper) + } + wallpaperChangeMonitor.start() + if let appearanceManager = appState?.appearanceManager { + // Only a shape draws the wallpaper, so it is read when one is chosen. + shapeKindObserver = ObservationLoop.observe { + appearanceManager.configuration.shapeKind + } onChange: { [weak self] _ in self?.insertUpdateFlag(.desktopWallpaper) } - }) + // Only the "Follow Wallpaper" tint needs the palette. + wallpaperTintObserver = ObservationLoop.observe { + appearanceManager.configuration.usesWallpaperTint + } onChange: { [weak self] _ in + self?.insertUpdateFlag(.desktopWallpaper) + } + } + + // A tint that follows the accent color changes with it at once. + observeNotifications(named: NSColor.systemColorsDidChangeNotification, in: NotificationCenter.default) { panel in + panel.contentView?.needsDisplay = true + } + // The panel steps aside while the system hides the menu bar and on a fullscreen + // space, and comes back when that ends. if let appState { - let menuBarManager = appState.menuBarManager - alphaValue = menuBarManager.isMenuBarHiddenBySystem ? 0 : 1 - menuBarHiddenObserver = ObservationLoop.observe { menuBarManager.isMenuBarHiddenBySystem } onChange: { [weak self] isHidden in - self?.alphaValue = isHidden ? 0 : 1 + alphaValue = Self.stepsAside(appState) ? 0 : 1 + menuBarHiddenObserver = ObservationLoop.observe { [weak appState] in + appState.map(Self.stepsAside) ?? false + } onChange: { [weak self] stepsAside in + self?.animator().alphaValue = stepsAside ? 0 : 1 } } } + /// Whether the panel is invisible: the system hides the menu bar, or the active space is + /// fullscreen. + private static func stepsAside(_ appState: AppState) -> Bool { + appState.menuBarManager.isMenuBarHiddenBySystem || appState.activeSpace.isFullscreen + } + /// Calls the handler for each notification with the given name, while the panel exists. private func observeNotifications( named name: Notification.Name, @@ -289,42 +370,10 @@ final class MenuBarOverlayPanel: NSPanel { } } - /// Follows the application menu frame for ten seconds after the menu bar's owner changes. - private func startApplicationMenuFrameUpdates() { - updateTaskContext.setTask(for: .applicationMenuFrame, timeout: .seconds(10)) { [weak self] in - var hasDoneInitialUpdate = false - while true { - try Task.checkCancellation() - guard let self else { - return - } - guard - let latestFrame = owningScreen.getApplicationMenuFrame(), - latestFrame != applicationMenuFrame - else { - if hasDoneInitialUpdate { - try await Task.sleep(for: .seconds(1)) - } else { - try await Task.sleep(for: .milliseconds(1)) - } - continue - } - insertUpdateFlag(.applicationMenuFrame) - hasDoneInitialUpdate = true - } - } - Task { - try? await Task.sleep(for: .milliseconds(100)) - if self.owningScreen != NSScreen.main { - self.updateTaskContext.cancelTask(for: .applicationMenuFrame) - } - } - } - - /// Updates the application menu frame once the space or a click settles. - private func scheduleApplicationMenuFrameUpdate() { + /// Reads the application menu frame again once a click settles, off the main thread. + private func scheduleApplicationMenuFrameRefresh() { applicationMenuDebouncer.schedule { [weak self] in - self?.insertUpdateFlag(.applicationMenuFrame) + self?.appState?.applicationMenuFrames.refresh(reason: "click", settling: false) } } @@ -354,11 +403,19 @@ final class MenuBarOverlayPanel: NSPanel { } guard appState.menuBarManager.hasValidMenuBar(in: windows, for: owningScreen.displayID) else { MenuBarOverlayPanel.logger.debug("No valid menu bar found. \(actionMessage, privacy: .public)") + needsRetry = true return false } + needsRetry = false return true } + /// Draws the panel again and updates the application menu frame and the wallpaper. + func refresh() { + updateFlags = [.applicationMenuFrame, .desktopWallpaper] + contentView?.needsDisplay = true + } + /// Stores the frame of the menu bar's application menu. private func updateApplicationMenuFrame(for screen: NSScreen) { guard @@ -367,30 +424,109 @@ final class MenuBarOverlayPanel: NSPanel { else { return } - applicationMenuFrame = screen.getApplicationMenuFrame() + applicationMenuFrame = appState?.applicationMenuFrames.frame(for: screen) } /// Stores the area of the desktop wallpaper that is under the menu bar /// of the given display. + /// + /// The picture is read from its file (`DesktopPicture`), with no capture and no Screen + /// Recording. Only a moving wallpaper has no file: before macOS 27 it is captured when + /// Screen Recording is already granted; on macOS 27 nothing is captured (a capture lights + /// the recording indicator) and the shape stands on the flat colour of the bar. private func updateDesktopWallpaper(for display: CGDirectDisplayID, with windows: [WindowInfo]) { - // Nothing captures the screen before Screen Recording is granted; the shape is - // then drawn without the wallpaper beside it. - guard ScreenCapture.cachedCheckPermissions() else { + guard + let appState, + appState.appearanceManager.configuration.shapeKind != .noShape, + let menuBarWindow = WindowInfo.menuBarWindow(from: windows, for: display) + else { + usesCaptureFallback = false if desktopWallpaper != nil { desktopWallpaper = nil } return } + let menuBarBounds = menuBarWindow.bounds + let wallpaperWindow = WindowInfo.wallpaperWindow(from: windows, for: display) + wallpaperTask?.cancel() + wallpaperTask = Task { [weak self] in + guard let self else { + return + } + let strip = await DesktopPicture.strip(for: owningScreen, height: menuBarBounds.height) + guard !Task.isCancelled else { + return + } + if let strip { + usesCaptureFallback = false + if desktopWallpaper !== strip { + desktopWallpaper = strip + } + return + } + if #available(macOS 27.0, *) { + usesCaptureFallback = false + desktopWallpaper = Self.solidImage(color: HolzBarShelfColorManager.flatColor27()) + return + } + // Nothing captures the screen before Screen Recording is granted; the shape is + // then drawn without the wallpaper beside it. + guard ScreenCapture.cachedCheckPermissions(), let wallpaperWindow else { + usesCaptureFallback = false + if desktopWallpaper != nil { + desktopWallpaper = nil + } + return + } + usesCaptureFallback = true + let wallpaper = ScreenCapture.captureWindow(with: wallpaperWindow.windowID, screenBounds: menuBarBounds) + if desktopWallpaper?.dataProvider?.data != wallpaper?.dataProvider?.data { + desktopWallpaper = wallpaper + } + } + } + + /// Reads the palette of the wallpaper under the menu bar, while a tint follows it. + private func updateWallpaperPalette(for screen: NSScreen) { + paletteTask?.cancel() guard - let wallpaperWindow = WindowInfo.wallpaperWindow(from: windows, for: display), - let menuBarWindow = WindowInfo.menuBarWindow(from: windows, for: display) + let appState, + appState.appearanceManager.configuration.usesWallpaperTint, + let height = screen.getMenuBarHeight() else { + if wallpaperPalette != nil { + wallpaperPalette = nil + } return } - let wallpaper = ScreenCapture.captureWindow(with: wallpaperWindow.windowID, screenBounds: menuBarWindow.bounds) - if desktopWallpaper?.dataProvider?.data != wallpaper?.dataProvider?.data { - desktopWallpaper = wallpaper + paletteTask = Task { [weak self] in + guard let self else { + return + } + let palette = await DesktopPicture.palette(for: owningScreen, height: height) + guard !Task.isCancelled, palette != wallpaperPalette else { + return + } + wallpaperPalette = palette + } + } + + /// A one-pixel image of the given colour, drawn stretched over the bar. + private static func solidImage(color: CGColor) -> CGImage? { + guard let context = CGContext( + data: nil, + width: 1, + height: 1, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue + ) else { + return nil } + context.setFillColor(color) + context.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + return context.makeImage() } /// Updates the panel to prepare for display. @@ -400,6 +536,7 @@ final class MenuBarOverlayPanel: NSPanel { } if flags.contains(.desktopWallpaper) { updateDesktopWallpaper(for: screen.displayID, with: windows) + updateWallpaperPalette(for: screen) } } @@ -415,6 +552,8 @@ final class MenuBarOverlayPanel: NSPanel { } guard let menuBarHeight = owningScreen.getMenuBarHeight() else { + MenuBarOverlayPanel.logger.debug("No menu bar window found. Preventing overlay panel from showing.") + needsRetry = true return } @@ -431,7 +570,7 @@ final class MenuBarOverlayPanel: NSPanel { updateFlags = [.applicationMenuFrame, .desktopWallpaper] - if !appState.menuBarManager.isMenuBarHiddenBySystem { + if !Self.stepsAside(appState) { animator().alphaValue = 1 } } @@ -447,12 +586,39 @@ private final class MenuBarOverlayPanelContentView: NSView { private var fullConfiguration: MenuBarAppearanceConfigurationV2 = .defaultConfiguration { didSet { needsDisplay = true + updateGlassView() } } private var previewConfiguration: MenuBarAppearancePartialConfiguration? { didSet { needsDisplay = true + updateGlassView() + } + } + + /// The system glass of the "System Glass" tint (macOS 26 and later), masked to the + /// shape; the way Thaw masks its glass tint (see NOTICE). + private var glassView: NSView? + + /// The shape the glass is masked to, set while drawing. + private let glassMask = CAShapeLayer() + + /// Adds or removes the system glass with the tint kind. + private func updateGlassView() { + if #available(macOS 26.0, *), configuration.tintKind == .systemGlass { + guard glassView == nil else { + return + } + let view = NSGlassEffectView(frame: bounds) + view.autoresizingMask = [.width, .height] + view.wantsLayer = true + view.layer?.mask = glassMask + addSubview(view) + glassView = view + } else if let glassView { + glassView.removeFromSuperview() + self.glassView = nil } } @@ -506,14 +672,9 @@ private final class MenuBarOverlayPanelContentView: NSView { } ) - // Redraw whenever the window frame of a control item changes. - // - // - NOTE: A previous attempt was made to redraw the view when the - // section's `isHidden` property was changed. This would be semantically - // ideal, but the property sometimes changes before the menu bar items - // are actually updated on-screen. Since the view's drawing process relies - // on getting an accurate position of each menu bar item, we need to use - // something that publishes its changes only after the items are updated. + // Redraw whenever the window frame of a control item changes: it changes only + // after the items moved on screen, so the drawing that depends on their positions + // is right then. let sections = appState.menuBarManager.sections observers.append( ObservationLoop.observe { @@ -523,6 +684,36 @@ private final class MenuBarOverlayPanelContentView: NSView { } ) + // Redraw too when a section is shown or hidden, so the shape appears with the + // icons during a reveal rather than after the next frame change, and when the + // item list changes: an application adding an item moves no control item, and on + // macOS 27 the control items' frames never change at all. + observers.append( + ObservationLoop.observe { + sections.map(\.isHidden) + } onChange: { [weak self] _ in + self?.needsDisplay = true + } + ) + let itemManager = appState.itemManager + observers.append( + ObservationLoop.observe { + itemManager.itemCache + } onChange: { [weak self] _ in + self?.needsDisplay = true + } + ) + if #available(macOS 27.0, *) { + let concealer = appState.concealer27 + observers.append( + ObservationLoop.observe { + concealer.concealedPIDs + } onChange: { [weak self] _ in + self?.needsDisplay = true + } + ) + } + // The application menu frame and the wallpaper redraw the view from the panel. } @@ -697,10 +888,14 @@ private final class MenuBarOverlayPanelContentView: NSView { /// Draws the tint defined by the given configuration in the given rectangle. private func drawTint(in rect: CGRect) { switch configuration.tintKind { - case .noTint: + case .noTint, .systemGlass: + // The system glass is a view of its own (`updateGlassView()`). break case .solid: - if let tintColor = NSColor(cgColor: configuration.tintColor)?.withAlphaComponent(0.2) { + let baseColor: NSColor? = configuration.tintFollowsAccentColor + ? NSColor.controlAccentColor + : NSColor(cgColor: configuration.tintColor) + if let tintColor = baseColor?.withAlphaComponent(0.2) { tintColor.setFill() rect.fill() } @@ -708,9 +903,43 @@ private final class MenuBarOverlayPanelContentView: NSView { if let tintGradient = configuration.tintGradient.withAlpha(0.2).nsGradient(using: .displayP3) { tintGradient.draw(in: rect, angle: 0) } + case .adaptive: + // A gradient from the wallpaper's two dominant colors; one color is solid. + guard + let palette = overlayPanel?.wallpaperPalette, + let primary = palette.primary, + let secondary = palette.secondary + else { + return + } + let colors = [primary, secondary].map { swatch in + NSColor(srgbRed: swatch.red, green: swatch.green, blue: swatch.blue, alpha: 0.2) + } + NSGradient(colors: colors)?.draw(in: rect, angle: 0) } } + /// Strokes a border path with the configured style: solid, dashed or dotted. + /// + /// - Parameter drawnWidth: The width the path is stroked with. + private func strokeBorder(_ path: NSBezierPath, color: NSColor, drawnWidth: CGFloat) { + path.lineWidth = drawnWidth + if let dashes = BorderPattern.dashes(for: configuration.borderStyle, width: drawnWidth) { + let pattern = dashes.map { CGFloat($0) } + path.setLineDash(pattern, count: pattern.count, phase: 0) + } + if BorderPattern.usesRoundCaps(configuration.borderStyle) { + path.lineCapStyle = .round + } + color.setStroke() + path.stroke() + } + + override func layout() { + super.layout() + glassMask.frame = bounds + } + override func draw(_ dirtyRect: NSRect) { guard let overlayPanel, @@ -723,6 +952,7 @@ private final class MenuBarOverlayPanelContentView: NSView { // A black menu bar replaces every other style: the notch disappears into it. if fullConfiguration.blackBackground.applies(to: overlayPanel.owningScreen) { + glassMask.path = nil NSColor.black.setFill() drawableBounds.fill() return @@ -747,6 +977,11 @@ private final class MenuBarOverlayPanelContentView: NSView { ) } + if glassView != nil { + glassMask.frame = bounds + glassMask.path = shapePath.cgPath + } + var hasBorder = false switch fullConfiguration.shapeKind { @@ -769,15 +1004,24 @@ private final class MenuBarOverlayPanelContentView: NSView { drawTint(in: drawableBounds) - if configuration.hasBorder { - let borderBounds = CGRect( - x: bounds.minX, - y: bounds.minY + 5, - width: bounds.width, - height: configuration.borderWidth - ) - NSColor(cgColor: configuration.borderColor)?.setFill() - NSBezierPath(rect: borderBounds).fill() + if configuration.hasBorder, let borderColor = NSColor(cgColor: configuration.borderColor) { + if configuration.borderStyle == .solid { + let borderBounds = CGRect( + x: bounds.minX, + y: bounds.minY + 5, + width: bounds.width, + height: configuration.borderWidth + ) + borderColor.setFill() + NSBezierPath(rect: borderBounds).fill() + } else { + // A line along the bottom of the bar, dashed or dotted. + let lineY = bounds.minY + 5 + configuration.borderWidth / 2 + let line = NSBezierPath() + line.move(to: CGPoint(x: bounds.minX, y: lineY)) + line.line(to: CGPoint(x: bounds.maxX, y: lineY)) + strokeBorder(line, color: borderColor, drawnWidth: configuration.borderWidth) + } } case .full, .split: if let desktopWallpaper = overlayPanel.desktopWallpaper { @@ -852,11 +1096,8 @@ private final class MenuBarOverlayPanelContentView: NSView { // HACK: Insetting a path to get an "inside" stroke is surprisingly // difficult. We can fake the correct line width by doubling it, as // anything outside the shape path will be clipped. - borderPath.lineWidth = configuration.borderWidth * 2 borderPath.setClip() - - borderColor.setStroke() - borderPath.stroke() + strokeBorder(borderPath, color: borderColor, drawnWidth: configuration.borderWidth * 2) } } } diff --git a/holzBar/MenuBar/Appearance/WallpaperChangeMonitor.swift b/holzBar/MenuBar/Appearance/WallpaperChangeMonitor.swift new file mode 100644 index 00000000..d60f2122 --- /dev/null +++ b/holzBar/MenuBar/Appearance/WallpaperChangeMonitor.swift @@ -0,0 +1,126 @@ +// +// WallpaperChangeMonitor.swift +// holzBar +// + +import Foundation +import OSLog + +/// Reports changes of the desktop picture as events. +/// +/// macOS posts no public notification when the wallpaper changes, so this watches the file +/// the system rewrites when a wallpaper is set: `com.apple.wallpaper`'s store index. It +/// replaced a 30 s timer for the wallpaper beside a menu bar shape. +/// +/// Adapted from Thaw's `WallpaperChangeMonitor` (GPL-3.0, see NOTICE). +@MainActor +final class WallpaperChangeMonitor { + /// The wallpaper store's index. + static let indexURL = URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/Store/Index.plist") + + /// The watch on the file. The system replaces the file rather than writing into it, so + /// the watch is opened anew after every event. + private nonisolated final class Watch { + private let source: any DispatchSourceFileSystemObject + + /// Starts watching the file, or returns `nil` when it cannot be opened (a fresh + /// account has no index until a wallpaper is set). + init?(url: URL, onEvent: @escaping @MainActor @Sendable () -> Void) { + let descriptor = open(url.path, O_EVTONLY) + guard descriptor >= 0 else { + return nil + } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, + eventMask: [.write, .delete, .rename, .extend], + queue: .main + ) + source.setEventHandler { + // The source reports on the main queue. + MainActor.assumeIsolated { + onEvent() + } + } + source.setCancelHandler { + close(descriptor) + } + self.source = source + source.resume() + } + + deinit { + source.cancel() + } + } + + /// Called on the main actor once a burst of writes has passed. + var onChange: (() -> Void)? + + private let url: URL + private var watch: Watch? + private var debounceTask: Task? + private var retryTask: Task? + private let logger = Logger(category: "WallpaperChangeMonitor") + + /// How long a burst of writes is coalesced: setting a wallpaper rewrites the index + /// several times. + private static let debounce = Duration.milliseconds(500) + + /// How long to wait before opening the file again when it was not there yet. + private static let retryDelay = Duration.milliseconds(500) + + init(url: URL = WallpaperChangeMonitor.indexURL) { + self.url = url + } + + /// Starts watching. Does nothing while already watching. + func start() { + guard watch == nil else { + return + } + watch = Watch(url: url) { [weak self] in + self?.fileDidChange() + } + if watch == nil { + logger.debug("The wallpaper index cannot be opened; wallpaper changes are noticed on space and screen changes") + } + } + + /// Stops watching. + func stop() { + debounceTask?.cancel() + debounceTask = nil + retryTask?.cancel() + retryTask = nil + watch = nil + } + + /// Opens the file anew (it may have been replaced) and reports the change once the + /// writes stop. + private func fileDidChange() { + watch = nil + start() + if watch == nil { + // The new file may not be linked yet: one more try a moment later. + retryTask?.cancel() + retryTask = Task { [weak self] in + do { + try await Task.sleep(for: Self.retryDelay) + } catch { + return + } + self?.start() + } + } + debounceTask?.cancel() + debounceTask = Task { [weak self] in + do { + try await Task.sleep(for: Self.debounce) + } catch { + return + } + self?.onChange?() + } + } +} diff --git a/holzBar/MenuBar/ApplicationMenuFrames.swift b/holzBar/MenuBar/ApplicationMenuFrames.swift new file mode 100644 index 00000000..79d1ab6f --- /dev/null +++ b/holzBar/MenuBar/ApplicationMenuFrames.swift @@ -0,0 +1,202 @@ +// +// ApplicationMenuFrames.swift +// holzBar +// + +import Cocoa +import Observation +import OSLog +import os + +/// The frame of the application menu on each display, read off the main thread. +/// +/// Show on click and hover, the secondary context menu, hiding application menus, +/// temporarily shown items and the menu bar overlay all need to know where the frontmost +/// application's menus end. Reading that asks the application through Accessibility, +/// which blocks until it answers. holzBar used to read it on the main thread, where its +/// event monitors and the click tap of macOS 27 wait too, so a hung application froze +/// every click on the Mac. Now it is read on a queue of its own, with a short messaging +/// timeout, when something that can change it happens; everything else reads the cache. +@MainActor +@Observable +final class ApplicationMenuFrames { + /// The frame of the application menu on each display; a missing entry means there is none. + private(set) var frames = [CGDirectDisplayID: CGRect]() + + /// The displays whose menu bar window is a menu bar Accessibility can reach. The menu + /// bar overlay draws only there (see `MenuBarManager.hasValidMenuBar(in:for:)`). + private(set) var validMenuBars = Set() + + /// The queue that reads the frames. Accessibility calls block, so they never run on + /// the main thread or in the Swift concurrency pool. + @ObservationIgnored private let queue = DispatchQueue( + label: "com.holzcloud.holzBar.ApplicationMenuFrames", + qos: .userInitiated + ) + + /// The number of the latest read. A read that a newer one replaced is dropped. + @ObservationIgnored private let latestGeneration = OSAllocatedUnfairLock(initialState: 0) + + /// The reads that follow a change once more, for applications that draw their menus late. + @ObservationIgnored private var settleTask: Task? + + /// Tasks that observe notifications, kept while the cache exists. + @ObservationIgnored private var observerTasks = [Task]() + + /// Key-value observers of the workspace. + @ObservationIgnored private var keyValueObservations = [NSKeyValueObservation]() + + @ObservationIgnored private let logger = Logger(category: "ApplicationMenuFrames") + + /// What one read found on every display. + private nonisolated struct ReadResult: Sendable { + var frames = [CGDirectDisplayID: CGRect]() + var validMenuBars = Set() + } + + /// When the follow-up reads run after a change, counted from the change: an application + /// that has just become frontmost may build its menus a moment later. + private static let settleReads: [Duration] = [.milliseconds(150), .milliseconds(600)] + + deinit { + for task in observerTasks { + task.cancel() + } + settleTask?.cancel() + } + + /// Starts following the events that can change the application menu, and reads it. + func performSetup() { + keyValueObservations.append( + NSWorkspace.shared.observe(\.frontmostApplication, options: [.new]) { [weak self] _, _ in + Task { @MainActor in + self?.refresh(reason: "frontmost application changed") + } + } + ) + keyValueObservations.append( + NSWorkspace.shared.observe(\.menuBarOwningApplication, options: [.new]) { [weak self] _, _ in + Task { @MainActor in + self?.refresh(reason: "menu bar owner changed") + } + } + ) + observerTasks.append(Task { [weak self] in + let center = NSWorkspace.shared.notificationCenter + for await _ in center.notifications(named: NSWorkspace.activeSpaceDidChangeNotification) { + self?.refresh(reason: "active space changed") + } + }) + observerTasks.append(Task { [weak self] in + let center = NotificationCenter.default + for await _ in center.notifications(named: NSApplication.didChangeScreenParametersNotification) { + self?.refresh(reason: "screens changed") + } + }) + refresh(reason: "setup") + } + + /// The frame of the application menu on the given screen, from the last read. + /// + /// It never asks another application, so it is safe on any input path. + func frame(for screen: NSScreen) -> CGRect? { + frames[screen.displayID] + } + + /// Whether the menu bar window of the given display was a menu bar at the last read. + /// + /// It never asks another application, so it is safe on the main thread. + func hasValidMenuBar(on displayID: CGDirectDisplayID) -> Bool { + validMenuBars.contains(displayID) + } + + /// Reads the frames now and, unless `settling` is `false`, twice more shortly after + /// (150 ms and 600 ms), then stops. A refresh replaces the one that is still running. + /// + /// - Parameters: + /// - reason: What changed, for the debug log. + /// - settling: Whether the follow-up reads run. + func refresh(reason: String, settling: Bool = true) { + logger.debug("Refreshing application menu frames: \(reason, privacy: .public)") + settleTask?.cancel() + settleTask = nil + read() + guard settling else { + return + } + settleTask = Task { [weak self] in + var elapsed = Duration.zero + for delay in Self.settleReads { + do { + try await Task.sleep(for: delay - elapsed) + } catch { + return + } + elapsed = delay + self?.read() + } + } + } + + /// Reads the frame on every screen on the queue and publishes the result on the main actor. + private func read() { + let queries = NSScreen.screens.map(\.applicationMenuQuery) + let generation = latestGeneration.withLock { value in + value += 1 + return value + } + Task { [weak self, queue, latestGeneration] in + guard let result = await Self.readFrames( + queries, + generation: generation, + latestGeneration: latestGeneration, + on: queue + ) else { + return + } + self?.publish(result, generation: generation) + } + } + + /// Reads the frames on the given queue, or returns `nil` when a newer read started first. + private nonisolated static func readFrames( + _ queries: [ApplicationMenuQuery], + generation: Int, + latestGeneration: OSAllocatedUnfairLock, + on queue: DispatchQueue + ) async -> ReadResult? { + await withCheckedContinuation { continuation in + queue.async { + var result = ReadResult() + for query in queries { + // A newer read has started; it answers instead. + guard latestGeneration.withLock({ $0 }) == generation else { + continuation.resume(returning: nil) + return + } + if query.hasValidMenuBar() { + result.validMenuBars.insert(query.displayID) + } + if let frame = query.getApplicationMenuFrame() { + result.frames[query.displayID] = frame + } + } + continuation.resume(returning: result) + } + } + } + + /// Stores the result of a read, unless a newer read has started since. + private func publish(_ result: ReadResult, generation: Int) { + guard latestGeneration.withLock({ $0 }) == generation else { + return + } + // Stored only when they change: observers redraw on every assignment. + if result.frames != frames { + frames = result.frames + } + if result.validMenuBars != validMenuBars { + validMenuBars = result.validMenuBars + } + } +} diff --git a/holzBar/MenuBar/Backends/AccessibilityBackend27.swift b/holzBar/MenuBar/Backends/AccessibilityBackend27.swift index 583c81c4..adeab9f2 100644 --- a/holzBar/MenuBar/Backends/AccessibilityBackend27.swift +++ b/holzBar/MenuBar/Backends/AccessibilityBackend27.swift @@ -69,6 +69,8 @@ final class AccessibilityBackend27: MenuBarBackend { // Observe the processes that own items now; observers of quit ones go. let ownPID = ProcessInfo.processInfo.processIdentifier itemChangeObserver.observe(owners: Set(items.map(\.ownerPID).filter { $0 != ownPID })) + // An application that launched while concealed is concealed once its item exists. + appState.concealer27.itemsAppeared(bundleIDs: Set(items.map { $0.tag.namespace.description })) appState.concealer27.seedLayoutIfNeeded(items: items) appState.concealer27.placeNewApplications(items: items) return appState.concealer27.cacheFromSavedLayout(items: items, displayID: displayID) @@ -87,7 +89,8 @@ final class AccessibilityBackend27: MenuBarBackend { point: point, items: hitTestItems(appState: appState), concealedPIDs: appState.concealer27.concealedPIDs, - systemFrames: systemFrames() + systemFrames: systemFrames(), + drawnFramesOnDisplay: drawnFrames(at: point) ) } @@ -98,7 +101,8 @@ final class AccessibilityBackend27: MenuBarBackend { items: hitTestItems(appState: appState), concealedPIDs: appState.concealer27.concealedPIDs, systemFrames: systemFrames(), - rememberedLeftEdge: MenuBarItemProvider27.leftEdge(for: screen.displayID) + rememberedLeftEdge: MenuBarItemProvider27.leftEdge(for: screen.displayID), + drawnFramesOnDisplay: MenuBarItemProvider27.drawnFrames(for: screen.displayID) ) } @@ -113,6 +117,15 @@ final class AccessibilityBackend27: MenuBarBackend { } } + /// The frames MenuBarAgent draws on the display under the point, when that display's bar + /// is not active (`ItemHitTest27`). + private func drawnFrames(at point: CGPoint) -> [CGRect] { + var displayID = CGDirectDisplayID(0) + var matches: UInt32 = 0 + CGGetDisplaysWithPoint(point, 1, &displayID, &matches) + return matches > 0 ? MenuBarItemProvider27.drawnFrames(for: displayID) : [] + } + /// The frames of the system items and the overflow button, from the last read. private func systemFrames() -> [CGRect] { MenuBarItemProvider27.systemItemFrames() + [MenuBarItemProvider27.overflowButtonFrame()].compactMap { $0 } diff --git a/holzBar/MenuBar/ControlItem/ControlItem.swift b/holzBar/MenuBar/ControlItem/ControlItem.swift index f2b317ce..9a2ec305 100644 --- a/holzBar/MenuBar/ControlItem/ControlItem.swift +++ b/holzBar/MenuBar/ControlItem/ControlItem.swift @@ -494,6 +494,16 @@ final class ControlItem { ControlItemDefaults[.preferredPosition, autosaveName] = cached } + /// Takes the control item out of the menu bar and puts it back, keeping its preferred + /// position. + /// + /// On macOS 27 MenuBarAgent can drop holzBar's own icon while assertions are active + /// (jordanbaird/Ice#1001); adding the status item again brings it back. + func reinsert() { + removeFromMenuBar() + addToMenuBar() + } + /// Performs the control item's action. @objc private func performAction() { guard @@ -547,7 +557,7 @@ final class ControlItem { let menu = NSMenu(title: "holzBar") let settingsItem = NSMenuItem( - title: "holzBar Settings…", + title: String(localized: "holzBar Settings…"), action: #selector(AppDelegate.openSettingsWindow), keyEquivalent: "," ) @@ -557,7 +567,7 @@ final class ControlItem { menu.addItem(.separator()) let searchItem = NSMenuItem( - title: "Search Menu Bar Items", + title: String(localized: "Search Menu Bar Items"), action: #selector(showSearchPanel), keyEquivalent: "" ) @@ -581,8 +591,14 @@ final class ControlItem { else { continue } + let title = switch (name, section.isHidden) { + case (.alwaysHidden, true): String(localized: "Show Always-Hidden Section") + case (.alwaysHidden, false): String(localized: "Hide Always-Hidden Section") + case (_, true): String(localized: "Show Hidden Section") + case (_, false): String(localized: "Hide Hidden Section") + } let item = NSMenuItem( - title: "\(section.isHidden ? "Show" : "Hide") \(name.displayString) Section", + title: title, action: #selector(toggleMenuBarSection), keyEquivalent: "" ) @@ -598,10 +614,26 @@ final class ControlItem { menu.addItem(item) } + let zenModeItem = NSMenuItem( + title: String(localized: "Zen Mode"), + action: #selector(toggleZenMode), + keyEquivalent: "" + ) + if + let hotkey = hotkey(withAction: .toggleZenMode), + let keyCombination = hotkey.keyCombination + { + zenModeItem.keyEquivalent = keyCombination.key.keyEquivalent + zenModeItem.keyEquivalentModifierMask = keyCombination.modifiers.nsEventFlags + } + zenModeItem.state = appState.menuBarManager.zenMode.isActive ? .on : .off + zenModeItem.target = self + menu.addItem(zenModeItem) + menu.addItem(.separator()) let howToUpdateItem = NSMenuItem( - title: "How to Update…", + title: String(localized: "How to Update…"), action: #selector(showHowToUpdate), keyEquivalent: "" ) @@ -611,7 +643,7 @@ final class ControlItem { menu.addItem(.separator()) let quitItem = NSMenuItem( - title: "Quit holzBar", + title: String(localized: "Quit holzBar"), action: #selector(NSApp.terminate), keyEquivalent: "q" ) @@ -638,6 +670,11 @@ final class ControlItem { section.toggle() } + /// Turns Zen mode on or off. + @objc private func toggleZenMode() { + appState?.menuBarManager.toggleZenMode() + } + /// Opens the menu bar search panel. @objc private func showSearchPanel() { appState?.menuBarManager.searchPanel.show() diff --git a/holzBar/MenuBar/Groups/MenuBarItemGroups.swift b/holzBar/MenuBar/Groups/MenuBarItemGroups.swift index 4d6196f8..83372b3b 100644 --- a/holzBar/MenuBar/Groups/MenuBarItemGroups.swift +++ b/holzBar/MenuBar/Groups/MenuBarItemGroups.swift @@ -7,6 +7,7 @@ import AppKit import Observation import OSLog import SwiftUI +import UniformTypeIdentifiers /// A named set of menu bar items behind an icon of its own. struct MenuBarItemGroup: Codable, Hashable, Identifiable { @@ -20,6 +21,22 @@ struct MenuBarItemGroup: Codable, Hashable, Identifiable { /// The tags of the items in the group, in order. var itemTags: [String] + + /// The group's color as `#RRGGBB`: its icon in the menu bar and its name in its panel. + /// Optional, so groups saved before decode unchanged. + var colorHex: String? + + /// An image of the user's choice for the menu bar, a file in holzBar's `ItemIcons` + /// folder, shown instead of the symbol. + var imageFile: String? + + /// The group's color, if it has a valid one. + var color: NSColor? { + guard let colorHex, let components = HexColor.components(colorHex) else { + return nil + } + return NSColor(srgbRed: components.red, green: components.green, blue: components.blue, alpha: 1) + } } /// Puts several menu bar items behind one icon of their own @@ -95,22 +112,118 @@ final class MenuBarItemGroups { } func toggle(_ item: MenuBarItem, in group: MenuBarItemGroup) { - guard let index = groups.firstIndex(where: { $0.id == group.id }) else { + guard + let itemManager = appState?.itemManager, + let index = groups.firstIndex(where: { $0.id == group.id }) + else { return } - let tag = item.tag.description - if let itemIndex = groups[index].itemTags.firstIndex(of: tag) { + // Members are stored under their identity, which survives a changing title; keys of + // earlier versions (`namespace:title`) match through it too. + let identityKey = itemManager.identityKey(for: item) + if let itemIndex = groups[index].itemTags.firstIndex(where: { itemManager.storedIdentityKey($0) == identityKey }) { groups[index].itemTags.remove(at: itemIndex) } else { - groups[index].itemTags.append(tag) + groups[index].itemTags.append(identityKey) } } + /// Whether the given item belongs to the group. + func contains(_ item: MenuBarItem, in group: MenuBarItemGroup) -> Bool { + guard let itemManager = appState?.itemManager else { + return false + } + let identityKey = itemManager.identityKey(for: item) + return group.itemTags.contains { itemManager.storedIdentityKey($0) == identityKey } + } + func setSymbol(_ symbolName: String, for group: MenuBarItemGroup) { guard let index = groups.firstIndex(where: { $0.id == group.id }) else { return } groups[index].symbolName = symbolName + setImageFile(nil, at: index) + } + + /// Sets the group's color, or removes it (`nil`). + func setColor(_ color: NSColor?, for group: MenuBarItemGroup) { + guard let index = groups.firstIndex(where: { $0.id == group.id }) else { + return + } + guard let color = color?.usingColorSpace(.sRGB) else { + groups[index].colorHex = nil + return + } + groups[index].colorHex = HexColor.string( + red: Double(color.redComponent), + green: Double(color.greenComponent), + blue: Double(color.blueComponent) + ) + } + + /// Lets the user choose an image for the group's icon in the menu bar (THAW-16). + func chooseImage(for group: MenuBarItemGroup) { + guard let iconStore = appState?.itemIconStore else { + return + } + let panel = NSOpenPanel() + panel.allowedContentTypes = [.image] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.message = String(localized: "Choose an image for the group \u{201C}\(group.name)\u{201D}.") + guard panel.runModal() == .OK, let url = panel.url else { + return + } + let id = group.id + Task { + guard + let name = await iconStore.storeImage(from: url), + let index = groups.firstIndex(where: { $0.id == id }) + else { + NSSound.beep() + return + } + setImageFile(name, at: index) + } + } + + /// Shows the group's symbol again instead of its image. + func useSymbol(for group: MenuBarItemGroup) { + guard let index = groups.firstIndex(where: { $0.id == group.id }) else { + return + } + setImageFile(nil, at: index) + } + + /// Sets the image of the group at the index and deletes the file it no longer uses. + private func setImageFile(_ name: String?, at index: Int) { + let previous = groups[index].imageFile + guard previous != name else { + return + } + groups[index].imageFile = name + if let previous { + appState?.itemIconStore.deleteFile(named: previous) + } + } + + /// The image of the group's icon in the menu bar: its chosen image, or its symbol in + /// its color (a template image without a color). + private func statusItemImage(for group: MenuBarItemGroup) -> NSImage? { + if + let imageFile = group.imageFile, + let image = appState?.itemIconStore.storedImage(named: imageFile) + { + return ItemIconStore.sizedForMenuBar(image) + } + let symbol = NSImage(systemSymbolName: group.symbolName, accessibilityDescription: group.name) + guard let color = group.color else { + symbol?.isTemplate = true + return symbol + } + let configured = symbol?.withSymbolConfiguration(NSImage.SymbolConfiguration(paletteColors: [color])) + configured?.isTemplate = false + return configured } // MARK: Status Items @@ -134,8 +247,7 @@ final class MenuBarItemGroups { } targets[group.id] = target if let button = statusItem.button { - button.image = NSImage(systemSymbolName: group.symbolName, accessibilityDescription: group.name) - button.image?.isTemplate = true + button.image = statusItemImage(for: group) button.toolTip = group.name button.target = target button.action = #selector(ClickTarget.clicked(_:)) @@ -191,17 +303,18 @@ private struct MenuBarItemGroupPanel: View { let close: () -> Void private var items: [MenuBarItem] { - let byTag = Dictionary( - itemManager.itemCache.managedItems.map { ($0.tag.description, $0) }, + let byIdentityKey = Dictionary( + itemManager.itemCache.managedItems.map { (itemManager.identityKey(for: $0), $0) }, uniquingKeysWith: { first, _ in first } ) - return group.itemTags.compactMap { byTag[$0] } + return group.itemTags.compactMap { byIdentityKey[itemManager.storedIdentityKey($0)] } } var body: some View { VStack(alignment: .leading, spacing: 8) { Text(group.name) .font(.headline) + .foregroundStyle(group.color.map { Color(nsColor: $0) } ?? .primary) if items.isEmpty { Text("Add items to this group in holzBar's Menu Bar Layout settings.") .foregroundStyle(.secondary) @@ -212,10 +325,11 @@ private struct MenuBarItemGroupPanel: View { Button { close() Task { - await MenuBarItemActions.click(item, with: .left, itemManager: itemManager) + try? await Task.sleep(for: .milliseconds(25)) + await itemManager.openItem(item, mouseButton: .left, shelfDisplayID: nil) } } label: { - if let image = imageCache.images[item.tag]?.nsImage { + if let image = itemManager.appState?.itemIconStore.image(for: item) ?? imageCache.images[item.tag]?.nsImage { Image(nsImage: image) } else { Text(item.displayName) @@ -231,23 +345,3 @@ private struct MenuBarItemGroupPanel: View { .frame(minWidth: 180) } } - -// MARK: - MenuBarItemActions - -/// Clicks a menu bar item from outside the menu bar, showing it first if it is -/// hidden, the way the holzBar Shelf does. -@MainActor -enum MenuBarItemActions { - static func click(_ item: MenuBarItem, with mouseButton: CGMouseButton, itemManager: MenuBarItemManager) async { - try? await Task.sleep(for: .milliseconds(25)) - if #available(macOS 27.0, *), let appState = itemManager.appState { - await ItemClicker27.click(item: item, mouseButton: mouseButton, shelfDisplayID: nil, appState: appState) - return - } - if Bridging.isWindowOnScreen(item.windowID) { - try? await itemManager.click(item: item, with: mouseButton) - } else { - await itemManager.temporarilyShow(item: item, clickingWith: mouseButton) - } - } -} diff --git a/holzBar/MenuBar/Hints/ItemHintsPanel.swift b/holzBar/MenuBar/Hints/ItemHintsPanel.swift new file mode 100644 index 00000000..00f30269 --- /dev/null +++ b/holzBar/MenuBar/Hints/ItemHintsPanel.swift @@ -0,0 +1,278 @@ +// +// ItemHintsPanel.swift +// holzBar +// + +import AppKit +import SwiftUI + +// MARK: - ItemHintsPanel + +/// Shows every menu bar item with a letter under the menu bar; typing the letter opens the +/// item's menu (item hints, THAW-14). +/// +/// The panel reads keys only while it is key, and only the keys it needs (letters, Delete +/// and Escape); nothing is logged. It shows the images holzBar already has, or the item's +/// name, so it needs no Screen Recording. +final class ItemHintsPanel: NSPanel { + /// The shared app state. + private weak var appState: AppState? + + /// The hints on display and what was typed. + private let model = ItemHintsModel() + + /// Whether the panel is closing, so losing key status meanwhile does not close it again. + private var isClosing = false + + /// The panel takes keys without activating holzBar. + override var canBecomeKey: Bool { true } + + /// Creates the panel. + init() { + super.init( + contentRect: .zero, + styleMask: [.nonactivatingPanel, .fullSizeContentView, .borderless], + backing: .buffered, + defer: false + ) + self.title = String(localized: "Open an Item by Letter") + self.isFloatingPanel = true + self.animationBehavior = .none + self.backgroundColor = .clear + self.hasShadow = false + self.level = .mainMenu + 1 + self.collectionBehavior = [.fullScreenAuxiliary, .ignoresCycle, .moveToActiveSpace] + } + + /// Sets up the panel. + func performSetup(with appState: AppState) { + self.appState = appState + } + + /// Shows or closes the hints. + func toggle() { + if isVisible { + close() + } else { + show() + } + } + + /// Shows the hints under the menu bar of the display with the pointer. + func show() { + guard + let appState, + let screen = NSScreen.screenWithMouse ?? NSScreen.main + else { + return + } + let cache = appState.itemManager.itemCache + let items = MenuBarSection.Name.allCases.flatMap { name in + cache[name].filter { !$0.isControlItem }.map { (item: $0, section: name) } + } + guard !items.isEmpty else { + NSSound.beep() + return + } + let hints = ItemHints.letters(count: items.count) + model.entries = zip(items, hints).enumerated().map { index, pair in + ItemHintsModel.Entry( + item: pair.0.item, + hint: pair.1, + startsSection: index > 0 && items[index - 1].section != pair.0.section + ) + } + model.typed = "" + model.maxWidth = screen.visibleFrame.width - 40 + + let hostingView = NSHostingView( + rootView: ItemHintsView(model: model, iconStore: appState.itemIconStore) { [weak self] entry in + self?.open(entry) + } + ) + let size = hostingView.fittingSize + contentView = hostingView + let menuBarHeight = screen.getMenuBarHeight() ?? 0 + setFrame( + CGRect( + x: screen.frame.midX - size.width / 2, + y: screen.frame.maxY - menuBarHeight - size.height - 4, + width: size.width, + height: size.height + ), + display: true + ) + makeKeyAndOrderFront(nil) + } + + /// Handles the keys while the panel is key. + override func sendEvent(_ event: NSEvent) { + guard event.type == .keyDown else { + super.sendEvent(event) + return + } + switch KeyCode(rawValue: Int(event.keyCode)) { + case .escape: + close() + case .delete: + if !model.typed.isEmpty { + model.typed.removeLast() + } + default: + guard + let characters = event.charactersIgnoringModifiers?.lowercased(), + characters.count == 1, + let character = characters.first, + character.isLetter + else { + NSSound.beep() + return + } + let typed = model.typed + String(character) + switch ItemHints.match(typed: typed, hints: model.entries.map(\.hint)) { + case .item(let index): + open(model.entries[index]) + case .pending: + model.typed = typed + case .none: + NSSound.beep() + } + } + } + + /// A click elsewhere closes the hints. + override func resignKey() { + super.resignKey() + if isVisible, !isClosing { + close() + } + } + + /// Closes the hints and opens the item's menu. + private func open(_ entry: ItemHintsModel.Entry) { + close() + guard let itemManager = appState?.itemManager else { + return + } + let item = entry.item + Task { + // Lets the panel go away before the click. + try? await Task.sleep(for: .milliseconds(25)) + await itemManager.openItem(item, mouseButton: .left, shelfDisplayID: nil) + } + } + + override func close() { + guard !isClosing else { + return + } + isClosing = true + defer { + isClosing = false + } + super.close() + contentView = nil + model.entries = [] + model.typed = "" + } +} + +// MARK: - ItemHintsModel + +/// The hints the panel shows. +@MainActor +@Observable +private final class ItemHintsModel { + /// One item with its letters. + struct Entry: Identifiable { + let item: MenuBarItem + let hint: String + /// Whether a separator comes before the item, because its section starts here. + let startsSection: Bool + + var id: String { hint } + } + + /// The items with their hints, in bar order: visible, hidden, always hidden. + var entries = [Entry]() + + /// The letters typed so far. + var typed = "" + + /// The widest the hints may be. + var maxWidth: CGFloat = 800 +} + +// MARK: - ItemHintsView + +/// The hints, in rows as wide as the display allows. +private struct ItemHintsView: View { + var model: ItemHintsModel + var iconStore: ItemIconStore + let open: (ItemHintsModel.Entry) -> Void + + /// The width of one hint. + private let cellWidth: CGFloat = 64 + + /// The hints split into rows. + private var rows: [[ItemHintsModel.Entry]] { + let perRow = max(1, Int(model.maxWidth / (cellWidth + 6))) + return stride(from: 0, to: model.entries.count, by: perRow).map { start in + Array(model.entries[start.. some View { + let isDimmed = !model.typed.isEmpty && !entry.hint.hasPrefix(model.typed) + Button { + open(entry) + } label: { + VStack(spacing: 3) { + Group { + if let image = iconStore.image(for: entry.item) { + Image(nsImage: image) + } else { + Text(entry.item.displayName) + .font(.caption) + .lineLimit(1) + .truncationMode(.tail) + } + } + .frame(width: cellWidth, height: 22) + Text(entry.hint.uppercased()) + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .foregroundStyle(.white) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .opacity(isDimmed ? 0.3 : 1) + .help(entry.item.displayName) + .accessibilityLabel("\(entry.hint.uppercased()), \(entry.item.displayName)") + } +} diff --git a/holzBar/MenuBar/LayoutBar/LayoutBarContainer.swift b/holzBar/MenuBar/LayoutBar/LayoutBarContainer.swift index 3c383450..03863904 100644 --- a/holzBar/MenuBar/LayoutBar/LayoutBarContainer.swift +++ b/holzBar/MenuBar/LayoutBar/LayoutBarContainer.swift @@ -68,6 +68,7 @@ final class LayoutBarContainer: NSView { self.translatesAutoresizingMaskIntoConstraints = false unregisterDraggedTypes() configureObservers() + LayoutBarRouter.shared.register(self) } @available(*, unavailable) @@ -96,6 +97,11 @@ final class LayoutBarContainer: NSView { ] } + /// Lays the views out again after one of them changed its size (a new image). + func arrangedViewDidResize() { + layoutArrangedViews() + } + /// Performs layout of the container's arranged views. /// /// The container removes from its subviews the views that are included @@ -284,3 +290,62 @@ final class LayoutBarContainer: NSView { } } } + +// MARK: - LayoutBarRouter + +/// Moves the keyboard focus between the item rows of the Menu Bar Layout pane, one row per +/// section (THAW-14). +/// +/// Each section's container registers itself; the arrows move within a row and to the +/// neighbouring section's row (`LayoutKeys`). +@MainActor +final class LayoutBarRouter { + /// The router of the Layout pane. + static let shared = LayoutBarRouter() + + /// A container, held weakly. + private struct Entry { + weak var container: LayoutBarContainer? + } + + /// The containers by section. + private var entries = [MenuBarSection.Name: Entry]() + + /// Registers the container of a section. + func register(_ container: LayoutBarContainer) { + entries[container.section] = Entry(container: container) + } + + /// The rows on screen, top to bottom. + private var rows: [LayoutBarContainer] { + MenuBarSection.Name.allCases.compactMap { name in + guard let container = entries[name]?.container, container.window != nil else { + return nil + } + return container + } + } + + /// Moves the focus from the given item for a focus command. + func moveFocus(from view: LayoutBarItemView, command: LayoutKeys.Command) { + let rows = self.rows + guard + let row = rows.firstIndex(where: { $0.arrangedViews.contains(view) }), + let index = rows[row].arrangedViews.firstIndex(of: view) + else { + return + } + let target = LayoutKeys.focusTarget( + from: LayoutKeys.Position(row: row, index: index), + command: command, + rowCounts: rows.map(\.arrangedViews.count) + ) + guard target != LayoutKeys.Position(row: row, index: index) else { + NSSound.beep() + return + } + let targetView = rows[target.row].arrangedViews[target.index] + targetView.window?.makeFirstResponder(targetView) + targetView.scrollToVisible(targetView.bounds) + } +} diff --git a/holzBar/MenuBar/LayoutBar/LayoutBarItemView.swift b/holzBar/MenuBar/LayoutBar/LayoutBarItemView.swift index ae8b060b..0cbae63a 100644 --- a/holzBar/MenuBar/LayoutBar/LayoutBarItemView.swift +++ b/holzBar/MenuBar/LayoutBar/LayoutBarItemView.swift @@ -4,6 +4,7 @@ // import Cocoa +import SwiftUI // MARK: - LayoutBarItemView @@ -31,15 +32,13 @@ final class LayoutBarItemView: NSView { /// A Boolean value that indicates whether the item view is currently inside a container. var hasContainer = false - /// The image displayed inside the view. - private var cachedImage: MenuBarItemImageCache.CapturedImage? { + /// The image displayed inside the view: the item's chosen image, its picture or its + /// app's icon (`ItemIconStore`). + private var displayImage: NSImage? { didSet { - if let image = cachedImage { - setFrameSize(image.scaledSize) - } else { - setFrameSize(.zero) - } + setFrameSize(displayImage?.size ?? .zero) needsDisplay = true + (superview as? LayoutBarContainer)?.arrangedViewDidResize() } } @@ -69,13 +68,19 @@ final class LayoutBarItemView: NSView { unregisterDraggedTypes() self.toolTip = item.displayName + // VoiceOver reads every item as a button with its name and section, and offers the + // moves as actions (see Accessibility below). + setAccessibilityElement(true) + self.isEnabled = item.isMovable if #available(macOS 27.0, *) { - // Lets Scripts/macos27/verify-layout.sh find the item. - setAccessibilityElement(true) - setAccessibilityRole(.image) - setAccessibilityLabel(item.displayName) + // macOS 27 hides whole apps, so an item is placed by its app. + if !item.isMovable { + self.toolTip = String(localized: "macOS 27 doesn't let apps hide this system item.") + } else if item.sourceApplication?.bundleIdentifier == nil { + self.isEnabled = false + self.toolTip = String(localized: "macOS 27 hides whole apps, and holzBar can't tell which app this item belongs to.") + } } - self.isEnabled = item.isMovable configureObservers() } @@ -89,37 +94,37 @@ final class LayoutBarItemView: NSView { guard let appState else { return } - let imageCache = appState.imageCache - let tag = item.tag - if let image = imageCache.images[tag] { - cachedImage = image + let iconStore = appState.itemIconStore + let item = item + if let image = iconStore.image(for: item) { + displayImage = image } - imageObserver = ObservationLoop.observe { imageCache.images[tag] } onChange: { [weak self] image in + imageObserver = ObservationLoop.observe { iconStore.image(for: item) } onChange: { [weak self] image in guard let self, let image else { return } - cachedImage = image + displayImage = image } } /// Provides an alert to display when the item view is disabled. func provideAlertForDisabledItem() -> NSAlert { let alert = NSAlert() - alert.messageText = "Menu bar item is not movable." - alert.informativeText = "macOS prohibits \"\(item.displayName)\" from being moved." + alert.messageText = String(localized: "Menu bar item is not movable.") + alert.informativeText = String(localized: "macOS prohibits \u{201C}\(item.displayName)\u{201D} from being moved.") return alert } /// Provides an alert to display when a menu bar item is unresponsive. func provideAlertForUnresponsiveItem() -> NSAlert { let alert = provideAlertForDisabledItem() - alert.informativeText = "\(item.displayName) is unresponsive. Until it is restarted, it cannot be moved. Movement of other menu bar items may also be affected until this is resolved." + alert.informativeText = String(localized: "\(item.displayName) is unresponsive. Until it is restarted, it cannot be moved. Movement of other menu bar items may also be affected until this is resolved.") return alert } override func draw(_ dirtyRect: NSRect) { if !isDraggingPlaceholder { - cachedImage?.nsImage.draw( + displayImage?.draw( in: bounds, from: .zero, operation: .sourceOver, @@ -145,6 +150,314 @@ final class LayoutBarItemView: NSView { } } + // MARK: Context Menu + + override func menu(for event: NSEvent) -> NSMenu? { + makeContextMenu() + } + + /// The item's menu: its hotkey and the other settings of the item. + private func makeContextMenu() -> NSMenu { + let menu = NSMenu(title: item.displayName) + let hotkeyItem = NSMenuItem( + title: String(localized: "Set Hotkey…"), + action: #selector(showHotkeyPopover), + keyEquivalent: "" + ) + hotkeyItem.target = self + menu.addItem(hotkeyItem) + if let iconStore = appState?.itemIconStore, !item.isControlItem { + menu.addItem(.separator()) + let choice = iconStore.choice(for: item) + let chooseImageItem = NSMenuItem( + title: String(localized: "Choose Image…"), + action: #selector(chooseImage), + keyEquivalent: "" + ) + let useAppIconItem = NSMenuItem( + title: String(localized: "Use App Icon"), + action: #selector(useAppIcon), + keyEquivalent: "" + ) + useAppIconItem.state = choice == .appIcon ? .on : .off + useAppIconItem.isEnabled = item.sourceApplication != nil + let resetImageItem = NSMenuItem( + title: String(localized: "Reset Image"), + action: #selector(resetImage), + keyEquivalent: "" + ) + resetImageItem.isEnabled = choice != nil + for menuItem in [chooseImageItem, useAppIconItem, resetImageItem] { + menuItem.target = self + menu.addItem(menuItem) + } + menu.autoenablesItems = false + menu.addItem(.separator()) + } + if let watcher = appState?.itemChangeWatcher, !item.isControlItem { + let changeItem = NSMenuItem( + title: String(localized: "Show When It Changes"), + action: #selector(toggleRevealOnChange), + keyEquivalent: "" + ) + changeItem.state = watcher.isRevealedOnChange(item) ? .on : .off + changeItem.target = self + menu.addItem(changeItem) + } + return menu + } + + /// Lets the user choose an image for the item. + @objc private func chooseImage() { + appState?.itemIconStore.chooseImage(for: item) + } + + /// Shows the app's icon for the item. + @objc private func useAppIcon() { + appState?.itemIconStore.useAppIcon(for: item) + } + + /// Goes back to the item's own picture. + @objc private func resetImage() { + appState?.itemIconStore.reset(for: item) + } + + /// Marks or unmarks the item "Show When It Changes". + @objc private func toggleRevealOnChange() { + guard let watcher = appState?.itemChangeWatcher else { + return + } + watcher.setRevealedOnChange(!watcher.isRevealedOnChange(item), for: item) + } + + /// Opens the item's menu below it, from the keyboard or VoiceOver. + private func showContextMenu() { + makeContextMenu().popUp(positioning: nil, at: CGPoint(x: 0, y: -4), in: self) + } + + // MARK: Keyboard + + /// The section whose row shows the item. + private var section: MenuBarSection.Name? { + (superview as? LayoutBarContainer)?.section + } + + /// Whether items keep an order within their section (before macOS 27). + private static var itemsKeepOrder: Bool { + if #available(macOS 27.0, *) { + false + } else { + true + } + } + + override var acceptsFirstResponder: Bool { true } + + override var canBecomeKeyView: Bool { true } + + override var focusRingMaskBounds: NSRect { bounds } + + override func drawFocusRingMask() { + NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 4, yRadius: 4).fill() + } + + override func keyDown(with event: NSEvent) { + let keyCode = Int(event.keyCode) + let modifiers = Modifiers(nsEventFlags: event.modifierFlags) + // Undo and redo, which the Edit menu would do: holzBar hides its main menu. + if keyCode == KeyCode.z.rawValue, modifiers == .command || modifiers == [.command, .shift] { + let undoManager = appState?.navigationState.settingsWindow?.undoManager + if modifiers == .command { + undoManager?.undo() + } else { + undoManager?.redo() + } + return + } + guard let command = LayoutKeys.command(keyCode: keyCode, modifiers: modifiers, itemsKeepOrder: Self.itemsKeepOrder) else { + super.keyDown(with: event) + return + } + perform(command) + } + + /// Performs what a key in the Layout pane asks for. + private func perform(_ command: LayoutKeys.Command) { + switch command { + case .focus: + LayoutBarRouter.shared.moveFocus(from: self, command: command) + case .moveWithinSection(let step): + moveWithinSection(by: step) + case .moveToSection(let target): + guard + let appState, + let section, + let index = MenuBarSection.Name.allCases.firstIndex(of: section) + else { + return + } + let sectionCount = appState.settings.advanced.enableAlwaysHiddenSection ? 3 : 2 + guard + let targetIndex = LayoutKeys.sectionIndex(for: target, from: index, sectionCount: sectionCount) + else { + NSSound.beep() + return + } + move(to: MenuBarSection.Name.allCases[targetIndex]) + case .openMenu: + showContextMenu() + case .refused: + NSSound.beep() + Self.announce(String(localized: "macOS orders the items within each section.")) + } + } + + /// Whether the item can be moved; otherwise beeps and says why. + private func checkMovable() -> Bool { + guard isEnabled, !Bridging.isProcessUnresponsive(item.ownerPID) else { + NSSound.beep() + Self.announce(toolTip ?? String(localized: "Menu bar item is not movable.")) + return false + } + return true + } + + /// Moves the item one place left (`-1`) or right (`1`) within its section. + private func moveWithinSection(by step: Int) { + guard + checkMovable(), + let appState, + let container = superview as? LayoutBarContainer, + let index = container.arrangedViews.firstIndex(of: self), + container.arrangedViews.indices.contains(index + step) + else { + NSSound.beep() + return + } + let neighbour = container.arrangedViews[index + step].item + let destination: MenuBarItemManager.MoveDestination = step < 0 ? .leftOfItem(neighbour) : .rightOfItem(neighbour) + let announcement = step < 0 + ? String(localized: "\(item.displayName) moved left") + : String(localized: "\(item.displayName) moved right") + LayoutBarMoves.move( + item, + to: destination, + appState: appState, + actionName: String(localized: "Move \(item.displayName)"), + announcement: announcement + ) + } + + /// Moves the item to another section. + private func move(to section: MenuBarSection.Name) { + guard checkMovable(), let appState else { + return + } + LayoutBarMoves.setSection(of: item, to: section, appState: appState) + } + + /// Asks VoiceOver to read a message. + static func announce(_ message: String) { + NSAccessibility.post( + element: NSApp as Any, + notification: .announcementRequested, + userInfo: [ + .announcement: message, + .priority: NSAccessibilityPriorityLevel.high.rawValue, + ] + ) + } + + // MARK: Accessibility + + override func accessibilityRole() -> NSAccessibility.Role? { + .button + } + + override func accessibilityLabel() -> String? { + guard let section else { + return item.displayName + } + return String(localized: "\(item.displayName), \(section.displayString)") + } + + override func accessibilityHelp() -> String? { + isEnabled ? nil : toolTip + } + + override func accessibilityPerformPress() -> Bool { + showContextMenu() + return true + } + + override func accessibilityCustomActions() -> [NSAccessibilityCustomAction]? { + var actions = [NSAccessibilityCustomAction]() + if isEnabled, let section, let appState { + let moves: [(MenuBarSection.Name, String, Selector)] = [ + (.visible, String(localized: "Move to Visible"), #selector(accessibilityMoveToVisible)), + (.hidden, String(localized: "Move to Hidden"), #selector(accessibilityMoveToHidden)), + (.alwaysHidden, String(localized: "Move to Always Hidden"), #selector(accessibilityMoveToAlwaysHidden)), + ] + for (name, title, selector) in moves where name != section { + if name == .alwaysHidden, !appState.settings.advanced.enableAlwaysHiddenSection { + continue + } + actions.append(NSAccessibilityCustomAction(name: title, target: self, selector: selector)) + } + if Self.itemsKeepOrder { + actions.append(NSAccessibilityCustomAction(name: String(localized: "Move Left"), target: self, selector: #selector(accessibilityMoveLeft))) + actions.append(NSAccessibilityCustomAction(name: String(localized: "Move Right"), target: self, selector: #selector(accessibilityMoveRight))) + } + } + actions.append(NSAccessibilityCustomAction(name: String(localized: "Set Hotkey…"), target: self, selector: #selector(accessibilitySetHotkey))) + return actions + } + + @objc private func accessibilityMoveToVisible() -> Bool { + move(to: .visible) + return true + } + + @objc private func accessibilityMoveToHidden() -> Bool { + move(to: .hidden) + return true + } + + @objc private func accessibilityMoveToAlwaysHidden() -> Bool { + move(to: .alwaysHidden) + return true + } + + @objc private func accessibilityMoveLeft() -> Bool { + moveWithinSection(by: -1) + return true + } + + @objc private func accessibilityMoveRight() -> Bool { + moveWithinSection(by: 1) + return true + } + + @objc private func accessibilitySetHotkey() -> Bool { + showHotkeyPopover() + return true + } + + /// Shows a recorder for the hotkey that opens the item's menu. + @objc private func showHotkeyPopover() { + guard let appState else { + return + } + let key = appState.itemManager.identityKey(for: item) + let hotkey = appState.settings.hotkeys.hotkey(for: .openItem(key)) + let popover = NSPopover() + popover.behavior = .transient + popover.contentViewController = NSHostingController( + rootView: ItemHotkeyView(hotkey: hotkey, itemName: item.displayName) + ) + popover.show(relativeTo: bounds, of: self, preferredEdge: .maxY) + } + override func mouseDragged(with event: NSEvent) { super.mouseDragged(with: event) @@ -165,7 +478,7 @@ final class LayoutBarItemView: NSView { pasteboardItem.setData(Data(), forType: .layoutBarItem) let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem) - draggingItem.setDraggingFrame(bounds, contents: cachedImage?.nsImage) + draggingItem.setDraggingFrame(bounds, contents: displayImage) beginDraggingSession(with: [draggingItem], event: event, source: self) } @@ -187,6 +500,9 @@ extension LayoutBarItemView: NSDraggingSource { // prevent the dragging image from animating back to its original location session.animatesToStartingPositionsOnCancelOrFail = false + // A profile bound to a display or a Space waits until the drop. + appState?.profiles.isLayoutDragInProgress = true + // async to prevent the view from disappearing before the dragging image appears Task { self.isDraggingPlaceholder = true @@ -197,6 +513,7 @@ extension LayoutBarItemView: NSDraggingSource { defer { // always remove container info at the end of a session oldContainerInfo = nil + appState?.profiles.isLayoutDragInProgress = false } // since the session's `animatesToStartingPositionsOnCancelOrFail` property was @@ -220,6 +537,26 @@ extension LayoutBarItemView: NSDraggingSource { extension LayoutBarItemView: NSAccessibilityLayoutItem { } +// MARK: - ItemHotkeyView + +/// The hotkey recorder for opening one item's menu. +private struct ItemHotkeyView: View { + let hotkey: Hotkey + let itemName: String + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Open \u{201C}\(itemName)\u{201D} with a hotkey") + .font(.headline) + HotkeyRecorder(hotkey: hotkey) { + Text("Hotkey") + } + } + .padding() + .frame(width: 320) + } +} + // MARK: Layout Bar Item Pasteboard Type extension NSPasteboard.PasteboardType { static let layoutBarItem = Self("\(Constants.bundleIdentifier).layout-bar-item") diff --git a/holzBar/MenuBar/LayoutBar/LayoutBarPaddingView.swift b/holzBar/MenuBar/LayoutBar/LayoutBarPaddingView.swift index 60f13b2c..1390ce9a 100644 --- a/holzBar/MenuBar/LayoutBar/LayoutBarPaddingView.swift +++ b/holzBar/MenuBar/LayoutBar/LayoutBarPaddingView.swift @@ -68,59 +68,93 @@ final class LayoutBarPaddingView: NSView { } } - guard let draggingSource = sender.draggingSource as? LayoutBarItemView else { + guard + let draggingSource = sender.draggingSource as? LayoutBarItemView, + let appState = container.appState + else { return false } - if #available(macOS 27.0, *), let appState = container.appState { + if #available(macOS 27.0, *) { // On macOS 27 the saved layout decides sections and macOS orders the items within // one, so a drop only moves the item's application to this section. - if !draggingSource.item.isControlItem, let bundleID = draggingSource.item.sourceApplication?.bundleIdentifier { - appState.concealer27.setSection(MacOS27Section(container.section), for: bundleID) + if !draggingSource.item.isControlItem { + LayoutBarMoves.setSection(of: draggingSource.item, to: container.section, appState: appState) } return true } + let item = draggingSource.item + let actionName = String(localized: "Move \(item.displayName)") if let index = arrangedViews.firstIndex(of: draggingSource) { if arrangedViews.count == 1 { - Task { - // dragging source is the only view in the layout bar, so we - // need to find a target item - let items = await MenuBarItem.getMenuBarItems(option: .activeSpace) - let targetItem: MenuBarItem? = switch container.section { - case .visible: nil // visible section always has more than 1 item - case .hidden: items.first(matching: .hiddenControlItem) - case .alwaysHidden: items.first(matching: .alwaysHiddenControlItem) - } - if let targetItem { - move(item: draggingSource.item, to: .leftOfItem(targetItem)) - } else { - Logger.default.error("No target item for layout bar drag") - } - } + // The dragging source is the only view in the layout bar, so it goes next to + // the section's control item. + LayoutBarMoves.moveToEnd(of: container.section, item: item, appState: appState, actionName: actionName) } else if arrangedViews.indices.contains(index + 1) { // we have a view to the right of the dragging source let targetItem = arrangedViews[index + 1].item - move(item: draggingSource.item, to: .leftOfItem(targetItem)) + LayoutBarMoves.move(item, to: .leftOfItem(targetItem), appState: appState, actionName: actionName) } else if arrangedViews.indices.contains(index - 1) { // we have a view to the left of the dragging source let targetItem = arrangedViews[index - 1].item - move(item: draggingSource.item, to: .rightOfItem(targetItem)) + LayoutBarMoves.move(item, to: .rightOfItem(targetItem), appState: appState, actionName: actionName) } } return true } +} - private func move(item: MenuBarItem, to destination: MenuBarItemManager.MoveDestination) { - guard let appState = container.appState else { - return +// MARK: - LayoutBarMoves + +/// Moves items from the Menu Bar Layout pane, by a drop or the keyboard, and registers the +/// inverse of every move on the Settings window's undo manager (THAW-14): before macOS 27 +/// a user move back next to the item's former neighbour, on macOS 27 the former section. +/// Undoing registers the inverse again, which is the redo. +@MainActor +enum LayoutBarMoves { + /// Where an item was before a move: beside its right neighbour, else its left one, else + /// at the end of its section. Only identifiers, so undo works with whatever the bar + /// holds then. + private enum Anchor { + case leftOf(CGWindowID) + case rightOf(CGWindowID) + case endOf(MenuBarSection.Name) + } + + /// The undo manager of the Settings window, which shows the Layout pane. + private static func undoManager(_ appState: AppState) -> UndoManager? { + appState.navigationState.settingsWindow?.undoManager + } + + // MARK: Before macOS 27 + + /// Moves an item to a destination on the bar, as the user asked. + /// + /// - Parameter registersUndo: Whether the move registers its inverse; an undo registers + /// the redo itself, at once. + static func move( + _ item: MenuBarItem, + to destination: MenuBarItemManager.MoveDestination, + appState: AppState, + actionName: String, + announcement: String? = nil, + registersUndo: Bool = true + ) { + if registersUndo, let anchor = anchor(of: item, appState: appState) { + registerUndo(windowID: item.windowID, anchor: anchor, appState: appState, actionName: actionName) } Task { try? await Task.sleep(for: .milliseconds(25)) do { - try await appState.itemManager.move(item: item, to: destination) + try await appState.itemManager.move(item: item, to: destination, origin: .user) appState.itemManager.removeTemporarilyShownItemFromCache(with: item.tag) + // The user arranged the item: its section is the one to restore from now on. + appState.itemManager.saveSectionsSoon() + if let announcement { + LayoutBarItemView.announce(announcement) + } } catch { Logger.default.error("Error moving menu bar item: \(error, privacy: .private)") let alert = NSAlert(error: error) @@ -128,4 +162,140 @@ final class LayoutBarPaddingView: NSView { } } } + + /// Moves an item to the end of a section, next to the section's control item. + static func moveToEnd( + of section: MenuBarSection.Name, + item: MenuBarItem, + appState: AppState, + actionName: String, + announcement: String? = nil + ) { + // Registered now, while the item is still where it was. + if let anchor = anchor(of: item, appState: appState) { + registerUndo(windowID: item.windowID, anchor: anchor, appState: appState, actionName: actionName) + } + Task { + let items = await MenuBarItem.getMenuBarItems(option: .activeSpace) + guard let destination = endDestination(of: section, in: items) else { + Logger.default.error("No control item to move next to") + return + } + move(item, to: destination, appState: appState, actionName: actionName, announcement: announcement, registersUndo: false) + } + } + + /// The place at the end of a section: left of its control item (right of the hidden + /// section's control item for the visible section). + private static func endDestination(of section: MenuBarSection.Name, in items: [MenuBarItem]) -> MenuBarItemManager.MoveDestination? { + switch section { + case .visible: + items.first(matching: .hiddenControlItem).map { .rightOfItem($0) } + case .hidden: + items.first(matching: .hiddenControlItem).map { .leftOfItem($0) } + case .alwaysHidden: + items.first(matching: .alwaysHiddenControlItem).map { .leftOfItem($0) } + } + } + + /// Where the item is now, read from the item cache, whose sections list the items from + /// left to right. + private static func anchor(of item: MenuBarItem, appState: AppState) -> Anchor? { + let cache = appState.itemManager.itemCache + guard let address = cache.address(for: item.tag) else { + return nil + } + let items = cache[address.section] + if items.indices.contains(address.index + 1) { + return .leftOf(items[address.index + 1].windowID) + } + if items.indices.contains(address.index - 1) { + return .rightOf(items[address.index - 1].windowID) + } + return .endOf(address.section) + } + + private static func registerUndo(windowID: CGWindowID, anchor: Anchor, appState: AppState, actionName: String) { + guard let undoManager = undoManager(appState) else { + return + } + undoManager.registerUndo(withTarget: appState) { appState in + MainActor.assumeIsolated { + LayoutBarMoves.moveBack(windowID: windowID, to: anchor, appState: appState, actionName: actionName) + } + } + undoManager.setActionName(actionName) + } + + /// Puts an item back where the anchor says. + /// + /// The redo, back to where the item is now, is registered at once: only while the undo + /// manager is undoing does a registration become the redo. + private static func moveBack(windowID: CGWindowID, to anchor: Anchor, appState: AppState, actionName: String) { + if + let item = appState.itemManager.itemCache.managedItems.first(where: { $0.windowID == windowID }), + let current = self.anchor(of: item, appState: appState) + { + registerUndo(windowID: windowID, anchor: current, appState: appState, actionName: actionName) + } + Task { + let items = await MenuBarItem.getMenuBarItems(option: .activeSpace) + guard let item = items.first(where: { $0.windowID == windowID }) else { + return + } + let destination: MenuBarItemManager.MoveDestination? = switch anchor { + case .leftOf(let neighbour): + items.first { $0.windowID == neighbour }.map { .leftOfItem($0) } + case .rightOf(let neighbour): + items.first { $0.windowID == neighbour }.map { .rightOfItem($0) } + case .endOf(let section): + endDestination(of: section, in: items) + } + guard let destination else { + return + } + move(item, to: destination, appState: appState, actionName: actionName, registersUndo: false) + } + } + + // MARK: Sections + + /// Moves an item to another section: on macOS 27 its application, before next to the + /// section's control item. + static func setSection(of item: MenuBarItem, to section: MenuBarSection.Name, appState: AppState) { + let actionName = String(localized: "Change Section") + let announcement = String(localized: "\(item.displayName) moved to \(section.displayString)") + if #available(macOS 27.0, *) { + guard let bundleID = item.sourceApplication?.bundleIdentifier else { + NSSound.beep() + return + } + setSection27(section, for: bundleID, appState: appState, actionName: actionName) + LayoutBarItemView.announce(announcement) + return + } + moveToEnd(of: section, item: item, appState: appState, actionName: actionName, announcement: announcement) + } + + /// Sets an application's section on macOS 27 and registers the former one for undo. + @available(macOS 27.0, *) + private static func setSection27(_ section: MenuBarSection.Name, for bundleID: String, appState: AppState, actionName: String) { + let layout = Defaults.dictionary(forKey: .macOS27Layout) as? [String: Int] ?? [:] + // An application missing from the layout is visible. + let former = layout[bundleID].flatMap(MacOS27Section.init(rawValue:)) ?? .visible + if let undoManager = undoManager(appState) { + let formerName: MenuBarSection.Name = switch former { + case .visible: .visible + case .hidden: .hidden + case .alwaysHidden: .alwaysHidden + } + undoManager.registerUndo(withTarget: appState) { appState in + MainActor.assumeIsolated { + LayoutBarMoves.setSection27(formerName, for: bundleID, appState: appState, actionName: actionName) + } + } + undoManager.setActionName(actionName) + } + appState.concealer27.setSection(MacOS27Section(section), for: bundleID) + } } diff --git a/holzBar/MenuBar/MacOS27/ClockCover27.swift b/holzBar/MenuBar/MacOS27/ClockCover27.swift new file mode 100644 index 00000000..1921c62e --- /dev/null +++ b/holzBar/MenuBar/MacOS27/ClockCover27.swift @@ -0,0 +1,145 @@ +// +// ClockCover27.swift +// holzBar +// + +import Cocoa + +/// Covers the part of the bar where concealed items appear while a click on the clock lifts +/// concealment on macOS 27. +/// +/// A click on the clock reaches MenuBarAgent only while no assertion stands, so the bridge +/// lifts concealment for a moment, and every hidden item flashed into view for it (Thaw +/// #1181). The cover stands over that part of the bar — from the end of the application +/// menus, or the notch, to the first item drawn while concealed — and shows the desktop +/// picture there, as the bar does, with the menu bar's tint. It takes no screenshot. +@available(macOS 27.0, *) +@MainActor +final class ClockCover27 { + /// The panel, created on first use. + private var panel: NSPanel? + + /// Takes the cover away after the replayed click. + private var hideTask: Task? + + /// Puts the cover over the concealed part of the active bar. Does nothing when that part + /// cannot be told. + func show(appState: AppState) { + hideTask?.cancel() + hideTask = nil + guard + let screen = NSScreen.screenWithActiveMenuBar, + let leftEdge = MenuBarItemProvider27.leftEdge(for: screen.displayID), + let menuBarHeight = screen.getMenuBarHeight() + else { + return + } + let displayBounds = CGDisplayBounds(screen.displayID) + var startX = displayBounds.minX + if let applicationMenuFrame = appState.applicationMenuFrames.frame(for: screen) { + startX = max(startX, applicationMenuFrame.maxX) + } + if let notch = screen.frameOfNotch, notch.maxX < leftEdge { + startX = max(startX, notch.maxX) + } + guard leftEdge - startX > 1 else { + return + } + let frame = CGRect( + x: startX, + y: screen.frame.maxY - menuBarHeight, + width: leftEdge - startX, + height: menuBarHeight + ) + let panel = panel ?? makePanel() + self.panel = panel + let view = panel.contentView as? CoverView + view?.strip = DesktopPicture.cachedStrip(for: screen, height: menuBarHeight) + view?.stripOriginX = screen.frame.minX - startX + view?.stripWidth = screen.frame.width + view?.tint = Self.tint(appState: appState) + view?.needsDisplay = true + panel.setFrame(frame, display: true) + panel.orderFrontRegardless() + // The next cover finds the picture ready. + Task { + _ = await DesktopPicture.strip(for: screen, height: menuBarHeight) + } + } + + /// Takes the cover away after the given delay. + func hide(after delay: Duration) { + hideTask?.cancel() + hideTask = Task { [weak self] in + do { + try await Task.sleep(for: delay) + } catch { + return + } + self?.panel?.orderOut(nil) + } + } + + /// The tint of a custom menu bar appearance, drawn over the picture as the overlay does. + private static func tint(appState: AppState) -> CGColor? { + let configuration = appState.appearanceManager.configuration.current + guard configuration.tintKind == .solid else { + return nil + } + return configuration.tintColor.copy(alpha: 0.2) + } + + private func makePanel() -> NSPanel { + let panel = NSPanel( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + // Above the items MenuBarAgent draws at the status bar's level. + panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue + 1) + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = false + panel.animationBehavior = .none + panel.hidesOnDeactivate = false + panel.ignoresMouseEvents = true + panel.isExcludedFromWindowsMenu = true + panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary] + panel.contentView = CoverView() + return panel + } +} + +// MARK: - CoverView + +@available(macOS 27.0, *) +private final class CoverView: NSView { + /// The desktop picture under the whole bar, or `nil` for the flat colour. + var strip: CGImage? + + /// Where the strip starts, relative to the view's left edge. + var stripOriginX: CGFloat = 0 + + /// The strip's width: the screen's. + var stripWidth: CGFloat = 0 + + /// The tint over the picture. + var tint: CGColor? + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { + return + } + if let strip { + context.draw(strip, in: CGRect(x: stripOriginX, y: 0, width: stripWidth, height: bounds.height)) + } else { + context.setFillColor(HolzBarShelfColorManager.flatColor27()) + context.fill(bounds) + } + if let tint { + context.setFillColor(tint) + context.fill(bounds) + } + } +} diff --git a/holzBar/MenuBar/MacOS27/Concealer27.swift b/holzBar/MenuBar/MacOS27/Concealer27.swift index 1aab156d..40ff8d62 100644 --- a/holzBar/MenuBar/MacOS27/Concealer27.swift +++ b/holzBar/MenuBar/MacOS27/Concealer27.swift @@ -44,6 +44,33 @@ final class Concealer27 { /// Process identifiers of the applications meant to be concealed right now. private(set) var concealedPIDs = Set() + /// Visible applications concealed for the moment so holzBar's icon clears the notch + /// (`NotchCover27`). Never part of the saved layout; cleared when the active display + /// changes or an application launches or quits. + @ObservationIgnored private var notchConcealed = Set() + + /// The display whose notch `notchConcealed` was worked out for. + @ObservationIgnored private var notchCoverDisplayID: CGDirectDisplayID? + + /// The display whose bar was active at the last settled read, and its width. + @ObservationIgnored private var lastActiveDisplay: (id: CGDirectDisplayID, width: CGFloat, hasNotch: Bool)? + + /// Whether the last settled read showed items folded behind the overflow button. + @ObservationIgnored private var lastReadHadFoldedItems = false + + /// Applications that launched while their section is concealed, shown until their item + /// exists (jordanbaird/Ice#1007). + @ObservationIgnored private var launchGrace = LaunchGrace27() + + /// The applications concealed at the last change, to tell a change of concealment. + @ObservationIgnored private var lastConcealed = Set() + + /// Settled reads in a row, while concealing, without holzBar's icon among the items. + @ObservationIgnored private var readsWithoutIcon = 0 + + /// Whether holzBar's icon was put back since concealment last changed. + @ObservationIgnored private var didReinsertIcon = false + /// The section of each application. Applications missing from it are visible. private var savedLayout: [String: MacOS27Section] { let stored = Defaults.dictionary(forKey: .macOS27Layout) as? [String: Int] ?? [:] @@ -57,13 +84,29 @@ final class Concealer27 { return } let workspaceCenter = NSWorkspace.shared.notificationCenter - for name in [NSWorkspace.didLaunchApplicationNotification, NSWorkspace.didTerminateApplicationNotification] { - observerTasks.append(Task { [weak self] in - for await _ in workspaceCenter.notifications(named: name) { - self?.update() + observerTasks.append(Task { [weak self] in + for await notification in workspaceCenter.notifications(named: NSWorkspace.didLaunchApplicationNotification) { + let application = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + self?.applicationDidLaunch(bundleID: application?.bundleIdentifier) + } + }) + observerTasks.append(Task { [weak self] in + for await _ in workspaceCenter.notifications(named: NSWorkspace.didTerminateApplicationNotification) { + // The bar is laid out anew, so the notch is worked out again. + self?.notchConcealed.removeAll() + self?.update() + } + }) + observerTasks.append(Task { [weak self] in + let center = NotificationCenter.default + for await _ in center.notifications(named: NSApplication.didChangeScreenParametersNotification) { + guard let self, !notchConcealed.isEmpty else { + continue } - }) - } + notchConcealed.removeAll() + update() + } + }) // The assertions are released at termination by `releaseAllForTermination()`, // which the app delegate calls synchronously. // Entering or leaving fullscreen swaps the menu bar the items are drawn in, and nothing @@ -96,6 +139,50 @@ final class Concealer27 { } } + /// Answers an application's launch. + /// + /// An application whose saved section is hidden or always hidden is shown until its item + /// exists, or for at most 10 s, then concealed: concealed before its status item exists, + /// it gets an item of 3 points (jordanbaird/Ice#1007). + private func applicationDidLaunch(bundleID: String?) { + // The bar is laid out anew, so the notch is worked out again. + notchConcealed.removeAll() + guard + let bundleID, + let section = savedLayout[bundleID], + launchGrace.begin(bundleID, isConcealed: section != .visible, at: .now) + else { + update() + return + } + logger.debug("An application in a concealed section launched, showing it until its item exists") + showTemporarily(bundleID: bundleID) + // One bounded wait per grace. + Task { [weak self] in + try? await Task.sleep(for: LaunchGrace27.timeout) + guard let self else { + return + } + endGraces(launchGrace.expired(at: .now)) + } + } + + /// Ends the launch graces of the applications whose items appeared in a read of the bar. + func itemsAppeared(bundleIDs: Set) { + guard !launchGrace.allowed.isEmpty else { + return + } + endGraces(launchGrace.itemsAppeared(bundleIDs)) + } + + /// Conceals the applications whose launch grace ended, balancing their allowance. + private func endGraces(_ bundleIDs: [String]) { + guard !bundleIDs.isEmpty else { + return + } + endTemporaryShow(bundleIDs: bundleIDs) + } + /// Releases every assertion as holzBar quits, so no application stays hidden. func releaseAllForTermination() { controller.releaseAll() @@ -106,18 +193,32 @@ final class Concealer27 { guard let appState, MenuBarAssessmentAssertion27.isAvailable else { return } + // While the screen is locked, the Mac sleeps or the session is away, the assertions + // stay as they are; the concealment is applied again once the bar has settled. + guard !appState.systemActivityMonitor.isPaused else { + return + } if let suspendedUntil, ContinuousClock.now < suspendedUntil { return } let applications = NSWorkspace.shared.runningApplications let running = Set(applications.compactMap(\.bundleIdentifier)) let layout = SectionLayout27.effectiveLayout(observed: [:], saved: savedLayout, running: running) - let target = ConcealmentPlanner27.concealedSets( - layout: layout, - state: revealState(appState), - temporarilyShown: Set(temporarilyShown.keys) + // Applications concealed for the notch join whatever the sections conceal. + let target = NotchCover27.adding( + notchConcealed.subtracting(temporarilyShown.keys), + to: ConcealmentPlanner27.concealedSets( + layout: layout, + state: revealState(appState), + temporarilyShown: Set(temporarilyShown.keys) + ) ) let concealed = ConcealmentPlanner27.effectivelyConcealed(sets: target) + if concealed != lastConcealed { + lastConcealed = concealed + readsWithoutIcon = 0 + didReinsertIcon = false + } isConcealing = !target.isEmpty defer { MenuBarItemProvider27.setConcealedPIDs(concealedPIDs) } concealedPIDs = Set(applications.compactMap { application in @@ -145,9 +246,118 @@ final class Concealer27 { try? await Task.sleep(for: .milliseconds(400)) await self?.appState?.itemManager.cacheItemsIfNeeded() await self?.checkStuckOverflow() + await self?.checkNotchCover() + await self?.checkOwnIcon() } } + /// Puts holzBar's icon back when MenuBarAgent dropped it while concealing. + /// + /// A known issue of the macOS 27 backend (jordanbaird/Ice#1001): MenuBarAgent can remove + /// holzBar's own item while assertions are active, which leaves nothing to click. After + /// two settled reads in a row without it, the icon is added again — once per change of + /// concealment, so it never loops. + private func checkOwnIcon() async { + guard + let appState, + isConcealing, + appState.settings.general.showHolzBarIcon, + !didReinsertIcon + else { + readsWithoutIcon = 0 + return + } + let items = await MenuBarItemProvider27.items() + guard !items.contains(where: { $0.tag == .visibleControlItem }) else { + readsWithoutIcon = 0 + return + } + readsWithoutIcon += 1 + guard readsWithoutIcon >= 2 else { + return + } + logger.notice("holzBar's icon went missing while concealing, putting it back") + readsWithoutIcon = 0 + didReinsertIcon = true + appState.menuBarManager.controlItem(withName: .visible)?.reinsert() + } + + /// Keeps holzBar's icon out from under the notch, and lets MenuBarAgent lay the bar out + /// again when it moves to a wider display with items folded. + /// + /// Runs after each settled read. While the icon lies under the notch of the active + /// display, the nearest visible application right of it is concealed (one per read, until + /// the icon is clear; Thaw #1195, #1153). When the active bar moves to a display without a + /// notch or a wider one while the last read showed folded items, concealment is released + /// and applied again once, so the folded items come back (Thaw #1106). + private func checkNotchCover() async { + guard let screen = NSScreen.screenWithActiveMenuBar else { + return + } + let displayID = screen.displayID + let displayBounds = CGDisplayBounds(displayID) + let items = await MenuBarItemProvider27.items() + let chevronFrame = MenuBarItemProvider27.overflowButtonFrame() + + let previous = lastActiveDisplay + lastActiveDisplay = (displayID, displayBounds.width, screen.hasNotch) + let hadFoldedItems = lastReadHadFoldedItems + lastReadHadFoldedItems = items.contains { item in + displayBounds.intersects(item.bounds) && OverflowDetection27.isInOverflow(itemFrame: item.bounds, chevronFrame: chevronFrame) + } + if let previous, previous.id != displayID { + if !notchConcealed.isEmpty { + notchConcealed.removeAll() + update() + return + } + let isRoomier = !screen.hasNotch || displayBounds.width > previous.width + if hadFoldedItems, isRoomier, isConcealing { + logger.notice("The bar moved to a roomier display with items folded, laying it out again") + suspend(for: Self.settleAfterChange) + return + } + } + + guard + screen.hasNotch, + let notchSpan = StuckOverflow27.notchSpan( + displayBounds: displayBounds, + leftAreaWidth: screen.auxiliaryTopLeftArea?.width, + rightAreaWidth: screen.auxiliaryTopRightArea?.width + ), + let icon = items.first(where: { $0.tag == .visibleControlItem && $0.bounds.width > 4 }) + else { + return + } + if notchCoverDisplayID != displayID { + notchConcealed.removeAll() + notchCoverDisplayID = displayID + } + let concealedBundleIDs = Set(NSWorkspace.shared.runningApplications.compactMap { application -> String? in + concealedPIDs.contains(application.processIdentifier) ? application.bundleIdentifier : nil + }) + let visibleApps = items.compactMap { item -> NotchCover27.App? in + guard item.isOnScreen, displayBounds.intersects(item.bounds), item.bounds.width > 4 else { + return nil + } + return (bundleID: item.tag.namespace.description, frame: item.bounds) + } + let answer = NotchCover27.appsToConceal( + iconFrame: icon.bounds, + visibleApps: visibleApps, + notchSpan: notchSpan, + concealed: concealedBundleIDs.union(notchConcealed), + ownBundleID: Constants.bundleIdentifier + ) + guard let next = answer.first else { + return + } + logger.notice("holzBar's icon is under the notch, concealing one more visible app for now") + notchConcealed.insert(next) + update() + } + /// Whether the notched bar looks stuck with items folded away and no way to reach them. /// /// Settings shows this; nothing acts on it. The cure measured so far is to relaunch the diff --git a/holzBar/MenuBar/MacOS27/Core/ClockBridgeZone27.swift b/holzBar/MenuBar/MacOS27/Core/ClockBridgeZone27.swift index d4269ab8..f6202ff4 100644 --- a/holzBar/MenuBar/MacOS27/Core/ClockBridgeZone27.swift +++ b/holzBar/MenuBar/MacOS27/Core/ClockBridgeZone27.swift @@ -9,11 +9,21 @@ import CoreGraphics /// /// While an assessment-mode assertion is live, MenuBarAgent ignores clicks on /// the clock (measured on macOS 27.0), so such clicks lift the assertion first. +/// +/// Only a click inside the menu bar's own rectangle, while the bar is on screen, is a +/// click on the bar: a click just below the clock over a maximised window, or near it in a +/// fullscreen app with the bar hidden, belongs to the app underneath (Thaw #1171, #1191). nonisolated enum ClockBridgeZone27 { - static func shouldBridge(click: CGPoint, systemItemFrames: [CGRect], isConcealing: Bool) -> Bool { - guard isConcealing else { + /// - Parameters: + /// - click: The click's location. + /// - systemItemFrames: The frames of the system items on the clicked display. + /// - isConcealing: Whether items are concealed. + /// - menuBarRect: The rectangle of the clicked display's menu bar, or `nil` while the + /// bar is not on screen. + static func shouldBridge(click: CGPoint, systemItemFrames: [CGRect], isConcealing: Bool, menuBarRect: CGRect?) -> Bool { + guard isConcealing, let menuBarRect, menuBarRect.contains(click) else { return false } - return systemItemFrames.contains { $0.insetBy(dx: -1, dy: -1).contains(click) } + return systemItemFrames.contains { $0.insetBy(dx: -1, dy: 0).contains(click) } } } diff --git a/holzBar/MenuBar/MacOS27/Core/ItemHitTest27.swift b/holzBar/MenuBar/MacOS27/Core/ItemHitTest27.swift index 62e41d47..f10893f4 100644 --- a/holzBar/MenuBar/MacOS27/Core/ItemHitTest27.swift +++ b/holzBar/MenuBar/MacOS27/Core/ItemHitTest27.swift @@ -10,8 +10,10 @@ import Darwin /// /// There are no item windows on macOS 27, so the test uses the Accessibility /// frames holzBar cached for the drawn items, plus the system items and the overflow -/// button. Accessibility reports frames only for the active menu bar, so on the -/// other display every spot counts as empty, as it did in the original Ice before macOS 27. +/// button. Accessibility reports the apps' frames only for the active menu bar; on the +/// other display the frames MenuBarAgent draws there (`drawnFramesOnDisplay`) stand in, +/// so an item there counts as an item and its menu opens instead of holzBar revealing +/// hidden items (Thaw #1159, #1138, #1137). nonisolated enum ItemHitTest27 { nonisolated struct Item: Equatable { let frame: CGRect @@ -34,7 +36,8 @@ nonisolated enum ItemHitTest27 { items: [Item], concealedPIDs: Set, systemFrames: [CGRect], - rememberedLeftEdge: CGFloat? + rememberedLeftEdge: CGFloat?, + drawnFramesOnDisplay: [CGRect] = [] ) -> Bool { guard displayBounds.minX...displayBounds.maxX ~= point.x else { return false @@ -46,6 +49,8 @@ nonisolated enum ItemHitTest27 { .filter { $0.isOnScreen && !concealedPIDs.contains($0.ownerPID) && isOnThisDisplay($0.frame) } .map(\.frame.minX) edges += systemFrames.filter(isOnThisDisplay).map(\.minX) + // On the display whose bar is not active, what MenuBarAgent draws there. + edges += drawnFramesOnDisplay.filter(isOnThisDisplay).map(\.minX) if let remembered = rememberedLeftEdge, displayBounds.minX...displayBounds.maxX ~= remembered { edges.append(remembered) } @@ -55,10 +60,20 @@ nonisolated enum ItemHitTest27 { return point.x >= leftEdge } - static func isInsideItem(point: CGPoint, items: [Item], concealedPIDs: Set, systemFrames: [CGRect]) -> Bool { + /// Whether the pointer rests on an item: a drawn cached item, a system item, or (on + /// the display whose bar is not active) a frame MenuBarAgent draws there. + static func isInsideItem( + point: CGPoint, + items: [Item], + concealedPIDs: Set, + systemFrames: [CGRect], + drawnFramesOnDisplay: [CGRect] = [] + ) -> Bool { let isInsideDrawnItem = items.contains { item in item.isOnScreen && !concealedPIDs.contains(item.ownerPID) && item.frame.contains(point) } - return isInsideDrawnItem || systemFrames.contains { $0.contains(point) } + return isInsideDrawnItem || + systemFrames.contains { $0.contains(point) } || + drawnFramesOnDisplay.contains { $0.contains(point) } } } diff --git a/holzBar/MenuBar/MacOS27/Core/LaunchGrace27.swift b/holzBar/MenuBar/MacOS27/Core/LaunchGrace27.swift new file mode 100644 index 00000000..0f85cca1 --- /dev/null +++ b/holzBar/MenuBar/MacOS27/Core/LaunchGrace27.swift @@ -0,0 +1,69 @@ +// +// LaunchGrace27.swift +// holzBar +// + +import Foundation + +/// Applications that launched while their section is concealed, allowed on the bar until +/// their item exists (macOS 27). +/// +/// Concealing an application before its status item exists makes MenuBarAgent give the item +/// 3 points, and it stays squashed when it is revealed (jordanbaird/Ice#1007). So a launching +/// application whose section is concealed is shown until its item appears in a read of the +/// bar, or for at most ``timeout``, and then concealed. Each grace is ended exactly once, so +/// the caller can balance its allowance. +nonisolated struct LaunchGrace27 { + /// How long a launching application is allowed at most. + static let timeout = Duration.seconds(10) + + /// The applications allowed now, with when their grace began. + private var started = [String: ContinuousClock.Instant]() + + /// The applications allowed now. + var allowed: Set { + Set(started.keys) + } + + /// Starts a grace for an application that launched. + /// + /// - Parameters: + /// - bundleID: The application's bundle identifier. + /// - isConcealed: Whether its saved section is concealed now; a visible application + /// needs no grace. + /// - now: When it launched. + /// - Returns: Whether a grace began (not when the app is visible or already allowed). + @discardableResult + mutating func begin(_ bundleID: String, isConcealed: Bool, at now: ContinuousClock.Instant) -> Bool { + guard isConcealed, started[bundleID] == nil else { + return false + } + started[bundleID] = now + return true + } + + /// Ends the graces of the applications whose items appeared. + /// + /// - Returns: The applications whose grace ended. + mutating func itemsAppeared(_ bundleIDs: Set) -> [String] { + let ended = started.keys.filter(bundleIDs.contains).sorted() + for bundleID in ended { + started[bundleID] = nil + } + return ended + } + + /// Ends the graces that lasted ``timeout`` without an item. + /// + /// - Returns: The applications whose grace ended. + mutating func expired(at now: ContinuousClock.Instant) -> [String] { + let ended = started + .filter { $0.value.duration(to: now) >= Self.timeout } + .map(\.key) + .sorted() + for bundleID in ended { + started[bundleID] = nil + } + return ended + } +} diff --git a/holzBar/MenuBar/MacOS27/Core/NotchCover27.swift b/holzBar/MenuBar/MacOS27/Core/NotchCover27.swift new file mode 100644 index 00000000..f70ff28e --- /dev/null +++ b/holzBar/MenuBar/MacOS27/Core/NotchCover27.swift @@ -0,0 +1,84 @@ +// +// NotchCover27.swift +// holzBar +// + +import CoreGraphics + +/// Keeps holzBar's icon out from under the notch on macOS 27. +/// +/// On a crowded notched bar, macOS 27 can lay holzBar's own icon out under the notch, where +/// nobody can click it (Thaw #1195, #1153). Concealing visible apps to the right of the icon +/// moves it right by their width; holzBar conceals the nearest ones until the icon clears the +/// notch, for as long as the bar stays as it is. That concealment is transient and never +/// written to the saved layout. +nonisolated enum NotchCover27 { + /// An app with an item drawn on the bar. + typealias App = (bundleID: String, frame: CGRect) + + /// The visible apps to conceal, nearest to the icon first, so the icon clears the notch. + /// + /// The icon has to move right until its left edge passes the notch's right edge. The apps + /// right of the icon are taken from the icon outwards (an app with several items counts + /// once, with all their widths) until their widths add up to that; when they cannot, all + /// of them are returned. + /// + /// - Parameters: + /// - iconFrame: The frame of holzBar's icon. + /// - visibleApps: The items drawn on the bar, with their apps. + /// - notchSpan: The horizontal span the notch covers. + /// - concealed: The apps already concealed. + /// - ownBundleID: holzBar's bundle identifier. + /// - Returns: The apps to conceal, or none when the icon is clear of the notch. + static func appsToConceal( + iconFrame: CGRect, + visibleApps: [App], + notchSpan: ClosedRange, + concealed: Set, + ownBundleID: String + ) -> [String] { + guard iconFrame.maxX > notchSpan.lowerBound, iconFrame.minX < notchSpan.upperBound else { + return [] + } + let needed = notchSpan.upperBound - iconFrame.minX + var order = [String]() + var widths = [String: CGFloat]() + for app in visibleApps.sorted(by: { $0.frame.minX < $1.frame.minX }) { + guard + app.frame.minX >= iconFrame.midX, + app.bundleID != ownBundleID, + !concealed.contains(app.bundleID) + else { + continue + } + if widths[app.bundleID] == nil { + order.append(app.bundleID) + } + widths[app.bundleID, default: 0] += app.frame.width + } + var freed: CGFloat = 0 + var answer = [String]() + for bundleID in order { + answer.append(bundleID) + freed += widths[bundleID, default: 0] + if freed >= needed { + break + } + } + return answer + } + + /// The concealed sets of the assertions with the given apps concealed as well. + /// + /// The apps concealed together are the intersection of the sets, so the extra apps join + /// every set; with no assertion they make one of their own. + static func adding(_ apps: Set, to sets: [Set]) -> [Set] { + guard !apps.isEmpty else { + return sets + } + guard !sets.isEmpty else { + return [apps] + } + return sets.map { $0.union(apps) } + } +} diff --git a/holzBar/MenuBar/MacOS27/ItemClicker27.swift b/holzBar/MenuBar/MacOS27/ItemClicker27.swift index 067ae932..3f6c48cf 100644 --- a/holzBar/MenuBar/MacOS27/ItemClicker27.swift +++ b/holzBar/MenuBar/MacOS27/ItemClicker27.swift @@ -67,6 +67,13 @@ enum ItemClicker27 { try? await Task.sleep(for: .milliseconds(250)) waited += 1 } + // "Hide opened items again after" counts from here, when the menu has closed + // (THAW-13). One bounded wait; opening the item again meanwhile shows it once more, + // and it stays until that show ends too. + let delay = min(max(appState.settings.advanced.tempShowInterval, 0), 30) + if delay > 0 { + try? await Task.sleep(for: .seconds(delay)) + } concealer.endTemporaryShow(bundleID: bundleID) } @@ -78,10 +85,14 @@ enum ItemClicker27 { } } + /// The menus on screen, with their owners: only menus keep an item shown + /// (`InterfaceWindowRule`), never another window of its app (Thaw #1158). private static func windowOwners() -> [(number: Int, ownerPID: Int32)] { - WindowInfo.createWindows(option: .onScreen).map { window in - (number: Int(window.windowID), ownerPID: window.ownerPID) - } + WindowInfo.createWindows(option: .onScreen) + .filter { InterfaceWindowRule.menuLayers.contains($0.layer) } + .map { window in + (number: Int(window.windowID), ownerPID: window.ownerPID) + } } private static func postMenuBarActivationClick(at point: CGPoint) { diff --git a/holzBar/MenuBar/MacOS27/ItemImageStore27.swift b/holzBar/MenuBar/MacOS27/ItemImageStore27.swift index a0275003..66c25a6a 100644 --- a/holzBar/MenuBar/MacOS27/ItemImageStore27.swift +++ b/holzBar/MenuBar/MacOS27/ItemImageStore27.swift @@ -135,6 +135,11 @@ final class ItemImageStore27 { /// going at once, 260–290 ms each (measured 2026-09-16), all asking the display server /// for the same strip while MenuBarAgent was animating the bar. func captureActiveMenuBar(appState: AppState, force: Bool = false) async { + // Nothing is photographed while the screen is locked, the Mac sleeps or the session + // is away. + guard !appState.systemActivityMonitor.isPaused else { + return + } if !force { // A capture already under way photographs the same bar this caller wants. if let captureTask { @@ -235,6 +240,9 @@ final class ItemImageStore27 { /// Shows the applications of items that have no image for a moment, and captures them. func photographMissing(items: [MenuBarItem], appState: AppState) async { + guard !appState.systemActivityMonitor.isPaused else { + return + } let now = ProcessInfo.processInfo.systemUptime let bundleIDs = Set(items.compactMap { item -> String? in guard !item.isControlItem, image(for: item) == nil else { diff --git a/holzBar/MenuBar/MacOS27/MenuBarItemProvider27.swift b/holzBar/MenuBar/MacOS27/MenuBarItemProvider27.swift index b547a029..76f00c00 100644 --- a/holzBar/MenuBar/MacOS27/MenuBarItemProvider27.swift +++ b/holzBar/MenuBar/MacOS27/MenuBarItemProvider27.swift @@ -50,6 +50,9 @@ nonisolated enum MenuBarItemProvider27 { /// System item frames per display. MenuBarAgent describes the bars of both displays in its /// windows, unlike other applications, whose items only have frames on the active one. var lastSystemFramesByDisplay = [CGDirectDisplayID: [CGRect]]() + /// Every frame MenuBarAgent draws on each display whose bar is not active: there it + /// draws the apps' items too, which have no Accessibility frames of their own. + var lastDrawnFramesByDisplay = [CGDirectDisplayID: [CGRect]]() /// The leftmost item drawn on each display, from the last read while that display's /// menu bar was active. Hover hit-testing needs it for the display that is not active, /// where Accessibility reports no frames at all. @@ -117,12 +120,17 @@ nonisolated enum MenuBarItemProvider27 { /// Returns the current frame of the item with the given synthetic identifier. /// /// holzBar's own items are answered from the last read: asking our own process - /// from the main thread would wait for the main thread itself. + /// from the main thread would wait for the main thread itself. So is every item when + /// asked on the main thread: holzBar's event monitors and the click tap wait on that + /// thread, and an application that hangs would hold up every click on the Mac. Off + /// the main thread the frame is read live. No shorter messaging timeout is set on the + /// element there: `ItemClicker27` presses the same element, and a press may wait for + /// the item's menu to open. static func currentBounds(for windowID: CGWindowID) -> CGRect? { guard let entry = withState({ $0.entries[windowID] }) else { return nil } - if entry.bundleID == Constants.bundleIdentifier { + if entry.bundleID == Constants.bundleIdentifier || Thread.isMainThread { return entry.frame } return frame(of: entry.element) ?? entry.frame @@ -163,6 +171,12 @@ nonisolated enum MenuBarItemProvider27 { withState { $0.lastSystemFramesByDisplay[displayID] ?? [] } } + /// Every frame MenuBarAgent draws on the given display, from the last read, when that + /// display's bar is not active (none for the active display). + static func drawnFrames(for displayID: CGDirectDisplayID) -> [CGRect] { + withState { $0.lastDrawnFramesByDisplay[displayID] ?? [] } + } + /// Frame of the system overflow button ("<<" / ">>"), from the last read. static func overflowButtonFrame() -> CGRect? { withState { $0.lastOverflowButtonFrame } @@ -290,7 +304,12 @@ nonisolated enum MenuBarItemProvider27 { } return frames.filter { $0.minX >= clock.minX - systemItemsSpan } } - withState { $0.lastSystemFramesByDisplay = perDisplay } + // Every frame on the displays whose bar is not active, for hit tests there. + let drawnByDisplay = framesByDisplay.filter { $0.key != activeDisplayID } + withState { state in + state.lastSystemFramesByDisplay = perDisplay + state.lastDrawnFramesByDisplay = drawnByDisplay + } let systemFrames = rawItems .filter { $0.bundleID == menuBarAgentBundleID && (activeDisplayBounds?.intersects($0.frame) ?? true) } .map(\.frame) diff --git a/holzBar/MenuBar/MacOS27/SystemItemClickBridge27.swift b/holzBar/MenuBar/MacOS27/SystemItemClickBridge27.swift index 2054f20e..7359ccc7 100644 --- a/holzBar/MenuBar/MacOS27/SystemItemClickBridge27.swift +++ b/holzBar/MenuBar/MacOS27/SystemItemClickBridge27.swift @@ -33,6 +33,10 @@ final class SystemItemClickBridge27: SystemItemClickBridge { "com.apple.menuextra.wifi", ] + /// Covers the concealed part of the bar while a click lifts concealment, so the hidden + /// items do not flash into view (Thaw #1181). + private let clockCover = ClockCover27() + /// The system item whose panel holzBar last opened, so a second click on the same item is /// understood as the click that dismisses it. private var itemShowingPanel: String? @@ -93,10 +97,18 @@ final class SystemItemClickBridge27: SystemItemClickBridge { // is not active is recognised as well. let clickedDisplay = NSScreen.screens.first { CGDisplayBounds($0.displayID).contains(event.location) }?.displayID let framesOnDisplay = clickedDisplay.map { MenuBarItemProvider27.systemItemFrames(for: $0) } ?? [] + // The bar's rectangle is worked out only for a click that may be bridged at all. + let menuBarRect = concealer.isConcealing ? clickedDisplay.flatMap { displayID in + Self.visibleMenuBarRect(on: displayID, isFullscreenSpace: appState.activeSpace.isFullscreen) + } : nil + // The overflow button ("»") ignores clicks while concealed like the clock (Thaw #1195). + let systemFrames = (framesOnDisplay.isEmpty ? MenuBarItemProvider27.systemItemFrames() : framesOnDisplay) + + [MenuBarItemProvider27.overflowButtonFrame()].compactMap { $0 } guard ClockBridgeZone27.shouldBridge( click: event.location, - systemItemFrames: framesOnDisplay.isEmpty ? MenuBarItemProvider27.systemItemFrames() : framesOnDisplay, - isConcealing: concealer.isConcealing + systemItemFrames: systemFrames, + isConcealing: concealer.isConcealing, + menuBarRect: menuBarRect ) else { return event } @@ -151,9 +163,12 @@ final class SystemItemClickBridge27: SystemItemClickBridge { // the replay, so the click is as quick as the release allows. let bridgeStarted = ProcessInfo.processInfo.systemUptime Self.bridgeLogger.debug("Click bridge: holding the click, lifting concealment") + self.clockCover.show(appState: appState) await concealer.suspendReleased(for: Self.clickRestoreDelay) let released = (ProcessInfo.processInfo.systemUptime - bridgeStarted) * 1000 Self.replayClick(at: location) + // One bounded wait, past the concealment's return. + self.clockCover.hide(after: .milliseconds(300)) self.itemShowingPanel = systemItem?.identifier Self.bridgeLogger.debug("Click bridge: lifted in \(released, privacy: .public) ms, click replayed") } @@ -243,6 +258,30 @@ final class SystemItemClickBridge27: SystemItemClickBridge { } } + /// The rectangle of the given display's menu bar, or `nil` while the bar is not on + /// screen. + /// + /// On a fullscreen space the bar shows only while the pointer reveals it, so it counts + /// only while the window server's menu bar window is on screen (the window list, not + /// Accessibility). Elsewhere the bar is the strip above the screen's visible frame, at + /// least as tall as the status bar. + private static func visibleMenuBarRect(on displayID: CGDirectDisplayID, isFullscreenSpace: Bool) -> CGRect? { + let displayBounds = CGDisplayBounds(displayID) + let height: CGFloat + if isFullscreenSpace { + guard let menuBarWindow = WindowInfo.menuBarWindow(for: displayID) else { + return nil + } + height = menuBarWindow.bounds.height + } else { + guard let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) else { + return nil + } + height = max(screen.frame.maxY - screen.visibleFrame.maxY, NSStatusBar.system.thickness) + } + return CGRect(x: displayBounds.minX, y: displayBounds.minY, width: displayBounds.width, height: height) + } + private static func replayClick(at location: CGPoint) { let source = CGEventSource(stateID: .hidSystemState) for type in [CGEventType.leftMouseDown, .leftMouseUp] { diff --git a/holzBar/MenuBar/MenuBarItems/ItemChangeWatcher.swift b/holzBar/MenuBar/MenuBarItems/ItemChangeWatcher.swift new file mode 100644 index 00000000..c613b522 --- /dev/null +++ b/holzBar/MenuBar/MenuBarItems/ItemChangeWatcher.swift @@ -0,0 +1,243 @@ +// +// ItemChangeWatcher.swift +// holzBar +// + +import AppKit +@preconcurrency import ApplicationServices +import OSLog + +/// Shows a hidden item for a moment when its title or value changes, for the items the +/// user marked "Show When It Changes" (THAW-12). +/// +/// One `AXObserver` per process that owns a marked item listens for +/// `kAXTitleChangedNotification` and `kAXValueChangedNotification` on those items' +/// elements only; its run-loop source runs on the main run loop. The observers follow the +/// item list (they are set up again when it changes) and nothing runs while no item is +/// marked. Each notification passes ``ChangeReveal``'s debounce and rate limit; a due item +/// is shown for 5 seconds (`MenuBarManager.revealBriefly(itemKey:)`). No timer and no +/// polling: one bounded wait per batch of changes. +/// +/// An item whose app only swaps its picture may post nothing, and is then never shown. +@MainActor +final class ItemChangeWatcher { + /// The watcher the observers' callback reports to. + fileprivate static weak var current: ItemChangeWatcher? + + /// The identity keys of the marked items, as stored. + private var keys = Set() + + /// The shared app state. + private weak var appState: AppState? + + /// Follows the item list. + private var cacheObserver: ObservationLoop? + + /// The observers, by process identifier. + private var observers = [pid_t: AXObserver]() + + /// The observed elements with their items' keys, by process identifier. + private var watched = [pid_t: [(element: AXUIElement, key: String)]]() + + /// The observed items and their windows, so an unchanged list sets nothing up again. + private var watchedWindows = [String: CGWindowID]() + + /// The debounce and rate limit. + private var changeReveal = ChangeReveal() + + /// The one wait until the next change is due. + private var dueTask: Task? + + private let logger = Logger(category: "ItemChangeWatcher") + + /// Starts following the marked items. + func performSetup(with appState: AppState) { + self.appState = appState + Self.current = self + keys = Set(Defaults.stringArray(forKey: .revealOnChangeItems) ?? []) + let itemManager = appState.itemManager + cacheObserver = ObservationLoop.observe { itemManager.itemCache } onChange: { [weak self] _ in + self?.updateObservers() + } + updateObservers() + } + + // MARK: Marked Items + + /// Whether the item is marked "Show When It Changes". + func isRevealedOnChange(_ item: MenuBarItem) -> Bool { + guard let itemManager = appState?.itemManager else { + return false + } + let key = itemManager.identityKey(for: item) + return keys.contains { itemManager.storedIdentityKey($0) == key } + } + + /// Marks or unmarks the item. + func setRevealedOnChange(_ isOn: Bool, for item: MenuBarItem) { + guard let itemManager = appState?.itemManager else { + return + } + let key = itemManager.identityKey(for: item) + keys = keys.filter { itemManager.storedIdentityKey($0) != key } + if isOn { + keys.insert(key) + } + Defaults.set(keys.sorted(), forKey: .revealOnChangeItems) + appState?.settingsSync.settingsDidChange() + updateObservers() + } + + // MARK: Observers + + /// Observes exactly the marked items that are in the menu bar now. + private func updateObservers() { + guard let appState else { + return + } + let itemManager = appState.itemManager + let wanted = Set(keys.map { itemManager.storedIdentityKey($0) }) + let items = itemManager.itemCache.managedItems.filter { item in + !item.isControlItem && wanted.contains(itemManager.identityKey(for: item)) + } + let windows = Dictionary( + items.map { (itemManager.identityKey(for: $0), $0.windowID) }, + uniquingKeysWith: { first, _ in first } + ) + guard windows != watchedWindows else { + return + } + watchedWindows = windows + removeAll() + for item in items { + let pid = item.sourcePID ?? item.ownerPID + guard let element = element(for: item, pid: pid) else { + continue + } + observe(element, key: itemManager.identityKey(for: item), pid: pid) + } + } + + /// The Accessibility element of the item: on macOS 27 the one the item list was read + /// from; before, the child of its app's extras menu bar at the item's place. Every call + /// to the app waits 0.25 s at most. + private func element(for item: MenuBarItem, pid: pid_t) -> AXUIElement? { + if #available(macOS 27.0, *) { + return MenuBarItemProvider27.element(forWindowID: item.windowID) + } + let application = AXUIElementCreateApplication(pid) + AXHelpers.setMessagingTimeout(0.25, for: application) + guard let extrasMenuBar = AXHelpers.extrasMenuBar(for: application) else { + return nil + } + AXHelpers.setMessagingTimeout(0.25, for: extrasMenuBar) + return AXHelpers.children(for: extrasMenuBar).first { child in + AXHelpers.setMessagingTimeout(0.25, for: child) + guard let frame = AXHelpers.frame(for: child) else { + return false + } + return abs(frame.minX - item.bounds.minX) < 2 && abs(frame.width - item.bounds.width) < 2 + } + } + + private func observe(_ element: AXUIElement, key: String, pid: pid_t) { + let observer: AXObserver + if let existing = observers[pid] { + observer = existing + } else { + var created: AXObserver? + guard AXObserverCreate(pid, itemChangeWatcherCallback, &created) == .success, let created else { + return + } + CFRunLoopAddSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(created), .defaultMode) + observers[pid] = created + observer = created + } + // The process identifier travels as the context pointer (it is never 0 here). + let context = UnsafeMutableRawPointer(bitPattern: Int(pid)) + let added = [kAXTitleChangedNotification, kAXValueChangedNotification].filter { notification in + AXObserverAddNotification(observer, element, notification as CFString, context) == .success + } + guard !added.isEmpty else { + logger.debug("A marked item posts no change notifications") + return + } + watched[pid, default: []].append((element: element, key: key)) + } + + private func removeAll() { + for observer in observers.values { + CFRunLoopRemoveSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .defaultMode) + } + observers.removeAll() + watched.removeAll() + } + + // MARK: Changes + + /// Handles a change notification of the element with the given hash. + fileprivate func elementChanged(hash: CFHashCode, pid: pid_t) { + guard + let appState, + let key = watched[pid]?.first(where: { CFHash($0.element) == hash })?.key + else { + return + } + changeReveal.noteChange( + key: key, + isZenActive: !appState.menuBarManager.zenMode.allows(.changeReveal), + isVisible: isVisible(key, appState: appState), + at: ProcessInfo.processInfo.systemUptime + ) + scheduleDueChanges() + } + + /// Whether the item with the given key can be seen anyway. + private func isVisible(_ key: String, appState: AppState) -> Bool { + guard + let item = appState.itemManager.item(withIdentityKey: key), + let address = appState.itemManager.itemCache.address(for: item.tag) + else { + return true + } + guard address.section != .visible else { + return true + } + return appState.menuBarManager.section(withName: address.section)?.isHidden == false + } + + /// Waits once until the next change is due, shows the due items and waits again while + /// changes wait. + private func scheduleDueChanges() { + guard dueTask == nil, let dueTime = changeReveal.nextDueTime else { + return + } + let delay = max(dueTime - ProcessInfo.processInfo.systemUptime, 0) + dueTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard let self else { + return + } + dueTask = nil + for key in changeReveal.due(at: ProcessInfo.processInfo.systemUptime) { + appState?.menuBarManager.revealBriefly(itemKey: key) + } + scheduleDueChanges() + } + } +} + +/// The observers' callback; it runs on the main run loop. Only the element's hash leaves +/// it, which identifies the element among the observed ones. +nonisolated private func itemChangeWatcherCallback( + _ observer: AXObserver, + _ element: AXUIElement, + _ notification: CFString, + _ context: UnsafeMutableRawPointer? +) { + let pid = context.map { pid_t(truncatingIfNeeded: Int(bitPattern: $0)) } ?? 0 + let hash = CFHash(element) + MainActor.assumeIsolated { + ItemChangeWatcher.current?.elementChanged(hash: hash, pid: pid) + } +} diff --git a/holzBar/MenuBar/MenuBarItems/ItemIconStore.swift b/holzBar/MenuBar/MenuBarItems/ItemIconStore.swift new file mode 100644 index 00000000..7b950428 --- /dev/null +++ b/holzBar/MenuBar/MenuBarItems/ItemIconStore.swift @@ -0,0 +1,252 @@ +// +// ItemIconStore.swift +// holzBar +// + +import AppKit +import ImageIO +import Observation +import OSLog +import UniformTypeIdentifiers + +/// Images of the user's choice for menu bar items, and the app icon as the fallback when +/// there is no picture of an item (THAW-16, jordanbaird/Ice#912, Thaw #1087). +/// +/// The choices are stored by item identity in the `ItemIcons` setting (synced like the +/// rest); the images themselves stay on this Mac, in Application Support/holzBar/ItemIcons. +/// A chosen file is read once from the open panel, scaled off the main thread to at most +/// 64 points high at 2x and stored as a PNG with a new name. A choice whose file is missing +/// falls back to the next one (``ItemIconChoice``). +@MainActor +@Observable +final class ItemIconStore { + /// The stored choices, by item identity key. + private(set) var choices = [String: String]() + + /// The images read from the folder, by file name. + @ObservationIgnored private var loadedImages = [String: NSImage]() + + /// The shared app state. + @ObservationIgnored private weak var appState: AppState? + + private let logger = Logger(category: "ItemIconStore") + + /// The height the chosen images and app icons are drawn at, like a menu bar item. + static let drawnHeight: CGFloat = 18 + + /// The folder of the chosen images. + static var folder: URL? { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first? + .appending(path: "holzBar", directoryHint: .isDirectory) + .appending(path: "ItemIcons", directoryHint: .isDirectory) + } + + func performSetup(with appState: AppState) { + self.appState = appState + choices = Defaults.dictionary(forKey: .itemIcons) as? [String: String] ?? [:] + } + + // MARK: Choices + + /// The stored key of the given item's choice, if it has one. + private func storedKey(for item: MenuBarItem) -> String? { + guard let itemManager = appState?.itemManager else { + return nil + } + let key = itemManager.identityKey(for: item) + if choices[key] != nil { + return key + } + return choices.keys.first { itemManager.storedIdentityKey($0) == key } + } + + /// The user's choice for the item. + func choice(for item: MenuBarItem) -> ItemIconChoice.Stored? { + storedKey(for: item).flatMap { ItemIconChoice.parse(choices[$0]) } + } + + /// Stores a choice for the item, or removes it (`nil`), and deletes an image file the + /// item no longer uses. + private func setChoice(_ stored: ItemIconChoice.Stored?, for item: MenuBarItem) { + guard let itemManager = appState?.itemManager else { + return + } + let previous = choice(for: item) + if let key = storedKey(for: item) { + choices[key] = nil + } + if let stored { + choices[itemManager.identityKey(for: item)] = ItemIconChoice.storedValue(stored) + } + Defaults.set(choices, forKey: .itemIcons) + appState?.settingsSync.settingsDidChange() + if case .file(let name) = previous, previous != stored { + deleteFile(named: name) + } + } + + /// Shows the item's app icon instead of its picture. + func useAppIcon(for item: MenuBarItem) { + setChoice(.appIcon, for: item) + } + + /// Goes back to the item's own picture. + func reset(for item: MenuBarItem) { + setChoice(nil, for: item) + } + + /// Lets the user choose an image file for the item. + func chooseImage(for item: MenuBarItem) { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.image] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.message = String(localized: "Choose an image for \u{201C}\(item.displayName)\u{201D}.") + guard panel.runModal() == .OK, let url = panel.url else { + return + } + Task { + guard let name = await storeImage(from: url) else { + NSSound.beep() + return + } + setChoice(.file(name), for: item) + } + } + + /// Reads, scales and stores the image at the URL, and returns its new file name. + func storeImage(from url: URL) async -> String? { + let data = await Task.detached(priority: .userInitiated) { + ItemIconStore.scaledPNG(from: url) + }.value + guard let data, let folder = Self.folder else { + logger.error("Could not read the chosen image") + return nil + } + let name = UUID().uuidString + ".png" + do { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try data.write(to: folder.appending(path: name), options: .atomic) + return name + } catch { + logger.error("Could not store the chosen image: \(error, privacy: .private)") + return nil + } + } + + /// The image scaled to at most 128 pixels high (64 points at 2x) as PNG data. + private nonisolated static func scaledPNG(from url: URL) -> Data? { + guard + let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Double, + let height = properties[kCGImagePropertyPixelHeight] as? Double, + width > 0, + height > 0 + else { + return nil + } + // At most 128 pixels high; a wide image may be up to four times as wide. + let maxPixelSize = min(max(128, 128 * width / height), 512) + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: Int(maxPixelSize), + ] + guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(data as CFMutableData, UTType.png.identifier as CFString, 1, nil) else { + return nil + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + return nil + } + return data as Data + } + + /// Deletes an image file, unless a group or another item still uses it. + func deleteFile(named name: String) { + guard ItemIconChoice.isValidFileName(name), let folder = Self.folder else { + return + } + let isUsed = choices.values.contains { ItemIconChoice.parse($0) == .file(name) } + || (appState?.itemGroups.groups.contains { $0.imageFile == name } ?? false) + guard !isUsed else { + return + } + loadedImages[name] = nil + try? FileManager.default.removeItem(at: folder.appending(path: name)) + } + + // MARK: Images + + /// The image stored under the given file name, read once. + func storedImage(named name: String) -> NSImage? { + if let image = loadedImages[name] { + return image + } + guard + ItemIconChoice.isValidFileName(name), + let folder = Self.folder, + let image = NSImage(contentsOf: folder.appending(path: name)) + else { + return nil + } + loadedImages[name] = image + return image + } + + /// The icon of the item's app. + func appIcon(for item: MenuBarItem) -> NSImage? { + guard let application = item.sourceApplication else { + return nil + } + if let icon = application.icon { + return icon + } + return application.bundleURL.map { NSWorkspace.shared.icon(forFile: $0.path(percentEncoded: false)) } + } + + /// What the item shows, in the order of ``ItemIconChoice``: its chosen image, its + /// picture, its app's icon, or `nil` for its name. + /// + /// - Parameter captured: The item's picture, if the caller has one; by default the one + /// in the image cache. + func image(for item: MenuBarItem, captured: NSImage? = nil) -> NSImage? { + let captured = captured ?? appState?.imageCache.images[item.tag]?.nsImage + let stored = choice(for: item) + var custom: NSImage? + if case .file(let name) = stored { + custom = storedImage(named: name) + } + let source = ItemIconChoice.source( + stored: stored, + hasCustomImage: custom != nil, + hasCapture: captured != nil, + hasAppIcon: item.sourceApplication != nil + ) + switch source { + case .custom: + return custom.map(Self.sizedForMenuBar) + case .captured: + return captured + case .appIcon: + return appIcon(for: item).map(Self.sizedForMenuBar) + case .name: + return nil + } + } + + /// A copy of the image drawn at the height of a menu bar item. + static func sizedForMenuBar(_ image: NSImage) -> NSImage { + guard image.size.height > 0, let copy = image.copy() as? NSImage else { + return image + } + let scale = drawnHeight / image.size.height + copy.size = CGSize(width: (image.size.width * scale).rounded(), height: drawnHeight) + return copy + } +} diff --git a/holzBar/MenuBar/MenuBarItems/ItemOpener.swift b/holzBar/MenuBar/MenuBarItems/ItemOpener.swift new file mode 100644 index 00000000..1d8ffad2 --- /dev/null +++ b/holzBar/MenuBar/MenuBarItems/ItemOpener.swift @@ -0,0 +1,128 @@ +// +// ItemOpener.swift +// holzBar +// + +import AppKit +@preconcurrency import ApplicationServices +import OSLog + +/// The one way holzBar opens a menu bar item's menu for the user: the holzBar Shelf, the +/// search, groups, item hotkeys, the item hints and the Shortcuts action all call +/// ``MenuBarItemManager/openItem(_:mouseButton:shelfDisplayID:)``. +extension MenuBarItemManager { + /// Opens the menu of the given item. + /// + /// With "Open hidden items in the menu bar" off, a hidden item is pressed through + /// Accessibility without being shown; when the app does not take the press, it is + /// shown as usual. Otherwise, on macOS 27 the item's application is shown for the click + /// (`ItemClicker27`); before, an item on screen is clicked and a hidden one is shown for + /// a moment and clicked. + /// + /// - Parameters: + /// - item: The item to open. + /// - mouseButton: `.left` for the item's menu, `.right` for its secondary menu. + /// - shelfDisplayID: The display of the holzBar Shelf the item was clicked in, if any. + func openItem(_ item: MenuBarItem, mouseButton: CGMouseButton, shelfDisplayID: CGDirectDisplayID?) async { + if + let appState, + !appState.settings.advanced.openHiddenItemsInMenuBar, + isHidden(item, appState: appState) + { + if await pressWithoutShowing(item, mouseButton: mouseButton) { + return + } + Logger.default.debug("A hidden item did not take the press, so it is shown") + } + if #available(macOS 27.0, *), let appState { + await ItemClicker27.click(item: item, mouseButton: mouseButton, shelfDisplayID: shelfDisplayID, appState: appState) + return + } + if Bridging.isWindowOnScreen(item.windowID) { + do { + try await click(item: item, with: mouseButton) + } catch { + Logger.default.error("Error opening menu bar item: \(error, privacy: .private)") + } + } else { + await temporarilyShow(item: item, clickingWith: mouseButton) + } + } + + /// Whether the item is hidden now: its application concealed on macOS 27, off screen + /// before. + private func isHidden(_ item: MenuBarItem, appState: AppState) -> Bool { + if #available(macOS 27.0, *) { + guard let pid = item.sourcePID else { + return false + } + return appState.concealer27.concealedPIDs.contains(pid) + } + return !Bridging.isWindowOnScreen(item.windowID) + } + + /// Presses a hidden item through Accessibility without showing it. + /// + /// The press runs off the main thread and every call to the app waits 0.25 s at most, + /// so an app that hangs cannot block holzBar. + /// + /// - Returns: Whether the app took the press. + private func pressWithoutShowing(_ item: MenuBarItem, mouseButton: CGMouseButton) async -> Bool { + let action = mouseButton == .right ? kAXShowMenuAction : kAXPressAction + let knownElement: AXUIElement? = if #available(macOS 27.0, *) { + MenuBarItemProvider27.element(forWindowID: item.windowID) + } else { + nil + } + let pid = item.sourcePID ?? item.ownerPID + let bounds = item.bounds + let result: AXError = await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + guard let element = knownElement ?? MenuBarItemManager.extrasMenuBarElement(pid: pid, matching: bounds) else { + continuation.resume(returning: .failure) + return + } + AXUIElementSetMessagingTimeout(element, 0.25) + continuation.resume(returning: AXUIElementPerformAction(element, action as CFString)) + } + } + return result == .success + } + + /// The child of the app's extras menu bar at the item's place (before macOS 27). + private nonisolated static func extrasMenuBarElement(pid: pid_t, matching bounds: CGRect) -> AXUIElement? { + let application = AXUIElementCreateApplication(pid) + AXUIElementSetMessagingTimeout(application, 0.25) + guard let extrasMenuBar = AXHelpers.extrasMenuBar(for: application) else { + return nil + } + AXUIElementSetMessagingTimeout(extrasMenuBar, 0.25) + return AXHelpers.children(for: extrasMenuBar).first { child in + AXUIElementSetMessagingTimeout(child, 0.25) + guard let frame = AXHelpers.frame(for: child) else { + return false + } + return abs(frame.minX - bounds.minX) < 2 && abs(frame.width - bounds.width) < 2 + } + } + + /// The cached item stored under the given identity key, if it is in the menu bar now. + func item(withIdentityKey stored: String) -> MenuBarItem? { + let key = storedIdentityKey(stored) + return itemCache.managedItems.first { item in + identityKey(for: item) == key + } + } + + /// Opens the menu of the item stored under the given identity key (an item's hotkey). + func openItem(withIdentityKey key: String) { + guard let item = item(withIdentityKey: key) else { + Logger.default.notice("The item of a hotkey is not in the menu bar") + NSSound.beep() + return + } + Task { + await openItem(item, mouseButton: .left, shelfDisplayID: nil) + } + } +} diff --git a/holzBar/MenuBar/MenuBarItems/MenuBarItemImageCache.swift b/holzBar/MenuBar/MenuBarItems/MenuBarItemImageCache.swift index 56f4c820..b709695b 100644 --- a/holzBar/MenuBar/MenuBarItems/MenuBarItemImageCache.swift +++ b/holzBar/MenuBar/MenuBarItems/MenuBarItemImageCache.swift @@ -389,6 +389,13 @@ final class MenuBarItemImageCache { return } + // Nothing is captured while the screen is locked, the Mac sleeps or the session is + // away; the cache is updated once the bar has settled afterwards. + guard !appState.systemActivityMonitor.isPaused else { + logger.debug("Skipping item image cache while the Mac is not in use") + return + } + if #available(macOS 27.0, *) { // There are no item windows to capture on macOS 27. See `ItemImageStore27`. var items = [MenuBarItem]() diff --git a/holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift b/holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift index a1669bcb..ba329e5f 100644 --- a/holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/holzBar/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -30,6 +30,45 @@ final class MenuBarItemManager { /// A timer for rehiding temporarily shown menu bar items. @ObservationIgnored private var rehideTimer: Timer? + /// Whether the "Hide opened items again after" delay runs: it starts when no shown + /// item's menu is open any more (THAW-13). + @ObservationIgnored private var isRehideDelayRunning = false + + /// Pauses automatic moves after repeated failures or a repeatedly moved item. + @ObservationIgnored private var moveBackoff = MoveBackoff() + + /// The end of the pause of automatic moves that was last logged, so it is logged once. + @ObservationIgnored private var loggedPauseEnd: ContinuousClock.Instant? + + /// Whether temporarily shown items wait to be rehidden until automatic moves may run + /// again (on the next settle or user move) instead of retrying every 3 seconds. + @ObservationIgnored private var isRehideWaitingForMoves = false + + /// The namespaces whose item titles change beyond their numbers (`ItemIdentity`). + @ObservationIgnored private(set) var titleChangingOwners = Set() + + /// The bar as last read, for learning which apps change their item titles. + @ObservationIgnored private var previousIdentityItems: [ItemIdentity.Item]? + + /// The identity key of each cached item, by window. + @ObservationIgnored private var identityKeysByWindow = [CGWindowID: String]() + + /// Whether the sections are saved after the next item cache, because the user arranged + /// items (see `SectionRestore.swift`). + @ObservationIgnored var needsSectionSave = false + + /// Whether the sections are being reconciled. + @ObservationIgnored var isReconcilingSections = false + + /// A reconciliation asked for while one was running; it runs once that one ends. + @ObservationIgnored var pendingReconciliation: (wanted: [String: MenuBarSection.Name]?, trigger: SectionRestoreTrigger)? + + /// The restore after an application launched, with its one re-check. + @ObservationIgnored var applicationLaunchRestoreTask: Task? + + /// The cache that records the sections the user just arranged. + @ObservationIgnored var sectionSaveTask: Task? + /// Tasks that observe the events that may change the item list. @ObservationIgnored private var observerTasks = [Task]() @@ -52,7 +91,9 @@ final class MenuBarItemManager { /// Sets up the manager. func performSetup(with appState: AppState) async { self.appState = appState + titleChangingOwners = Set(Defaults.array(forKey: .titleChangingItemOwners) as? [String] ?? []) await cacheItemsRegardless() + await reconcileSections(trigger: .launch) configureObservers(with: appState) } @@ -107,6 +148,17 @@ final class MenuBarItemManager { } } + if backend.canMoveItems { + // An application that launches may put its items anywhere; they go back to their + // sections (see `SectionRestore.swift`). + observerTasks.append(Task { [weak self] in + let center = NSWorkspace.shared.notificationCenter + for await _ in center.notifications(named: NSWorkspace.didLaunchApplicationNotification) { + self?.applicationDidLaunch() + } + }) + } + if backend.refreshesAfterApplicationActivation { // Accessibility reports frames only for the active menu bar, so read the // items again soon after it moves to another display. @@ -128,7 +180,13 @@ final class MenuBarItemManager { } /// Reads the item list again once the events that may have changed it pause for 1 s. + /// + /// Nothing is read while the screen is locked, the Mac sleeps or the session is away; + /// the list is read once the bar has settled afterwards. private func itemListMayHaveChanged() { + if appState?.systemActivityMonitor.isPaused == true { + return + } itemListDebouncer.schedule { [weak self] in guard let self else { return @@ -275,7 +333,7 @@ extension MenuBarItemManager { /// A pair of control items, taken from a list of menu bar items /// during a menu bar item cache operation. - private struct ControlItemPair { + struct ControlItemPair { let hidden: MenuBarItem let alwaysHidden: MenuBarItem? @@ -360,7 +418,7 @@ extension MenuBarItemManager { context.shouldClearCachedItemWindowIDs = true } - if let temp = temporarilyShownItemContexts.first(where: { $0.tag == item.tag }) { + if let temp = temporarilyShownItemContexts.first(where: { $0.matches(item) }) { // Cache temporarily shown items as if they were in their original locations. // Keep track of them separately and use their return destinations to insert // them into the cache once all other items have been handled. @@ -407,6 +465,11 @@ extension MenuBarItemManager { return } + guard appState?.systemActivityMonitor.isPaused != true else { + logger.debug("Skipping menu bar item cache while the Mac is not in use") + return + } + guard !lastMoveOperationOccurred(within: .seconds(1)) else { logger.debug("Skipping menu bar item cache due to recent item movement") return @@ -453,13 +516,19 @@ extension MenuBarItemManager { await enforceControlItemOrder(controlItems: controlItems) // Forget the UUIDs of item windows that are gone (on every space). pruneUUIDCache(keeping: Bridging.getMenuBarWindowList(option: .itemsOnly)) + updateIdentities(with: items) } await uncheckedCacheItems(items: items, controlItems: controlItems, displayID: displayID) if backend.canMoveItems { + // The sections the user arranged, or of the first run, are recorded. + if needsSectionSave || Defaults.dictionary(forKey: .itemSections) == nil { + needsSectionSave = false + saveSections() + } // Moving runs outside the cache task, which it would otherwise hold up. Task { - await self.placeNewItems(items, controlItems: controlItems) + await self.reconcileSections(trigger: .itemListChange, items: items, controlItems: controlItems) } } } @@ -479,134 +548,6 @@ extension MenuBarItemManager { } } -// MARK: - Moving Items Into Sections - -extension MenuBarItemManager { - /// Moves items into the sections given by their tags' descriptions, for - /// example to apply a layout profile. Items already in their section, and - /// items that are not on the bar, are left alone. - func move(itemsTo sections: [String: MenuBarSection.Name]) async { - var items = await MenuBarItem.getMenuBarItems(option: .activeSpace) - guard let controlItems = ControlItemPair(items: &items) else { - logger.warning("Missing control item for hidden section, cannot move items into sections") - return - } - for item in items where item.isMovable && !item.isControlItem { - guard - var section = sections[item.tag.description], - itemCache.address(for: item.tag)?.section != section - else { - continue - } - if section == .alwaysHidden && controlItems.alwaysHidden == nil { - section = .hidden - } - let destination: MoveDestination = switch section { - case .visible: .rightOfItem(controlItems.hidden) - case .hidden: .leftOfItem(controlItems.hidden) - case .alwaysHidden: .leftOfItem(controlItems.alwaysHidden ?? controlItems.hidden) - } - do { - try await move(item: item, to: destination) - } catch { - logger.error("Error moving \(item.logString, privacy: .private(mask: .hash)) into \(section.logString, privacy: .public): \(error, privacy: .private)") - } - } - await cacheItemsRegardless() - } -} - -// MARK: - Placing New Items - -extension MenuBarItemManager { - /// Moves menu bar items that holzBar has not seen before into the section - /// chosen in the settings (jordanbaird/Ice#6, jordanbaird/Ice#767, - /// jordanbaird/Ice#378). - /// - /// macOS puts a new item at the far left of the bar, which is wherever the - /// leftmost section happens to be. holzBar remembers every item it has seen, - /// so only items that are new to it are moved, and the first run only records - /// what is there. Items whose identity changes on every launch are left alone, - /// as they would be new every time. - private func placeNewItems(_ items: [MenuBarItem], controlItems: ControlItemPair) async { - guard let appState else { - return - } - - if appState.settings.advanced.keepLiveActivitiesVisible { - await keepLiveActivitiesVisible(items, controlItems: controlItems) - } - - let candidates = items.filter { item in - item.isMovable && - item.canBeHidden && - !item.isControlItem && - !item.isSystemClone && - !item.tag.namespace.isUUID - } - let stored = Defaults.array(forKey: .knownItemTags) as? [String] - var known = Set(stored ?? []) - let newItems = candidates.filter { !known.contains($0.tag.description) } - - guard stored == nil || !newItems.isEmpty else { - return - } - known.formUnion(candidates.map(\.tag.description)) - Defaults.set(known.sorted(), forKey: .knownItemTags) - - guard - stored != nil, - var section = appState.settings.advanced.newItemsPlacement.section - else { - return - } - if section == .alwaysHidden && controlItems.alwaysHidden == nil { - section = .hidden - } - - for item in newItems where itemCache.address(for: item.tag)?.section != section { - let destination: MoveDestination = switch section { - case .visible: .rightOfItem(controlItems.hidden) - case .hidden: .leftOfItem(controlItems.hidden) - case .alwaysHidden: .leftOfItem(controlItems.alwaysHidden ?? controlItems.hidden) - } - do { - logger.info("Placing new item \(item.logString, privacy: .private(mask: .hash)) in \(section.logString, privacy: .public)") - try await move(item: item, to: destination) - } catch { - logger.error("Error placing new item \(item.logString, privacy: .private(mask: .hash)): \(error, privacy: .private)") - } - } - } -} - -extension MenuBarItemManager { - /// Moves Live Activities that macOS put in a hidden section to the visible - /// one (jordanbaird/Ice#731). - /// - /// A Live Activity appears as a new item at the far left of the bar, which - /// is a hidden section, so without this it is only seen by showing that section. - private func keepLiveActivitiesVisible(_ items: [MenuBarItem], controlItems: ControlItemPair) async { - for item in items where item.isMovable && !item.isControlItem { - let isHidden = item.bounds.maxX <= controlItems.hidden.bounds.minX - guard isHidden else { - continue - } - if item.tag.isLiveActivity { - do { - logger.info("Keeping Live Activity \(item.logString, privacy: .private(mask: .hash)) visible") - try await move(item: item, to: .rightOfItem(controlItems.hidden)) - } catch { - logger.error("Error moving Live Activity \(item.logString, privacy: .private(mask: .hash)): \(error, privacy: .private)") - } - } else if item.tag.namespace.isUUID || item.tag.namespace.description.hasPrefix("com.apple.") { - // Helps find the process that draws Live Activities. - logger.debug("Hidden system item: \(item.tag.description, privacy: .private(mask: .hash))") - } - } - } -} - // MARK: - Event Helpers extension MenuBarItemManager { @@ -628,6 +569,8 @@ extension MenuBarItemManager { case itemResponseTimeout(MenuBarItem) /// A menu bar item's bounds cannot be found. case missingItemBounds(MenuBarItem) + /// Automatic moves are paused (see `MoveBackoff` and `SystemActivityMonitor`). + case automaticMovesPaused var description: String { switch self { @@ -647,33 +590,41 @@ extension MenuBarItemManager { "\(Self.self).itemResponseTimeout(item: \(item.tag))" case .missingItemBounds(let item): "\(Self.self).missingItemBounds(item: \(item.tag))" + case .automaticMovesPaused: + "\(Self.self).automaticMovesPaused" } } var errorDescription: String? { switch self { case .cannotComplete: - "Operation could not be completed" + String(localized: "Operation could not be completed") case .invalidEventSource: - "Invalid event source" + String(localized: "Invalid event source") case .missingMouseLocation: - "Missing mouse location" + String(localized: "Missing mouse location") case .eventCreationFailure(let item): - "Could not create event for \"\(item.displayName)\"" + String(localized: "Could not create event for \u{201C}\(item.displayName)\u{201D}") case .eventOperationTimeout(let item): - "Event operation timed out for \"\(item.displayName)\"" + String(localized: "Event operation timed out for \u{201C}\(item.displayName)\u{201D}") case .itemNotMovable(let item): - "\"\(item.displayName)\" is not movable" + String(localized: "\u{201C}\(item.displayName)\u{201D} is not movable") case .itemResponseTimeout(let item): - "\"\(item.displayName)\" took too long to respond" + String(localized: "\u{201C}\(item.displayName)\u{201D} took too long to respond") case .missingItemBounds(let item): - "Missing bounds rectangle for \"\(item.displayName)\"" + String(localized: "Missing bounds rectangle for \u{201C}\(item.displayName)\u{201D}") + case .automaticMovesPaused: + String(localized: "Automatic moves are paused") } } var recoverySuggestion: String? { - if case .itemNotMovable = self { return nil } - return "Please try again. If the error persists, please file a bug report." + switch self { + case .itemNotMovable, .automaticMovesPaused: + return nil + default: + return String(localized: "Please try again. If the error persists, please file a bug report.") + } } } } @@ -704,19 +655,82 @@ extension MenuBarItemManager { } } + /// Who asked for a move. + nonisolated enum MoveOrigin { + /// The user, by dragging an item in the Layout pane or opening one from the Shelf, + /// search or a group. Always allowed; it ends a pause of automatic moves. + case user + /// holzBar itself: placing new items, keeping Live Activities visible, rehiding, + /// ordering the dividers or applying a profile. Paused by `MoveBackoff` and while + /// the Mac is not in use. + case automatic + } + /// Moves a menu bar item to the given destination. /// + /// An automatic move throws ``EventError/automaticMovesPaused`` while automatic moves + /// are paused: after repeated failures or a repeatedly moved item (`MoveBackoff`), and + /// while the screen is locked, the Mac sleeps or the session is away. + /// /// - Parameters: /// - item: The menu bar item to move. /// - destination: The destination to move the item to. - func move(item: MenuBarItem, to destination: MoveDestination) async throws { + /// - origin: Who asked for the move. + func move(item: MenuBarItem, to destination: MoveDestination, origin: MoveOrigin = .automatic) async throws { guard item.isMovable else { throw EventError.itemNotMovable(item) } guard let appState else { throw EventError.cannotComplete } - try await backend.move(item: item, to: destination, appState: appState) + switch origin { + case .user: + moveBackoff.recordUserMove() + loggedPauseEnd = nil + case .automatic: + try checkAutomaticMove(of: item, appState: appState) + } + do { + try await backend.move(item: item, to: destination, appState: appState) + } catch { + if origin == .automatic, moveBackoff.recordFailure(at: .now) { + logPausedMoves() + } + throw error + } + if origin == .user, isRehideWaitingForMoves { + // The user's move ended the pause; the waiting items are rehidden after the + // usual interval (not at once: this may be an item the user is opening). + isRehideWaitingForMoves = false + runRehideTimer() + } + } + + /// Throws when an automatic move of the given item may not run now, and counts it + /// otherwise. + private func checkAutomaticMove(of item: MenuBarItem, appState: AppState) throws { + guard !appState.systemActivityMonitor.isPaused else { + throw EventError.automaticMovesPaused + } + let now = ContinuousClock.now + guard moveBackoff.allowsAutomaticMove(at: now) else { + logPausedMoves() + throw EventError.automaticMovesPaused + } + if moveBackoff.recordAutomaticMove(identifier: item.tag.description, at: now) { + logPausedMoves() + throw EventError.automaticMovesPaused + } + } + + /// Logs the current pause of automatic moves, once per pause. + private func logPausedMoves() { + guard let pausedUntil = moveBackoff.pausedUntil, pausedUntil != loggedPauseEnd else { + return + } + loggedPauseEnd = pausedUntil + let seconds = Int(ContinuousClock.now.duration(to: pausedUntil).components.seconds) + logger.warning("Automatic moves paused until \(seconds, privacy: .public) s from now") } /// Clicks a menu bar item with the given mouse button. @@ -740,6 +754,12 @@ extension MenuBarItemManager { /// The tag associated with the item. let tag: MenuBarItemTag + /// The item's window. + let windowID: CGWindowID + + /// The item's identity key (`ItemIdentity`). + let identityKey: String + /// The destination to return the item to. let returnDestination: MoveDestination @@ -751,6 +771,9 @@ extension MenuBarItemManager { /// A Boolean value that indicates whether the menu bar item's /// interface is showing. + /// + /// Only a menu counts (`InterfaceWindowRule`): another window of the app, such as a + /// small floating one, never kept the item from being hidden again (Thaw #1158). var isShowingInterface: Bool { guard let window = shownInterfaceWindow, @@ -759,22 +782,25 @@ extension MenuBarItemManager { // Window no longer exists, so assume closed. return false } - if - current.layer != CGWindowLevelForKey(.popUpMenuWindow), - current.layer != CGWindowLevelForKey(.popUpMenuWindow) - 1, - current.layer != CGWindowLevelForKey(.statusWindow), - current.layer != CGWindowLevelForKey(.mainMenuWindow), - let app = current.owningApplication - { - return app.isActive && current.isOnScreen - } - return current.isOnScreen + // The window was recorded as a new window of the item's app after the click. + return current.isOnScreen && InterfaceWindowRule.counts( + layer: current.layer, + isOwnersWindow: true, + appearedAfterClick: true + ) } - init(tag: MenuBarItemTag, returnDestination: MoveDestination) { + init(tag: MenuBarItemTag, windowID: CGWindowID, identityKey: String, returnDestination: MoveDestination) { self.tag = tag + self.windowID = windowID + self.identityKey = identityKey self.returnDestination = returnDestination } + + /// Whether the context belongs to the given item: the same window, or the same tag. + func matches(_ item: MenuBarItem) -> Bool { + item.windowID == windowID || item.tag == tag + } } /// Gets the destination to return the given item to after it is @@ -831,7 +857,7 @@ extension MenuBarItemManager { return } - guard let applicationMenuFrame = screen.getApplicationMenuFrame() else { + guard let applicationMenuFrame = appState.applicationMenuFrames.frame(for: screen) else { logger.error("No application menu frame, so not showing \(item.logString, privacy: .private(mask: .hash))") return } @@ -867,7 +893,7 @@ extension MenuBarItemManager { guard let targetItem = items.first else { logger.warning("Not enough room to show \(item.logString, privacy: .private(mask: .hash))") let alert = NSAlert() - alert.messageText = "Not enough room to show \"\(item.displayName)\"" + alert.messageText = String(localized: "Not enough room to show \u{201C}\(item.displayName)\u{201D}") alert.runModal() return } @@ -880,18 +906,25 @@ extension MenuBarItemManager { logger.debug("Temporarily showing \(item.logString, privacy: .private(mask: .hash))") do { - try await move(item: item, to: .leftOfItem(targetItem)) + try await move(item: item, to: .leftOfItem(targetItem), origin: .user) } catch { logger.error("Error showing item: \(error, privacy: .private)") return } - let context = TemporarilyShownItemContext(tag: item.tag, returnDestination: destination) + let context = TemporarilyShownItemContext( + tag: item.tag, + windowID: item.windowID, + identityKey: identityKey(for: item), + returnDestination: destination + ) temporarilyShownItemContexts.append(context) rehideTimer?.invalidate() + isRehideDelayRunning = false defer { - runRehideTimer() + // The first check comes soon; the delay counts from when the menu closes. + runRehideTimer(for: 1) } await MenuBarItemEventPoster.eventSleep(for: .milliseconds(100)) @@ -908,7 +941,11 @@ extension MenuBarItemManager { let windowsAfterClick = WindowInfo.createWindows(option: .onScreen) context.shownInterfaceWindow = windowsAfterClick.first { window in - window.ownerPID == item.sourcePID && !idsBeforeClick.contains(window.windowID) + InterfaceWindowRule.counts( + layer: window.layer, + isOwnersWindow: window.ownerPID == item.sourcePID, + appearedAfterClick: !idsBeforeClick.contains(window.windowID) + ) } } @@ -924,16 +961,37 @@ extension MenuBarItemManager { guard !temporarilyShownItemContexts.isEmpty else { return } + // While automatic moves are paused, the items wait for the next settle or user + // move instead of retrying every few seconds. + guard + !appState.systemActivityMonitor.isPaused, + moveBackoff.allowsAutomaticMove(at: .now) + else { + waitToRehide(appState: appState) + return + } + isRehideWaitingForMoves = false guard !temporarilyShownItemContexts.contains(where: { $0.isShowingInterface }) else { logger.debug("Menu bar item interface is shown, so waiting to rehide") + isRehideDelayRunning = false runRehideTimer(for: 3) return } + // "Hide opened items again after" counts from when the menu was seen closed. + if !isRehideDelayRunning { + let delay = min(max(appState.settings.advanced.tempShowInterval, 0), 30) + if delay > 0 { + isRehideDelayRunning = true + runRehideTimer(for: delay) + return + } + } guard MenuBarItemEventPoster.hasUserPausedInput(for: .milliseconds(250)) else { logger.debug("Found recent user input, so waiting to rehide") runRehideTimer(for: 1) return } + isRehideDelayRunning = false var currentContexts = temporarilyShownItemContexts temporarilyShownItemContexts.removeAll() @@ -955,12 +1013,22 @@ extension MenuBarItemManager { MouseHelpers.showCursor() } + let keys = identityKeys(for: items) while let context = currentContexts.popLast() { - guard let item = items.first(matching: context.tag) else { + // The window first (it lasts while the app runs), then the identity, which + // survives a title that changed while the item was shown. + let item = items.first { $0.windowID == context.windowID } + ?? items.first { keys[$0.windowID] == context.identityKey } + guard let item else { continue } do { try await move(item: item, to: context.returnDestination) + } catch EventError.automaticMovesPaused { + // Paused during the rehide: the rest waits for the next settle or user move. + failedContexts.append(context) + failedContexts.append(contentsOf: currentContexts.reversed()) + currentContexts.removeAll() } catch { context.rehideAttempts += 1 logger.warning( @@ -991,10 +1059,46 @@ extension MenuBarItemManager { """ ) temporarilyShownItemContexts.append(contentsOf: failedContexts.reversed()) - runRehideTimer(for: 3) + if appState.systemActivityMonitor.isPaused || !moveBackoff.allowsAutomaticMove(at: .now) { + waitToRehide(appState: appState) + } else { + runRehideTimer(for: 3) + } } } + /// Lets the temporarily shown items wait while automatic moves are paused: they are + /// rehidden on the next settle or user move, or once when the back-off's pause ends. + private func waitToRehide(appState: AppState) { + logger.debug("Automatic moves are paused, so waiting to rehide") + isRehideWaitingForMoves = true + rehideTimer?.invalidate() + rehideTimer = nil + // While the Mac is not in use the settle brings them back; otherwise one timer + // fires as the pause ends. + guard !appState.systemActivityMonitor.isPaused, let pausedUntil = moveBackoff.pausedUntil else { + return + } + let remaining = ContinuousClock.now.duration(to: pausedUntil) + let seconds = Double(remaining.components.seconds) + Double(remaining.components.attoseconds) / 1e18 + runRehideTimer(for: max(seconds, 0) + 1) + } + + /// Rehides the temporarily shown items once, if they wait for automatic moves to be + /// allowed again (after a settle or a user move). + func retryPausedRehide() async { + guard isRehideWaitingForMoves else { + return + } + isRehideWaitingForMoves = false + await rehideTemporarilyShownItems() + } + + /// Whether the given item is shown for a moment and waits to be rehidden. + func isTemporarilyShown(_ item: MenuBarItem) -> Bool { + temporarilyShownItemContexts.contains { $0.matches(item) } + } + /// Removes a temporarily shown item from the cache, ensuring that /// the item is _not_ returned to its original location. func removeTemporarilyShownItemFromCache(with tag: MenuBarItemTag) { @@ -1035,6 +1139,62 @@ extension MenuBarItemManager { } } +// MARK: - Item Identity + +extension MenuBarItemManager { + /// The identity keys of the given items, by window, keyed in the bar's order (left to + /// right) (`ItemIdentity`). + func identityKeys(for items: [MenuBarItem]) -> [CGWindowID: String] { + let ordered = items.sorted { $0.bounds.minX < $1.bounds.minX } + let keys = ItemIdentity.keys( + for: ordered.map { (namespace: $0.tag.namespace.description, title: $0.tag.title) }, + titleChangingOwners: titleChangingOwners + ) + return Dictionary(zip(ordered.map(\.windowID), keys)) { first, _ in first } + } + + /// The key under which the given item is stored in profiles, groups, the known items and + /// the saved sections. It is the only way an item becomes a stored key. + func identityKey(for item: MenuBarItem) -> String { + if let key = identityKeysByWindow[item.windowID] { + return key + } + let keys = ItemIdentity.keys( + for: [(namespace: item.tag.namespace.description, title: item.tag.title)], + titleChangingOwners: titleChangingOwners + ) + return keys.first ?? item.tag.description + } + + /// The key a stored key (of this or an earlier version) matches today. + func storedIdentityKey(_ stored: String) -> String { + ItemIdentity.storedKey(stored, titleChangingOwners: titleChangingOwners) + } + + /// Learns which apps change their item titles from this and the previous read of the bar, + /// and keys the items anew. + private func updateIdentities(with items: [MenuBarItem]) { + let current = items + .sorted { $0.bounds.minX < $1.bounds.minX } + .map { (namespace: $0.tag.namespace.description, title: $0.tag.title) } + if let previousIdentityItems { + let learned = ItemIdentity.learnTitleChangingOwners( + previous: previousIdentityItems, + current: current, + excluding: [Constants.bundleIdentifier] + ) + let newlyLearned = learned.subtracting(titleChangingOwners) + if !newlyLearned.isEmpty { + titleChangingOwners.formUnion(newlyLearned) + Defaults.set(titleChangingOwners.sorted(), forKey: .titleChangingItemOwners) + logger.info("Learned \(newlyLearned.count, privacy: .public) apps whose item titles change") + } + } + previousIdentityItems = current + identityKeysByWindow = identityKeys(for: items) + } +} + // MARK: - Logger Helpers nonisolated private extension Logger { diff --git a/holzBar/MenuBar/MenuBarItems/SectionRestore.swift b/holzBar/MenuBar/MenuBarItems/SectionRestore.swift new file mode 100644 index 00000000..0a9b4f26 --- /dev/null +++ b/holzBar/MenuBar/MenuBarItems/SectionRestore.swift @@ -0,0 +1,330 @@ +// +// SectionRestore.swift +// holzBar +// + +import Cocoa +import OSLog + +/// The one path that saves and restores the section of each item before macOS 27. +/// +/// Before macOS 27 the bar itself was the only record of an item's section, so an item that +/// macOS put elsewhere — after the app relaunched or updated, after holzBar restarted, after +/// a display was connected — stayed there. Now the section of every item is saved under its +/// identity (`ItemIdentity`) once the user arranged the items (a drop in the Layout pane, a +/// Command-drag on the bar, a profile) and on the first run, and ``reconcileSections(wanted:trigger:)`` +/// puts items back. It also places new items (the new-items setting) and keeps Live +/// Activities visible. macOS 27 keeps its sections per app (`Concealer27`) and does not use it. +extension MenuBarItemManager { + /// Logger for saving and restoring sections. + private static let restoreLogger = Logger(category: "SectionRestore") + + /// What asks for the sections to be reconciled. + nonisolated enum SectionRestoreTrigger: String { + /// holzBar has set up. + case launch + /// An application has launched (and its one re-check 2 s later). + case applicationLaunch + /// The bar has settled after a display change, wake or unlock. + case settle + /// A profile is applied. + case profile + /// The item list changed: only new items are placed. + case itemListChange + + /// Whether items go back to their saved sections, not only new ones to theirs. + var restoresSavedSections: Bool { + self != .itemListChange + } + } + + // MARK: Saving + + /// Saves the section of every cached item under its identity key. Sections of items that + /// are not on the bar now (their apps are not running) are kept. + func saveSections() { + guard backend.canMoveItems else { + return + } + var stored = Defaults.dictionary(forKey: .itemSections) as? [String: Int] ?? [:] + for section in MenuBarSection.Name.allCases { + for item in itemCache[section] where !item.isControlItem && !item.tag.namespace.isUUID { + stored[identityKey(for: item)] = section.profileIndex + } + } + Defaults.set(stored, forKey: .itemSections) + Self.restoreLogger.debug("Saved the sections of \(stored.count, privacy: .public) items") + } + + /// Saves the sections of the given items (by identity key), keeping the others. + func storeSections(_ sections: [String: MenuBarSection.Name]) { + guard !sections.isEmpty else { + return + } + var stored = Defaults.dictionary(forKey: .itemSections) as? [String: Int] ?? [:] + for (key, section) in sections { + stored[key] = section.profileIndex + } + Defaults.set(stored, forKey: .itemSections) + } + + /// Saves the sections once the bar has taken the arrangement the user just made: a drop + /// in the Layout pane or a Command-drag on the bar. Until then nothing is restored, so + /// the old sections never undo the user's move. + func saveSectionsSoon() { + guard backend.canMoveItems else { + return + } + needsSectionSave = true + sectionSaveTask?.cancel() + // One read after holzBar's own recent-move pause (1 s) has passed; the read saves. + sectionSaveTask = Task { [weak self] in + do { + try await Task.sleep(for: .milliseconds(1500)) + } catch { + return + } + await self?.cacheItemsRegardless() + } + } + + /// The saved section of each identity key. + private func savedSections() -> [String: MenuBarSection.Name] { + let stored = Defaults.dictionary(forKey: .itemSections) as? [String: Int] ?? [:] + var sections = [String: MenuBarSection.Name]() + for (key, index) in stored { + if let section = MenuBarSection.Name(profileIndex: index) { + sections[storedIdentityKey(key)] = section + } + } + return sections + } + + // MARK: Restoring + + /// Restores an application's items a moment after it launched, and checks once more 2 s + /// later (a single bounded re-check; the app may add its items late). + func applicationDidLaunch() { + applicationLaunchRestoreTask?.cancel() + applicationLaunchRestoreTask = Task { [weak self] in + for delay in [Duration.milliseconds(1500), .seconds(2)] { + do { + try await Task.sleep(for: delay) + } catch { + return + } + guard let self else { + return + } + await cacheItemsIfNeeded() + await reconcileSections(trigger: .applicationLaunch) + } + } + } + + /// Moves every item into its section: the one in `wanted` (a profile), else the saved + /// one, and new items into the section the new-items setting chooses. It is the one + /// path that restores sections. + /// + /// Nothing happens while the user drags an item, while the Mac is not in use, while a + /// save of the user's arrangement is pending, or while the items wait to be rehidden; + /// a call during another reconciliation runs once that one ends. The moves are + /// automatic, so `MoveBackoff` can pause them. + /// + /// - Parameters: + /// - wanted: The section of each identity key, when a profile is applied. + /// - trigger: What asks for the reconciliation. + func reconcileSections(wanted: [String: MenuBarSection.Name]? = nil, trigger: SectionRestoreTrigger) async { + await reconcileSections(wanted: wanted, trigger: trigger, items: nil, controlItems: nil) + } + + /// Reconciles the sections of the given items (read anew when `nil`). + func reconcileSections( + wanted: [String: MenuBarSection.Name]? = nil, + trigger: SectionRestoreTrigger, + items givenItems: [MenuBarItem]?, + controlItems givenControlItems: ControlItemPair? + ) async { + guard backend.canMoveItems, let appState else { + return + } + guard !isReconcilingSections else { + // A profile or a restore outranks placing new items. + if wanted != nil || trigger.restoresSavedSections || pendingReconciliation == nil { + pendingReconciliation = (wanted, trigger) + } + return + } + guard + !appState.isDraggingMenuBarItem, + !appState.systemActivityMonitor.isPaused, + wanted != nil || !needsSectionSave + else { + Self.restoreLogger.debug("Not reconciling sections now (\(trigger.rawValue, privacy: .public))") + return + } + + isReconcilingSections = true + await performReconciliation( + wanted: wanted, + trigger: trigger, + items: givenItems, + controlItems: givenControlItems, + appState: appState + ) + isReconcilingSections = false + + if let pending = pendingReconciliation { + pendingReconciliation = nil + await reconcileSections(wanted: pending.wanted, trigger: pending.trigger) + } + } + + /// The work of ``reconcileSections(wanted:trigger:)``. + private func performReconciliation( + wanted: [String: MenuBarSection.Name]?, + trigger: SectionRestoreTrigger, + items givenItems: [MenuBarItem]?, + controlItems givenControlItems: ControlItemPair?, + appState: AppState + ) async { + var items: [MenuBarItem] + let controlItems: ControlItemPair + if let givenItems, let givenControlItems { + items = givenItems + controlItems = givenControlItems + } else { + items = await MenuBarItem.getMenuBarItems(option: .activeSpace) + guard let pair = ControlItemPair(items: &items) else { + Self.restoreLogger.warning("Missing control item for hidden section, not reconciling sections") + return + } + controlItems = pair + } + + if appState.settings.advanced.keepLiveActivitiesVisible { + await keepLiveActivitiesVisible(items, controlItems: controlItems) + } + + let keys = identityKeys(for: items) + let candidates = items.filter { item in + item.isMovable && + item.canBeHidden && + !item.isControlItem && + !item.isSystemClone && + !item.tag.namespace.isUUID && + !isTemporarilyShown(item) + } + + let storedKnown = Defaults.array(forKey: .knownItemTags) as? [String] + var known = Set((storedKnown ?? []).map(storedIdentityKey)) + let saved = savedSections() + let newItemsSection = appState.settings.advanced.newItemsPlacement.section + + var moves = [(item: MenuBarItem, key: String, section: MenuBarSection.Name, isNew: Bool)]() + for item in candidates { + guard let key = keys[item.windowID] else { + continue + } + let isNew = storedKnown != nil && saved[key] == nil && !known.contains(key) + var target: MenuBarSection.Name? + if let wanted { + target = wanted[key] + } else if trigger.restoresSavedSections, let section = saved[key] { + target = section + } else if isNew { + target = newItemsSection + } + guard var target else { + continue + } + if target == .alwaysHidden && controlItems.alwaysHidden == nil { + target = .hidden + } + if target != section(of: item, controlItems: controlItems) { + moves.append((item, key, target, isNew)) + } + } + + let candidateKeys = Set(candidates.compactMap { keys[$0.windowID] }) + if storedKnown == nil || !candidateKeys.isSubset(of: known) { + known.formUnion(candidateKeys) + Defaults.set(known.sorted(), forKey: .knownItemTags) + } + + var placedSections = [String: MenuBarSection.Name]() + for move in moves { + let destination: MoveDestination = switch move.section { + case .visible: .rightOfItem(controlItems.hidden) + case .hidden: .leftOfItem(controlItems.hidden) + case .alwaysHidden: .leftOfItem(controlItems.alwaysHidden ?? controlItems.hidden) + } + do { + Self.restoreLogger.info( + """ + Moving \(move.item.logString, privacy: .private(mask: .hash)) into \ + \(move.section.logString, privacy: .public) (\(trigger.rawValue, privacy: .public)) + """ + ) + try await self.move(item: move.item, to: destination) + if move.isNew { + placedSections[move.key] = move.section + } + } catch EventError.automaticMovesPaused { + Self.restoreLogger.warning("Automatic moves are paused, so not moving the remaining items into their sections") + break + } catch { + Self.restoreLogger.error("Error moving \(move.item.logString, privacy: .private(mask: .hash)): \(error, privacy: .private)") + } + } + + // A profile's sections, and where new items were placed, are the sections to restore. + if let wanted { + storeSections(wanted) + } + storeSections(placedSections) + } + + /// The section an item is in, from its position relative to the control items. + private func section(of item: MenuBarItem, controlItems: ControlItemPair) -> MenuBarSection.Name { + let bounds = Bridging.getWindowBounds(for: item.windowID) ?? item.bounds + let hiddenBounds = Bridging.getWindowBounds(for: controlItems.hidden.windowID) ?? controlItems.hidden.bounds + if bounds.minX >= hiddenBounds.maxX { + return .visible + } + if let alwaysHidden = controlItems.alwaysHidden { + let alwaysHiddenBounds = Bridging.getWindowBounds(for: alwaysHidden.windowID) ?? alwaysHidden.bounds + if bounds.maxX <= alwaysHiddenBounds.minX { + return .alwaysHidden + } + } + return .hidden + } + + /// Moves Live Activities that macOS put in a hidden section to the visible + /// one (jordanbaird/Ice#731). + /// + /// A Live Activity appears as a new item at the far left of the bar, which + /// is a hidden section, so without this it is only seen by showing that section. + private func keepLiveActivitiesVisible(_ items: [MenuBarItem], controlItems: ControlItemPair) async { + for item in items where item.isMovable && !item.isControlItem { + let isHidden = item.bounds.maxX <= controlItems.hidden.bounds.minX + guard isHidden else { + continue + } + if item.tag.isLiveActivity { + do { + Self.restoreLogger.info("Keeping Live Activity \(item.logString, privacy: .private(mask: .hash)) visible") + try await move(item: item, to: .rightOfItem(controlItems.hidden)) + } catch EventError.automaticMovesPaused { + return + } catch { + Self.restoreLogger.error("Error moving Live Activity \(item.logString, privacy: .private(mask: .hash)): \(error, privacy: .private)") + } + } else if item.tag.namespace.isUUID || item.tag.namespace.description.hasPrefix("com.apple.") { + // Helps find the process that draws Live Activities. + Self.restoreLogger.debug("Hidden system item: \(item.tag.description, privacy: .private(mask: .hash))") + } + } + } +} diff --git a/holzBar/MenuBar/MenuBarManager.swift b/holzBar/MenuBar/MenuBarManager.swift index cb7e237d..e4fa1409 100644 --- a/holzBar/MenuBar/MenuBarManager.swift +++ b/holzBar/MenuBar/MenuBarManager.swift @@ -26,6 +26,9 @@ final class MenuBarManager { /// A Boolean value that indicates whether the "ShowOnHover" feature is allowed. var showOnHoverAllowed = true + /// Zen mode: while it is active, hidden items stay hidden (see ``ZenMode``). + private(set) var zenMode = ZenMode() + /// Logger for the menu bar manager. @ObservationIgnored private let logger = Logger(category: "MenuBarManager") @@ -62,6 +65,16 @@ final class MenuBarManager { return panel }() + /// The panel that shows a letter for every item (item hints), created the first time + /// it is used. + @ObservationIgnored private(set) lazy var itemHintsPanel: ItemHintsPanel = { + let panel = ItemHintsPanel() + if let appState = self.appState { + panel.performSetup(with: appState) + } + return panel + }() + /// The panel that contains a portable version of the menu bar /// appearance editor interface let appearanceEditorPanel = MenuBarAppearanceEditorPanel() @@ -238,7 +251,7 @@ final class MenuBarManager { } // Get the application menu frame for the display. - guard let applicationMenuFrame = screen.getApplicationMenuFrame() else { + guard let applicationMenuFrame = appState.applicationMenuFrames.frame(for: screen) else { return } @@ -340,14 +353,18 @@ final class MenuBarManager { /// Returns a Boolean value that indicates whether the given display /// has a valid menu bar. + /// + /// Whether the menu bar window is a menu bar Accessibility can reach comes from + /// `ApplicationMenuFrames`, read off the main thread: asking here blocked the main + /// thread, and every click on the Mac with it, while the frontmost application hung. func hasValidMenuBar(in windows: [WindowInfo], for display: CGDirectDisplayID) -> Bool { guard - let window = WindowInfo.menuBarWindow(from: windows, for: display), - let element = AXHelpers.element(at: window.bounds.origin) + let appState, + WindowInfo.menuBarWindow(from: windows, for: display) != nil else { return false } - return AXHelpers.role(for: element) == kAXMenuBarRole + return appState.applicationMenuFrames.hasValidMenuBar(on: display) } /// Shows the secondary context menu. @@ -355,7 +372,7 @@ final class MenuBarManager { let menu = NSMenu(title: "holzBar") let editAppearanceItem = NSMenuItem( - title: "Edit Menu Bar Appearance…", + title: String(localized: "Edit Menu Bar Appearance…"), action: #selector(showAppearanceEditorPanel), keyEquivalent: "" ) @@ -365,7 +382,7 @@ final class MenuBarManager { menu.addItem(.separator()) let settingsItem = NSMenuItem( - title: "holzBar Settings…", + title: String(localized: "holzBar Settings…"), action: #selector(AppDelegate.openSettingsWindow), keyEquivalent: "," ) @@ -375,13 +392,20 @@ final class MenuBarManager { } /// Hides the application menus. - func hideApplicationMenus() { + /// + /// - Parameter reason: Why: shown items reach the menus, or the user asked. + func hideApplicationMenus(for reason: DockIconPolicy.ActivationReason = .hideApplicationMenus) { guard let appState else { logger.error("Error hiding application menus: Missing app state") return } + // macOS hides another app's menus only while holzBar is a regular app with a Dock + // icon; with "Keep the Dock icon hidden" on, the menus stay unless the user asked. + guard appState.activate(for: reason) else { + logger.debug("Not hiding application menus: the Dock icon stays hidden") + return + } logger.info("Hiding application menus") - appState.activate(withPolicy: .regular) isHidingApplicationMenus = true } @@ -401,7 +425,7 @@ final class MenuBarManager { if isHidingApplicationMenus { showApplicationMenus() } else { - hideApplicationMenus() + hideApplicationMenus(for: .toggleApplicationMenus) } } @@ -413,6 +437,96 @@ final class MenuBarManager { appearanceEditorPanel.show(on: screen) } + // MARK: Zen Mode + + /// Turns Zen mode on or off, as the user asked (its hotkey, holzBar's menu, + /// `holzbar://zen/toggle` or the Shortcuts action). + func toggleZenMode() { + setZenMode(zenMode.toggled(), cause: "manual") + } + + /// Turns the automatic part of Zen mode on or off, while the screen is mirrored or + /// shared (`PresentationMonitor`). A Zen mode the user turned on stays on. + func setAutomaticZenMode(_ isOn: Bool) { + guard zenMode.isAutomatic != isOn else { + return + } + var updated = zenMode + updated.isAutomatic = isOn + setZenMode(updated, cause: "automatic") + } + + /// Stores the new Zen mode; becoming active hides every section (on macOS 27 the + /// concealment follows the sections). + private func setZenMode(_ newValue: ZenMode, cause: String) { + let wasActive = zenMode.isActive + zenMode = newValue + guard newValue.isActive != wasActive else { + return + } + let state = newValue.isActive ? "on" : "off" + logger.notice("Zen mode \(state, privacy: .public) (\(cause, privacy: .public))") + if newValue.isActive { + for section in sections { + section.hide() + } + } + } + + // MARK: Reveal on Change + + /// Shows a hidden item for 5 seconds because it changed (THAW-12, `ItemChangeWatcher`). + /// + /// On macOS 27 its application is shown for the moment; before, its section is shown + /// and hidden again unless the pointer is in the menu bar by then. Nothing happens in + /// Zen mode. + func revealBriefly(itemKey key: String) { + guard + let appState, + zenMode.allows(.changeReveal), + let item = appState.itemManager.item(withIdentityKey: key) + else { + return + } + logger.info("Showing an item that changed") + if #available(macOS 27.0, *) { + guard let bundleID = item.sourceApplication?.bundleIdentifier else { + return + } + let concealer = appState.concealer27 + concealer.showTemporarily(bundleID: bundleID) + Task { + try? await Task.sleep(for: .seconds(5)) + concealer.endTemporaryShow(bundleID: bundleID) + } + return + } + guard + let address = appState.itemManager.itemCache.address(for: item.tag), + address.section != .visible, + let section = self.section(withName: address.section), + section.isHidden + else { + return + } + section.show() + Task { [weak self] in + try? await Task.sleep(for: .seconds(5)) + guard let self, let appState = self.appState, !section.isHidden else { + return + } + let hidEventManager = appState.hidEventManager + if + let screen = hidEventManager.bestScreen(appState: appState), + hidEventManager.isMouseInsideMenuBar(appState: appState, screen: screen) + { + // The user is at the menu bar now. + return + } + section.hide() + } + } + /// Returns the menu bar section with the given name. func section(withName name: MenuBarSection.Name) -> MenuBarSection? { sections.first { $0.name == name } diff --git a/holzBar/MenuBar/MenuBarSection.swift b/holzBar/MenuBar/MenuBarSection.swift index 3034c337..8e7e329e 100644 --- a/holzBar/MenuBar/MenuBarSection.swift +++ b/holzBar/MenuBar/MenuBarSection.swift @@ -14,12 +14,12 @@ final class MenuBarSection { case hidden case alwaysHidden - /// A string to show in the interface. + /// A string to show in the interface, in the user's language. var displayString: String { switch self { - case .visible: "Visible" - case .hidden: "Hidden" - case .alwaysHidden: "Always-Hidden" + case .visible: String(localized: "Visible") + case .hidden: String(localized: "Hidden") + case .alwaysHidden: String(localized: "Always-Hidden") } } @@ -34,7 +34,11 @@ final class MenuBarSection { /// Localized string key representation. var localized: LocalizedStringKey { - LocalizedStringKey(displayString) + switch self { + case .visible: "Visible" + case .hidden: "Hidden" + case .alwaysHidden: "Always-Hidden" + } } } diff --git a/holzBar/MenuBar/PresentationMonitor.swift b/holzBar/MenuBar/PresentationMonitor.swift new file mode 100644 index 00000000..ff2c5ea5 --- /dev/null +++ b/holzBar/MenuBar/PresentationMonitor.swift @@ -0,0 +1,105 @@ +// +// PresentationMonitor.swift +// holzBar +// + +import AppKit +import OSLog + +/// Turns Zen mode on while the screen is mirrored or shared, and off again afterwards +/// ("Turn on Zen mode while the screen is mirrored or shared", THAW-10). +/// +/// The mirroring check (`CGDisplayIsInMirrorSet` when the displays change) is adapted +/// from Thaw's `PresentationMonitor` (see NOTICE). Thaw found screen sharing by reading the +/// process table every 5 seconds; holzBar looks for macOS's screen sharing agent among the +/// running applications when an application launches or quits instead (see +/// ``PresentationSignals``), so nothing polls and no permission is needed. Nothing runs +/// while the setting is off. +@MainActor +final class PresentationMonitor { + /// The shared app state. + private weak var appState: AppState? + + /// Follows the setting. + private var settingObserver: ObservationLoop? + + /// The tasks that receive the notifications, while the setting is on. + private var notificationTasks = [Task]() + + /// The last answer, so only a change turns Zen mode on or off. + private var isPresenting = false + + private let logger = Logger(category: "PresentationMonitor") + + /// Starts following the setting. + func performSetup(with appState: AppState) { + self.appState = appState + let advanced = appState.settings.advanced + settingObserver = ObservationLoop.observe { advanced.autoZenWhileSharingScreen } onChange: { [weak self] isOn in + self?.setRunning(isOn) + } + setRunning(advanced.autoZenWhileSharingScreen) + } + + private func setRunning(_ isOn: Bool) { + if isOn { + start() + } else { + stop() + } + } + + private func start() { + guard notificationTasks.isEmpty else { + return + } + notificationTasks = [ + Task { [weak self] in + for await _ in NotificationCenter.default.notifications(named: NSApplication.didChangeScreenParametersNotification) { + self?.evaluate() + } + }, + Task { [weak self] in + let center = NSWorkspace.shared.notificationCenter + for await _ in center.notifications(named: NSWorkspace.didLaunchApplicationNotification) { + self?.evaluate() + } + }, + Task { [weak self] in + let center = NSWorkspace.shared.notificationCenter + for await _ in center.notifications(named: NSWorkspace.didTerminateApplicationNotification) { + self?.evaluate() + } + }, + ] + evaluate() + } + + /// Stops listening and withdraws only the automatic part of Zen mode. + private func stop() { + for task in notificationTasks { + task.cancel() + } + notificationTasks.removeAll() + isPresenting = false + appState?.menuBarManager.setAutomaticZenMode(false) + } + + private func evaluate() { + let mirroredDisplays = NSScreen.screens.filter { screen in + CGDisplayIsInMirrorSet(screen.displayID) != 0 + }.count + let running = Set(NSWorkspace.shared.runningApplications.compactMap(\.bundleIdentifier)) + let presenting = PresentationSignals.isPresenting(mirroredDisplays: mirroredDisplays, runningBundleIDs: running) + guard presenting != isPresenting else { + return + } + isPresenting = presenting + if presenting { + logger.notice("The screen is mirrored or shared: Zen mode on") + } else { + logger.notice("The screen is no longer mirrored or shared: Zen mode off") + } + appState?.menuBarManager.setAutomaticZenMode(presenting) + } +} diff --git a/holzBar/MenuBar/Profiles/LayoutProfiles.swift b/holzBar/MenuBar/Profiles/LayoutProfiles.swift index eb0a8c7b..10b08549 100644 --- a/holzBar/MenuBar/Profiles/LayoutProfiles.swift +++ b/holzBar/MenuBar/Profiles/LayoutProfiles.swift @@ -3,8 +3,8 @@ // holzBar // +import AppKit import Observation -import Foundation import OSLog /// A saved arrangement of menu bar items into sections. @@ -18,7 +18,19 @@ struct LayoutProfile: Codable, Hashable, Identifiable { /// The section of each application, keyed by bundle identifier (macOS 27). var applicationSections: [String: Int] + /// The UUID of the display whose connection applies the profile. Optional, so + /// profiles saved before bindings existed decode unchanged. + var displayUUID: String? + + /// The UUID of the Space whose activation applies the profile. + var spaceUUID: String? + var id: String { name } + + /// Whether the profile is bound to a display or a Space. + var isBound: Bool { + displayUUID != nil || spaceUUID != nil + } } /// Saves and applies layout profiles, such as "Work" and "Home" @@ -36,12 +48,34 @@ final class LayoutProfiles { /// The name of the profile that was applied last. private(set) var currentProfileName: String? + /// Whether an item is being dragged in the Menu Bar Layout pane; a bound profile waits + /// until the drop. + @ObservationIgnored var isLayoutDragInProgress = false + @ObservationIgnored private let logger = Logger(category: "LayoutProfiles") @ObservationIgnored private weak var appState: AppState? + /// The UUIDs of the displays connected at the last check. + @ObservationIgnored private var connectedDisplays = Set() + + /// Receives the changes of the active Space. + @ObservationIgnored private var spaceTask: Task? + func performSetup(with appState: AppState) { self.appState = appState load() + connectedDisplays = Self.connectedDisplayUUIDs() + // A display change is acted on once the bar has settled after it (THAW-11): only + // the displays that are new then can apply a profile. + appState.systemActivityMonitor.onSettled { [weak self] in + self?.systemActivityDidSettle() + } + spaceTask = Task { [weak self] in + let center = NSWorkspace.shared.notificationCenter + for await _ in center.notifications(named: NSWorkspace.activeSpaceDidChangeNotification) { + self?.activeSpaceDidChange() + } + } } private func load() { @@ -74,15 +108,26 @@ final class LayoutProfiles { guard !name.isEmpty else { return } - let cache = appState.itemManager.itemCache + let itemManager = appState.itemManager + let cache = itemManager.itemCache var itemSections = [String: Int]() for section in MenuBarSection.Name.allCases { for item in cache[section] where !item.isControlItem { - itemSections[item.tag.description] = section.profileIndex + // Stored under the item's identity, which survives a changing title. + itemSections[itemManager.identityKey(for: item)] = section.profileIndex } } let applicationSections = Defaults.dictionary(forKey: .macOS27Layout) as? [String: Int] ?? [:] - let profile = LayoutProfile(name: name, itemSections: itemSections, applicationSections: applicationSections) + // A profile saved again under its name keeps its bindings. + let previous = profiles.first { $0.name == name } + let profile = LayoutProfile( + name: name, + itemSections: itemSections, + applicationSections: applicationSections, + displayUUID: previous?.displayUUID, + spaceUUID: previous?.spaceUUID + ) + registerUndo(named: String(localized: "Save Profile")) profiles.removeAll { $0.name == name } profiles.append(profile) currentProfileName = name @@ -92,11 +137,13 @@ final class LayoutProfiles { /// Deletes the profile with the given name. func delete(named name: String) { + registerUndo(named: String(localized: "Delete Profile")) profiles.removeAll { $0.name == name } if currentProfileName == name { currentProfileName = nil } save() + appState?.settings.hotkeys.removeHotkey(for: .applyProfile(name)) } /// Applies the profile with the given name, matched without regard to case. @@ -124,9 +171,16 @@ final class LayoutProfiles { } return } - let sections = profile.itemSections.compactMapValues(MenuBarSection.Name.init(profileIndex:)) + // Keys of earlier versions (`namespace:title`) match through the item identity. + let itemManager = appState.itemManager + var sections = [String: MenuBarSection.Name]() + for (key, index) in profile.itemSections { + if let section = MenuBarSection.Name(profileIndex: index) { + sections[itemManager.storedIdentityKey(key)] = section + } + } Task { - await appState.itemManager.move(itemsTo: sections) + await itemManager.reconcileSections(wanted: sections, trigger: .profile) } } @@ -135,6 +189,177 @@ final class LayoutProfiles { self.profiles = profiles save() } + + /// Renames a profile; its hotkey moves with it. A name that another profile has is + /// refused. + func rename(_ profile: LayoutProfile, to newName: String) { + let newName = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard + !newName.isEmpty, + newName != profile.name, + !profiles.contains(where: { $0.name == newName }), + let index = profiles.firstIndex(where: { $0.name == profile.name }) + else { + return + } + registerUndo(named: String(localized: "Rename Profile")) + profiles[index].name = newName + if currentProfileName == profile.name { + currentProfileName = newName + } + save() + appState?.settings.hotkeys.moveHotkey(from: .applyProfile(profile.name), to: .applyProfile(newName)) + } + + // MARK: Undo + + /// Registers "restore the profiles as they are now" on the Settings window's undo + /// manager, before a change. Undo restores the list, the applied profile's name and + /// the profiles' hotkeys; it never touches the menu bar. + private func registerUndo(named actionName: String) { + guard let undoManager = appState?.navigationState.settingsWindow?.undoManager else { + return + } + let profiles = self.profiles + let currentProfileName = self.currentProfileName + let hotkeys = profileHotkeys() + undoManager.registerUndo(withTarget: self) { target in + MainActor.assumeIsolated { + target.restore(profiles: profiles, currentProfileName: currentProfileName, hotkeys: hotkeys, actionName: actionName) + } + } + undoManager.setActionName(actionName) + } + + /// Puts back the profiles of an undone change, and registers the redo. + private func restore( + profiles restored: [LayoutProfile], + currentProfileName restoredCurrent: String?, + hotkeys: [String: KeyCombination], + actionName: String + ) { + registerUndo(named: actionName) + let removedNames = Set(profiles.map(\.name)).subtracting(restored.map(\.name)) + profiles = restored + currentProfileName = restoredCurrent + save() + guard let hotkeySettings = appState?.settings.hotkeys else { + return + } + for name in removedNames where hotkeys[name] == nil { + hotkeySettings.removeHotkey(for: .applyProfile(name)) + } + for (name, keyCombination) in hotkeys { + hotkeySettings.setKeyCombination(keyCombination, for: .applyProfile(name)) + } + } + + /// The key combinations of the profiles' hotkeys, by profile name. + private func profileHotkeys() -> [String: KeyCombination] { + guard let hotkeySettings = appState?.settings.hotkeys else { + return [:] + } + var result = [String: KeyCombination]() + for profile in profiles { + if let keyCombination = hotkeySettings.existingHotkey(for: .applyProfile(profile.name))?.keyCombination { + result[profile.name] = keyCombination + } + } + return result + } + + // MARK: Bindings + + /// Binds a profile to a display, or removes its display binding (`nil`). A display is + /// bound to one profile at most. + func bind(_ profile: LayoutProfile, toDisplay displayUUID: String?) { + registerUndo(named: String(localized: "Bind Profile")) + for index in profiles.indices { + if profiles[index].name == profile.name { + profiles[index].displayUUID = displayUUID + } else if displayUUID != nil, profiles[index].displayUUID == displayUUID { + profiles[index].displayUUID = nil + } + } + save() + } + + /// Binds a profile to a Space, or removes its Space binding (`nil`). A Space is bound + /// to one profile at most. + func bind(_ profile: LayoutProfile, toSpace spaceUUID: String?) { + registerUndo(named: String(localized: "Bind Profile")) + for index in profiles.indices { + if profiles[index].name == profile.name { + profiles[index].spaceUUID = spaceUUID + } else if spaceUUID != nil, profiles[index].spaceUUID == spaceUUID { + profiles[index].spaceUUID = nil + } + } + save() + } + + /// Removes both bindings of a profile. + func unbind(_ profile: LayoutProfile) { + guard let index = profiles.firstIndex(where: { $0.name == profile.name }) else { + return + } + registerUndo(named: String(localized: "Remove Binding")) + profiles[index].displayUUID = nil + profiles[index].spaceUUID = nil + save() + } + + /// The UUIDs of the connected displays. + static func connectedDisplayUUIDs() -> Set { + Set(NSScreen.screens.compactMap { screen in + Bridging.getDisplayUUIDString(for: screen.displayID) + }) + } + + /// Applies the profile bound to a display that was connected meanwhile. + private func systemActivityDidSettle() { + let current = Self.connectedDisplayUUIDs() + let newlyConnected = ProfileBinding.newlyConnected(previous: connectedDisplays, current: current) + connectedDisplays = current + guard !newlyConnected.isEmpty, profiles.contains(where: { $0.displayUUID != nil }) else { + return + } + applyBoundProfile(for: .displaysConnected(newlyConnected, activeSpace: Bridging.getActiveSpaceUUID())) + } + + /// Applies the profile bound to the Space that became active. + private func activeSpaceDidChange() { + guard + profiles.contains(where: { $0.spaceUUID != nil }), + let spaceUUID = Bridging.getActiveSpaceUUID() + else { + return + } + applyBoundProfile(for: .spaceChanged(spaceUUID)) + } + + /// Applies the profile `ProfileBinding` chooses for the event, unless Zen mode is on or + /// an item is being dragged in the Menu Bar Layout pane. + private func applyBoundProfile(for event: ProfileBinding.Event) { + guard + let appState, + !appState.menuBarManager.zenMode.isActive, + !isLayoutDragInProgress + else { + return + } + let bindings = profiles.map { profile in + ProfileBinding.Profile(name: profile.name, displayUUID: profile.displayUUID, spaceUUID: profile.spaceUUID) + } + guard + let name = ProfileBinding.profileToApply(profiles: bindings, event: event, currentProfile: currentProfileName), + let profile = profiles.first(where: { $0.name == name }) + else { + return + } + logger.notice("Applying the bound layout profile \(name, privacy: .private)") + apply(profile) + } } extension MenuBarSection.Name { diff --git a/holzBar/MenuBar/RevealRules/RevealRules.swift b/holzBar/MenuBar/RevealRules/RevealRules.swift index ef705556..d4206220 100644 --- a/holzBar/MenuBar/RevealRules/RevealRules.swift +++ b/holzBar/MenuBar/RevealRules/RevealRules.swift @@ -278,6 +278,10 @@ final class RevealRules { else { return } + guard appState.menuBarManager.zenMode.allows(.revealRule) else { + logger.notice("Not showing hidden items because \(reason, privacy: .public): Zen mode") + return + } logger.notice("Showing hidden items because \(reason, privacy: .public)") section.show() appState.menuBarManager.showOnHoverAllowed = false diff --git a/holzBar/MenuBar/Search/MenuBarSearchModel.swift b/holzBar/MenuBar/Search/MenuBarSearchModel.swift index 45072e1b..b6fdaf2f 100644 --- a/holzBar/MenuBar/Search/MenuBarSearchModel.swift +++ b/holzBar/MenuBar/Search/MenuBarSearchModel.swift @@ -43,6 +43,16 @@ final class MenuBarSearchModel { } private func updateAverageColorInfo(for screen: NSScreen) { + // On macOS 27 a capture lights the recording indicator, so the search stands on the + // flat colour of the bar, as the Shelf does. + if #available(macOS 27.0, *) { + let info = MenuBarAverageColorInfo(color: HolzBarShelfColorManager.flatColor27(), source: .menuBarWindow) + if averageColorInfo != info { + averageColorInfo = info + } + return + } + // Nothing captures the screen before Screen Recording is granted. guard ScreenCapture.cachedCheckPermissions() else { return diff --git a/holzBar/MenuBar/Search/MenuBarSearchPanel.swift b/holzBar/MenuBar/Search/MenuBarSearchPanel.swift index 00e4d22a..1e88288e 100644 --- a/holzBar/MenuBar/Search/MenuBarSearchPanel.swift +++ b/holzBar/MenuBar/Search/MenuBarSearchPanel.swift @@ -283,7 +283,7 @@ private struct MenuBarSearchContentView: View { HStack { SettingsButton { closePanel() - itemManager.appState?.activate(withPolicy: .regular) + itemManager.appState?.activate(for: .settings) itemManager.appState?.openWindow(.settings) } @@ -364,15 +364,8 @@ private struct MenuBarSearchContentView: View { closePanel() Task { try? await Task.sleep(for: .milliseconds(25)) - if Bridging.isWindowOnScreen(item.windowID) { - do { - try await itemManager.click(item: item, with: .left) - } catch { - Logger.default.error("Error clicking menu bar item from search: \(error, privacy: .private)") - } - } else { - await itemManager.temporarilyShow(item: item, clickingWith: .left) - } + // The same path as the Shelf, groups, hotkeys and hints (`ItemOpener.swift`). + await itemManager.openItem(item, mouseButton: .left, shelfDisplayID: nil) } } } @@ -406,8 +399,14 @@ private struct ShowItemButton: View { var body: some View { Button(action: action) { HStack { - Text("\(Bridging.isWindowOnScreen(item.windowID) ? "Click" : "Show") Item") - .padding(.leading, 5) + Group { + if Bridging.isWindowOnScreen(item.windowID) { + Text("Click Item") + } else { + Text("Show Item") + } + } + .padding(.leading, 5) Image(systemName: "return") .resizable() @@ -475,18 +474,20 @@ private struct MenuBarSearchItemView: View { let item: MenuBarItem + /// The item's chosen image, its trimmed picture or its app's icon (`ItemIconStore`). private var itemImage: NSImage { - guard + var captured: NSImage? + if let cached = imageCache.images[item.tag], let trimmed = cached.cgImage.trimmingTransparency(around: [.minXEdge, .maxXEdge]) - else { - return NSImage() + { + let size = CGSize( + width: CGFloat(trimmed.width) / cached.scale, + height: CGFloat(trimmed.height) / cached.scale + ) + captured = NSImage(cgImage: trimmed, size: size) } - let size = CGSize( - width: CGFloat(trimmed.width) / cached.scale, - height: CGFloat(trimmed.height) / cached.scale - ) - return NSImage(cgImage: trimmed, size: size) + return appState.itemIconStore.image(for: item, captured: captured) ?? NSImage() } private var appIcon: NSImage? { diff --git a/holzBar/MenuBar/Shelf/HolzBarShelf.swift b/holzBar/MenuBar/Shelf/HolzBarShelf.swift index 6292b65a..4194448d 100644 --- a/holzBar/MenuBar/Shelf/HolzBarShelf.swift +++ b/holzBar/MenuBar/Shelf/HolzBarShelf.swift @@ -27,6 +27,12 @@ final class HolzBarShelfPanel: NSPanel { /// Follows the control item at most every 0.1 s, with its latest frame. private let controlItemThrottle = Debouncer(delay: .milliseconds(100)) + /// Whether the Shelf takes keys: it was opened with a hotkey, so the arrows, Return and + /// Escape work in it. Opened with the mouse, it stays non-key as before. + var acceptsKeyboard = false + + override var canBecomeKey: Bool { acceptsKeyboard } + /// Creates a new holzBar Shelf panel. init() { super.init( @@ -231,6 +237,9 @@ final class HolzBarShelfPanel: NSPanel { } orderFrontRegardless() + if acceptsKeyboard { + makeKey() + } if #available(macOS 27.0, *) { let elapsed = (ContinuousClock.now - requestedAt).components let milliseconds = Double(elapsed.seconds) * 1000 + Double(elapsed.attoseconds) / 1e15 @@ -253,6 +262,7 @@ final class HolzBarShelfPanel: NSPanel { super.close() contentView = nil currentSection = nil + acceptsKeyboard = false appState?.navigationState.isShelfPresented = false } } @@ -305,6 +315,9 @@ private struct HolzBarShelfContentView: View { var menuBarManager: MenuBarManager @State private var frame = CGRect.zero @State private var scrollIndicatorsFlashTrigger = 0 + /// The item the arrows highlight, while the Shelf takes keys. + @State private var highlightedWindowID: CGWindowID? + @FocusState private var hasKeyboardFocus: Bool let screen: NSScreen let section: MenuBarSection.Name @@ -393,17 +406,63 @@ private struct HolzBarShelfContentView: View { .frame(maxWidth: screen.frame.width) .fixedSize() .onFrameChange(update: $frame) + .focusable(menuBarManager.shelfPanel.acceptsKeyboard) + .focused($hasKeyboardFocus) + .focusEffectDisabled() + .onKeyPress(.leftArrow) { + moveHighlight(by: -1) + } + .onKeyPress(.rightArrow) { + moveHighlight(by: 1) + } + .onKeyPress(.return) { + openHighlightedItem() + } + .onKeyPress(.space) { + openHighlightedItem() + } + .onKeyPress(.escape) { + menuBarManager.section(withName: section)?.hide() + return .handled + } + .onAppear { + guard menuBarManager.shelfPanel.acceptsKeyboard else { + return + } + hasKeyboardFocus = true + highlightedWindowID = items.last?.windowID + } + } + + /// Moves the highlight to the item on the left (`-1`) or right (`1`). + private func moveHighlight(by step: Int) -> KeyPress.Result { + let items = items + guard !items.isEmpty else { + return .ignored + } + let current = items.firstIndex { $0.windowID == highlightedWindowID } ?? items.count + let index = min(max(current + step, 0), items.count - 1) + highlightedWindowID = items[index].windowID + return .handled + } + + /// Opens the highlighted item through the same path as a click. + private func openHighlightedItem() -> KeyPress.Result { + guard let item = items.first(where: { $0.windowID == highlightedWindowID }) else { + return .ignored + } + let shelfDisplayID = menuBarManager.shelfPanel.screen?.displayID + menuBarManager.section(withName: section)?.hide() + Task { + try? await Task.sleep(for: .milliseconds(25)) + await itemManager.openItem(item, mouseButton: .left, shelfDisplayID: shelfDisplayID) + } + return .handled } @ViewBuilder private var content: some View { - if !ScreenRecordingAccess.isGranted(appState) { - ScreenRecordingHint(feature: .shelf, appState: appState, isCompact: true) { - // The Shelf would cover the system prompt. - menuBarManager.section(withName: section)?.hide() - } - .padding(.horizontal, 10) - } else if menuBarManager.isMenuBarHiddenBySystemUserDefaults { + if menuBarManager.isMenuBarHiddenBySystemUserDefaults { Text("holzBar cannot display menu bar items for automatically hidden menu bars") .padding(.horizontal, 10) } else if itemManager.itemCache.managedItems.isEmpty { @@ -413,30 +472,47 @@ private struct HolzBarShelfContentView: View { .controlSize(.small) } .padding(.horizontal, 10) - } else if imageCache.cacheFailed(for: section) { + } else if ScreenRecordingAccess.isGranted(appState), imageCache.cacheFailed(for: section) { Text("Unable to display menu bar items") .padding(.horizontal, 10) } else { - ScrollView(.horizontal) { - HStack(spacing: 0) { - ForEach(items, id: \.windowID) { item in - HolzBarShelfItemView( - imageCache: imageCache, - itemManager: itemManager, - menuBarManager: menuBarManager, - item: item, - section: section - ) + HStack(spacing: 0) { + if !ScreenRecordingAccess.isGranted(appState) { + // Without Screen Recording the items show their apps' icons (or the + // images chosen for them); the hint offers their real pictures. + ScreenRecordingHint(feature: .shelf, appState: appState, isCompact: true) { + // The Shelf would cover the system prompt. + menuBarManager.section(withName: section)?.hide() } + .padding(.horizontal, 10) } + itemsScrollView } - .environment(\.isScrollEnabled, frame.width == screen.frame.width) - .defaultScrollAnchor(.trailing) - .scrollIndicatorsFlash(trigger: scrollIndicatorsFlashTrigger) - .task { - scrollIndicatorsFlashTrigger += 1 + } + } + + @ViewBuilder + private var itemsScrollView: some View { + ScrollView(.horizontal) { + HStack(spacing: 0) { + ForEach(items, id: \.windowID) { item in + HolzBarShelfItemView( + imageCache: imageCache, + itemManager: itemManager, + menuBarManager: menuBarManager, + item: item, + section: section, + isHighlighted: item.windowID == highlightedWindowID + ) + } } } + .environment(\.isScrollEnabled, frame.width == screen.frame.width) + .defaultScrollAnchor(.trailing) + .scrollIndicatorsFlash(trigger: scrollIndicatorsFlashTrigger) + .task { + scrollIndicatorsFlashTrigger += 1 + } } } @@ -449,6 +525,8 @@ private struct HolzBarShelfItemView: View { let item: MenuBarItem let section: MenuBarSection.Name + /// Whether the arrows of the keyboard highlight the item. + var isHighlighted = false private var leftClickAction: () -> Void { return { [weak itemManager, weak menuBarManager] in @@ -459,19 +537,7 @@ private struct HolzBarShelfItemView: View { menuBarManager.section(withName: section)?.hide() Task { try? await Task.sleep(for: .milliseconds(25)) - if #available(macOS 27.0, *), let appState = itemManager.appState { - await ItemClicker27.click(item: item, mouseButton: .left, shelfDisplayID: shelfDisplayID, appState: appState) - return - } - if Bridging.isWindowOnScreen(item.windowID) { - do { - try await itemManager.click(item: item, with: .left) - } catch { - Logger.default.error("Error left-clicking menu bar item from the holzBar Shelf: \(error, privacy: .private)") - } - } else { - await itemManager.temporarilyShow(item: item, clickingWith: .left) - } + await itemManager.openItem(item, mouseButton: .left, shelfDisplayID: shelfDisplayID) } } } @@ -485,45 +551,50 @@ private struct HolzBarShelfItemView: View { menuBarManager.section(withName: section)?.hide() Task { try? await Task.sleep(for: .milliseconds(25)) - if #available(macOS 27.0, *), let appState = itemManager.appState { - await ItemClicker27.click(item: item, mouseButton: .right, shelfDisplayID: shelfDisplayID, appState: appState) - return - } - if Bridging.isWindowOnScreen(item.windowID) { - do { - try await itemManager.click(item: item, with: .right) - } catch { - Logger.default.error("Error right-clicking menu bar item from the holzBar Shelf: \(error, privacy: .private)") - } - } else { - await itemManager.temporarilyShow(item: item, clickingWith: .right) - } + await itemManager.openItem(item, mouseButton: .right, shelfDisplayID: shelfDisplayID) } } } + /// The item's chosen image, its picture or its app's icon (`ItemIconStore`). private var image: NSImage? { - guard let cachedImage = imageCache.images[item.tag] else { - return nil + let captured = imageCache.images[item.tag]?.nsImage + guard let iconStore = itemManager.appState?.itemIconStore else { + return captured } - return cachedImage.nsImage + return iconStore.image(for: item, captured: captured) } var body: some View { - if let image { - Image(nsImage: image) - .contentShape(Rectangle()) - .overlay { - HolzBarShelfItemClickView( - item: item, - leftClickAction: leftClickAction, - rightClickAction: rightClickAction - ) - } - .accessibilityLabel(item.displayName) - .accessibilityAction(named: "left click", leftClickAction) - .accessibilityAction(named: "right click", rightClickAction) + Group { + if let image { + Image(nsImage: image) + } else { + Text(item.displayName) + .font(.caption) + .lineLimit(1) + .padding(.horizontal, 4) + } + } + .contentShape(Rectangle()) + .overlay { + HolzBarShelfItemClickView( + item: item, + leftClickAction: leftClickAction, + rightClickAction: rightClickAction + ) + } + .background { + if isHighlighted { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(Color.primary.opacity(0.2)) + } } + .accessibilityAddTraits(.isButton) + .accessibilityLabel(item.displayName) + .accessibilityAction(.default, leftClickAction) + .accessibilityAction(named: "Open", leftClickAction) + .accessibilityAction(named: "Open Menu", rightClickAction) } } diff --git a/holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift b/holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift index 199915c0..c477b6e9 100644 --- a/holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift +++ b/holzBar/MenuBar/Spacing/MenuBarItemSpacingManager.swift @@ -106,10 +106,19 @@ final class MenuBarItemSpacingManager { return false } - /// Asynchronously launches the app at the given URL. - private nonisolated func launchApp(at applicationURL: URL, bundleIdentifier: String) async throws { - if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bundleIdentifier }) { - logger.debug("Application \"\(app.logString, privacy: .private(mask: .hash))\" is already open, so skipping launch") + /// Asynchronously launches the app at the given URL, unless it respawned on its own. + /// + /// - Parameters: + /// - applicationURL: The app's location. + /// - bundleIdentifier: The app's bundle identifier. + /// - oldPID: The process that was quit; while it is still listed it does not count as + /// the app running again. + private nonisolated func launchApp(at applicationURL: URL, bundleIdentifier: String, oldPID: pid_t) async throws { + let running = NSWorkspace.shared.runningApplications.map { app in + (pid: app.processIdentifier, bundleID: app.bundleIdentifier, isTerminated: app.isTerminated) + } + if SpacingRelaunch.isRelaunched(oldPID: oldPID, bundleID: bundleIdentifier, running: running) { + logger.debug("Application \"\(bundleIdentifier, privacy: .private(mask: .hash))\" respawned on its own, so skipping launch") return } let configuration = NSWorkspace.OpenConfiguration() @@ -132,7 +141,7 @@ final class MenuBarItemSpacingManager { guard await quit(app) else { throw RelaunchError() } - try await launchApp(at: url, bundleIdentifier: bundleIdentifier) + try await launchApp(at: url, bundleIdentifier: bundleIdentifier, oldPID: app.processIdentifier) } /// Relaunches the application with the given process identifier and returns its diff --git a/holzBar/Permissions/Permission.swift b/holzBar/Permissions/Permission.swift index b9771dfa..3cab7a47 100644 --- a/holzBar/Permissions/Permission.swift +++ b/holzBar/Permissions/Permission.swift @@ -239,11 +239,11 @@ class Permission: Identifiable { final class AccessibilityPermission: Permission { init() { super.init( - title: "Accessibility", + title: String(localized: "Accessibility"), details: [ - "Read where menu bar items are.", - "Move, show and click menu bar items for you.", - "Notice clicks, scrolls and hovers in the menu bar for show on click, scroll and hover.", + String(localized: "Read where menu bar items are."), + String(localized: "Move, show and click menu bar items for you."), + String(localized: "Notice clicks, scrolls and hovers in the menu bar for show on click, scroll and hover."), ], isRequired: true, requestsAtLaunch: true, @@ -264,8 +264,8 @@ final class AccessibilityPermission: Permission { final class ScreenRecordingPermission: Permission { init() { super.init( - title: "Screen Recording", - details: ScreenRecordingFeature.allCases.map(\.reason), + title: String(localized: "Screen Recording"), + details: ScreenRecordingFeature.allCases.map(\.reason.localizedFromCatalog), isRequired: false, requestsAtLaunch: false, settingsURL: URL(string: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture"), diff --git a/holzBar/Permissions/PermissionsView.swift b/holzBar/Permissions/PermissionsView.swift index e7892906..939308ff 100644 --- a/holzBar/Permissions/PermissionsView.swift +++ b/holzBar/Permissions/PermissionsView.swift @@ -112,7 +112,7 @@ struct PermissionsView: View { appState.performSetup(hasPermissions: true) Task { - appState.activate(withPolicy: .regular) + appState.activate(for: .settings) appState.openWindow(.settings) } } label: { @@ -132,7 +132,7 @@ struct PermissionsView: View { .font(.title.weight(.medium)) .underline() - Text(ScreenRecordingFeature.summary) + Text(ScreenRecordingFeature.summary.localizedFromCatalog) .multilineTextAlignment(.center) .fontWeight(.medium) @@ -179,7 +179,7 @@ struct PermissionsView: View { guard await permission.waitForPermission() else { return } - appState.activate(withPolicy: .regular) + appState.activate(for: .permissions) appState.openWindow(.permissions) } } label: { @@ -203,7 +203,7 @@ struct PermissionsView: View { guard await permission.waitForPermission() else { return } - appState.activate(withPolicy: .regular) + appState.activate(for: .permissions) appState.openWindow(.permissions) } } diff --git a/holzBar/Permissions/ScreenRecordingAccess.swift b/holzBar/Permissions/ScreenRecordingAccess.swift index 614b0715..5a202db9 100644 --- a/holzBar/Permissions/ScreenRecordingAccess.swift +++ b/holzBar/Permissions/ScreenRecordingAccess.swift @@ -65,13 +65,13 @@ struct ScreenRecordingHint: View { /// macOS may need holzBar to reopen before the pictures appear. private var reopenNote: String { - "If macOS offers to quit and reopen holzBar after you allow it, choose Quit & Reopen." + String(localized: "If macOS offers to quit and reopen holzBar after you allow it, choose Quit & Reopen.") } var body: some View { if isCompact { HStack { - Text(feature.reason) + Text(feature.reason.localizedFromCatalog) .lineLimit(1) allowButton .buttonStyle(.plain) @@ -80,7 +80,7 @@ struct ScreenRecordingHint: View { .help(reopenNote) } else { VStack(spacing: 8) { - Text(feature.reason) + Text(feature.reason.localizedFromCatalog) .multilineTextAlignment(.center) allowButton Text(reopenNote) diff --git a/holzBar/Resources/AppShortcuts.xcstrings b/holzBar/Resources/AppShortcuts.xcstrings new file mode 100644 index 00000000..a01b5ba8 --- /dev/null +++ b/holzBar/Resources/AppShortcuts.xcstrings @@ -0,0 +1,146 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Apply a layout profile in ${applicationName}" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein Layoutprofil in ${applicationName} anwenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appliquer un profil de disposition dans ${applicationName}" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applica un profilo di layout in ${applicationName}" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applitgar in profil da disposiziun en ${applicationName}" + } + } + } + }, + "Open a menu bar item with ${applicationName}" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein Menüleistenelement mit ${applicationName} öffnen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir un élément de la barre des menus avec ${applicationName}" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apri un elemento della barra dei menu con ${applicationName}" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avrir in element da la trav da menu cun ${applicationName}" + } + } + } + }, + "Search menu bar items with ${applicationName}" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleistenelemente mit ${applicationName} suchen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechercher des éléments de la barre des menus avec ${applicationName}" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerca elementi della barra dei menu con ${applicationName}" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tschertgar elements da la trav da menu cun ${applicationName}" + } + } + } + }, + "Toggle Zen mode in ${applicationName}" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zen-Modus in ${applicationName} umschalten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer ou désactiver le mode zen dans ${applicationName}" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva o disattiva la modalità zen in ${applicationName}" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Midar il modus zen en ${applicationName}" + } + } + } + }, + "Toggle hidden items in ${applicationName}" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeblendete Elemente in ${applicationName} umschalten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher ou masquer les éléments cachés dans ${applicationName}" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra o nascondi gli elementi nascosti in ${applicationName}" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar u zuppentar ils elements zuppads en ${applicationName}" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/holzBar/Resources/InfoPlist.xcstrings b/holzBar/Resources/InfoPlist.xcstrings new file mode 100644 index 00000000..dd57942d --- /dev/null +++ b/holzBar/Resources/InfoPlist.xcstrings @@ -0,0 +1,55 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "holzBar" + } + } + }, + "shouldTranslate" : false + }, + "NSHumanReadableCopyright" : { + "comment" : "Copyright (human-readable)", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copyright © 2025 Jordan Baird. Änderungen für holzBar © 2026 holzcloud." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copyright © 2025 Jordan Baird. holzBar modifications © 2026 holzcloud." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copyright © 2025 Jordan Baird. Modifications pour holzBar © 2026 holzcloud." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copyright © 2025 Jordan Baird. Modifiche per holzBar © 2026 holzcloud." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copyright © 2025 Jordan Baird. Midadas per holzBar © 2026 holzcloud." + } + } + } + } + }, + "version" : "1.0" +} diff --git a/holzBar/Resources/Localizable.xcstrings b/holzBar/Resources/Localizable.xcstrings new file mode 100644 index 00000000..95199583 --- /dev/null +++ b/holzBar/Resources/Localizable.xcstrings @@ -0,0 +1,9812 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%@ is unresponsive. Until it is restarted, it cannot be moved. Movement of other menu bar items may also be affected until this is resolved." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ reagiert nicht. Bis es neu gestartet wird, lässt es sich nicht verschieben. Bis dahin können auch andere Menüleistenelemente betroffen sein." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ne répond pas. Tant qu'il n'est pas relancé, il ne peut pas être déplacé. Le déplacement d'autres éléments peut aussi en souffrir." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ non risponde. Finché non viene riavviato non può essere spostato. Anche lo spostamento di altri elementi potrebbe risentirne." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ na reagescha betg. Fin ch'el vegn avià da nov, na sa lascha el betg spustar. Er auters elements pon esser pertutgads fin lura." + } + } + } + }, + "%@ moved left" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ nach links bewegt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ déplacé vers la gauche" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ spostato a sinistra" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ spustà a sanestra" + } + } + } + }, + "%@ moved right" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ nach rechts bewegt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ déplacé vers la droite" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ spostato a destra" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ spustà a dretga" + } + } + } + }, + "%@ moved to %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ nach %2$@ bewegt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ déplacé vers %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ spostato in %2$@" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ spustà en %2$@" + } + } + } + }, + "%@ second" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Sekunde" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ seconde" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ secondo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ secunda" + } + } + } + }, + "%@ seconds" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Sekunden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ secondes" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ secondi" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ secundas" + } + } + } + }, + "%lld items" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Elemente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld éléments" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld elementi" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld elements" + } + } + } + }, + "A group puts several items behind an icon of its own." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eine Gruppe fasst mehrere Elemente hinter einem eigenen Symbol zusammen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un groupe réunit plusieurs éléments derrière une icône à lui." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un gruppo raccoglie più elementi dietro un'icona propria." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ina gruppa unescha plirs elements davos in agen simbol." + } + } + } + }, + "A menu bar shape draws the wallpaper beside the shape. holzBar reads most wallpapers from their file; a moving wallpaper can be read only with Screen Recording." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eine Menüleistenform zeichnet das Hintergrundbild neben der Form. holzBar liest die meisten Hintergrundbilder aus ihrer Datei; ein bewegtes Hintergrundbild lässt sich nur mit der Bildschirmaufnahme lesen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une forme de barre des menus dessine le fond d'écran à côté de la forme. holzBar lit la plupart des fonds d'écran dans leur fichier ; un fond d'écran animé ne se lit qu'avec l'enregistrement de l'écran." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Una forma della barra dei menu disegna lo sfondo accanto alla forma. holzBar legge la maggior parte degli sfondi dal loro file; uno sfondo animato si può leggere solo con la registrazione schermo." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ina furma da la trav da menu dissegna il maletg dal fund sper la furma. holzBar legia la gronda part dals maletgs dal fund or da lur datoteca; in maletg dal fund en moviment sa lascha leger mo cun la Bildschirmaufnahme." + } + } + } + }, + "A profile with the same name is replaced. Apply it later from here, or with holzbar://profile/." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein Profil mit demselben Namen wird ersetzt. Wende es später hier an oder mit holzbar://profile/." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un profil du même nom est remplacé. Appliquez-le plus tard d'ici, ou avec holzbar://profile/." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un profilo con lo stesso nome viene sostituito. Applicalo più tardi da qui o con holzbar://profile/." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "In profil cun il medem num vegn remplazzà. Applitgescha el pli tard da qua u cun holzbar://profile/." + } + } + } + }, + "About" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Über" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "À propos" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Informazioni" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Davart" + } + } + } + }, + "Absolutely no personal information is collected or stored." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Es werden keinerlei persönliche Daten gesammelt oder gespeichert." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune donnée personnelle n'est collectée ni conservée." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non viene raccolto né conservato alcun dato personale." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "I na vegnan rimnadas u memorisadas naginas datas persunalas." + } + } + } + }, + "Accessibility" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bedienungshilfen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accessibilité" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accessibilità" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bedienungshilfen" + } + } + } + }, + "Acknowledgements" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Danksagungen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remerciements" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ringraziamenti" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Engraziaments" + } + } + } + }, + "Action" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktion" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Action" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Azione" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acziun" + } + } + } + }, + "Add items to this group in holzBar's Menu Bar Layout settings." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Füge in den holzBar-Einstellungen unter «Menüleistenlayout» Elemente zu dieser Gruppe hinzu." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajoutez des éléments à ce groupe dans les réglages « Disposition de la barre des menus » de holzBar." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi elementi a questo gruppo nelle impostazioni «Layout della barra dei menu» di holzBar." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agiunta elements a questa gruppa en ils parameters «Disposiziun da la trav da menu» da holzBar." + } + } + } + }, + "Advanced" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erweitert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avancé" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avanzate" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avanzà" + } + } + } + }, + "All displays" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Bildschirme" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tous les écrans" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tutti i monitor" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tut ils monitors" + } + } + } + }, + "Allow Screen Recording…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildschirmaufnahme erlauben …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autoriser l'enregistrement de l'écran…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consenti la registrazione schermo…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permetter la Bildschirmaufnahme …" + } + } + } + }, + "Allow holzBar in System Settings to launch it at login." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erlaube holzBar in den Systemeinstellungen, beim Anmelden zu starten." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorisez holzBar dans les Réglages Système à s'ouvrir à la connexion." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consenti a holzBar nelle Impostazioni di Sistema di aprirsi al login." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permetta a holzBar en ils parameters dal sistem da s'aviar a l'annunzia." + } + } + } + }, + "Already granted in System Settings?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereits in den Systemeinstellungen erlaubt?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déjà autorisé dans les Réglages Système ?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Già consentito nelle Impostazioni di Sistema?" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gia permess en ils parameters dal sistem?" + } + } + } + }, + "Always Hidden" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Immer ausgeblendet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toujours masqué" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sempre nascosto" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adina zuppà" + } + } + } + }, + "Always-Hidden" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Immer ausgeblendet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toujours masqué" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sempre nascosto" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adina zuppà" + } + } + } + }, + "Another app asked holzBar to rearrange your menu bar." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eine andere App hat holzBar gebeten, deine Menüleiste neu anzuordnen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une autre app a demandé à holzBar de réorganiser votre barre des menus." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un'altra app ha chiesto a holzBar di riordinare la barra dei menu." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ina autra app ha dumandà holzBar d'ordinar da nov tia trav da menu." + } + } + } + }, + "Applied" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Angewendet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appliqué" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applicato" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applitgà" + } + } + } + }, + "Applies to items holzBar has not seen before. Items that are already arranged stay where they are." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gilt für Elemente, die holzBar noch nicht kennt. Bereits angeordnete Elemente bleiben, wo sie sind." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "S'applique aux éléments que holzBar n'a encore jamais vus. Les éléments déjà disposés restent où ils sont." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vale per gli elementi che holzBar non ha ancora visto. Gli elementi già sistemati restano dove sono." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vala per elements che holzBar n'ha anc betg vis. Elements gia ordinads restan nua ch'els èn." + } + } + } + }, + "Apply" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anwenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appliquer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applica" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applitgar" + } + } + } + }, + "Apply Layout Profile" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Layoutprofil anwenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appliquer un profil de disposition" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applica profilo di layout" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applitgar in profil da disposiziun" + } + } + } + }, + "Apply different settings based on the current system appearance." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je nach aktuellem Erscheinungsbild des Systems andere Einstellungen verwenden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliser d'autres réglages selon l'apparence actuelle du système." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usa impostazioni diverse in base all'aspetto attuale del sistema." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duvrar auters parameters tenor l'apparientscha actuala dal sistem." + } + } + } + }, + "Apply the current spacing" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktuellen Abstand anwenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appliquer l'espacement actuel" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applica la spaziatura attuale" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applitgar la distanza actuala" + } + } + } + }, + "Apply the layout profile “%@”?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Layoutprofil «%@» anwenden?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appliquer le profil de disposition « %@ » ?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applicare il profilo di layout «%@»?" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applitgar il profil da disposiziun «%@»?" + } + } + } + }, + "Applying this setting will relaunch all apps with menu bar items. Some apps may need to be manually relaunched." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beim Anwenden dieser Einstellung werden alle Apps mit Menüleistenelementen neu gestartet. Manche Apps müssen eventuell von Hand neu gestartet werden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'application de ce réglage relance toutes les apps qui ont des éléments dans la barre des menus. Certaines devront peut-être être relancées à la main." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applicando questa impostazione vengono riaperte tutte le app con elementi nella barra dei menu. Alcune app potrebbero dover essere riaperte a mano." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sche ti applitgeschas quest parameter, vegnan tut las apps cun elements en la trav da menu aviadas da nov. Tschertas apps ston forsa vegnir aviadas da nov a maun." + } + } + } + }, + "Automatic moves are paused" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatische Verschiebungen sind pausiert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les déplacements automatiques sont en pause" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gli spostamenti automatici sono in pausa" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ils spustaments automatics èn en pausa" + } + } + } + }, + "Automatically rehide" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisch wieder ausblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masquer à nouveau automatiquement" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nascondi di nuovo automaticamente" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuppentar puspè automaticamain" + } + } + } + }, + "BETA" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "BETA" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "BÊTA" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "BETA" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "BETA" + } + } + } + }, + "Back up or move your settings" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen sichern oder übertragen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sauvegarder ou transférer vos réglages" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esegui il backup o trasferisci le impostazioni" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Far ina copia da segirezza u transferir ils parameters" + } + } + } + }, + "Based on" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basiert auf" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basé sur" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basato su" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sa basa sin" + } + } + } + }, + "Below %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unter %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sous %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sotto %@" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sut %@" + } + } + } + }, + "Bind" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verknüpfen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associa" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associar" + } + } + } + }, + "Bind Profile" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil verknüpfen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associer le profil" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associa profilo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associar il profil" + } + } + } + }, + "Bind a profile to a display or a Space to apply it when you connect the display or switch to the Space." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verknüpfe ein Profil mit einem Bildschirm oder einem Space, damit es angewendet wird, wenn du den Bildschirm anschliesst oder zum Space wechselst." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associez un profil à un écran ou à un Space pour l'appliquer quand vous branchez l'écran ou passez à ce Space." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associa un profilo a un monitor o a uno Space per applicarlo quando colleghi il monitor o passi a quello Space." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associescha in profil cun in monitur u in Space per l'applitgar, cura che ti colliaschas il monitur u midas al Space." + } + } + } + }, + "Black menu bar" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schwarze Menüleiste" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Barre des menus noire" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Barra dei menu nera" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trav da menu naira" + } + } + } + }, + "Border" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rahmen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bordure" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bordo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rom" + } + } + } + }, + "Border Color" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rahmenfarbe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couleur de la bordure" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Colore del bordo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Colur dal rom" + } + } + } + }, + "Border Style" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rahmenstil" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Style de la bordure" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stile del bordo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stil dal rom" + } + } + } + }, + "Border Width" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rahmenbreite" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Épaisseur de la bordure" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spessore del bordo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ladezza dal rom" + } + } + } + }, + "Bound to %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verknüpft mit %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associé à %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associato a %@" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associà cun %@" + } + } + } + }, + "Bound to %@ and a Space" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verknüpft mit %@ und einem Space" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associé à %@ et à un Space" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associato a %@ e a uno Space" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associà cun %@ ed in Space" + } + } + } + }, + "Bound to a Space" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verknüpft mit einem Space" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associé à un Space" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associato a uno Space" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Associà cun in Space" + } + } + } + }, + "Built-in display only" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur integrierter Bildschirm" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Écran intégré uniquement" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo monitor integrato" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mo il monitur integrà" + } + } + } + }, + "Cancel" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuler" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annulla" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interrumper" + } + } + } + }, + "Change Section" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereich ändern" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Changer de section" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambia sezione" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Midar la secziun" + } + } + } + }, + "Change…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ändern …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifier…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifica…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Midar …" + } + } + } + }, + "Chevron" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Winkel" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chevron" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Freccia" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Angul" + } + } + } + }, + "Choose Image…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bild auswählen …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisir une image…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli immagine…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tscherner in maletg …" + } + } + } + }, + "Choose a custom icon to show in the menu bar." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle ein eigenes Symbol für die Menüleiste." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez une icône personnalisée pour la barre des menus." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli un'icona personalizzata per la barra dei menu." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tscherna in agen simbol per la trav da menu." + } + } + } + }, + "Choose a folder your Macs keep in sync, such as iCloud Drive or a Nextcloud, Dropbox, OneDrive or Syncthing folder. holzBar keeps its settings in a holzBar folder inside it." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle einen Ordner, den deine Macs abgleichen, etwa iCloud Drive oder einen Nextcloud-, Dropbox-, OneDrive- oder Syncthing-Ordner. holzBar legt seine Einstellungen darin in einem Ordner «holzBar» ab." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez un dossier que vos Mac synchronisent, par exemple iCloud Drive ou un dossier Nextcloud, Dropbox, OneDrive ou Syncthing. holzBar y range ses réglages dans un dossier « holzBar »." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli una cartella che i tuoi Mac sincronizzano, per esempio iCloud Drive o una cartella Nextcloud, Dropbox, OneDrive o Syncthing. holzBar vi tiene le sue impostazioni in una cartella «holzBar»." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tscherna in ordinatur che tes Macs sincroniseschan, per exempel iCloud Drive u in ordinatur da Nextcloud, Dropbox, OneDrive u Syncthing. holzBar metta ses parameters en in ordinatur «holzBar» lien." + } + } + } + }, + "Choose an image for the group “%@”." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle ein Bild für die Gruppe «%@»." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez une image pour le groupe « %@ »." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli un'immagine per il gruppo «%@»." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tscherna in maletg per la gruppa «%@»." + } + } + } + }, + "Choose an image for “%@”." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle ein Bild für «%@»." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez une image pour « %@ »." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli un'immagine per «%@»." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tscherna in maletg per «%@»." + } + } + } + }, + "Choose image…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bild auswählen …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisir une image…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli immagine…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tscherner in maletg …" + } + } + } + }, + "Clear" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Löschen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancella" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stizzar" + } + } + } + }, + "Click Item" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Element klicken" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cliquer sur l'élément" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fai clic sull'elemento" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cliccar sin l'element" + } + } + } + }, + "Click inside an empty area of the menu bar to show hidden menu bar items." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klicke auf eine leere Stelle der Menüleiste, um ausgeblendete Menüleistenelemente einzublenden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cliquez dans une zone vide de la barre des menus pour afficher les éléments masqués." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fai clic in un'area vuota della barra dei menu per mostrare gli elementi nascosti." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clicca en ina zona vida da la trav da menu per mussar ils elements zuppads." + } + } + } + }, + "Click to show hidden menu bar items. Right-click to access holzBar's settings." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klicken, um ausgeblendete Menüleistenelemente einzublenden. Rechtsklick für die Einstellungen von holzBar." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cliquez pour afficher les éléments masqués. Clic droit pour les réglages de holzBar." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fai clic per mostrare gli elementi nascosti. Clic destro per le impostazioni di holzBar." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clicca per mussar ils elements zuppads. Clic dretg per ils parameters da holzBar." + } + } + } + }, + "Color" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Farbe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couleur" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Colore" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Colur" + } + } + } + }, + "Continue" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fortfahren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continuer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continua" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuntinuar" + } + } + } + }, + "Contribute" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mitwirken" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contribuer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contribuisci" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cooperar" + } + } + } + }, + "Corner radius: %lld pt" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eckenradius: %lld pt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rayon des coins : %lld pt" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raggio degli angoli: %lld pt" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radius dals cantuns: %lld pt" + } + } + } + }, + "Could not create event for “%@”" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ereignis für «%@» konnte nicht erstellt werden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de créer l'événement pour « %@ »" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile creare l'evento per «%@»" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "I n'è betg reussì da crear l'eveniment per «%@»" + } + } + } + }, + "Counted from when the item's menu closes. 0 hides it right away." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ab dem Schliessen des Menüs gezählt. Bei 0 wird das Element sofort ausgeblendet." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compté à partir de la fermeture du menu de l'élément. 0 le masque aussitôt." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contato dalla chiusura del menu dell'elemento. 0 lo nasconde subito." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quintà a partir da cura ch'il menu da l'element sa serra. 0 al zuppenta immediatamain." + } + } + } + }, + "Create" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erstellen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Créer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crea" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crear" + } + } + } + }, + "Custom icon uses dynamic appearance" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eigenes Symbol mit dynamischem Erscheinungsbild" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Icône personnalisée avec apparence dynamique" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Icona personalizzata con aspetto dinamico" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agen simbol cun apparientscha dinamica" + } + } + } + }, + "Dark Appearance" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dunkles Erscheinungsbild" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apparence sombre" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aspetto scuro" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apparientscha stgira" + } + } + } + }, + "Dashed" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestrichelt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tirets" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tratteggiato" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stritgà" + } + } + } + }, + "Delete" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Löschen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elimina" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stizzar" + } + } + } + }, + "Delete Profile" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil löschen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer le profil" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elimina profilo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stizzar il profil" + } + } + } + }, + "Delete the profile “%@”?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil «%@» löschen?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer le profil « %@ » ?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminare il profilo «%@»?" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stizzar il profil «%@»?" + } + } + } + }, + "Delete…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Löschen …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elimina…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stizzar …" + } + } + } + }, + "Display the icon as a monochrome image that dynamically adjusts to match the menu bar's appearance. This setting removes all color from the icon, but ensures consistent rendering with both light and dark backgrounds." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeigt das Symbol einfarbig an, passend zum Erscheinungsbild der Menüleiste. Dabei geht die Farbe des Symbols verloren, dafür wirkt es auf hellem und dunklem Hintergrund gleich." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Affiche l'icône en monochrome, adaptée à l'apparence de la barre des menus. L'icône perd ses couleurs, mais s'affiche de la même façon sur fond clair et sombre." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra l'icona in monocromia, adattata all'aspetto della barra dei menu. L'icona perde i colori, ma appare uguale su sfondi chiari e scuri." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussa il simbol en ina colur, adattà a l'apparientscha da la trav da menu. Il simbol perda sias colurs, ma cumpara a moda uniforma sin fund cler e stgir." + } + } + } + }, + "Displays with a notch" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildschirme mit Notch" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Écrans avec encoche" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monitor con notch" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monitors cun notch" + } + } + } + }, + "Displays with a notch only" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur Bildschirme mit Notch" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Écrans avec encoche uniquement" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo monitor con notch" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mo monitors cun notch" + } + } + } + }, + "Done" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fertig" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminé" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fine" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finì" + } + } + } + }, + "Dotted" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gepunktet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pointillé" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Punteggiato" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puntà" + } + } + } + }, + "Drag to arrange your menu bar items into different sections." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ziehe deine Menüleistenelemente in die gewünschten Bereiche." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Faites glisser les éléments de la barre des menus dans les sections voulues." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trascina gli elementi della barra dei menu nelle sezioni che preferisci." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tira ils elements da la trav da menu en las secziuns giavischadas." + } + } + } + }, + "Draws rounded corners on every display, like the corners of a MacBook display." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeichnet auf jedem Bildschirm abgerundete Ecken wie beim Bildschirm eines MacBook." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrondit les coins de chaque écran, comme ceux d'un écran de MacBook." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrotonda gli angoli di ogni monitor, come quelli dello schermo di un MacBook." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dissegna cantuns radunds sin mintga monitur, sco ils cantuns d'in monitur da MacBook." + } + } + } + }, + "Draws the menu bar solid black, so the notch blends into it. Works best in dark mode, where the menu bar text is light." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeichnet die Menüleiste ganz schwarz, sodass die Notch darin verschwindet. Am besten im dunklen Modus, in dem der Text der Menüleiste hell ist." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dessine la barre des menus en noir uni pour que l'encoche s'y fonde. Idéal en mode sombre, où le texte de la barre est clair." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disegna la barra dei menu in nero pieno, così il notch si confonde con essa. Ideale con la modalità scura, in cui il testo della barra è chiaro." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dissegna la trav da menu tut naira, uschia che la notch svanescha en ella. Il meglier en il modus stgir, nua ch'il text da la trav è cler." + } + } + } + }, + "Dynamic" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamisch" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamique" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dinamica" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dinamic" + } + } + } + }, + "ERROR" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "FEHLER" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ERREUR" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ERRORE" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "SBAGL" + } + } + } + }, + "Edit Menu Bar Appearance…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erscheinungsbild der Menüleiste bearbeiten …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifier l'apparence de la barre des menus…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifica l'aspetto della barra dei menu…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifitgar l'apparientscha da la trav da menu …" + } + } + } + }, + "Empty items that add space between others. ⌘ Command-drag them where you want them, or arrange them below." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leere Elemente, die Platz zwischen anderen schaffen. Ziehe sie mit ⌘ Befehlstaste an die gewünschte Stelle oder ordne sie unten an." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Des éléments vides qui ajoutent de l'espace entre les autres. Faites-les glisser avec ⌘ Commande où vous voulez, ou disposez-les ci-dessous." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elementi vuoti che aggiungono spazio tra gli altri. Trascinali con ⌘ Comando dove vuoi o sistemali qui sotto." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elements vids che fan plazza tranter auters. Tira els cun ⌘ Command nua che ti vuls u ordina els qua sutvart." + } + } + } + }, + "Enable secondary context menu" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zweites Kontextmenü aktivieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer le menu contextuel secondaire" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva il menu contestuale secondario" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activar il segund menu contextual" + } + } + } + }, + "Enable the always-hidden section" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereich «Immer ausgeblendet» aktivieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer la section « Toujours masqué »" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva la sezione «Sempre nascosto»" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activar la secziun «Adina zuppà»" + } + } + } + }, + "Enable the holzBar Shelf" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar Shelf aktivieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer le holzBar Shelf" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva holzBar Shelf" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activar il holzBar Shelf" + } + } + } + }, + "Event operation timed out for “%@”" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitüberschreitung beim Ereignis für «%@»" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Délai dépassé pour l'événement de « %@ »" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempo scaduto per l'evento di «%@»" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il temp è scadì per l'eveniment da «%@»" + } + } + } + }, + "Export holzBar Settings" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar-Einstellungen exportieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporter les réglages de holzBar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esporta le impostazioni di holzBar" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportar ils parameters da holzBar" + } + } + } + }, + "Exports layout, hotkeys and appearance to a file that can be imported on another Mac." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportiert Layout, Tastenkürzel und Erscheinungsbild in eine Datei, die sich auf einem anderen Mac importieren lässt." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporte la disposition, les raccourcis clavier et l'apparence dans un fichier à importer sur un autre Mac." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esporta layout, abbreviazioni da tastiera e aspetto in un file da importare su un altro Mac." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporta la disposiziun, las cumbinaziuns da tastas e l'apparientscha en ina datoteca che sa lascha importar sin in auter Mac." + } + } + } + }, + "Export…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportieren …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporter…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esporta…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportar …" + } + } + } + }, + "Focused app" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive App" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "App active" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "App attiva" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "App activa" + } + } + } + }, + "Follow Wallpaper" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hintergrundbild folgen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suivre le fond d'écran" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Segui lo sfondo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suandar il maletg dal fund" + } + } + } + }, + "Follows the accent color chosen in System Settings, Appearance." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Folgt der in den Systemeinstellungen unter «Erscheinungsbild» gewählten Akzentfarbe." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suit la couleur d'accentuation choisie dans Réglages Système, Apparence." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Segue il colore di contrasto scelto in Impostazioni di Sistema, Aspetto." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suonda la colur d'accent tschernida en ils parameters dal sistem sut «Apparientscha»." + } + } + } + }, + "Full" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ganz" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entière" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intera" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entira" + } + } + } + }, + "General" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allgemein" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Général" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generali" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "General" + } + } + } + }, + "Give a menu bar item its own hotkey with Set Hotkey… in its menu in the Menu Bar Layout pane." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gib einem Menüleistenelement mit «Tastenkürzel festlegen …» in seinem Menü im Bereich «Menüleistenlayout» ein eigenes Tastenkürzel." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attribuez un raccourci à un élément avec « Définir un raccourci… » dans son menu, dans la sous-fenêtre « Disposition de la barre des menus »." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assegna un'abbreviazione a un elemento con «Imposta abbreviazione…» nel suo menu, nel pannello «Layout della barra dei menu»." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dà ad in element da la trav da menu ina atgna cumbinaziun da tastas cun «Definir la cumbinaziun …» en ses menu en il champ «Disposiziun da la trav da menu»." + } + } + } + }, + "Gradient" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verlauf" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dégradé" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sfumatura" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sfumadira" + } + } + } + }, + "Grant Permission" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erlauben" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autoriser" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consenti" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permetter" + } + } + } + }, + "Groups" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gruppen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Groupes" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gruppi" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gruppas" + } + } + } + }, + "Hidden" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeblendet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masqué" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nascosto" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuppà" + } + } + } + }, + "Hidden items are shown for the time set in “Hide opened items again after”, then hidden again." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeblendete Elemente werden für die unter «Geöffnete Elemente wieder ausblenden nach» eingestellte Zeit eingeblendet und dann wieder ausgeblendet." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les éléments masqués s'affichent pendant la durée définie dans « Masquer à nouveau les éléments ouverts après », puis sont de nouveau masqués." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gli elementi nascosti vengono mostrati per il tempo impostato in «Nascondi di nuovo gli elementi aperti dopo», poi nascosti di nuovo." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ils elements zuppads vegnan mussads per il temp definì en «Zuppentar puspè elements averts suenter» e lura puspè zuppentads." + } + } + } + }, + "Hide" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masquer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nascondi" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuppentar" + } + } + } + }, + "Hide Always-Hidden Section" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereich «Immer ausgeblendet» ausblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masquer la section « Toujours masqué »" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nascondi la sezione «Sempre nascosto»" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuppentar la secziun «Adina zuppà»" + } + } + } + }, + "Hide Hidden Section" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeblendeten Bereich ausblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masquer la section masquée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nascondi la sezione nascosta" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuppentar la secziun zuppada" + } + } + } + }, + "Hide app menus when showing menu bar items" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-Menüs ausblenden, wenn Menüleistenelemente eingeblendet werden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masquer les menus de l'app à l'affichage des éléments" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nascondi i menu delle app quando mostri gli elementi" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuppentar ils menus da l'app, cura che elements vegnan mussads" + } + } + } + }, + "Hide opened items again after" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geöffnete Elemente wieder ausblenden nach" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masquer à nouveau les éléments ouverts après" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nascondi di nuovo gli elementi aperti dopo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuppentar puspè elements averts suenter" + } + } + } + }, + "Hold to Preview" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Für Vorschau gedrückt halten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maintenir pour prévisualiser" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tieni premuto per l'anteprima" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tegnair smatgà per la prevista" + } + } + } + }, + "Hotkey" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tastenkürzel" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raccourci clavier" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbreviazione da tastiera" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cumbinaziun da tastas" + } + } + } + }, + "Hotkey is reserved by macOS" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tastenkürzel ist von macOS belegt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ce raccourci est réservé par macOS" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbreviazione riservata da macOS" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questa cumbinaziun da tastas è reservada da macOS" + } + } + } + }, + "Hotkeys" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tastenkürzel" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raccourcis clavier" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbreviazioni da tastiera" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cumbinaziuns da tastas" + } + } + } + }, + "Hover over an empty area of the menu bar to show hidden menu bar items." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bewege den Zeiger über eine leere Stelle der Menüleiste, um ausgeblendete Elemente einzublenden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Survolez une zone vide de la barre des menus pour afficher les éléments masqués." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Passa il puntatore su un'area vuota della barra dei menu per mostrare gli elementi nascosti." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Va cun il cursur sur ina zona vida da la trav da menu per mussar ils elements zuppads." + } + } + } + }, + "How to Update…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "So aktualisierst du …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comment mettre à jour…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Come aggiornare…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Co actualisar …" + } + } + } + }, + "If macOS offers to quit and reopen holzBar after you allow it, choose Quit & Reopen." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wenn macOS nach dem Erlauben anbietet, holzBar zu beenden und neu zu öffnen, wähle «Beenden und erneut öffnen»." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Si macOS propose ensuite de quitter et rouvrir holzBar, choisissez « Quitter et rouvrir »." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se dopo il consenso macOS propone di uscire e riaprire holzBar, scegli «Esci e riapri»." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sche macOS porscha suenter da terminar ed avrir da nov holzBar, tscherna «Terminar ed avrir da nov»." + } + } + } + }, + "Import and Restart" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importieren und neu starten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importer et redémarrer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importa e riavvia" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importar e reaviar" + } + } + } + }, + "Import holzBar Settings" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar-Einstellungen importieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importer les réglages de holzBar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importa le impostazioni di holzBar" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importar ils parameters da holzBar" + } + } + } + }, + "Import…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importieren …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importer…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importa…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importar …" + } + } + } + }, + "Invalid event source" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ungültige Ereignisquelle" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Source d'événements non valide" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Origine eventi non valida" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Funtauna d'eveniments nunvalaivla" + } + } + } + }, + "Items" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elemente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Éléments" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elementi" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elements" + } + } + } + }, + "Items can also be arranged by ⌘ Command + dragging them in the menu bar." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elemente lassen sich auch mit ⌘ Befehlstaste + Ziehen direkt in der Menüleiste anordnen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vous pouvez aussi disposer les éléments en les faisant glisser avec ⌘ Commande dans la barre des menus." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puoi anche sistemare gli elementi trascinandoli con ⌘ Comando nella barra dei menu." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ti pos era ordinar ils elements cun ⌘ Command + tirar en la trav da menu." + } + } + } + }, + "Keep Live Activities visible" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live-Aktivitäten sichtbar lassen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Garder les Activités en direct visibles" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mantieni visibili le Attività in tempo reale" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Laschar visiblas las activitads live" + } + } + } + }, + "Keep the Dock icon hidden" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dock-Symbol ausgeblendet lassen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Garder l'icône du Dock masquée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mantieni nascosta l'icona del Dock" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Laschar zuppà il simbol dal Dock" + } + } + } + }, + "Keeps layout, profiles, hotkeys and appearance the same on all your Macs through a folder they sync: iCloud Drive, Nextcloud, Dropbox, OneDrive, Syncthing or a network share. The folder's own app carries the file; holzBar never goes online. Changes from another Mac apply after a restart." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hält Layout, Profile, Tastenkürzel und Erscheinungsbild auf all deinen Macs gleich, über einen Ordner, den sie abgleichen: iCloud Drive, Nextcloud, Dropbox, OneDrive, Syncthing oder eine Netzwerkfreigabe. Die App des Ordners überträgt die Datei; holzBar geht nie online. Änderungen von einem anderen Mac gelten nach einem Neustart." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Garde la disposition, les profils, les raccourcis et l'apparence identiques sur tous vos Mac grâce à un dossier qu'ils synchronisent : iCloud Drive, Nextcloud, Dropbox, OneDrive, Syncthing ou un partage réseau. L'app du dossier transporte le fichier ; holzBar ne se connecte jamais. Les changements d'un autre Mac s'appliquent après un redémarrage." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mantiene uguali layout, profili, abbreviazioni e aspetto su tutti i tuoi Mac tramite una cartella che sincronizzano: iCloud Drive, Nextcloud, Dropbox, OneDrive, Syncthing o una condivisione di rete. L'app della cartella trasporta il file; holzBar non va mai online. Le modifiche da un altro Mac valgono dopo un riavvio." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tegna la disposiziun, ils profils, las cumbinaziuns da tastas e l'apparientscha uguals sin tut tes Macs tras in ordinatur ch'els sincroniseschan: iCloud Drive, Nextcloud, Dropbox, OneDrive, Syncthing u ina partiziun da rait. L'app da l'ordinatur porta la datoteca; holzBar na va mai online. Midadas d'in auter Mac valan suenter in reaviament." + } + } + } + }, + "Later" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Später" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plus tard" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Più tardi" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pli tard" + } + } + } + }, + "Launch at login" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beim Anmelden starten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir à la connexion" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apri al login" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aviar a l'annunzia" + } + } + } + }, + "Layout Profile" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Layoutprofil" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil de disposition" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profilo di layout" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil da disposiziun" + } + } + } + }, + "Layout Profiles" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Layoutprofile" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profils de disposition" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profili di layout" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profils da disposiziun" + } + } + } + }, + "Leading End Cap" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linkes Ende" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extrémité gauche" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estremità sinistra" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fin sanestra" + } + } + } + }, + "Light Appearance" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Helles Erscheinungsbild" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apparence claire" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aspetto chiaro" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apparientscha clera" + } + } + } + }, + "Live Activities from your iPhone stay in the menu bar instead of being hidden. Experimental." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live-Aktivitäten deines iPhone bleiben in der Menüleiste, statt ausgeblendet zu werden. Experimentell." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les Activités en direct de votre iPhone restent dans la barre des menus au lieu d'être masquées. Expérimental." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le Attività in tempo reale del tuo iPhone restano nella barra dei menu invece di essere nascoste. Sperimentale." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las activitads live da tes iPhone restan en la trav da menu empè da vegnir zuppentadas. Experimental." + } + } + } + }, + "Loading menu bar items…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleistenelemente werden geladen …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chargement des éléments de la barre des menus…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caricamento degli elementi della barra dei menu…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ils elements da la trav da menu vegnan chargiads …" + } + } + } + }, + "Location" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Position" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Emplacement" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posizione" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posiziun" + } + } + } + }, + "Make more room in the menu bar by hiding the current app menus if needed. macOS requires holzBar to make itself visible in the Dock while this setting is in effect." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schafft bei Bedarf Platz in der Menüleiste, indem die Menüs der aktuellen App ausgeblendet werden. macOS verlangt dafür, dass holzBar währenddessen im Dock erscheint." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fait de la place dans la barre des menus en masquant au besoin les menus de l'app active. macOS exige alors que holzBar apparaisse dans le Dock." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fa spazio nella barra dei menu nascondendo se serve i menu dell'app attiva. macOS richiede che holzBar compaia nel Dock mentre questa impostazione è attiva." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fa plazza en la trav da menu cun zuppentar sche necessari ils menus da l'app actuala. macOS pretenda che holzBar cumparia durant quel temp en il Dock." + } + } + } + }, + "Menu Bar Appearance" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erscheinungsbild der Menüleiste" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apparence de la barre des menus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aspetto della barra dei menu" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apparientscha da la trav da menu" + } + } + } + }, + "Menu Bar Items" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleistenelemente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Éléments de la barre des menus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elementi della barra dei menu" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elements da la trav da menu" + } + } + } + }, + "Menu Bar Layout" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleistenlayout" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disposition de la barre des menus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Layout della barra dei menu" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disposiziun da la trav da menu" + } + } + } + }, + "Menu Bar Section" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleistenbereich" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Section de la barre des menus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sezione della barra dei menu" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secziun da la trav da menu" + } + } + } + }, + "Menu Bar Sections" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleistenbereiche" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sections de la barre des menus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sezioni della barra dei menu" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secziuns da la trav da menu" + } + } + } + }, + "Menu Bar Shape" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Form der Menüleiste" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forme de la barre des menus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forma della barra dei menu" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Furma da la trav da menu" + } + } + } + }, + "Menu bar item is not movable." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Menüleistenelement lässt sich nicht verschieben." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cet élément de la barre des menus ne peut pas être déplacé." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questo elemento della barra dei menu non può essere spostato." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quest element da la trav da menu na sa lascha betg spustar." + } + } + } + }, + "Menu bar item spacing" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abstand der Menüleistenelemente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Espacement des éléments de la barre des menus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spaziatura degli elementi della barra dei menu" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distanza tranter ils elements da la trav da menu" + } + } + } + }, + "Menu bar items are rehidden after a fixed amount of time." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleistenelemente werden nach einer festen Zeit wieder ausgeblendet." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les éléments sont de nouveau masqués après un délai fixe." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gli elementi vengono nascosti di nuovo dopo un tempo fisso." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ils elements vegnan puspè zuppentads suenter in temp fix." + } + } + } + }, + "Menu bar items are rehidden using a smart algorithm." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleistenelemente werden nach einem intelligenten Verfahren wieder ausgeblendet." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les éléments sont de nouveau masqués selon un algorithme intelligent." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gli elementi vengono nascosti di nuovo con un algoritmo intelligente." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ils elements vegnan puspè zuppentads cun in algoritmus intelligent." + } + } + } + }, + "Menu bar items are rehidden when the focused app changes." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleistenelemente werden wieder ausgeblendet, wenn die aktive App wechselt." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les éléments sont de nouveau masqués quand l'app active change." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gli elementi vengono nascosti di nuovo quando cambia l'app attiva." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ils elements vegnan puspè zuppentads, cura che l'app activa sa mida." + } + } + } + }, + "Missing bounds rectangle for “%@”" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rahmen für «%@» fehlt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rectangle de « %@ » introuvable" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rettangolo di «%@» mancante" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il rectangul da «%@» manca" + } + } + } + }, + "Missing mouse location" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeigerposition fehlt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Position du pointeur introuvable" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posizione del puntatore mancante" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "La posiziun dal cursur manca" + } + } + } + }, + "More" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mehr" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altro" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dapli" + } + } + } + }, + "Mouse pointer" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mauszeiger" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pointeur" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puntatore" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cursur" + } + } + } + }, + "Move %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ bewegen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déplacer %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sposta %@" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spustar %@" + } + } + } + }, + "Move Left" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nach links bewegen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déplacer vers la gauche" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sposta a sinistra" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spustar a sanestra" + } + } + } + }, + "Move Right" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nach rechts bewegen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déplacer vers la droite" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sposta a destra" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spustar a dretga" + } + } + } + }, + "Move to Always Hidden" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In «Immer ausgeblendet» bewegen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déplacer vers « Toujours masqué »" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sposta in «Sempre nascosto»" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spustar en «Adina zuppà»" + } + } + } + }, + "Move to Hidden" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In «Ausgeblendet» bewegen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déplacer vers « Masqué »" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sposta in «Nascosto»" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spustar en «Zuppà»" + } + } + } + }, + "Move to Visible" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In «Sichtbar» bewegen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déplacer vers « Visible »" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sposta in «Visibile»" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spustar en «Visibel»" + } + } + } + }, + "Move, show and click menu bar items for you." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleistenelemente für dich verschieben, einblenden und anklicken." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déplacer, afficher et cliquer les éléments de la barre des menus pour vous." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spostare, mostrare e fare clic sugli elementi della barra dei menu per te." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spustar, mussar e cliccar elements da la trav da menu per tai." + } + } + } + }, + "Name" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nom" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nome" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Num" + } + } + } + }, + "New Group" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neue Gruppe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nouveau groupe" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuovo gruppo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nova gruppa" + } + } + } + }, + "New Group…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neue Gruppe …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nouveau groupe…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuovo gruppo…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nova gruppa …" + } + } + } + }, + "No menu bar item matches “%@”." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Menüleistenelement passt zu «%@»." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun élément de la barre des menus ne correspond à « %@ »." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun elemento della barra dei menu corrisponde a «%@»." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nagin element da la trav da menu na correspunda a «%@»." + } + } + } + }, + "No shape kind selected" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Form ausgewählt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune forme choisie" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessuna forma scelta" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nagina furma tschernida" + } + } + } + }, + "None" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessuna" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nagina" + } + } + } + }, + "Not enough room to show “%@”" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht genug Platz, um «%@» einzublenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas assez de place pour afficher « %@ »" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spazio insufficiente per mostrare «%@»" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Betg avunda plazza per mussar «%@»" + } + } + } + }, + "Not needed on macOS 27, which folds items that do not fit behind its own overflow button." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unter macOS 27 nicht nötig: Elemente, die nicht passen, landen dort hinter einer eigenen Überlauftaste." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inutile sous macOS 27, qui replie les éléments qui ne tiennent pas derrière son propre bouton de débordement." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non serve su macOS 27, che raccoglie gli elementi che non ci stanno dietro un proprio pulsante di overflow." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Betg necessari sut macOS 27 che metta ils elements che n'han betg plazza davos in agen buttun da surpli." + } + } + } + }, + "Notch and Screen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notch und Bildschirm" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encoche et écran" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notch e schermo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notch e monitur" + } + } + } + }, + "Notice clicks, scrolls and hovers in the menu bar for show on click, scroll and hover." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klicks, Scrollen und Zeigerbewegungen in der Menüleiste erkennen, für das Einblenden per Klick, Scrollen und Zeiger." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Détecter les clics, défilements et survols dans la barre des menus pour l'affichage au clic, au défilement et au survol." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rilevare clic, scorrimenti e passaggi del puntatore nella barra dei menu per mostrare con clic, scorrimento e puntatore." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Percorscher clics, scrollar e moviments dal cursur en la trav da menu per mussar cun clic, scrollar e cursur." + } + } + } + }, + "OK" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + } + } + }, + "Off" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désactivé" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disattivato" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deactivà" + } + } + } + }, + "On other displays, hidden menu bar items are shown in the menu bar itself." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auf anderen Bildschirmen werden ausgeblendete Elemente direkt in der Menüleiste eingeblendet." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sur les autres écrans, les éléments masqués s'affichent dans la barre des menus elle-même." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sugli altri monitor gli elementi nascosti vengono mostrati nella barra dei menu stessa." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin auters monitors vegnan ils elements zuppads mussads directamain en la trav da menu." + } + } + } + }, + "Open" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öffnen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apri" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avrir" + } + } + } + }, + "Open Login Items Settings" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen für Anmeldeobjekte öffnen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir les réglages Ouverture" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apri le impostazioni Elementi login" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avrir ils parameters dals elements d'annunzia" + } + } + } + }, + "Open Menu" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menü öffnen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir le menu" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apri menu" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avrir il menu" + } + } + } + }, + "Open Menu Bar Item" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleistenelement öffnen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir un élément de la barre des menus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apri elemento della barra dei menu" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avrir in element da la trav da menu" + } + } + } + }, + "Open an Item by Letter" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Element per Buchstabe öffnen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir un élément par lettre" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apri un elemento con una lettera" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avrir in element cun ina letra" + } + } + } + }, + "Open an item by letter" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Element per Buchstabe öffnen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir un élément par lettre" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apri un elemento con una lettera" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avrir in element cun ina letra" + } + } + } + }, + "Open hidden items in the menu bar" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeblendete Elemente in der Menüleiste öffnen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir les éléments masqués dans la barre des menus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apri gli elementi nascosti nella barra dei menu" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avrir elements zuppads en la trav da menu" + } + } + } + }, + "Open “%@” with a hotkey" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "«%@» mit einem Tastenkürzel öffnen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir « %@ » avec un raccourci clavier" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apri «%@» con un'abbreviazione da tastiera" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avrir «%@» cun ina cumbinaziun da tastas" + } + } + } + }, + "Open-source packages" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open-Source-Pakete" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paquets open source" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pacchetti open source" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pachets open source" + } + } + } + }, + "Operation could not be completed" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vorgang konnte nicht abgeschlossen werden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'opération n'a pas pu aboutir" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile completare l'operazione" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'operaziun n'ha betg pudì vegnir terminada" + } + } + } + }, + "Other" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weitere" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autres" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altro" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ulteriurs" + } + } + } + }, + "Permission Granted" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erlaubt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorisé" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consentito" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permess" + } + } + } + }, + "Permissions" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Berechtigungen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorisations" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorizzazioni" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permissiuns" + } + } + } + }, + "Pictures of your menu bar items in the holzBar Shelf, the search and the Menu Bar Layout pane (on macOS 27 taken once per item), and a moving wallpaper beside a menu bar shape (before macOS 27)." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilder deiner Menüleistenelemente im holzBar Shelf, in der Suche und im Bereich «Menüleistenlayout» (unter macOS 27 einmal pro Element) sowie ein bewegtes Hintergrundbild neben einer Menüleistenform (vor macOS 27)." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Des images de vos éléments dans le holzBar Shelf, la recherche et la sous-fenêtre « Disposition de la barre des menus » (sous macOS 27, une fois par élément), et un fond d'écran animé à côté d'une forme de barre des menus (avant macOS 27)." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Immagini dei tuoi elementi in holzBar Shelf, nella ricerca e nel pannello «Layout della barra dei menu» (su macOS 27 una volta per elemento) e uno sfondo animato accanto a una forma della barra dei menu (prima di macOS 27)." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maletgs da tes elements en il holzBar Shelf, en la tschertga ed en il champ «Disposiziun da la trav da menu» (sut macOS 27 ina giada per element) ed in maletg dal fund en moviment sper ina furma da la trav da menu (avant macOS 27)." + } + } + } + }, + "Place new menu bar items in" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neue Menüleistenelemente einordnen in" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Placer les nouveaux éléments dans" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metti i nuovi elementi in" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plazzar novs elements en" + } + } + } + }, + "Please try again. If the error persists, please file a bug report." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte versuche es nochmals. Wenn der Fehler bleibt, melde ihn bitte." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veuillez réessayer. Si l'erreur persiste, signalez-la." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riprova. Se l'errore persiste, segnalalo." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Emprova anc ina giada. Sch'il sbagl resta, annunzia el per plaschair." + } + } + } + }, + "Profile" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profilo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil" + } + } + } + }, + "Profiles" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profile" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profils" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profili" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profils" + } + } + } + }, + "Quit" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitter" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esci" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminar" + } + } + } + }, + "Quit %@ and Continue" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ beenden und fortfahren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitter %@ et continuer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esci da %@ e continua" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminar %@ e cuntinuar" + } + } + } + }, + "Quit %@ first?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuerst %@ beenden?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitter d'abord %@ ?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uscire prima da %@?" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminar l'emprim %@?" + } + } + } + }, + "Quit holzBar" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar beenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitter holzBar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esci da holzBar" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminar holzBar" + } + } + } + }, + "Read where menu bar items are." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erkennen, wo die Menüleistenelemente sind." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lire l'emplacement des éléments de la barre des menus." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leggere dove si trovano gli elementi della barra dei menu." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leger nua ch'ils elements da la trav da menu èn." + } + } + } + }, + "Record" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufnehmen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registra" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registrar" + } + } + } + }, + "Record Hotkey" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tastenkürzel aufnehmen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer un raccourci" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registra abbreviazione" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registrar ina cumbinaziun" + } + } + } + }, + "Release notes for every version are on GitHub." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Versionshinweise zu jeder Version stehen auf GitHub." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les notes de version de chaque version sont sur GitHub." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le note di rilascio di ogni versione sono su GitHub." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las notas da versiun da mintga versiun èn sin GitHub." + } + } + } + }, + "Releases" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versionen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versions" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versioni" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versiuns" + } + } + } + }, + "Remove" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entfernen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rimuovi" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allontanar" + } + } + } + }, + "Remove Binding" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verknüpfung entfernen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer l'association" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rimuovi associazione" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allontanar l'associaziun" + } + } + } + }, + "Remove the color" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Farbe entfernen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer la couleur" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rimuovi il colore" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allontanar la colur" + } + } + } + }, + "Remove the hotkey of this item" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tastenkürzel dieses Elements entfernen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer le raccourci de cet élément" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rimuovi l'abbreviazione di questo elemento" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allontanar la cumbinaziun da quest element" + } + } + } + }, + "Rename" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Umbenennen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renommer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rinomina" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renumnar" + } + } + } + }, + "Rename Profile" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil umbenennen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renommer le profil" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rinomina profilo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renumnar il profil" + } + } + } + }, + "Rename…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Umbenennen …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renommer…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rinomina…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renumnar …" + } + } + } + }, + "Replace your settings?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen ersetzen?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remplacer vos réglages ?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sostituire le impostazioni?" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remplazzar tes parameters?" + } + } + } + }, + "Report a Bug" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehler melden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signaler un bug" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Segnala un bug" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annunziar in sbagl" + } + } + } + }, + "Reset" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zurücksetzen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ripristina" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resetar" + } + } + } + }, + "Reset Image" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bild zurücksetzen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser l'image" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ripristina immagine" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resetar il maletg" + } + } + } + }, + "Reset Menu Bar Appearance" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erscheinungsbild der Menüleiste zurücksetzen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser l'apparence de la barre des menus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ripristina l'aspetto della barra dei menu" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resetar l'apparientscha da la trav da menu" + } + } + } + }, + "Reset and Grant Again" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zurücksetzen und erneut erlauben" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser et autoriser à nouveau" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ripristina e consenti di nuovo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resetar e permetter danovamain" + } + } + } + }, + "Reset to the default spacing" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auf den Standardabstand zurücksetzen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Revenir à l'espacement par défaut" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ripristina la spaziatura predefinita" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resetar a la distanza da standard" + } + } + } + }, + "Restart" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neu starten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redémarrer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riavvia" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reaviar" + } + } + } + }, + "Right-click in an empty area of the menu bar to display a minimal version of holzBar's menu. Disable this setting if you encounter conflicts with other apps." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechtsklick auf eine leere Stelle der Menüleiste zeigt eine kurze Version des holzBar-Menüs. Deaktiviere diese Einstellung bei Konflikten mit anderen Apps." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un clic droit dans une zone vide de la barre des menus affiche une version réduite du menu de holzBar. Désactivez ce réglage en cas de conflit avec d'autres apps." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il clic destro in un'area vuota della barra dei menu mostra una versione ridotta del menu di holzBar. Disattiva questa impostazione in caso di conflitti con altre app." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "In clic dretg en ina zona vida da la trav da menu mussa ina versiun curta dal menu da holzBar. Deactivescha quest parameter, sch'i dat conflicts cun autras apps." + } + } + } + }, + "Round Cap" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rund" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrondie" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrotondata" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radund" + } + } + } + }, + "Round the screen corners" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildschirmecken abrunden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrondir les coins de l'écran" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrotonda gli angoli dello schermo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radundar ils cantuns dal monitur" + } + } + } + }, + "Save" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sichern" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salva" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memorisar" + } + } + } + }, + "Save Current Layout…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktuelles Layout sichern …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer la disposition actuelle…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salva il layout attuale…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memorisar la disposiziun actuala …" + } + } + } + }, + "Save Layout as Profile" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Layout als Profil sichern" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer la disposition comme profil" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salva il layout come profilo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memorisar la disposiziun sco profil" + } + } + } + }, + "Save Profile" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil sichern" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer le profil" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salva profilo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memorisar il profil" + } + } + } + }, + "Save a layout profile in the Menu Bar Layout pane to give it a hotkey." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sichere im Bereich «Menüleistenlayout» ein Layoutprofil, um ihm ein Tastenkürzel zu geben." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrez un profil de disposition dans la sous-fenêtre « Disposition de la barre des menus » pour lui attribuer un raccourci." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salva un profilo di layout nel pannello «Layout della barra dei menu» per assegnargli un'abbreviazione." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memorisescha in profil da disposiziun en il champ «Disposiziun da la trav da menu» per al dar ina cumbinaziun da tastas." + } + } + } + }, + "Save the current layout as a profile, for example “Work” or “Home”." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sichere das aktuelle Layout als Profil, zum Beispiel «Arbeit» oder «Zuhause»." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrez la disposition actuelle comme profil, par exemple « Travail » ou « Maison »." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salva il layout attuale come profilo, per esempio «Lavoro» o «Casa»." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memorisescha la disposiziun actuala sco profil, per exempel «Lavur» u «Chasa»." + } + } + } + }, + "Screen Recording" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildschirmaufnahme" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrement de l'écran" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registrazione schermo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildschirmaufnahme" + } + } + } + }, + "Scroll or swipe in the menu bar to show hidden menu bar items." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scrolle oder streiche in der Menüleiste, um ausgeblendete Elemente einzublenden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Faites défiler ou balayez dans la barre des menus pour afficher les éléments masqués." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scorri o fai swipe nella barra dei menu per mostrare gli elementi nascosti." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scrolla u stritga en la trav da menu per mussar ils elements zuppads." + } + } + } + }, + "Search Menu Bar Items" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleistenelemente suchen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechercher des éléments de la barre des menus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerca elementi della barra dei menu" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tschertgar elements da la trav da menu" + } + } + } + }, + "Search menu bar items" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleistenelemente suchen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechercher des éléments de la barre des menus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerca elementi della barra dei menu" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tschertgar elements da la trav da menu" + } + } + } + }, + "Search menu bar items…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleistenelemente suchen …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechercher des éléments…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerca elementi…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tschertgar elements …" + } + } + } + }, + "Section" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereich" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Section" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sezione" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secziun" + } + } + } + }, + "Section Action" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereichsaktion" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Action sur la section" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Azione sulla sezione" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acziun da la secziun" + } + } + } + }, + "Section divider style" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stil der Bereichstrenner" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Style des séparateurs de section" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stile dei divisori di sezione" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stil dals separaturs da secziun" + } + } + } + }, + "Set Hotkey…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tastenkürzel festlegen …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Définir un raccourci…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Imposta abbreviazione…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Definir la cumbinaziun …" + } + } + } + }, + "Settings" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réglages" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostazioni" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parameters" + } + } + } + }, + "Settings changed on another Mac" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen auf einem anderen Mac geändert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réglages modifiés sur un autre Mac" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostazioni modificate su un altro Mac" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parameters midads sin in auter Mac" + } + } + } + }, + "Shadow" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schatten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ombre" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ombra" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sumbriva" + } + } + } + }, + "Shape Kind" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Form" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forme" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forma" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Furma" + } + } + } + }, + "Show" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar" + } + } + } + }, + "Show Always-Hidden Section" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereich «Immer ausgeblendet» einblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher la section « Toujours masqué »" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra la sezione «Sempre nascosto»" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar la secziun «Adina zuppà»" + } + } + } + }, + "Show Hidden Items Automatically" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeblendete Elemente automatisch einblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher automatiquement les éléments masqués" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra automaticamente gli elementi nascosti" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar automaticamain ils elements zuppads" + } + } + } + }, + "Show Hidden Section" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeblendeten Bereich einblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher la section masquée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra la sezione nascosta" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar la secziun zuppada" + } + } + } + }, + "Show Item" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Element einblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher l'élément" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra elemento" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar l'element" + } + } + } + }, + "Show When It Changes" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bei Änderung einblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher quand il change" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra quando cambia" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar, cura ch'el sa mida" + } + } + } + }, + "Show all sections when ⌘ Command + dragging menu bar items" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Bereiche einblenden, wenn Elemente mit ⌘ Befehlstaste gezogen werden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher toutes les sections pendant le glissement avec ⌘ Commande" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra tutte le sezioni mentre trascini con ⌘ Comando" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar tut las secziuns, cura che elements vegnan tirads cun ⌘ Command" + } + } + } + }, + "Show hidden menu bar items in a separate bar below the menu bar." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeblendete Elemente in einer eigenen Leiste unter der Menüleiste zeigen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher les éléments masqués dans une barre séparée sous la barre des menus." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra gli elementi nascosti in una barra separata sotto la barra dei menu." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar ils elements zuppads en ina atgna trav sut la trav da menu." + } + } + } + }, + "Show holzBar icon" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar-Symbol zeigen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher l'icône de holzBar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra l'icona di holzBar" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar il simbol da holzBar" + } + } + } + }, + "Show items covered by the notch" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Von der Notch verdeckte Elemente zeigen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher les éléments cachés par l'encoche" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra gli elementi coperti dal notch" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar ils elements cuvrids da la notch" + } + } + } + }, + "Show on click" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Per Klick einblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher au clic" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra con un clic" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar cun in clic" + } + } + } + }, + "Show on hover" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Per Zeiger einblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher au survol" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra al passaggio del puntatore" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar cun il cursur" + } + } + } + }, + "Show on hover delay" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verzögerung beim Einblenden per Zeiger" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Délai d'affichage au survol" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ritardo della comparsa al passaggio" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retard da mussar cun il cursur" + } + } + } + }, + "Show on scroll" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Per Scrollen einblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher au défilement" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra con lo scorrimento" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar cun scrollar" + } + } + } + }, + "Show the hidden section for a moment" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeblendeten Bereich kurz einblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher la section masquée un instant" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra per un attimo la sezione nascosta" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar per in mument la secziun zuppada" + } + } + } + }, + "Shows a hidden item in the menu bar and opens its menu under it. Off, the menu opens without showing the item." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blendet ein ausgeblendetes Element in der Menüleiste ein und öffnet sein Menü darunter. Ist dies aus, öffnet sich das Menü, ohne das Element einzublenden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Affiche un élément masqué dans la barre des menus et ouvre son menu en dessous. Désactivé, le menu s'ouvre sans afficher l'élément." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra un elemento nascosto nella barra dei menu e ne apre il menu sotto. Se disattivato, il menu si apre senza mostrare l'elemento." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussa in element zuppà en la trav da menu ed avra ses menu sutvart. Sche quai è deactivà, s'avra il menu senza mussar l'element." + } + } + } + }, + "Since macOS 15, a hotkey whose only modifiers are Option, or Option and Shift, cannot be registered. Add Command or Control." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seit macOS 15 lassen sich Tastenkürzel nur mit Wahltaste oder Wahl- und Umschalttaste nicht mehr registrieren. Füge die Befehls- oder Controltaste hinzu." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Depuis macOS 15, un raccourci dont les seules touches de modification sont Option, ou Option et Maj, ne peut plus être enregistré. Ajoutez Commande ou Contrôle." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Da macOS 15 non si può più registrare un'abbreviazione i cui soli modificatori sono Opzione, o Opzione e Maiuscole. Aggiungi Comando o Controllo." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dapi macOS 15 na sa lascha nagina cumbinaziun pli registrar che ha sco unicas tastas da modificaziun Option u Option e Shift. Agiunta Command u Control." + } + } + } + }, + "Smart" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intelligent" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intelligent" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intelligente" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intelligent" + } + } + } + }, + "Solid" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durchgehend" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uni" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pieno" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuntinuà" + } + } + } + }, + "Some items are folded away on the built-in display." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einige Elemente sind auf dem integrierten Bildschirm weggeklappt." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Certains éléments sont repliés sur l'écran intégré." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alcuni elementi sono ripiegati sul monitor integrato." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intgins elements èn zuppads sin il monitur integrà." + } + } + } + }, + "Space" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leertaste" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Espace" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spazio" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spazi" + } + } + } + }, + "Spacer" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abstandhalter" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Espaceur" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spaziatore" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distanziader" + } + } + } + }, + "Spacers" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abstandhalter" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Espaceurs" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spaziatori" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distanziaders" + } + } + } + }, + "Spacers: %lld" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abstandhalter: %lld" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Espaceurs : %lld" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spaziatori: %lld" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distanziaders: %lld" + } + } + } + }, + "Split" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geteilt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Divisée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Divisa" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Partida" + } + } + } + }, + "Square Cap" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eckig" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carrée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Squadrata" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quadrat" + } + } + } + }, + "Strategy" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strategie" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stratégie" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strategia" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strategia" + } + } + } + }, + "Sync Here" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hier abgleichen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchroniser ici" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sincronizza qui" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sincronisar qua" + } + } + } + }, + "Sync settings between your Macs" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen zwischen deinen Macs abgleichen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchroniser les réglages entre vos Mac" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sincronizza le impostazioni tra i tuoi Mac" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sincronisar ils parameters tranter tes Macs" + } + } + } + }, + "System Glass" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Systemglas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verre du système" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vetro di sistema" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vaider dal sistem" + } + } + } + }, + "The Menu Bar Layout pane shows pictures of your menu bar items, which macOS lets an app take only with Screen Recording." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Bereich «Menüleistenlayout» zeigt Bilder deiner Menüleistenelemente, die eine App unter macOS nur mit der Bildschirmaufnahme erstellen darf." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La sous-fenêtre « Disposition de la barre des menus » affiche des images de vos éléments, que macOS ne laisse prendre qu'avec l'enregistrement de l'écran." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il pannello «Layout della barra dei menu» mostra immagini dei tuoi elementi, che macOS lascia acquisire solo con la registrazione schermo." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il champ «Disposiziun da la trav da menu» mussa maletgs da tes elements, ils quals macOS lascha far mo cun la Bildschirmaufnahme." + } + } + } + }, + "The amount of time to wait before showing on hover." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wie lange gewartet wird, bevor per Zeiger eingeblendet wird." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le temps d'attente avant l'affichage au survol." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il tempo di attesa prima della comparsa al passaggio del puntatore." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quant ditg ch'i vegn spetgà avant che mussar cun il cursur." + } + } + } + }, + "The color of the group's icon and name" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Farbe von Symbol und Name der Gruppe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La couleur de l'icône et du nom du groupe" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il colore dell'icona e del nome del gruppo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "La colur dal simbol e dal num da la gruppa" + } + } + } + }, + "The holzBar Shelf is centered below the holzBar icon." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das holzBar Shelf erscheint mittig unter dem holzBar-Symbol." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le holzBar Shelf est centré sous l'icône de holzBar." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar Shelf è centrato sotto l'icona di holzBar." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il holzBar Shelf cumpara al center sut il simbol da holzBar." + } + } + } + }, + "The holzBar Shelf is centered below the mouse pointer." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das holzBar Shelf erscheint mittig unter dem Mauszeiger." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le holzBar Shelf est centré sous le pointeur." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar Shelf è centrato sotto il puntatore." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il holzBar Shelf cumpara al center sut il cursur." + } + } + } + }, + "The holzBar Shelf shows pictures of your menu bar items, which macOS lets an app take only with Screen Recording." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das holzBar Shelf zeigt Bilder deiner Menüleistenelemente, die eine App unter macOS nur mit der Bildschirmaufnahme erstellen darf." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le holzBar Shelf affiche des images de vos éléments de la barre des menus, que macOS ne laisse prendre qu'avec l'enregistrement de l'écran." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar Shelf mostra immagini degli elementi della barra dei menu, che macOS lascia acquisire solo con la registrazione schermo." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il holzBar Shelf mussa maletgs da tes elements da la trav da menu, ils quals macOS lascha far mo cun la Bildschirmaufnahme." + } + } + } + }, + "The holzBar Shelf's location changes based on context." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Position des holzBar Shelf hängt von der Situation ab." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'emplacement du holzBar Shelf dépend du contexte." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La posizione di holzBar Shelf dipende dal contesto." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "La posiziun dal holzBar Shelf dependa da la situaziun." + } + } + } + }, + "The license text is missing from this copy of holzBar." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Lizenztext fehlt in dieser Kopie von holzBar." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le texte de la licence manque dans cette copie de holzBar." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il testo della licenza manca in questa copia di holzBar." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il text da la licenza manca en questa copia da holzBar." + } + } + } + }, + "The menu bar stays as it is. You can undo this with ⌘Z." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Menüleiste bleibt, wie sie ist. Du kannst dies mit ⌘Z widerrufen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La barre des menus reste telle quelle. Vous pouvez annuler avec ⌘Z." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La barra dei menu resta com'è. Puoi annullare con ⌘Z." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "La trav da menu resta sco ch'ella è. Ti pos revocar quai cun ⌘Z." + } + } + } + }, + "The search shows pictures of your menu bar items, which macOS lets an app take only with Screen Recording. You can search by name without it." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Suche zeigt Bilder deiner Menüleistenelemente, die eine App unter macOS nur mit der Bildschirmaufnahme erstellen darf. Nach Namen suchen geht auch ohne." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La recherche affiche des images de vos éléments, que macOS ne laisse prendre qu'avec l'enregistrement de l'écran. La recherche par nom fonctionne sans." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La ricerca mostra immagini dei tuoi elementi, che macOS lascia acquisire solo con la registrazione schermo. Puoi cercare per nome anche senza." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "La tschertga mussa maletgs da tes elements, ils quals macOS lascha far mo cun la Bildschirmaufnahme. Tschertgar tenor num va era senza." + } + } + } + }, + "The settings could not be exported." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Einstellungen konnten nicht exportiert werden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les réglages n'ont pas pu être exportés." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile esportare le impostazioni." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ils parameters n'han betg pudì vegnir exportads." + } + } + } + }, + "The settings could not be imported." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Einstellungen konnten nicht importiert werden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les réglages n'ont pas pu être importés." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile importare le impostazioni." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ils parameters n'han betg pudì vegnir importads." + } + } + } + }, + "The sync folder cannot be found. Choose it again." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Abgleichordner wurde nicht gefunden. Wähle ihn erneut." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le dossier de synchronisation est introuvable. Choisissez-le à nouveau." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile trovare la cartella di sincronizzazione. Sceglila di nuovo." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'ordinatur da sincronisaziun n'è betg vegnì chattà. Tscherna el danovamain." + } + } + } + }, + "This action cannot be undone." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dies kann nicht widerrufen werden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cette action est irréversible." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questa azione non può essere annullata." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questa acziun na sa lascha betg revocar." + } + } + } + }, + "This section is turned off in holzBar's settings." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieser Bereich ist in den Einstellungen von holzBar ausgeschaltet." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cette section est désactivée dans les réglages de holzBar." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questa sezione è disattivata nelle impostazioni di holzBar." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questa secziun è deactivada en ils parameters da holzBar." + } + } + } + }, + "Through %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Über %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Via %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tramite %@" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tras %@" + } + } + } + }, + "Timed" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitgesteuert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minuté" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "A tempo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temporisà" + } + } + } + }, + "Tint" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tönung" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teinte" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tinta" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tinta" + } + } + } + }, + "Tints the menu bar with the main colors of your wallpaper, read from its file." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tönt die Menüleiste in den Hauptfarben deines Hintergrundbilds, gelesen aus seiner Datei." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teinte la barre des menus avec les couleurs principales de votre fond d'écran, lues dans son fichier." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Colora la barra dei menu con i colori principali del tuo sfondo, letti dal suo file." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tinta la trav da menu cun las colurs principalas da tes maletg dal fund, ditgas da sia datoteca." + } + } + } + }, + "To This Display (%@)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mit diesem Bildschirm (%@)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "À cet écran (%@)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "A questo monitor (%@)" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cun quest monitur (%@)" + } + } + } + }, + "To This Space" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mit diesem Space" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "À ce Space" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "A questo Space" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cun quest Space" + } + } + } + }, + "Toggle" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Umschalten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basculer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alterna" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Midar" + } + } + } + }, + "Toggle Zen Mode" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zen-Modus umschalten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer ou désactiver le mode Zen" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva o disattiva la modalità Zen" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activar u deactivar il modus Zen" + } + } + } + }, + "Toggle application menus" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-Menüs ein- und ausblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher ou masquer les menus de l'app" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra o nascondi i menu delle app" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar u zuppentar ils menus da l'app" + } + } + } + }, + "Toggle the always-hidden section" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereich «Immer ausgeblendet» ein- und ausblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher ou masquer la section « Toujours masqué »" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra o nascondi la sezione «Sempre nascosto»" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar u zuppentar la secziun «Adina zuppà»" + } + } + } + }, + "Toggle the hidden section" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeblendeten Bereich ein- und ausblenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher ou masquer la section masquée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra o nascondi la sezione nascosta" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mussar u zuppentar la secziun zuppada" + } + } + } + }, + "Trailing End Cap" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechtes Ende" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extrémité droite" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estremità destra" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fin dretga" + } + } + } + }, + "Turn Off" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausschalten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désactiver" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disattiva" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deactivar" + } + } + } + }, + "Turn On…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einschalten …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activar …" + } + } + } + }, + "Turn auto-rehide on or off" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisches Ausblenden ein- oder ausschalten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer ou désactiver le masquage automatique" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva o disattiva l'occultamento automatico" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activar u deactivar il zuppentar automatic" + } + } + } + }, + "Turn on Zen mode while the screen is mirrored or shared" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zen-Modus einschalten, solange der Bildschirm gespiegelt oder geteilt wird" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer le mode Zen pendant la recopie ou le partage de l'écran" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva la modalità Zen mentre lo schermo è duplicato o condiviso" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activar il modus Zen, durant ch'il monitur vegn spievlà u partì" + } + } + } + }, + "Type Hotkey" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tastenkürzel drücken" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tapez le raccourci" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Digita l'abbreviazione" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Smatga la cumbinaziun" + } + } + } + }, + "Unable to display menu bar items" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleistenelemente können nicht angezeigt werden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible d'afficher les éléments de la barre des menus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile mostrare gli elementi della barra dei menu" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impussibel da mussar ils elements da la trav da menu" + } + } + } + }, + "Updates are installed with Homebrew:" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktualisierungen werden mit Homebrew installiert:" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les mises à jour s'installent avec Homebrew :" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gli aggiornamenti si installano con Homebrew:" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualisaziuns vegnan installadas cun Homebrew:" + } + } + } + }, + "Use Accent Color" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akzentfarbe verwenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliser la couleur d'accentuation" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usa il colore di contrasto" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duvrar la colur d'accent" + } + } + } + }, + "Use App Icon" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-Symbol verwenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliser l'icône de l'app" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usa l'icona dell'app" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duvrar il simbol da l'app" + } + } + } + }, + "Use Symbol" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Symbol verwenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliser le symbole" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usa il simbolo" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duvrar il simbol" + } + } + } + }, + "Use dynamic appearance" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamisches Erscheinungsbild verwenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliser l'apparence dynamique" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usa l'aspetto dinamico" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duvrar l'apparientscha dinamica" + } + } + } + }, + "Use holzBar Shelf" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar Shelf verwenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliser le holzBar Shelf" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usa holzBar Shelf" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duvrar il holzBar Shelf" + } + } + } + }, + "Use inset shape on screens with notch" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auf Bildschirmen mit Notch eingerückte Form verwenden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliser une forme en retrait sur les écrans avec encoche" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usa una forma rientrata sugli schermi con notch" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duvrar ina furma retratga sin monitors cun notch" + } + } + } + }, + "Use on" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwenden auf" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliser sur" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usa su" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duvrar sin" + } + } + } + }, + "Uses no permission: holzBar notices a mirrored display and macOS's screen sharing agent." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Braucht keine Berechtigung: holzBar erkennt einen gespiegelten Bildschirm und den Bildschirmfreigabe-Agenten von macOS." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune autorisation requise : holzBar détecte un écran recopié et l'agent de partage d'écran de macOS." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non serve alcuna autorizzazione: holzBar rileva un monitor duplicato e l'agente di condivisione schermo di macOS." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Na dovra nagina permissiun: holzBar percorscha in monitur spievlà e l'agent da partiziun dal monitur da macOS." + } + } + } + }, + "Version %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versione %@" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versiun %@" + } + } + } + }, + "Visible" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sichtbar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visible" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visibile" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visibel" + } + } + } + }, + "Visible items that the notch hides, for example behind a long application menu, also appear in the holzBar Shelf." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sichtbare Elemente, die von der Notch verdeckt werden, etwa hinter einem langen App-Menü, erscheinen ebenfalls im holzBar Shelf." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les éléments visibles que l'encoche cache, par exemple derrière un long menu d'app, apparaissent aussi dans le holzBar Shelf." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gli elementi visibili nascosti dal notch, per esempio dietro un lungo menu di un'app, compaiono anche in holzBar Shelf." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elements visibels che la notch cuvra, per exempel davos in lung menu d'app, cumparan era en il holzBar Shelf." + } + } + } + }, + "Website" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Website" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Site web" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sito web" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Website" + } + } + } + }, + "When the battery is low" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wenn die Batterie schwach ist" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quand la batterie est faible" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quando la batteria è scarica" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cura che la battaria è flaivla" + } + } + } + }, + "When the network connection is lost" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wenn die Netzwerkverbindung abbricht" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quand la connexion réseau est perdue" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quando si perde la connessione di rete" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cura che la colliaziun da rait è interrutta" + } + } + } + }, + "Where macOS puts them" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wo macOS sie platziert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Là où macOS les place" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dove li mette macOS" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nua che macOS als metta" + } + } + } + }, + "Width: %lld pt" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Breite: %lld pt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Largeur : %lld pt" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Larghezza: %lld pt" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ladezza: %lld pt" + } + } + } + }, + "Zen Mode" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zen-Modus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mode Zen" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modalità Zen" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modus Zen" + } + } + } + }, + "Zen mode" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zen-Modus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mode Zen" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modalità Zen" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modus Zen" + } + } + } + }, + "a display that is not connected" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "einem nicht angeschlossenen Bildschirm" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "un écran non branché" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "un monitor non collegato" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "in monitur betg collià" + } + } + } + }, + "brew update && brew upgrade --cask holzbar" : { + "shouldTranslate" : false + }, + "by Jordan Baird" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "von Jordan Baird" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "par Jordan Baird" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "di Jordan Baird" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "da Jordan Baird" + } + } + } + }, + "default" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "par défaut" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "predefinito" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "standard" + } + } + } + }, + "holzBar" : { + "shouldTranslate" : false + }, + "holzBar Settings…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar-Einstellungen …" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réglages de holzBar…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostazioni di holzBar…" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parameters da holzBar …" + } + } + } + }, + "holzBar and %@ would both try to manage the menu bar. They get in each other's way, and can leave the pointer unable to click anything. holzBar can quit %@ for you." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar und %1$@ würden beide versuchen, die Menüleiste zu verwalten. Sie kommen sich in die Quere, und der Zeiger kann danach womöglich nichts mehr anklicken. holzBar kann %2$@ für dich beenden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar et %1$@ essaieraient tous deux de gérer la barre des menus. Ils se gênent et peuvent empêcher le pointeur de cliquer quoi que ce soit. holzBar peut quitter %2$@ pour vous." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar e %1$@ cercherebbero entrambi di gestire la barra dei menu. Si intralciano e il puntatore potrebbe non riuscire più a fare clic. holzBar può chiudere %2$@ per te." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar e %1$@ empruvassan omadus d'administrar la trav da menu. Els sa disturban, ed il cursur na po forsa betg pli cliccar insatge. holzBar po terminar %2$@ per tai." + } + } + } + }, + "holzBar asks for it the first time you use one of these." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar fragt danach, wenn du eine dieser Funktionen zum ersten Mal verwendest." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar la demande la première fois que vous utilisez l'une de ces fonctions." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar la chiede la prima volta che usi una di queste funzioni." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar dumonda per quai l'emprima giada che ti duvras ina da questas funcziuns." + } + } + } + }, + "holzBar can restart now to use the settings from the sync folder." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar kann jetzt neu starten, um die Einstellungen aus dem Abgleichordner zu verwenden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar peut redémarrer maintenant pour utiliser les réglages du dossier de synchronisation." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar può riavviarsi ora per usare le impostazioni della cartella di sincronizzazione." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar po reaviar ussa per duvrar ils parameters da l'ordinatur da sincronisaziun." + } + } + } + }, + "holzBar cannot arrange menu bar items in automatically hidden menu bars." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar kann in automatisch ausgeblendeten Menüleisten keine Elemente anordnen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar ne peut pas disposer les éléments dans une barre des menus masquée automatiquement." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar non può sistemare gli elementi in barre dei menu nascoste automaticamente." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar na po betg ordinar elements en travs da menu zuppentadas automaticamain." + } + } + } + }, + "holzBar cannot display menu bar items for automatically hidden menu bars" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar kann für automatisch ausgeblendete Menüleisten keine Elemente anzeigen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar ne peut pas afficher les éléments d'une barre des menus masquée automatiquement" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar non può mostrare gli elementi di barre dei menu nascoste automaticamente" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar na po betg mussar elements da travs da menu zuppentadas automaticamain" + } + } + } + }, + "holzBar cannot edit the appearance of automatically hidden menu bars." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar kann das Erscheinungsbild automatisch ausgeblendeter Menüleisten nicht bearbeiten." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar ne peut pas modifier l'apparence d'une barre des menus masquée automatiquement." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar non può modificare l'aspetto di barre dei menu nascoste automaticamente." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar na po betg modifitgar l'apparientscha da travs da menu zuppentadas automaticamain." + } + } + } + }, + "holzBar could not restart itself. Quit holzBar and open it again." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar konnte sich nicht neu starten. Beende holzBar und öffne es erneut." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar n'a pas pu redémarrer. Quittez holzBar et rouvrez-le." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar non è riuscito a riavviarsi. Esci da holzBar e riaprilo." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar n'ha betg pudì reaviar. Termina holzBar ed avra el danovamain." + } + } + } + }, + "holzBar icon" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar-Symbol" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Icône de holzBar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Icona di holzBar" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "Simbol da holzBar" + } + } + } + }, + "holzBar is free software, licensed under the %@." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar ist freie Software unter der %@." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar est un logiciel libre sous %@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar è software libero con licenza %@." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar è software liber sut la %@." + } + } + } + }, + "holzBar is not ready yet. Open holzBar and grant its permissions first." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar ist noch nicht bereit. Öffne holzBar und erteile zuerst seine Berechtigungen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar n'est pas encore prêt. Ouvrez holzBar et accordez d'abord ses autorisations." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar non è ancora pronto. Apri holzBar e concedi prima le sue autorizzazioni." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar n'è anc betg pront. Avra holzBar e concéda l'emprim sias permissiuns." + } + } + } + }, + "holzBar needs this to:" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar braucht dies, um:" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar en a besoin pour :" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar ne ha bisogno per:" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar dovra quai per:" + } + } + } + }, + "holzBar needs your permission to manage the menu bar." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar braucht deine Erlaubnis, um die Menüleiste zu verwalten." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar a besoin de votre autorisation pour gérer la barre des menus." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar ha bisogno del tuo consenso per gestire la barra dei menu." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar dovra tia permissiun per administrar la trav da menu." + } + } + } + }, + "holzBar never connects to the network." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar verbindet sich nie mit dem Netzwerk." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar ne se connecte jamais au réseau." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar non si connette mai alla rete." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar na sa collia mai cun la rait." + } + } + } + }, + "holzBar will replace its current settings with the ones from “%@” and restart." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar ersetzt seine aktuellen Einstellungen durch die aus «%@» und startet neu." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar remplacera ses réglages actuels par ceux de « %@ » et redémarrera." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar sostituirà le impostazioni attuali con quelle di «%@» e si riavvierà." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "holzBar remplazza ses parameters actuals cun quels da «%@» e reavia." + } + } + } + }, + "macOS 27 doesn't let apps hide this system item." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS 27 lässt Apps dieses Systemelement nicht ausblenden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS 27 ne permet pas aux apps de masquer cet élément système." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS 27 non consente alle app di nascondere questo elemento di sistema." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS 27 na lubescha betg a las apps da zuppentar quest element dal sistem." + } + } + } + }, + "macOS 27 hides whole apps, and holzBar can't tell which app this item belongs to." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS 27 blendet ganze Apps aus, und holzBar kann nicht erkennen, zu welcher App dieses Element gehört." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS 27 masque des apps entières, et holzBar ne sait pas à quelle app appartient cet élément." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS 27 nasconde app intere e holzBar non riesce a capire a quale app appartiene questo elemento." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS 27 zuppenta apps entiras, e holzBar na po betg percorscher a tge app ch'quest element tutga." + } + } + } + }, + "macOS does not allow this hotkey" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS erlaubt dieses Tastenkürzel nicht" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS n'autorise pas ce raccourci" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS non consente questa abbreviazione" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS na lubescha betg questa cumbinaziun" + } + } + } + }, + "macOS hides another app's menus only while holzBar is in the Dock. With this on, holzBar never shows a Dock icon, and the menus stay." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS blendet die Menüs einer anderen App nur aus, solange holzBar im Dock ist. Ist dies an, zeigt holzBar nie ein Dock-Symbol, und die Menüs bleiben." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS ne masque les menus d'une autre app que si holzBar est dans le Dock. Activé, holzBar n'affiche jamais d'icône dans le Dock et les menus restent." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS nasconde i menu di un'altra app solo se holzBar è nel Dock. Se attivo, holzBar non mostra mai un'icona nel Dock e i menu restano." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS zuppenta ils menus d'in'autra app mo durant che holzBar è en il Dock. Sche quai è activà, na mussa holzBar mai in simbol en il Dock, ed ils menus restan." + } + } + } + }, + "macOS orders the items within each section." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS ordnet die Elemente innerhalb jedes Bereichs." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS ordonne les éléments au sein de chaque section." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS ordina gli elementi all'interno di ogni sezione." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS ordinescha ils elements entaifer mintga secziun." + } + } + } + }, + "macOS prohibits “%@” from being moved." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS verhindert, dass «%@» verschoben wird." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS empêche le déplacement de « %@ »." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS impedisce di spostare «%@»." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS impedescha da spustar «%@»." + } + } + } + }, + "macOS stopped laying them out when holzBar freed the space beside the notch, and left no control to reach them. Quitting and reopening the application whose item is missing brings it back." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS hat sie nicht mehr angeordnet, als holzBar den Platz neben der Notch freigab, und keine Möglichkeit gelassen, sie zu erreichen. Beende die App, deren Element fehlt, und öffne sie erneut, dann ist es wieder da." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS a cessé de les disposer quand holzBar a libéré l'espace près de l'encoche, sans laisser de moyen d'y accéder. Quittez puis rouvrez l'app dont l'élément manque pour le récupérer." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS ha smesso di disporli quando holzBar ha liberato lo spazio accanto al notch, senza lasciare un modo per raggiungerli. Esci e riapri l'app il cui elemento manca per farlo tornare." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS ha chalà da disponer els, cura che holzBar ha liberà la plazza sper la notch, e n'ha laschà nagina pussaivladad d'als cuntanscher. Termina l'app, da la quala l'element manca, ed avra la danovamain, lura è el puspè qua." + } + } + } + }, + "macOS uses this combination for one of its own shortcuts. Choose another one." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS verwendet diese Kombination für eines seiner eigenen Tastenkürzel. Wähle eine andere." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS utilise cette combinaison pour l'un de ses propres raccourcis. Choisissez-en une autre." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS usa questa combinazione per una delle sue abbreviazioni. Scegline un'altra." + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS dovra questa cumbinaziun per ina da sias atgnas. Tscherna in'autra." + } + } + } + }, + "max" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "max." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "max." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "max" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "max." + } + } + } + }, + "none" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "keiner" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "aucun" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "nessuno" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "nagin" + } + } + } + }, + "“%@” is not movable" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "«%@» lässt sich nicht verschieben" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "« %@ » ne peut pas être déplacé" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "«%@» non può essere spostato" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "«%@» na sa lascha betg spustar" + } + } + } + }, + "“%@” took too long to respond" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "«%@» hat zu lange nicht geantwortet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "« %@ » a mis trop de temps à répondre" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "«%@» ha impiegato troppo a rispondere" + } + }, + "rm" : { + "stringUnit" : { + "state" : "translated", + "value" : "«%@» ha dovrà memia ditg per respunder" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/holzBar/Settings/Models/AdvancedSettings.swift b/holzBar/Settings/Models/AdvancedSettings.swift index 78fa1173..b4f3d40e 100644 --- a/holzBar/Settings/Models/AdvancedSettings.swift +++ b/holzBar/Settings/Models/AdvancedSettings.swift @@ -43,6 +43,14 @@ final class AdvancedSettings { } } + /// A Boolean value that indicates whether holzBar never shows a Dock icon, so the + /// application menus are not hidden (macOS hides them only for an app in the Dock). + var keepsDockIconHidden = true { + didSet { + Defaults.set(keepsDockIconHidden, forKey: .keepsDockIconHidden) + } + } + /// A Boolean value that indicates whether to show a context menu /// when the user right-clicks the menu bar. var enableSecondaryContextMenu = true { @@ -58,7 +66,9 @@ final class AdvancedSettings { } } - /// Time interval to temporarily show items for. + /// How long an item opened from the holzBar Shelf, the search or elsewhere stays in the + /// menu bar after its menu closes ("Hide opened items again after", 0 to 30 s), and how + /// long the hidden section shows for a moment. Stored under its earlier name. var tempShowInterval: TimeInterval = 15 { didSet { Defaults.set(tempShowInterval, forKey: .tempShowInterval) @@ -80,6 +90,22 @@ final class AdvancedSettings { } } + /// A Boolean value that indicates whether a hidden item is shown in the menu bar to + /// open its menu; off, its menu opens without showing it, where the app allows. + var openHiddenItemsInMenuBar = true { + didSet { + Defaults.set(openHiddenItemsInMenuBar, forKey: .openHiddenItemsInMenuBar) + } + } + + /// A Boolean value that indicates whether Zen mode turns on while a display is + /// mirrored or the screen is shared (`PresentationMonitor`). + var autoZenWhileSharingScreen = false { + didSet { + Defaults.set(autoZenWhileSharingScreen, forKey: .autoZenWhileSharingScreen) + } + } + /// The shared app state. @ObservationIgnored private(set) weak var appState: AppState? @@ -96,10 +122,17 @@ final class AdvancedSettings { Defaults.ifPresent(key: .enableAlwaysHiddenSection, assign: &enableAlwaysHiddenSection) Defaults.ifPresent(key: .showAllSectionsOnUserDrag, assign: &showAllSectionsOnUserDrag) Defaults.ifPresent(key: .hideApplicationMenus, assign: &hideApplicationMenus) + Defaults.ifPresent(key: .keepsDockIconHidden, assign: &keepsDockIconHidden) Defaults.ifPresent(key: .enableSecondaryContextMenu, assign: &enableSecondaryContextMenu) Defaults.ifPresent(key: .showOnHoverDelay, assign: &showOnHoverDelay) Defaults.ifPresent(key: .tempShowInterval, assign: &tempShowInterval) + // Earlier versions allowed up to 60 s; the slider goes to 30. + if tempShowInterval > 30 { + tempShowInterval = 30 + } + Defaults.ifPresent(key: .openHiddenItemsInMenuBar, assign: &openHiddenItemsInMenuBar) Defaults.ifPresent(key: .keepLiveActivitiesVisible, assign: &keepLiveActivitiesVisible) + Defaults.ifPresent(key: .autoZenWhileSharingScreen, assign: &autoZenWhileSharingScreen) Defaults.ifPresent(key: .sectionDividerStyle) { rawValue in if let style = SectionDividerStyle(rawValue: rawValue) { diff --git a/holzBar/Settings/Models/HotkeysSettings.swift b/holzBar/Settings/Models/HotkeysSettings.swift index 0d21e60a..55221c9e 100644 --- a/holzBar/Settings/Models/HotkeysSettings.swift +++ b/holzBar/Settings/Models/HotkeysSettings.swift @@ -8,25 +8,34 @@ import Observation import OSLog /// Model for the app's Hotkeys settings. +/// +/// Every hotkey is stored in the `Hotkeys` setting under its target's storage key +/// (``HotkeyTarget/storageKey``): one per action, under Ice's names, and one for each +/// layout profile and menu bar item the user gave a hotkey. Saving changes only its own key, +/// so keys of other versions survive. @MainActor @Observable final class HotkeysSettings { /// The app's hotkey registry. @ObservationIgnored let registry = HotkeyRegistry() - /// The app's hotkeys. + /// The hotkeys of holzBar's actions, one for each action. let hotkeys = HotkeyAction.allCases.map { action in - Hotkey(action: action) + Hotkey(target: .action(action)) } + /// The hotkeys that apply a layout profile or open a menu bar item, in the order they + /// were created. + private(set) var dynamicHotkeys = [Hotkey]() + /// Encoder for properties. @ObservationIgnored private let encoder = JSONEncoder() /// Decoder for properties. @ObservationIgnored private let decoder = JSONDecoder() - /// Observers that save each hotkey when it changes. - @ObservationIgnored private var observers = [ObservationLoop]() + /// Observers that save each hotkey when it changes, by target. + @ObservationIgnored private var observers = [HotkeyTarget: ObservationLoop]() /// The shared app state. @ObservationIgnored private(set) weak var appState: AppState? @@ -38,7 +47,9 @@ final class HotkeysSettings { hotkey.performSetup(with: appState) } loadInitialState() - configureObservers() + for hotkey in hotkeys + dynamicHotkeys { + observe(hotkey) + } } /// Loads the model's initial state. @@ -50,34 +61,70 @@ final class HotkeysSettings { return } for hotkey in hotkeys { - guard let data = dictionary[hotkey.action.rawValue] else { + guard let data = dictionary[hotkey.target.storageKey] else { continue } - do { - if let keyCombination = try decoder.decode(KeyCombination?.self, from: data) { - hotkey.keyCombination = keyCombination - } - } catch { - Logger.serialization.error("Error decoding hotkey: \(error, privacy: .private)") + if let keyCombination = decodeKeyCombination(data, for: hotkey.target) { + hotkey.keyCombination = keyCombination } } + for key in dictionary.keys.sorted() { + guard + let target = HotkeyTarget(storageKey: key), + target.isDynamic, + let data = dictionary[key], + let keyCombination = decodeKeyCombination(data, for: target) + else { + continue + } + let hotkey = Hotkey(target: target) + if let appState { + hotkey.performSetup(with: appState) + } + hotkey.keyCombination = keyCombination + dynamicHotkeys.append(hotkey) + } } - /// Saves each hotkey whenever its key combination changes. - private func configureObservers() { - observers = hotkeys.map { hotkey in - ObservationLoop.observe { hotkey.keyCombination } onChange: { [weak self] keyCombination in - self?.save(keyCombination, for: hotkey.action) + /// Decodes a stored key combination, or returns `nil` for none or an invalid one. + private func decodeKeyCombination(_ data: Data, for target: HotkeyTarget) -> KeyCombination? { + do { + // An invalid stored combination (a key code out of range, unknown modifier + // bits) fails to decode and is ignored (jordanbaird/Ice#985). + guard let keyCombination = try decoder.decode(KeyCombination?.self, from: data) else { + return nil + } + guard !keyCombination.isSystemReserved else { + Logger.hotkeys.error("Ignoring stored hotkey of \(target.logDescription, privacy: .public): reserved by the system") + return nil } + return keyCombination + } catch { + Logger.serialization.error("Ignoring stored hotkey of \(target.logDescription, privacy: .public): \(error, privacy: .private)") + return nil } } - /// Saves the key combination of the hotkey with the given action. - private func save(_ keyCombination: KeyCombination?, for action: HotkeyAction) { + /// Saves the hotkey whenever its key combination changes. + private func observe(_ hotkey: Hotkey) { + let target = hotkey.target + observers[target] = ObservationLoop.observe { hotkey.keyCombination } onChange: { [weak self] keyCombination in + self?.save(keyCombination, for: target) + } + } + + /// Saves the key combination of the hotkey with the given target. + /// + /// A profile or an item whose hotkey was cleared loses its key, so nothing is left behind. + private func save(_ keyCombination: KeyCombination?, for target: HotkeyTarget) { do { let data = try encoder.encode(keyCombination) withMutableCopy(of: Defaults.dictionary(forKey: .hotkeys) ?? [:]) { dictionary in - dictionary[action.rawValue] = data + if keyCombination == nil, target.isDynamic { + dictionary[target.storageKey] = nil + } else { + dictionary[target.storageKey] = data + } Defaults.set(dictionary, forKey: .hotkeys) } } catch { @@ -89,4 +136,63 @@ final class HotkeysSettings { func hotkey(withAction action: HotkeyAction) -> Hotkey? { hotkeys.first { $0.action == action } } + + /// Returns the hotkey of a profile or an item, if it has one. + func existingHotkey(for target: HotkeyTarget) -> Hotkey? { + if case .action(let action) = target { + return hotkey(withAction: action) + } + return dynamicHotkeys.first { $0.target == target } + } + + /// Returns the hotkey for the given target, creating one without a key combination for + /// a profile or an item that has none yet. + func hotkey(for target: HotkeyTarget) -> Hotkey { + if let existing = existingHotkey(for: target) { + return existing + } + let hotkey = Hotkey(target: target) + if let appState { + hotkey.performSetup(with: appState) + } + dynamicHotkeys.append(hotkey) + observe(hotkey) + return hotkey + } + + /// Sets the key combination of the hotkey for the given target. + func setKeyCombination(_ keyCombination: KeyCombination?, for target: HotkeyTarget) { + hotkey(for: target).keyCombination = keyCombination + } + + /// Removes the hotkey of a profile or an item and its stored key. + func removeHotkey(for target: HotkeyTarget) { + guard target.isDynamic else { + return + } + if let index = dynamicHotkeys.firstIndex(where: { $0.target == target }) { + let hotkey = dynamicHotkeys.remove(at: index) + observers[target] = nil + hotkey.disable() + } + withMutableCopy(of: Defaults.dictionary(forKey: .hotkeys) ?? [:]) { dictionary in + guard dictionary[target.storageKey] != nil else { + return + } + dictionary[target.storageKey] = nil + Defaults.set(dictionary, forKey: .hotkeys) + } + } + + /// Moves the hotkey of one target to another, for a renamed profile. + func moveHotkey(from oldTarget: HotkeyTarget, to newTarget: HotkeyTarget) { + guard oldTarget != newTarget else { + return + } + let keyCombination = existingHotkey(for: oldTarget)?.keyCombination + removeHotkey(for: oldTarget) + if let keyCombination { + setKeyCombination(keyCombination, for: newTarget) + } + } } diff --git a/holzBar/Settings/SettingsPanes/AcknowledgementsView.swift b/holzBar/Settings/SettingsPanes/AcknowledgementsView.swift index 173e4f75..67bb9f87 100644 --- a/holzBar/Settings/SettingsPanes/AcknowledgementsView.swift +++ b/holzBar/Settings/SettingsPanes/AcknowledgementsView.swift @@ -81,7 +81,7 @@ struct AcknowledgementsView: View { /// A titled group of the acknowledgements sheet. private struct AcknowledgementsGroup: View { - let title: String + let title: LocalizedStringKey @ViewBuilder let content: Content var body: some View { @@ -151,7 +151,7 @@ private struct AcknowledgementRow: View { let url, let text = try? String(contentsOf: url, encoding: .utf8) else { - return "The license text is missing from this copy of holzBar." + return String(localized: "The license text is missing from this copy of holzBar.") } return text } diff --git a/holzBar/Settings/SettingsPanes/AdvancedSettingsPane.swift b/holzBar/Settings/SettingsPanes/AdvancedSettingsPane.swift index 15237be4..7ba35b7d 100644 --- a/holzBar/Settings/SettingsPanes/AdvancedSettingsPane.swift +++ b/holzBar/Settings/SettingsPanes/AdvancedSettingsPane.swift @@ -17,26 +17,38 @@ struct AdvancedSettingsPane: View { private func formattedToSeconds(_ interval: TimeInterval) -> LocalizedStringKey { let formatted = interval.formatted() return if interval == 1 { - LocalizedStringKey(formatted + " second") + "\(formatted) second" } else { - LocalizedStringKey(formatted + " seconds") + "\(formatted) seconds" } } var body: some View { HolzBarForm { + // Settings that do nothing on macOS 27 are not shown there (jordanbaird/Ice#1001): + // there are no dividers to style, no drags on the bar, no item moves and no + // hiding of application menus. Their stored values are kept. HolzBarSection("Menu Bar Sections") { enableAlwaysHiddenSection - showAllSectionsOnUserDrag - sectionDividerStyle + if !isMacOS27 { + showAllSectionsOnUserDrag + sectionDividerStyle + } newItemsPlacement - keepLiveActivitiesVisible + if !isMacOS27 { + keepLiveActivitiesVisible + } } HolzBarSection("Other") { - hideApplicationMenus + if !isMacOS27 { + hideApplicationMenus + keepsDockIconHidden + } enableSecondaryContextMenu showOnHoverDelay tempShowInterval + openHiddenItemsInMenuBar + autoZenWhileSharingScreen } HolzBarSection("Show Hidden Items Automatically") { RevealRulesSettings(rules: appState.revealRules) @@ -118,6 +130,21 @@ struct AdvancedSettingsPane: View { .disabled(isMacOS27) } + @ViewBuilder + private var keepsDockIconHidden: some View { + Toggle("Keep the Dock icon hidden", isOn: $settings.keepsDockIconHidden) + .annotation { + Text( + """ + macOS hides another app's menus only while holzBar is in the Dock. With \ + this on, holzBar never shows a Dock icon, and the menus stay. + """ + ) + .padding(.trailing, 75) + } + .disabled(isMacOS27 || !settings.hideApplicationMenus) + } + private var isMacOS27: Bool { if #available(macOS 27.0, *) { true @@ -169,17 +196,29 @@ struct AdvancedSettingsPane: View { HolzBarSlider( formattedToSeconds(settings.tempShowInterval), value: $settings.tempShowInterval, - in: 0...60, + in: 0...30, step: 1 ) } label: { - Text("Temporarily shown item delay") + Text("Hide opened items again after") .frame(minWidth: maxSliderLabelWidth, alignment: .leading) .onFrameChange { frame in maxSliderLabelWidth = max(maxSliderLabelWidth, frame.width) } } - .annotation("The amount of time to wait before hiding temporarily shown menu bar items.") + .annotation("Counted from when the item's menu closes. 0 hides it right away.") + } + + @ViewBuilder + private var openHiddenItemsInMenuBar: some View { + Toggle("Open hidden items in the menu bar", isOn: $settings.openHiddenItemsInMenuBar) + .annotation("Shows a hidden item in the menu bar and opens its menu under it. Off, the menu opens without showing the item.") + } + + @ViewBuilder + private var autoZenWhileSharingScreen: some View { + Toggle("Turn on Zen mode while the screen is mirrored or shared", isOn: $settings.autoZenWhileSharingScreen) + .annotation("Uses no permission: holzBar notices a mirrored display and macOS's screen sharing agent.") } @ViewBuilder @@ -239,31 +278,55 @@ private struct RevealRulesSettings: View { Toggle("When the battery is low", isOn: $rules.revealsOnLowBattery) if rules.revealsOnLowBattery { Stepper(value: $rules.lowBatteryThreshold, in: 5...50, step: 5) { - Text("Below \(rules.lowBatteryThreshold) %") + Text("Below \((Double(rules.lowBatteryThreshold) / 100).formatted(.percent))") } } Toggle("When the network connection is lost", isOn: $rules.revealsWhenOffline) - .annotation("Hidden items are shown for the temporarily shown item delay, then hidden again.") + .annotation("Hidden items are shown for the time set in \u{201C}Hide opened items again after\u{201D}, then hidden again.") } } // MARK: - SettingsSyncToggle -/// Turns syncing the settings through iCloud Drive on or off (jordanbaird/Ice#95). +/// Turns syncing the settings on or off, through iCloud Drive or any folder the Macs keep +/// in sync (jordanbaird/Ice#95, SYNC-01). private struct SettingsSyncToggle: View { @Bindable var sync: SettingsSync - private var annotation: LocalizedStringKey { - if SettingsSync.iCloudDriveURL == nil { - "Turn on iCloud Drive in System Settings to sync holzBar's settings between your Macs." - } else { - "Keeps layout, profiles, hotkeys and appearance the same on all your Macs. Changes from another Mac apply after a restart." - } - } - var body: some View { - Toggle("Sync settings with iCloud Drive", isOn: $sync.isEnabled) - .disabled(SettingsSync.iCloudDriveURL == nil) - .annotation(annotation) + LabeledContent { + HStack { + if sync.isEnabled { + Button("Change…") { + sync.chooseFolder() + } + Button("Turn Off") { + sync.isEnabled = false + } + } else { + Button("Turn On…") { + sync.chooseFolder() + } + } + } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("Sync settings between your Macs") + if sync.isEnabled { + if let folder = sync.folderDisplayName { + Text("Through \(folder)") + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + Text("The sync folder cannot be found. Choose it again.") + .font(.subheadline) + .foregroundStyle(.orange) + } + } + } + } + .annotation( + "Keeps layout, profiles, hotkeys and appearance the same on all your Macs through a folder they sync: iCloud Drive, Nextcloud, Dropbox, OneDrive, Syncthing or a network share. The folder's own app carries the file; holzBar never goes online. Changes from another Mac apply after a restart." + ) } } diff --git a/holzBar/Settings/SettingsPanes/GeneralSettingsPane.swift b/holzBar/Settings/SettingsPanes/GeneralSettingsPane.swift index 1dd39308..731e28c0 100644 --- a/holzBar/Settings/SettingsPanes/GeneralSettingsPane.swift +++ b/holzBar/Settings/SettingsPanes/GeneralSettingsPane.swift @@ -19,16 +19,16 @@ struct GeneralSettingsPane: View { case -16: "none" case 0: "default" case 16: "max" - default: LocalizedStringKey(tempItemSpacingOffset.formatted()) + default: "\(tempItemSpacingOffset.formatted())" } } private var rehideIntervalKey: LocalizedStringKey { let formatted = settings.rehideInterval.formatted() if settings.rehideInterval == 1 { - return LocalizedStringKey(formatted + " second") + return "\(formatted) second" } else { - return LocalizedStringKey(formatted + " seconds") + return "\(formatted) seconds" } } diff --git a/holzBar/Settings/SettingsPanes/HotkeysSettingsPane.swift b/holzBar/Settings/SettingsPanes/HotkeysSettingsPane.swift index 13471345..1bc83c4f 100644 --- a/holzBar/Settings/SettingsPanes/HotkeysSettingsPane.swift +++ b/holzBar/Settings/SettingsPanes/HotkeysSettingsPane.swift @@ -9,6 +9,29 @@ struct HotkeysSettingsPane: View { @Environment(AppState.self) var appState var settings: HotkeysSettings + /// The names of the saved layout profiles. + private var profileNames: [String] { + appState.profiles.profiles.map(\.name) + } + + /// A hotkey that opens a menu bar item, with the item's identity key. + private struct ItemHotkey: Identifiable { + let key: String + let hotkey: Hotkey + + var id: String { key } + } + + /// The hotkeys that open a menu bar item. + private var itemHotkeys: [ItemHotkey] { + settings.dynamicHotkeys.compactMap { hotkey in + guard case .openItem(let key) = hotkey.target, hotkey.keyCombination != nil else { + return nil + } + return ItemHotkey(key: key, hotkey: hotkey) + } + } + var body: some View { HolzBarForm { HolzBarSection("Menu Bar Sections") { @@ -18,13 +41,71 @@ struct HotkeysSettingsPane: View { } HolzBarSection("Menu Bar Items") { hotkeyRecorder(forAction: .searchMenuBarItems) + hotkeyRecorder(forAction: .showItemHints) + itemHotkeyRows + } + HolzBarSection("Layout Profiles") { + profileHotkeyRows } HolzBarSection("Other") { hotkeyRecorder(forAction: .enableShelf) hotkeyRecorder(forAction: .toggleApplicationMenus) hotkeyRecorder(forAction: .toggleAutoRehide) + hotkeyRecorder(forAction: .toggleZenMode) + } + } + .task(id: profileNames) { + // Each profile gets its hotkey object here, outside the view update, so the + // rows below only read them. + for name in profileNames { + _ = settings.hotkey(for: .applyProfile(name)) + } + } + } + + @ViewBuilder + private var profileHotkeyRows: some View { + if profileNames.isEmpty { + Text("Save a layout profile in the Menu Bar Layout pane to give it a hotkey.") + .foregroundStyle(.secondary) + } else { + ForEach(profileNames, id: \.self) { name in + if let hotkey = settings.existingHotkey(for: .applyProfile(name)) { + HotkeyRecorder(hotkey: hotkey) { + Text(name) + } + } + } + } + } + + @ViewBuilder + private var itemHotkeyRows: some View { + ForEach(itemHotkeys) { entry in + HStack { + HotkeyRecorder(hotkey: entry.hotkey) { + Text(itemName(forKey: entry.key)) + } + Button { + settings.removeHotkey(for: .openItem(entry.key)) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .help("Remove the hotkey of this item") + .accessibilityLabel("Remove") } } + if itemHotkeys.isEmpty { + Text("Give a menu bar item its own hotkey with Set Hotkey… in its menu in the Menu Bar Layout pane.") + .foregroundStyle(.secondary) + } + } + + /// The name of the item stored under the given key, or the key when the item is not in + /// the menu bar now. + private func itemName(forKey key: String) -> String { + appState.itemManager.item(withIdentityKey: key)?.displayName ?? key } @ViewBuilder @@ -46,6 +127,10 @@ struct HotkeysSettingsPane: View { Text("Show the hidden section for a moment") case .toggleAutoRehide: Text("Turn auto-rehide on or off") + case .toggleZenMode: + Text("Zen mode") + case .showItemHints: + Text("Open an item by letter") } } } diff --git a/holzBar/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift b/holzBar/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift index d886b201..0620d4bc 100644 --- a/holzBar/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift +++ b/holzBar/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift @@ -148,30 +148,39 @@ private struct LayoutProfilesSection: View { var profiles: LayoutProfiles @State private var isNamingProfile = false @State private var newProfileName = "" + @State private var renamedProfile: LayoutProfile? + @State private var renamedName = "" + + /// Whether the rename alert is shown. + private var isRenaming: Binding { + Binding { + renamedProfile != nil + } set: { isPresented in + if !isPresented { + renamedProfile = nil + } + } + } var body: some View { HolzBarSection("Profiles") { + ForEach(profiles.profiles) { profile in + LayoutProfileRow( + profiles: profiles, + profile: profile, + isCurrent: profile.name == profiles.currentProfileName + ) { + renamedName = profile.name + renamedProfile = profile + } + } HStack { if profiles.profiles.isEmpty { Text("Save the current layout as a profile, for example \u{201C}Work\u{201D} or \u{201C}Home\u{201D}.") .foregroundStyle(.secondary) } else { - Menu(profiles.currentProfileName ?? "Apply a Profile") { - ForEach(profiles.profiles) { profile in - Button(profile.name) { - profiles.apply(profile) - } - } - Divider() - Menu("Delete") { - ForEach(profiles.profiles) { profile in - Button(profile.name, role: .destructive) { - profiles.delete(named: profile.name) - } - } - } - } - .fixedSize() + Text("Bind a profile to a display or a Space to apply it when you connect the display or switch to the Space.") + .foregroundStyle(.secondary) } Spacer() Button("Save Current Layout…") { @@ -188,6 +197,118 @@ private struct LayoutProfilesSection: View { } message: { Text("A profile with the same name is replaced. Apply it later from here, or with holzbar://profile/.") } + .alert("Rename Profile", isPresented: isRenaming) { + TextField("Name", text: $renamedName) + Button("Rename") { + if let renamedProfile { + profiles.rename(renamedProfile, to: renamedName) + } + } + Button("Cancel", role: .cancel) { } + } + } + } +} + +// MARK: - LayoutProfileRow + +/// One saved profile: apply it, bind it to a display or a Space, rename or delete it. +private struct LayoutProfileRow: View { + var profiles: LayoutProfiles + let profile: LayoutProfile + let isCurrent: Bool + let rename: () -> Void + @State private var isConfirmingDelete = false + + /// The screen of the Settings window, which "This Display" means. + private var currentScreen: NSScreen? { + NSScreen.main + } + + /// The name of the bound display, or `nil` when it is not connected. + private func displayName(for uuid: String) -> String? { + NSScreen.screens.first { screen in + Bridging.getDisplayUUIDString(for: screen.displayID) == uuid + }?.localizedName + } + + /// What the profile is bound to, for the line below its name. + private var bindingDescription: Text? { + let display = profile.displayUUID.map { uuid in + displayName(for: uuid) ?? String(localized: "a display that is not connected") + } + switch (display, profile.spaceUUID) { + case (let display?, _?): + return Text("Bound to \(display) and a Space") + case (let display?, nil): + return Text("Bound to \(display)") + case (nil, _?): + return Text("Bound to a Space") + case (nil, nil): + return nil + } + } + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(profile.name) + if isCurrent { + Image(systemName: "checkmark") + .foregroundStyle(.secondary) + .accessibilityLabel("Applied") + } + } + if let bindingDescription { + bindingDescription + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Spacer() + Button("Apply") { + profiles.apply(profile) + } + Menu("Bind") { + if let currentScreen, let uuid = Bridging.getDisplayUUIDString(for: currentScreen.displayID) { + Button("To This Display (\(currentScreen.localizedName))") { + profiles.bind(profile, toDisplay: uuid) + } + } + if let spaceUUID = Bridging.getActiveSpaceUUID() { + Button("To This Space") { + profiles.bind(profile, toSpace: spaceUUID) + } + } + Divider() + Button("Remove Binding") { + profiles.unbind(profile) + } + .disabled(!profile.isBound) + } + .fixedSize() + Menu { + Button("Rename…", action: rename) + Button("Delete…", role: .destructive) { + isConfirmingDelete = true + } + } label: { + Image(systemName: "ellipsis.circle") + .accessibilityLabel("More") + } + .menuStyle(.button) + .menuIndicator(.hidden) + .buttonStyle(.borderless) + .fixedSize() + } + .confirmationDialog("Delete the profile \u{201C}\(profile.name)\u{201D}?", isPresented: $isConfirmingDelete) { + Button("Delete", role: .destructive) { + profiles.delete(named: profile.name) + } + Button("Cancel", role: .cancel) { } + } message: { + Text("The menu bar stays as it is. You can undo this with \u{2318}Z.") } } } @@ -205,6 +326,15 @@ private struct ItemGroupsSection: View { itemManager.itemCache.managedItems.filter { !$0.isControlItem } } + /// The group's color for the color well; the primary color while it has none. + private func colorBinding(for group: MenuBarItemGroup) -> Binding { + Binding { + group.color.map { Color(nsColor: $0) } ?? .primary + } set: { color in + groups.setColor(NSColor(color), for: group) + } + } + var body: some View { HolzBarSection("Groups") { ForEach(groups.groups) { group in @@ -217,10 +347,31 @@ private struct ItemGroupsSection: View { Image(systemName: symbol) } } + Divider() + Button("Choose Image…") { + groups.chooseImage(for: group) + } + Button("Use Symbol") { + groups.useSymbol(for: group) + } + .disabled(group.imageFile == nil) } label: { Image(systemName: group.symbolName) } .fixedSize() + ColorPicker("Color", selection: colorBinding(for: group), supportsOpacity: false) + .labelsHidden() + .help("The color of the group's icon and name") + if group.colorHex != nil { + Button { + groups.setColor(nil, for: group) + } label: { + Image(systemName: "xmark.circle") + } + .buttonStyle(.borderless) + .help("Remove the color") + .accessibilityLabel("Remove the color") + } Text(group.name) Text("\(group.itemTags.count) items") .foregroundStyle(.secondary) @@ -228,7 +379,7 @@ private struct ItemGroupsSection: View { Menu("Items") { ForEach(items, id: \.tag) { item in Toggle(item.displayName, isOn: Binding( - get: { group.itemTags.contains(item.tag.description) }, + get: { groups.contains(item, in: group) }, set: { _ in groups.toggle(item, in: group) } )) } diff --git a/holzBar/UI/Utilities/HolzBarColor.swift b/holzBar/UI/Utilities/HolzBarColor.swift index fc7975d6..b4ccd30f 100644 --- a/holzBar/UI/Utilities/HolzBarColor.swift +++ b/holzBar/UI/Utilities/HolzBarColor.swift @@ -30,6 +30,17 @@ extension HolzBarColor: Codable { debugDescription: "Invalid ICC profile data" ) } + // `CGColor` reads one component per channel of the color space plus alpha, whatever + // the array holds, so a stored array of the wrong length, or with values that are + // not numbers, is rejected first. + components = ColorComponents.clamped(components) + guard ColorComponents.isValid(components, colorSpaceComponents: colorSpace.numberOfComponents) else { + throw DecodingError.dataCorruptedError( + forKey: .components, + in: container, + debugDescription: "Expected \(colorSpace.numberOfComponents + 1) color components between 0 and 1" + ) + } guard let cgColor = CGColor(colorSpace: colorSpace, components: &components) else { throw DecodingError.dataCorrupted( DecodingError.Context( diff --git a/holzBar/UI/Utilities/HolzBarGradient.swift b/holzBar/UI/Utilities/HolzBarGradient.swift index b621c67f..46e026cd 100644 --- a/holzBar/UI/Utilities/HolzBarGradient.swift +++ b/holzBar/UI/Utilities/HolzBarGradient.swift @@ -139,7 +139,13 @@ struct HolzBarGradient: Codable, Hashable { var count: CGFloat = 0 for color in colors { - guard let components = color.components else { + // Each color is read as red, green, blue and alpha in the RGB color space; + // one that does not convert to four components is skipped. + guard + let rgbColor = color.converted(to: colorSpace, intent: .defaultIntent, options: nil), + let components = rgbColor.components, + components.count >= 4 + else { continue } totals.red += components[0] @@ -149,6 +155,10 @@ struct HolzBarGradient: Codable, Hashable { count += 1 } + guard count > 0 else { + return nil + } + var components: [CGFloat] = [ totals.red / count, totals.green / count, diff --git a/holzBar/UI/Views/HotkeyRecorder.swift b/holzBar/UI/Views/HotkeyRecorder.swift index e3dd2d4e..574f3206 100644 --- a/holzBar/UI/Views/HotkeyRecorder.swift +++ b/holzBar/UI/Views/HotkeyRecorder.swift @@ -114,7 +114,7 @@ struct HotkeyRecorder: View { .resizable() .aspectRatio(1, contentMode: .fit) .padding(padding) - .accessibilityLabel(label) + .accessibilityLabel(Text(LocalizedStringKey(label))) } } @@ -138,9 +138,9 @@ private final class HotkeyRecorderModel { var title: String { switch self { case .systemReserved: - "Hotkey is reserved by macOS" + String(localized: "Hotkey is reserved by macOS") case .optionOnly: - "macOS does not allow this hotkey" + String(localized: "macOS does not allow this hotkey") } } @@ -148,9 +148,9 @@ private final class HotkeyRecorderModel { var message: String { switch self { case .systemReserved: - "macOS uses this combination for one of its own shortcuts. Choose another one." + String(localized: "macOS uses this combination for one of its own shortcuts. Choose another one.") case .optionOnly: - "Since macOS 15, a hotkey whose only modifiers are Option, or Option and Shift, cannot be registered. Add Command or Control." + String(localized: "Since macOS 15, a hotkey whose only modifiers are Option, or Option and Shift, cannot be registered. Add Command or Control.") } } } diff --git a/holzBar/UI/Views/MenuBarItemContainer.swift b/holzBar/UI/Views/MenuBarItemContainer.swift index dbfa3db8..5b047a87 100644 --- a/holzBar/UI/Views/MenuBarItemContainer.swift +++ b/holzBar/UI/Views/MenuBarItemContainer.swift @@ -76,7 +76,11 @@ struct MenuBarItemContainer: View { private var contentOverlay: some View { if !appState.activeSpace.isFullscreen { if case .solid = configuration.tintKind { - Color(cgColor: configuration.tintColor) + if configuration.tintFollowsAccentColor { + Color(nsColor: .controlAccentColor) + } else { + Color(cgColor: configuration.tintColor) + } } else if case .gradient = configuration.tintKind, let color = configuration.tintGradient.averageColor() diff --git a/holzBar/Utilities/Extensions.swift b/holzBar/Utilities/Extensions.swift index 821d1b8a..c1c8b18f 100644 --- a/holzBar/Utilities/Extensions.swift +++ b/holzBar/Utilities/Extensions.swift @@ -548,37 +548,86 @@ extension NSScreen { return menuBarWindow?.bounds.height } - /// Returns the frame of the application menu on this screen. + /// What reading the frame of the application menu on this screen needs to know, taken + /// on the main actor so the read itself can run on another thread. + var applicationMenuQuery: ApplicationMenuQuery { + // The Accessibility API always returns the menu bar for the main screen. Long + // application menus can reach past a notch, which makes the frame invalid for + // every other screen, so on those the frame is dropped when it reaches the notch. + var notchLimit: CGFloat? + if + let mainScreen = NSScreen.main, + self != mainScreen, + let notchedScreen = NSScreen.screens.first(where: { $0.hasNotch }), + let leftArea = notchedScreen.auxiliaryTopLeftArea + { + notchLimit = leftArea.maxX + } + let menuBarWindow = WindowInfo.menuBarWindow(for: displayID) + return ApplicationMenuQuery( + displayID: displayID, + menuBarOrigin: menuBarWindow?.bounds.origin, + menuBarHeight: menuBarWindow?.bounds.height ?? NSStatusBar.system.thickness, + notchLimit: notchLimit + ) + } +} + +// MARK: - ApplicationMenuQuery + +/// A request for the frame of the application menu on one display. +nonisolated struct ApplicationMenuQuery: Sendable { + /// The display to read the application menu of. + let displayID: CGDirectDisplayID + + /// The origin of the display's menu bar window, or `nil` when it has none. + let menuBarOrigin: CGPoint? + + /// The height of the display's menu bar. + let menuBarHeight: CGFloat + + /// The width at which the frame reaches the notch of another screen (see + /// `NSScreen.applicationMenuQuery`), or `nil` when that does not matter. + let notchLimit: CGFloat? + + /// Returns whether the element at the origin of the display's menu bar window is a + /// menu bar. + /// + /// - Important: This asks Accessibility and blocks; like ``getApplicationMenuFrame()`` + /// it must stay off the main thread. + func hasValidMenuBar() -> Bool { + guard let menuBarOrigin, let element = AXHelpers.element(at: menuBarOrigin) else { + return false + } + AXHelpers.setMessagingTimeout(AXHelpers.applicationMenuTimeout, for: element) + return AXHelpers.role(for: element) == kAXMenuBarRole + } + + /// Returns the frame of the application menu on the display. + /// + /// - Important: This asks another application through Accessibility and blocks until it + /// answers or its messaging timeout (0.25 s) passes. It must stay off the main thread: + /// only `ApplicationMenuFrames` calls it, on its own queue, and everything else reads + /// that cache. func getApplicationMenuFrame() -> CGRect? { let displayBounds = CGDisplayBounds(displayID) - guard let menuBar = AXHelpers.applicationMenuBar(at: displayBounds.origin) else { + guard let menuBar = AXHelpers.applicationMenuBar(in: displayBounds, menuBarHeight: menuBarHeight) else { return nil } let applicationMenuFrame = AXHelpers.children(for: menuBar).reduce(into: CGRect.null) { result, child in + AXHelpers.setMessagingTimeout(AXHelpers.applicationMenuTimeout, for: child) if AXHelpers.isEnabled(child), let childFrame = AXHelpers.frame(for: child) { result = result.union(childFrame) } } - if applicationMenuFrame.width <= 0 || applicationMenuFrame.isNull { + if applicationMenuFrame.isNull || applicationMenuFrame.width <= 0 { return nil } - // FIXME: The Accessibility API always returns the menu bar for the main screen. - // This can cause issues if one of the screens has a notch, since long app menus - // can display items the trailing side of the notch. This causes the frame to be - // invalid for all other screens. For now, we're working around this by checking - // the app menu's frame on inactive screens, and returning `nil` if it overlaps - // with the notch. - if - let mainScreen = NSScreen.main, - self != mainScreen, - let notchedScreen = NSScreen.screens.first(where: { $0.hasNotch }), - let leftArea = notchedScreen.auxiliaryTopLeftArea, - applicationMenuFrame.width >= leftArea.maxX - { + if let notchLimit, applicationMenuFrame.width >= notchLimit { return nil } @@ -631,3 +680,14 @@ nonisolated extension Sequence where Element == MenuBarItem { first { $0.tag == tag } } } + +// MARK: - String + +nonisolated extension String { + /// The translation of an English text that is a key of holzBar's String Catalog, for + /// texts held as plain strings, such as those of `holzBar/Core`, which has no access to + /// the app's resources. + var localizedFromCatalog: String { + Bundle.main.localizedString(forKey: self, value: nil, table: nil) + } +} diff --git a/holzBar/Utilities/SettingsBackup.swift b/holzBar/Utilities/SettingsBackup.swift index bc73aca7..0cb59a2d 100644 --- a/holzBar/Utilities/SettingsBackup.swift +++ b/holzBar/Utilities/SettingsBackup.swift @@ -85,7 +85,7 @@ enum SettingsBackup { let panel = NSSavePanel() panel.allowedContentTypes = [.propertyList] panel.nameFieldStringValue = "holzBar Settings.plist" - panel.title = "Export holzBar Settings" + panel.title = String(localized: "Export holzBar Settings") NSApp.activate() guard panel.runModal() == .OK, let url = panel.url else { return @@ -95,7 +95,7 @@ enum SettingsBackup { try data.write(to: url, options: .atomic) logger.notice("Exported settings to \(url.path(percentEncoded: false), privacy: .private)") } catch { - show(error, message: "The settings could not be exported.") + show(error, message: String(localized: "The settings could not be exported.")) } } @@ -104,7 +104,7 @@ enum SettingsBackup { let panel = NSOpenPanel() panel.allowedContentTypes = [.propertyList] panel.allowsMultipleSelection = false - panel.title = "Import holzBar Settings" + panel.title = String(localized: "Import holzBar Settings") NSApp.activate() guard panel.runModal() == .OK, let url = panel.url else { return @@ -119,10 +119,10 @@ enum SettingsBackup { throw CocoaError(.fileReadCorruptFile) } let alert = NSAlert() - alert.messageText = "Replace your settings?" - alert.informativeText = "holzBar will replace its current settings with the ones from “\(url.lastPathComponent)” and restart." - alert.addButton(withTitle: "Import and Restart") - alert.addButton(withTitle: "Cancel") + alert.messageText = String(localized: "Replace your settings?") + alert.informativeText = String(localized: "holzBar will replace its current settings with the ones from “\(url.lastPathComponent)” and restart.") + alert.addButton(withTitle: String(localized: "Import and Restart")) + alert.addButton(withTitle: String(localized: "Cancel")) guard alert.runModal() == .alertFirstButtonReturn else { return } @@ -130,7 +130,7 @@ enum SettingsBackup { logger.notice("Imported settings from \(url.path(percentEncoded: false), privacy: .private)") relaunch() } catch { - show(error, message: "The settings could not be imported.") + show(error, message: String(localized: "The settings could not be imported.")) } } @@ -151,7 +151,7 @@ enum SettingsBackup { NSWorkspace.shared.openApplication(at: Bundle.main.bundleURL, configuration: configuration) { @Sendable _, error in Task { @MainActor in if let error { - Self.show(error, message: "holzBar could not restart itself. Quit holzBar and open it again.") + Self.show(error, message: String(localized: "holzBar could not restart itself. Quit holzBar and open it again.")) } else { NSApp.terminate(nil) } @@ -160,7 +160,7 @@ enum SettingsBackup { } private static func show(_ error: Error, message: String) { - logger.error("\(message, privacy: .public) \(error, privacy: .private)") + logger.error("\(message, privacy: .private) \(error, privacy: .private)") let alert = NSAlert(error: error) alert.messageText = message alert.runModal() diff --git a/holzBar/Utilities/SettingsSync.swift b/holzBar/Utilities/SettingsSync.swift index 4b8c9851..5dda9f60 100644 --- a/holzBar/Utilities/SettingsSync.swift +++ b/holzBar/Utilities/SettingsSync.swift @@ -8,19 +8,26 @@ import Observation import OSLog import SystemConfiguration -/// Keeps holzBar's settings in step across Macs through a file in iCloud Drive -/// (jordanbaird/Ice#95). +/// Keeps holzBar's settings in step across Macs through a file in a folder the Macs sync: +/// iCloud Drive or any folder the user chooses, such as a Nextcloud, Dropbox, OneDrive or +/// Syncthing folder or a network share (jordanbaird/Ice#95, SYNC-01). /// /// iCloud's key-value store needs an iCloud entitlement, which an ad hoc signed -/// app cannot have, so the settings travel as `holzBar/Settings.plist` in -/// iCloud Drive instead. Each change is written there; newer settings from -/// another Mac are applied at launch, or after a restart the user agrees to. +/// app cannot have, so the settings travel as `holzBar/Settings.plist` in the folder +/// instead. Each change is written there; newer settings from another Mac are applied at +/// launch, or after a restart the user agrees to. The folder's own app syncs the file; +/// holzBar never connects to the network. +/// +/// The folder is stored as a bookmark (`SettingsSyncLocation`). Without one, iCloud Drive +/// is used, as before folders could be chosen, and stored as the choice. /// /// Without an iCloud entitlement, `NSMetadataQuery`'s ubiquitous scopes are out of reach /// too. File coordination is what iCloud Drive itself uses to update the file, so a file /// presenter on the `holzBar` folder hears about every version that arrives from another -/// Mac, with no polling, and coordinated reads and writes never see half a file. The -/// presenter and the observer of this Mac's settings exist only while sync is on. +/// Mac, with no polling, and coordinated reads and writes never see half a file. Other +/// sync apps replace the file without coordination; a file system event source on the +/// folder hears about those. The presenter, the event source and the observer of this +/// Mac's settings exist only while sync is on. @MainActor @Observable final class SettingsSync { @@ -62,16 +69,78 @@ final class SettingsSync { return FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) ? url : nil } - /// The folder in iCloud Drive that holds the sync file. + /// The key of the bookmark of the chosen folder. It starts with "SettingsSync", so it + /// stays on this Mac (`SettingsBackup.excludedKeyPrefixes`). + private static let folderBookmarkKey = "SettingsSyncFolderBookmark" + + /// The folder the Macs sync, chosen by the user or iCloud Drive (see + /// `SettingsSyncLocation`). A stale bookmark, or iCloud Drive used without a choice, is + /// stored as the choice. + static var syncFolderURL: URL? { + let resolution: SettingsSyncLocation.Resolution + var resolvedURL: URL? + if let bookmark = UserDefaults.standard.data(forKey: folderBookmarkKey) { + var isStale = false + if let url = try? URL(resolvingBookmarkData: bookmark, options: [.withoutUI], relativeTo: nil, bookmarkDataIsStale: &isStale) { + resolvedURL = url + resolution = .resolved(path: url.path(percentEncoded: false), isStale: isStale) + } else { + resolution = .failed + } + } else { + resolution = .none + } + let iCloudDriveURL = iCloudDriveURL + let decision = SettingsSyncLocation.decide( + resolution: resolution, + iCloudDrivePath: iCloudDriveURL?.path(percentEncoded: false) + ) + guard let folderPath = decision.folderPath else { + return nil + } + let url = resolvedURL ?? iCloudDriveURL ?? URL(filePath: folderPath, directoryHint: .isDirectory) + if decision.storesBookmark { + storeBookmark(of: url) + } + return url + } + + /// Stores the bookmark of the folder the Macs sync. + private static func storeBookmark(of folderURL: URL) { + do { + let bookmark = try folderURL.bookmarkData(options: [], includingResourceValuesForKeys: nil, relativeTo: nil) + UserDefaults.standard.set(bookmark, forKey: folderBookmarkKey) + } catch { + logger.error("Could not store the sync folder: \(error, privacy: .private)") + } + } + + /// The folder in the synced folder that holds the sync file. static var folderURL: URL? { - iCloudDriveURL?.appending(path: "holzBar", directoryHint: .isDirectory) + syncFolderURL?.appending(path: SettingsSyncLocation.fileComponents[0], directoryHint: .isDirectory) } /// The file the settings are synced through. static var fileURL: URL? { - folderURL?.appending(path: "Settings.plist") + folderURL?.appending(path: SettingsSyncLocation.fileComponents[1]) } + /// The name of the synced folder to show, or `nil` when there is none. + var folderDisplayName: String? { + _ = folderChangeCount + guard let url = Self.syncFolderURL else { + return nil + } + return SettingsSyncLocation.displayName( + forFolder: url.path(percentEncoded: false), + homePath: FileManager.default.homeDirectoryForCurrentUser.path(percentEncoded: false), + iCloudDrivePath: Self.iCloudDriveURL?.path(percentEncoded: false) + ) + } + + /// Counts the changes of the folder, so the view showing its name updates. + private var folderChangeCount = 0 + /// A Boolean value that indicates whether syncing is turned on. var isEnabled = false { didSet { @@ -96,6 +165,9 @@ final class SettingsSync { /// Hears about new versions of the sync file while sync is on. @ObservationIgnored private var presenter: SettingsSyncPresenter? + /// Hears about files other sync apps replace in the folder while sync is on. + @ObservationIgnored private var folderWatcher: SettingsSyncFolderWatcher? + /// The pending check after the sync file changed. @ObservationIgnored private var checkTask: Task? @@ -112,11 +184,7 @@ final class SettingsSync { defaultsDebouncer.cancel() checkTask?.cancel() checkTask = nil - if let presenter { - NSFileCoordinator.removeFilePresenter(presenter) - self.presenter = nil - Self.logger.info("Stopped watching the sync file") - } + stopWatchingFolder() return } @@ -134,9 +202,9 @@ final class SettingsSync { do { try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) } catch { - Self.logger.error("Error creating the sync folder in iCloud Drive: \(error, privacy: .private)") + Self.logger.error("Error creating the holzBar folder in the sync folder: \(error, privacy: .private)") } - let presenter = SettingsSyncPresenter(folderURL: folderURL) { [weak self] in + let onChange: @Sendable () -> Void = { [weak self] in guard let self else { return } @@ -144,12 +212,56 @@ final class SettingsSync { self.syncFileDidChange() } } + let presenter = SettingsSyncPresenter(folderURL: folderURL, onChange: onChange) NSFileCoordinator.addFilePresenter(presenter) self.presenter = presenter + folderWatcher = SettingsSyncFolderWatcher(folderURL: folderURL, onChange: onChange) Self.logger.info("Watching the sync file") } } + /// Stops listening to the sync folder. + private func stopWatchingFolder() { + folderWatcher?.cancel() + folderWatcher = nil + if let presenter { + NSFileCoordinator.removeFilePresenter(presenter) + self.presenter = nil + Self.logger.info("Stopped watching the sync file") + } + } + + // MARK: Folder + + /// Lets the user choose the folder the Macs sync, and turns sync on with it. + /// + /// - Returns: Whether a folder was chosen. + @discardableResult + func chooseFolder() -> Bool { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.canCreateDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = String(localized: "Sync Here") + panel.message = String(localized: "Choose a folder your Macs keep in sync, such as iCloud Drive or a Nextcloud, Dropbox, OneDrive or Syncthing folder. holzBar keeps its settings in a holzBar folder inside it.") + panel.directoryURL = Self.syncFolderURL ?? Self.iCloudDriveURL + guard panel.runModal() == .OK, let url = panel.url else { + return false + } + stopWatchingFolder() + Self.storeBookmark(of: url) + folderChangeCount += 1 + lastPushedData = nil + if isEnabled { + updateObservers() + push() + } else { + isEnabled = true + } + return true + } + /// Checks the sync file shortly after it changed, once for a burst of changes. private func syncFileDidChange() { checkTask?.cancel() @@ -172,7 +284,7 @@ final class SettingsSync { private func push() { guard let fileURL = Self.fileURL else { - Self.logger.warning("iCloud Drive is off, not syncing settings") + Self.logger.warning("No sync folder, not syncing settings") return } let settings = SettingsBackup.currentSettings().filter { !Self.localKeys.contains($0.key) } @@ -193,7 +305,8 @@ final class SettingsSync { try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) let data = try PropertyListSerialization.data(fromPropertyList: file, format: .xml, options: 0) // Coordinated, so iCloud Drive never uploads half a file; this Mac's presenter - // is not told about its own write. + // is not told about its own write (its folder watcher is, and finds the file + // its own). var coordinationError: NSError? var writeError: (any Error)? NSFileCoordinator(filePresenter: presenter).coordinate( @@ -215,9 +328,9 @@ final class SettingsSync { } lastPushedData = settingsData UserDefaults.standard.set(modified, forKey: Self.lastSyncedKey) - Self.logger.info("Wrote settings to iCloud Drive") + Self.logger.info("Wrote settings to the sync folder") } catch { - Self.logger.error("Error writing settings to iCloud Drive: \(error, privacy: .private)") + Self.logger.error("Error writing settings to the sync folder: \(error, privacy: .private)") } } @@ -270,7 +383,7 @@ final class SettingsSync { SettingsBackup.apply(newer.settings) Defaults.set(true, forKey: .syncsSettingsWithICloud) UserDefaults.standard.set(newer.modified, forKey: lastSyncedKey) - logger.notice("Applied settings from iCloud Drive") + logger.notice("Applied settings from the sync folder") } /// Offers to restart when another Mac has changed the settings. @@ -283,10 +396,10 @@ final class SettingsSync { isAskingToRestart = false } let alert = NSAlert() - alert.messageText = "Settings changed on another Mac" - alert.informativeText = "holzBar can restart now to use the settings from iCloud Drive." - alert.addButton(withTitle: "Restart") - alert.addButton(withTitle: "Later") + alert.messageText = String(localized: "Settings changed on another Mac") + alert.informativeText = String(localized: "holzBar can restart now to use the settings from the sync folder.") + alert.addButton(withTitle: String(localized: "Restart")) + alert.addButton(withTitle: String(localized: "Later")) NSApp.activate() if alert.runModal() == .alertFirstButtonReturn { SettingsSync.pullIfNeeded() @@ -295,6 +408,40 @@ final class SettingsSync { } } +// MARK: - SettingsSyncFolderWatcher + +/// Tells settings sync when a file in the sync folder is added, replaced or removed, as +/// sync apps other than iCloud Drive do without file coordination. +/// +/// A file system event source on the folder: an event, never a poll. Its state never +/// changes after it is created. +private nonisolated final class SettingsSyncFolderWatcher: @unchecked Sendable { + private let source: any DispatchSourceFileSystemObject + + init?(folderURL: URL, onChange: @escaping @Sendable () -> Void) { + let descriptor = open(folderURL.path(percentEncoded: false), O_EVTONLY) + guard descriptor >= 0 else { + return nil + } + source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, + eventMask: [.write, .rename, .delete, .link], + queue: .global(qos: .utility) + ) + source.setEventHandler { + onChange() + } + source.setCancelHandler { + close(descriptor) + } + source.resume() + } + + func cancel() { + source.cancel() + } +} + // MARK: - SettingsSyncPresenter /// Tells settings sync when a new version of the sync file arrives in iCloud Drive.