diff --git a/.context/nlc/barshelf_dock_folder_close.json b/.context/nlc/barshelf_dock_folder_close.json new file mode 100644 index 0000000..69fd8ff --- /dev/null +++ b/.context/nlc/barshelf_dock_folder_close.json @@ -0,0 +1,27 @@ +{ + "name": "barshelf_dock_folder_close", + "target": "com.barshelf.app", + "steps": [ + { + "action": "click", + "selector": { + "role": "Button", + "desc": "Dl" + }, + "intent_text": "그리드 닫기", + "postcondition": { + "absent": "Open in Finder" + }, + "safety": { + "effects": [ + "read" + ], + "reversibility": "reversible", + "resource": { + "object_type": "폴더 보기", + "scope": "item" + } + } + } + ] +} \ No newline at end of file diff --git a/.context/nlc/barshelf_dock_folder_grid.json b/.context/nlc/barshelf_dock_folder_grid.json new file mode 100644 index 0000000..d404c43 --- /dev/null +++ b/.context/nlc/barshelf_dock_folder_grid.json @@ -0,0 +1,14 @@ +{ + "name": "barshelf_dock_folder_grid", + "target": "com.barshelf.app", + "steps": [ + { "action": "click", + "selector": { "role": "Button", "desc": "Dl" }, + "intent_text": "독의 다운로드 폴더를 그리드로 열기", + "postcondition": { "contains": "Open in Finder" }, + "captures": [ { "name": "files", "many": true, "limit": 8, "optional": true, + "field": { "role": "Button", "ancestor": "Open in Finder" }, "attribute": "Description" } ], + "safety": { "effects": ["read"], "reversibility": "reversible", + "resource": { "object_type": "폴더 보기", "scope": "item" } } } + ] +} diff --git a/.context/nlc/barshelf_dock_folder_into.json b/.context/nlc/barshelf_dock_folder_into.json new file mode 100644 index 0000000..c29af7c --- /dev/null +++ b/.context/nlc/barshelf_dock_folder_into.json @@ -0,0 +1,74 @@ +{ + "name": "barshelf_dock_folder_into", + "target": "com.barshelf.app", + "params": { + "folder": "output" + }, + "steps": [ + { + "action": "click", + "selector": { + "role": "Button", + "desc": "{{folder}}", + "ancestor": "Dl" + }, + "intent_text": "그리드에서 {{folder}} 폴더로 들어가기", + "postcondition": { + "contains": "Back" + }, + "safety": { + "effects": [ + "read" + ], + "reversibility": "reversible", + "resource": { + "object_type": "폴더 보기", + "scope": "item" + } + } + }, + { + "action": "click", + "selector": { + "role": "Button", + "desc": "Back", + "ancestor": "Dl" + }, + "intent_text": "상위 폴더로 돌아가기", + "postcondition": { + "contains": "{{folder}}" + }, + "safety": { + "effects": [ + "read" + ], + "reversibility": "reversible", + "resource": { + "object_type": "폴더 보기", + "scope": "item" + } + } + }, + { + "action": "click", + "selector": { + "role": "Button", + "desc": "Dl" + }, + "intent_text": "그리드 닫기", + "postcondition": { + "absent": "Open in Finder" + }, + "safety": { + "effects": [ + "read" + ], + "reversibility": "reversible", + "resource": { + "object_type": "폴더 보기", + "scope": "item" + } + } + } + ] +} \ No newline at end of file diff --git a/.context/nlc/barshelf_dock_menu_probe.json b/.context/nlc/barshelf_dock_menu_probe.json new file mode 100644 index 0000000..6c8e7a2 --- /dev/null +++ b/.context/nlc/barshelf_dock_menu_probe.json @@ -0,0 +1,13 @@ +{ + "name": "barshelf_dock_menu_probe", + "target": "com.barshelf.app", + "steps": [ + { "action": "secondary_action", + "selector": { "role": "Button", "desc": "Calendar" }, + "params": { "action": "AXShowMenu" }, + "intent_text": "독의 캘린더 우클릭 메뉴 열기", + "unverified_ok": true, + "safety": { "effects": ["read"], "reversibility": "reversible", + "resource": { "object_type": "컨텍스트 메뉴", "scope": "entry" } } } + ] +} diff --git a/.context/nlc/barshelf_dock_mode.json b/.context/nlc/barshelf_dock_mode.json new file mode 100644 index 0000000..82acf5d --- /dev/null +++ b/.context/nlc/barshelf_dock_mode.json @@ -0,0 +1,96 @@ +{ + "name": "barshelf_dock_mode", + "target": "com.barshelf.app", + "params": { + "mode": "Alongside the Apple Dock" + }, + "steps": [ + { + "action": "menu", + "params": { + "path": [ + "BarShelf", + [ + "Settings…", + "설정…" + ] + ] + }, + "postcondition": { + "contains": "Shelf" + }, + "intent_text": "BarShelf 설정 창 열기", + "safety": { + "effects": [ + "read" + ], + "reversibility": "reversible", + "resource": { + "object_type": "설정 창", + "scope": "entry" + } + } + }, + { + "action": "click", + "selector": { + "role": "Button", + "desc": "Dock", + "ancestor": "Sidebar" + }, + "intent_text": "Dock 페이지로 이동", + "postcondition": { + "contains": "Instead of the Apple Dock" + }, + "safety": { + "effects": [ + "read" + ], + "reversibility": "reversible", + "resource": { + "object_type": "설정 페이지", + "scope": "entry" + } + } + }, + { + "action": "click", + "selector": { + "role": "RadioButton", + "desc": "{{mode}}" + }, + "intent_text": "BarShelf Dock 모드: {{mode}}", + "postcondition": { + "field": { + "role": "RadioButton", + "desc": "{{mode}}" + }, + "equals": "1", + "attribute": "Value" + }, + "safety": { + "effects": [ + "write_persistent" + ], + "reversibility": "reversible", + "resource": { + "object_type": "독 설정", + "scope": "item" + }, + "evidence": [ + { + "source": "user_intent", + "field": "asked", + "value": "실제로 테스트도해주세요 … 다른것들도 다 개선합시다" + } + ] + }, + "binding": { + "bundle_id": "com.barshelf.app", + "role": "RadioButton", + "desc": "{{mode}}", + "operation": "click" + } + } + ] +} \ No newline at end of file diff --git a/.context/nlc/barshelf_dock_open_notes.json b/.context/nlc/barshelf_dock_open_notes.json new file mode 100644 index 0000000..a2b6dde --- /dev/null +++ b/.context/nlc/barshelf_dock_open_notes.json @@ -0,0 +1,15 @@ +{ + "name": "barshelf_dock_open_notes", + "target": "com.barshelf.app", + "steps": [ + { "action": "click", + "selector": { "role": "Button", "desc": "Notes" }, + "intent_text": "BarShelf Dock에서 메모 앱 열기", + "postcondition": { "field": { "role": "Button", "desc": "Notes" }, + "equals": "Running", "attribute": "Value" }, + "safety": { "effects": ["write_ephemeral"], "reversibility": "reversible", + "resource": { "object_type": "앱 실행", "scope": "item" }, + "evidence": [{ "source": "user_intent", "field": "asked", + "value": "실제로 테스트도해주세요 … 한번확인해서 잘작동되나해보세요" }] } } + ] +} diff --git a/.context/nlc/barshelf_open_dock_settings.json b/.context/nlc/barshelf_open_dock_settings.json new file mode 100644 index 0000000..df2716e --- /dev/null +++ b/.context/nlc/barshelf_open_dock_settings.json @@ -0,0 +1,14 @@ +{ + "name": "barshelf_open_dock_settings", + "target": "com.barshelf.app", + "steps": [ + { "action": "click", + "selector": { "role": "StaticText", "value": "Dock", "ancestor": "Sidebar", + "in_focused_window": true }, + "click_ancestor": ["Row", "Cell"], + "intent_text": "BarShelf 창에서 Dock 페이지 열기", + "postcondition": { "contains": "Alongside the Apple Dock" }, + "safety": { "effects": ["read"], "reversibility": "reversible", + "resource": { "object_type": "설정 페이지 탐색", "scope": "entry" } } } + ] +} diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index e06a0c8..6677ec8 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -58,6 +58,16 @@ } } }, + "%@ Copy": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 사본" + } + } + } + }, "%@ accent": { "localizations": { "ko": { @@ -85,6 +95,16 @@ } } }, + "%@ has no Apple Dock layout saved; switching to it leaves the Apple Dock as it is.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에 저장된 Apple Dock 레이아웃이 없습니다. 이 프로필로 바꿔도 Apple Dock은 그대로입니다." + } + } + } + }, "%@ is included more than once.": { "localizations": { "ko": { @@ -95,6 +115,16 @@ } } }, + "%@ is taken": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 사용 중" + } + } + } + }, "%@ needs your permission to run.": { "localizations": { "ko": { @@ -116,6 +146,16 @@ } } }, + "%@ s": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@초" + } + } + } + }, "%@ stepper": { "localizations": { "ko": { @@ -126,6 +166,16 @@ } } }, + "%@ used by Automation": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@: Automation에서 사용 중" + } + } + } + }, "%@ was found on your Mac": { "localizations": { "ko": { @@ -226,6 +276,22 @@ } } }, + "%@, and %lld more": { + "localizations": { + "en": { + "stringUnit": { + "state": "new", + "value": "%1$@, and %2$lld more" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 외 %lld개" + } + } + } + }, "%lld": { "localizations": { "ko": { @@ -252,6 +318,16 @@ } } }, + "%lld more…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld개 더…" + } + } + } + }, "%lld ms": { "comment": "A duration in milliseconds", "localizations": { @@ -479,6 +555,16 @@ } } }, + "A dock for apps, folders, and widgets, with profiles to switch between.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱·폴더·위젯을 올리는 독. 프로필로 구성을 바꿀 수 있습니다." + } + } + } + }, "A graph needs the item's own place in the menu bar; sharing the BarShelf icon, it shows text only.": { "localizations": { "ko": { @@ -519,6 +605,37 @@ } } }, + "A sturdier bar with names under icons, and widgets as full cards.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "아이콘 아래에 이름이 붙는 단단한 막대. 위젯은 전체 카드로 보입니다." + } + } + } + }, + "A sturdier bar with names under icons; roomy for widgets.": { + "extractionState": "stale", + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "아이콘 아래에 이름이 붙는 단단한 막대. 위젯을 올리기 좋습니다." + } + } + } + }, + "A web page to open from the dock.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독에서 열 웹 페이지입니다." + } + } + } + }, "A widget can also show its reading right in the menu bar — a battery level, CPU load, a temperature.": { "localizations": { "ko": { @@ -620,6 +737,26 @@ } } }, + "Add Link": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "링크 추가" + } + } + } + }, + "Add Profile…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필 추가…" + } + } + } + }, "Add Widget": { "localizations": { "ko": { @@ -680,6 +817,16 @@ } } }, + "Add to Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독에 추가" + } + } + } + }, "Add ⌘, ⌥, ⌃, or ⇧ so the shortcut doesn't fire while you type.": { "localizations": { "ko": { @@ -690,6 +837,16 @@ } } }, + "Adds the Apple Dock's apps, folders, and files to this profile.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock의 앱·폴더·파일을 이 프로필에 추가합니다." + } + } + } + }, "Advanced": { "localizations": { "ko": { @@ -801,6 +958,16 @@ } } }, + "Alongside the Apple Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock과 함께" + } + } + } + }, "An SF Symbol name or an emoji. Uncheck for no icon.": { "localizations": { "ko": { @@ -831,6 +998,26 @@ } } }, + "Animate opening apps": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱을 열 때 애니메이션 표시" + } + } + } + }, + "Another app or an Automation shortcut already uses %@. Free it, then turn profile shortcuts off and on.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "다른 앱이나 Automation 단축키가 이미 %@을(를) 쓰고 있습니다. 비운 뒤 프로필 단축키를 껐다가 다시 켜세요." + } + } + } + }, "App": { "comment": "Menu bar icon choice", "localizations": { @@ -852,6 +1039,26 @@ } } }, + "Apple Dock Layout": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock 레이아웃" + } + } + } + }, + "Apply Now": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "지금 적용" + } + } + } + }, "Apply Preset": { "localizations": { "ko": { @@ -862,6 +1069,26 @@ } } }, + "App…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱…" + } + } + } + }, + "Arrange the Apple Dock the way you want it for this profile, then save. Switching restarts the Dock briefly; open apps and windows stay as they are. The previous layout is backed up first.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 프로필에 맞게 Apple Dock을 정리한 뒤 저장하세요. 전환할 때 Dock이 잠깐 다시 시작되지만 열린 앱과 창은 그대로입니다. 이전 레이아웃은 먼저 백업됩니다." + } + } + } + }, "Arrange your pages and widgets.": { "localizations": { "ko": { @@ -942,6 +1169,16 @@ } } }, + "Automatically hide and show the dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독을 자동으로 숨기고 보이기" + } + } + } + }, "Automation": { "localizations": { "ko": { @@ -1049,6 +1286,16 @@ } } }, + "BarShelf Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "BarShelf Dock" + } + } + } + }, "BarShelf Widgets": { "localizations": { "ko": { @@ -1252,6 +1499,16 @@ } } }, + "Bottom": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "아래" + } + } + } + }, "Build": { "localizations": { "ko": { @@ -1639,6 +1896,16 @@ } } }, + "Classic": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "클래식" + } + } + } + }, "Clear Filters": { "localizations": { "ko": { @@ -1888,6 +2155,16 @@ } } }, + "Copy Link": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "링크 복사" + } + } + } + }, "Copy a field": { "localizations": { "ko": { @@ -1908,6 +2185,16 @@ } } }, + "Copy from Apple Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock에서 가져오기" + } + } + } + }, "Copy install command": { "localizations": { "ko": { @@ -2000,6 +2287,16 @@ } } }, + "Couldn't read the Apple Dock's layout, so nothing was saved.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock 레이아웃을 읽지 못해 아무것도 저장하지 않았습니다." + } + } + } + }, "Couldn't remove widget": { "localizations": { "ko": { @@ -2010,6 +2307,16 @@ } } }, + "Couldn't save dock settings: %@": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독 설정을 저장하지 못했습니다: %@" + } + } + } + }, "Couldn't save settings: %@": { "localizations": { "ko": { @@ -2203,6 +2510,26 @@ } } }, + "Delete": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제" + } + } + } + }, + "Delete %@?": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@을(를) 삭제할까요?" + } + } + } + }, "Delete this widget from your Mac": { "localizations": { "ko": { @@ -2213,6 +2540,16 @@ } } }, + "Delete…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제…" + } + } + } + }, "Demo": { "localizations": { "ko": { @@ -2355,6 +2692,16 @@ } } }, + "Display with the pointer": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "포인터가 있는 디스플레이" + } + } + } + }, "Displays": { "localizations": { "ko": { @@ -2365,6 +2712,47 @@ } } }, + "Divider": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "구분선" + } + } + } + }, + "Dock": { + "comment": "App menu: the BarShelf Dock submenu\nHub page: the BarShelf Dock", + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독" + } + } + } + }, + "Dock Settings…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독 설정…" + } + } + } + }, + "Dock profile: %@": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독 프로필: %@" + } + } + } + }, "Docker containers": { "localizations": { "ko": { @@ -2375,6 +2763,16 @@ } } }, + "Don't change": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "바꾸지 않음" + } + } + } + }, "Done": { "localizations": { "ko": { @@ -2522,6 +2920,16 @@ } } }, + "Drop apps, folders, or files here": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱, 폴더, 파일을 여기에 놓으세요" + } + } + } + }, "Drop here": { "localizations": { "ko": { @@ -2612,6 +3020,16 @@ } } }, + "Each profile has its own dock items, and can carry an Apple Dock layout and a popup page. Drag to reorder; the first nine get ⌃⌥1–9.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필마다 독 아이템이 따로 있고, Apple Dock 레이아웃과 팝업 페이지를 함께 담을 수 있습니다. 끌어서 순서를 바꾸세요. 처음 아홉 개에 ⌃⌥1–9가 붙습니다." + } + } + } + }, "Edit": { "comment": "The main menu's Edit menu (Undo, Cut, Copy, Paste).", "localizations": { @@ -2643,6 +3061,16 @@ } } }, + "Empty Folder": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "빈 폴더" + } + } + } + }, "Enable": { "localizations": { "ko": { @@ -2703,6 +3131,16 @@ } } }, + "Error": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "오류" + } + } + } + }, "Every %lf s": { "comment": "Refresh interval in seconds", "localizations": { @@ -3106,6 +3544,26 @@ } } }, + "Folder icon": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 아이콘" + } + } + } + }, + "Folder or File…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 또는 파일…" + } + } + } + }, "For example ⇧⌘B.": { "localizations": { "ko": { @@ -3116,6 +3574,16 @@ } } }, + "Forget": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "지우기" + } + } + } + }, "Full Width": { "comment": "Widget size name", "localizations": { @@ -3442,6 +3910,16 @@ } } }, + "Hide": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "가리기" + } + } + } + }, "Hide Details": { "localizations": { "ko": { @@ -3562,6 +4040,17 @@ } } }, + "Icon size": { + "extractionState": "stale", + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "아이콘 크기" + } + } + } + }, "Icon, login, and sounds.": { "localizations": { "ko": { @@ -3582,6 +4071,16 @@ } } }, + "Immediately": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "즉시" + } + } + } + }, "Import Hammerspoon Settings": { "localizations": { "ko": { @@ -3628,6 +4127,16 @@ } } }, + "In a full-screen app the dock waits at the edge and slides out when the pointer rests there.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 화면 앱에서는 독이 가장자리에서 기다리다가 포인터를 대면 나옵니다." + } + } + } + }, "In the Menu Bar": { "localizations": { "ko": { @@ -3919,6 +4428,16 @@ } } }, + "Instead of the Apple Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock 대신" + } + } + } + }, "It keeps refreshing while the popup is closed. At most %lld widgets are shown.": { "localizations": { "ko": { @@ -3949,6 +4468,16 @@ } } }, + "Items in %@": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@의 아이템" + } + } + } + }, "Items that share the BarShelf icon appear next to it in this order. An item of its own can be moved by holding ⌘ and dragging it in the menu bar.": { "localizations": { "ko": { @@ -3959,6 +4488,16 @@ } } }, + "Its items and saved Apple Dock layout are removed. Nothing on disk is touched.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "아이템과 저장된 Apple Dock 레이아웃이 지워집니다. 디스크의 파일은 그대로입니다." + } + } + } + }, "Its own item": { "localizations": { "ko": { @@ -4059,6 +4598,16 @@ } } }, + "Keep in Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독에 유지" + } + } + } + }, "Keeps the item the same width as a reading goes from 9 to 10. A longer reading widens it once and it stays that wide.": { "localizations": { "ko": { @@ -4230,6 +4779,37 @@ } } }, + "Left": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "왼쪽" + } + } + } + }, + "Like the Apple Dock: icons on glass, names on hover.": { + "extractionState": "stale", + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock처럼: 유리 위의 아이콘, 가리키면 이름 표시." + } + } + } + }, + "Like the Apple Dock: icons on glass, names on hover. Widgets show their main reading at icon height.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock처럼: 유리 위의 아이콘, 가리키면 이름 표시. 위젯은 아이콘 높이에 주요 값만 보여 줍니다." + } + } + } + }, "Limit number of rows": { "localizations": { "ko": { @@ -4250,6 +4830,26 @@ } } }, + "Link to %@": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 링크" + } + } + } + }, + "Link…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "링크…" + } + } + } + }, "List": { "comment": "Widget display style", "localizations": { @@ -4321,6 +4921,16 @@ } } }, + "Loading…": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "불러오는 중…" + } + } + } + }, "Look": { "comment": "Menu bar settings tab: appearance\nWidget inspector tab: appearance", "localizations": { @@ -4372,6 +4982,36 @@ } } }, + "Magnification": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "확대" + } + } + } + }, + "Magnification amount": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "확대 정도" + } + } + } + }, + "Main display": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "주 디스플레이" + } + } + } + }, "Make this card full width": { "localizations": { "ko": { @@ -4402,6 +5042,17 @@ } } }, + "Match Apple Dock": { + "extractionState": "stale", + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock에 맞추기" + } + } + } + }, "Max": { "localizations": { "ko": { @@ -4787,6 +5438,16 @@ } } }, + "Name": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "이름" + } + } + } + }, "Name & placement": { "localizations": { "ko": { @@ -4877,6 +5538,16 @@ } } }, + "New Profile": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 프로필" + } + } + } + }, "New page name": { "localizations": { "ko": { @@ -4907,6 +5578,16 @@ } } }, + "No Shortcuts found": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "단축어가 없습니다" + } + } + } + }, "No data yet": { "localizations": { "ko": { @@ -4927,6 +5608,16 @@ } } }, + "No items yet. Add some below, or drop apps, folders, and files on the dock.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 아이템이 없습니다. 아래에서 추가하거나 앱·폴더·파일을 독에 끌어다 놓으세요." + } + } + } + }, "No matches": { "localizations": { "ko": { @@ -5128,6 +5819,16 @@ } } }, + "Off": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "끔" + } + } + } + }, "On open": { "comment": "Widget refresh interval: only when the popup opens", "localizations": { @@ -5249,6 +5950,16 @@ } } }, + "Open Shortcuts": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "단축어 열기" + } + } + } + }, "Open a field as a URL": { "localizations": { "ko": { @@ -5289,6 +6000,26 @@ } } }, + "Open folders as": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 열기 방식" + } + } + } + }, + "Open in Finder": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Finder에서 열기" + } + } + } + }, "Open it from anywhere": { "localizations": { "ko": { @@ -5661,6 +6392,26 @@ } } }, + "Popup page": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "팝업 페이지" + } + } + } + }, + "Position on screen": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "화면상의 위치" + } + } + } + }, "Posts notifications": { "localizations": { "ko": { @@ -5741,6 +6492,46 @@ } } }, + "Profile": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필" + } + } + } + }, + "Profile name": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필 이름" + } + } + } + }, + "Profiles": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필" + } + } + } + }, + "Profiles below also work with the BarShelf Dock off: they can switch the Apple Dock's apps.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "아래 프로필은 BarShelf Dock을 꺼도 동작합니다. Apple Dock의 앱을 바꿀 수 있습니다." + } + } + } + }, "Project Page": { "localizations": { "ko": { @@ -5781,6 +6572,16 @@ } } }, + "Quit": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "종료" + } + } + } + }, "Quit BarShelf": { "localizations": { "ko": { @@ -6251,6 +7052,16 @@ } } }, + "Remove from Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독에서 제거" + } + } + } + }, "Remove from Menu Bar": { "localizations": { "ko": { @@ -6261,6 +7072,16 @@ } } }, + "Remove from the dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독에서 제거" + } + } + } + }, "Remove header %lld": { "localizations": { "ko": { @@ -6321,6 +7142,16 @@ } } }, + "Replace with Current Apple Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 Apple Dock으로 바꾸기" + } + } + } + }, "Requested permissions:": { "localizations": { "ko": { @@ -6511,6 +7342,16 @@ } } }, + "Right": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽" + } + } + } + }, "Right-align numbers": { "localizations": { "ko": { @@ -6583,6 +7424,16 @@ } } }, + "Run Shortcut": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "단축어 실행" + } + } + } + }, "Run a JSON command or use sample JSON to auto-fill fields. You can also type field paths manually.": { "localizations": { "ko": { @@ -6639,6 +7490,16 @@ } } }, + "Running": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행 중" + } + } + } + }, "Running…": { "localizations": { "ko": { @@ -6700,6 +7561,16 @@ } } }, + "Same size as the Apple Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock과 같은 크기" + } + } + } + }, "Save & Reload": { "localizations": { "ko": { @@ -6710,6 +7581,16 @@ } } }, + "Save Current Apple Dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 Apple Dock 저장" + } + } + } + }, "Save Script": { "localizations": { "ko": { @@ -6730,6 +7611,16 @@ } } }, + "Saved in %@": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에 저장됨" + } + } + } + }, "Saved separately from the original file. No Hammerspoon settings are modified.": { "localizations": { "ko": { @@ -7122,6 +8013,16 @@ } } }, + "Show Trash": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통 보기" + } + } + } + }, "Show Widget": { "localizations": { "ko": { @@ -7142,6 +8043,16 @@ } } }, + "Show after": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시까지 대기" + } + } + } + }, "Show an icon": { "localizations": { "ko": { @@ -7212,6 +8123,16 @@ } } }, + "Show in full-screen apps": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 화면 앱에서도 보기" + } + } + } + }, "Show in the menu bar": { "localizations": { "ko": { @@ -7222,6 +8143,16 @@ } } }, + "Show indicators for open apps": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "열린 앱에 표시기 보기" + } + } + } + }, "Show information from another app, command-line tool, or web service. Some need that tool installed first.": { "localizations": { "ko": { @@ -7242,6 +8173,16 @@ } } }, + "Show on": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시할 위치" + } + } + } + }, "Show on the Shelf": { "localizations": { "ko": { @@ -7282,6 +8223,16 @@ } } }, + "Show open apps that aren't in the dock": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독에 없는 열린 앱 보기" + } + } + } + }, "Show or hide the popup from any app.": { "localizations": { "ko": { @@ -7292,6 +8243,16 @@ } } }, + "Show recent apps": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "최근 앱 보기" + } + } + } + }, "Show the card": { "localizations": { "ko": { @@ -7556,6 +8517,16 @@ } } }, + "Starts empty. Add apps, folders, and widgets below.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "빈 상태로 시작합니다. 아래에서 앱·폴더·위젯을 추가하세요." + } + } + } + }, "Static text": { "localizations": { "ko": { @@ -7674,6 +8645,36 @@ } } }, + "Switch": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "전환" + } + } + } + }, + "Switch profiles with ⌃⌥1–9": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "⌃⌥1–9로 프로필 전환" + } + } + } + }, + "Switch the Apple Dock's apps with the profile": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필에 따라 Apple Dock의 앱 바꾸기" + } + } + } + }, "Switches": { "comment": "Menu bar icon choice", "localizations": { @@ -7705,6 +8706,26 @@ } } }, + "Switching": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "전환" + } + } + } + }, + "Symbol": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "기호" + } + } + } + }, "Table": { "comment": "Widget display style", "localizations": { @@ -7845,6 +8866,26 @@ } } }, + "The Apple Dock is hidden while BarShelf runs and comes back when you quit BarShelf or turn this off. If it ever stays hidden, run barshelf dock restore-apple-dock in Terminal.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "BarShelf가 실행되는 동안 Apple Dock은 숨겨지고, BarShelf를 종료하거나 이 옵션을 끄면 돌아옵니다. 계속 숨겨져 있다면 터미널에서 barshelf dock restore-apple-dock을 실행하세요." + } + } + } + }, + "The Apple Dock is on the same edge. The BarShelf Dock sits just above it; another position, or auto-hide, keeps them apart.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Dock이 같은 가장자리에 있습니다. BarShelf Dock은 그 바로 위에 놓입니다. 위치를 바꾸거나 자동 숨기기를 켜면 겹치지 않습니다." + } + } + } + }, "The address this widget reads wasn’t found (HTTP 404).": { "localizations": { "ko": { @@ -8342,6 +9383,16 @@ } } }, + "To follow a Focus: in Shortcuts, add a personal automation for when the Focus turns on, with the Open URLs action and this link. From Terminal: barshelf dock use \"%@\".": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "집중 모드에 맞추려면: 단축어 앱에서 집중 모드가 켜질 때 실행되는 개인용 자동화를 만들고 ‘URL 열기’ 동작에 이 링크를 넣으세요. 터미널에서는: barshelf dock use \"%@\"." + } + } + } + }, "Trailing value": { "localizations": { "ko": { @@ -8352,6 +9403,16 @@ } } }, + "Trash": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통" + } + } + } + }, "Tray": { "comment": "Menu bar icon choice", "localizations": { @@ -8575,6 +9636,16 @@ } } }, + "Up to three apps you used lately that aren't in the dock or open.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독에 없고 열려 있지도 않은, 최근에 쓴 앱을 세 개까지 보여 줍니다." + } + } + } + }, "Update": { "comment": "Button: install a newer version of a widget", "localizations": { @@ -9047,6 +10118,16 @@ } } }, + "Widget not installed": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "설치되지 않은 위젯" + } + } + } + }, "Widget search": { "localizations": { "ko": { @@ -9077,6 +10158,16 @@ } } }, + "Widget size": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "위젯 크기" + } + } + } + }, "Widgets": { "localizations": { "ko": { @@ -9179,6 +10270,16 @@ } } }, + "You can also swipe sideways with two fingers on the dock, or scroll over it holding ⌘, or pick a profile from the BarShelf menu.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "독 위에서 두 손가락으로 옆으로 쓸거나, ⌘를 누른 채 스크롤하거나, BarShelf 메뉴에서 프로필을 고를 수도 있습니다." + } + } + } + }, "You won't be reminded about this version. Newer ones still show up.": { "localizations": { "ko": { @@ -9229,6 +10330,16 @@ } } }, + "Your Automation script uses %@, so it goes there instead of switching profiles.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Automation 스크립트가 %@을(를) 쓰고 있어 프로필 전환 대신 그쪽으로 갑니다." + } + } + } + }, "Your installed copy was left untouched.": { "localizations": { "ko": { @@ -9366,6 +10477,16 @@ } } }, + "https://example.com": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://example.com" + } + } + } + }, "is": { "localizations": { "ko": { diff --git a/README.md b/README.md index a0b4717..d44231b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ new SDK to learn. - **🪟 One icon, many widgets** — bucket pages, trackpad swipe, pinned row, ⌘F search. - **📊 Live in the menu bar** — opt a widget in and its value updates right on the bar (`✦ 42% · 61% · 58°`), sharing the BarShelf icon or on its own. +- **🧭 A dock, if you want one** — put apps, folders, links, Shortcuts, and live + widgets on a screen-edge dock, alongside the Apple Dock or instead of it, and + switch profiles (⌃⌥1–9, swipe, Focus) — the Apple Dock's apps can follow. - **⚡ CLI is the API** — `aas usage --json`, `otpeek`, `gh`, `kubectl`… pipe them straight in. - **🎨 Native, not web** — SwiftUI rendering, dark mode, SF Symbols, vibrancy. No Electron. - **🧩 Three ways to build** — declarative workflows, a Shortcuts-style visual builder, or full scripts. @@ -114,6 +117,14 @@ shortcuts, and window cycling, then customize the generated JavaScript. Requires Accessibility access; no additional runtime is needed. See [setup, import scope, and scripting API](docs/AUTOMATION.md). +## BarShelf Dock + +Optional and off by default: **BarShelf ▸ Dock** adds a dock on the bottom, +left, or right edge with apps, folders, files, links, Shortcuts, and any +BarShelf widget. Run it alongside the Apple Dock or instead of it, keep several +profiles, and let each one rearrange the Apple Dock too. +See [the Dock guide](docs/DOCK.md). + ## Gallery widgets Native widgets ship in the gallery — most are declarative **workflows** diff --git a/Sources/BarShelfCLI/BarShelfKit/BarShelfMain.swift b/Sources/BarShelfCLI/BarShelfKit/BarShelfMain.swift index 3c557bd..cfbb46b 100644 --- a/Sources/BarShelfCLI/BarShelfKit/BarShelfMain.swift +++ b/Sources/BarShelfCLI/BarShelfKit/BarShelfMain.swift @@ -26,6 +26,8 @@ public enum BarShelfMain { barshelf pack [-o .mbw] Pack a widget directory into a .mbw archive (includes manifest.sha256). barshelf list List installed widgets. + barshelf dock list|use |next|previous|restore-apple-dock + Dock profiles and the Apple Dock. barshelf agent-spec Print the widget-authoring spec (docs/AGENTS.md) for LLM agents. barshelf upgrade [--check] Update the barshelf CLI and @@ -61,6 +63,8 @@ public enum BarShelfMain { return runPack(arguments: rest) case "list": return runList(arguments: rest) + case "dock": + return DockCommand.run(arguments: rest) case "agent-spec": return runAgentSpec(arguments: rest) // "update" is what people type first; accept it rather than printing diff --git a/Sources/BarShelfCLI/BarShelfKit/DockCommand.swift b/Sources/BarShelfCLI/BarShelfKit/DockCommand.swift new file mode 100644 index 0000000..b24387c --- /dev/null +++ b/Sources/BarShelfCLI/BarShelfKit/DockCommand.swift @@ -0,0 +1,136 @@ +import Foundation +import MenubucketCore + +/// `barshelf dock …` — the BarShelf Dock's profiles from Terminal (R15). +/// +/// `list` reads `dock.json`; `use` asks the running app through its URL +/// scheme (the app owns the switch); `restore-apple-dock` works with the app +/// not running at all, which is the point of it. +public enum DockCommand { + public static let usage = """ + usage: + barshelf dock list List dock profiles (* = active). + barshelf dock use Switch profile by name, id, or number. + barshelf dock next | previous Switch to the next or previous profile. + barshelf dock restore-apple-dock Bring back the Apple Dock if BarShelf + left it hidden. + """ + + public static var configurationURL: URL { + HeadlessInstaller.defaultWidgetsDirectory + .deletingLastPathComponent() + .appendingPathComponent("dock.json") + } + + public static func run( + arguments: [String], + configurationURL: URL = DockCommand.configurationURL, + openURL: (URL) -> Bool = DockCommand.openInApp, + appleDock: () -> AppleDock = { AppleDock() }, + appIsRunning: () -> Bool = DockCommand.appIsRunning + ) -> Int32 { + guard let subcommand = arguments.first else { + BarShelfMain.printError(usage) + return 1 + } + let rest = Array(arguments.dropFirst()) + switch subcommand { + case "list", "ls": + let config = DockConfiguration.load(from: configurationURL) + print(list(config)) + return 0 + case "use", "switch": + let query = rest.joined(separator: " ").trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { + BarShelfMain.printError("usage: barshelf dock use ") + return 1 + } + let config = DockConfiguration.load(from: configurationURL) + guard let profile = config.profile(matching: query) else { + BarShelfMain.printError("barshelf: no dock profile matches \"\(query)\"") + BarShelfMain.printError(list(config)) + return 1 + } + return send(query: "profile=\(encode(profile.id))", openURL: openURL) + case "next": + return send(query: "next", openURL: openURL) + case "previous", "prev": + return send(query: "previous", openURL: openURL) + case "restore-apple-dock": + // A running BarShelf owns dock.json and would save its own copy + // over an edit made here, hiding the Dock again at next launch. + if appIsRunning() { + let status = send(query: "restore-apple-dock", openURL: openURL) + if status == 0 { print("asked BarShelf to bring back the Apple Dock") } + return status + } + let dock = appleDock() + var config = DockConfiguration.load(from: configurationURL) + if let backup = config.appleDockBackup { + dock.restore(backup) + config.appleDockBackup = nil + // "Instead of the Apple Dock" would hide it again at launch. + if config.mode == .replace { config.mode = .alongside } + try? config.save(to: configurationURL) + print("restored the Apple Dock's own settings") + } else if dock.isHidden { + dock.unhideWithoutBackup() + print("the Apple Dock shows again") + } else { + print("the Apple Dock is not hidden") + } + return 0 + default: + BarShelfMain.printError("barshelf: unknown dock command \"\(subcommand)\"") + BarShelfMain.printError(usage) + return 1 + } + } + + static func list(_ config: DockConfiguration) -> String { + config.profiles.enumerated().map { index, profile in + let marker = profile.id == config.activeProfileID ? "*" : " " + var line = "\(marker) \(index + 1). \(profile.name) [\(profile.id)] \(profile.items.count) item(s)" + if profile.appleDock != nil { line += ", Apple Dock layout" } + return line + }.joined(separator: "\n") + } + + private static func encode(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&=+"))) ?? value + } + + private static func send(query: String, openURL: (URL) -> Bool) -> Int32 { + guard let url = URL(string: "barshelf://dock?\(query)") else { return 1 } + guard openURL(url) else { + BarShelfMain.printError("barshelf: couldn't reach BarShelf (is it installed?)") + return 1 + } + return 0 + } + + public static func appIsRunning() -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") + process.arguments = ["-x", "barshelf-app"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + guard (try? process.run()) != nil else { return false } + process.waitUntilExit() + return process.terminationStatus == 0 + } + + /// `open -g` hands the URL to BarShelf without bringing anything forward. + public static func openInApp(_ url: URL) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-g", url.absoluteString] + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } +} diff --git a/Sources/MenubucketApp/AppCommands.swift b/Sources/MenubucketApp/AppCommands.swift index 6ff07b0..b496266 100644 --- a/Sources/MenubucketApp/AppCommands.swift +++ b/Sources/MenubucketApp/AppCommands.swift @@ -4,13 +4,14 @@ import AppKit /// share. Both menus are built from `AppMenu.sections`, so the two cannot /// drift apart: a command added here appears in both. enum AppMenuCommand: Hashable { - case editShelf, addWidget, menuBar, openBarShelf, settings, checkForUpdates, quit + case editShelf, addWidget, menuBar, dock, openBarShelf, settings, checkForUpdates, quit var title: String { switch self { case .editShelf: return String(localized: "Edit Shelf") case .addWidget: return String(localized: "Add Widget…") case .menuBar: return String(localized: "Menu Bar") + case .dock: return String(localized: "Dock", comment: "App menu: the BarShelf Dock submenu") case .openBarShelf: return String(localized: "Open BarShelf…") case .settings: return String(localized: "Settings…") case .checkForUpdates: @@ -31,7 +32,7 @@ enum AppMenuCommand: Hashable { case .editShelf: return "e" case .settings: return "," case .quit: return "q" - case .addWidget, .menuBar, .openBarShelf, .checkForUpdates: return "" + case .addWidget, .menuBar, .dock, .openBarShelf, .checkForUpdates: return "" } } @@ -40,6 +41,7 @@ enum AppMenuCommand: Hashable { case .editShelf: return "pencil" case .addWidget: return "plus" case .menuBar: return "menubar.rectangle" + case .dock: return "dock.rectangle" case .openBarShelf: return "macwindow" case .settings: return "gearshape" case .checkForUpdates: return "arrow.down.circle" @@ -51,7 +53,7 @@ enum AppMenuCommand: Hashable { enum AppMenu { /// Groups, in order; a separator goes between groups. static let sections: [[AppMenuCommand]] = [ - [.editShelf, .addWidget, .menuBar], + [.editShelf, .addWidget, .menuBar, .dock], [.openBarShelf, .settings, .checkForUpdates], [.quit], ] @@ -99,6 +101,33 @@ enum AppMenu { let isOn = runtime.menuBarWidgetIDs.contains(widgetID) runtime.updateMenuBarPlacement(for: widgetID) { $0.enabled = !isOn } } + + /// One row of "Dock ▸": a profile, checked while active (R15). + struct DockProfileRow: Identifiable, Equatable { + let id: String + let title: String + let symbol: String + let isActive: Bool + } + + /// "Dock ▸" lists the profiles once there is more than one to pick from; + /// the submenu always offers the dock's settings, which is where the + /// feature is found in the first place. + static func dockProfiles(store: DockStore) -> [DockProfileRow] { + let config = store.configuration + guard config.profiles.count > 1 else { return [] } + return config.profiles.enumerated().map { index, profile in + let hotkey = config.profileHotkeysEnabled ? DockHotkeys.label(forPosition: index + 1) : nil + return DockProfileRow( + id: profile.id, + title: hotkey.map { "\(profile.name) \($0)" } ?? profile.name, + symbol: profile.symbol, + isActive: profile.id == config.activeProfileID + ) + } + } + + static var dockSettingsTitle: String { String(localized: "Dock Settings…") } } /// The app's commands as real main-menu items, so their shortcuts work in @@ -183,6 +212,10 @@ extension StatusItemController: NSMenuItemValidation { if let item = makeMenuBarSubmenuItem() { menu.addItem(item) } continue } + if command == .dock { + menu.addItem(makeDockSubmenuItem()) + continue + } let item = NSMenuItem( title: command.title, action: #selector(performAppMenuItem(_:)), @@ -227,6 +260,37 @@ extension StatusItemController: NSMenuItemValidation { perform(command, fromPopup: false) } + private func makeDockSubmenuItem() -> NSMenuItem { + let item = NSMenuItem(title: AppMenuCommand.dock.title, action: nil, keyEquivalent: "") + item.image = NSImage(systemSymbolName: AppMenuCommand.dock.symbol, accessibilityDescription: nil) + let submenu = NSMenu() + submenu.autoenablesItems = false + let rows = AppMenu.dockProfiles(store: DockStore.shared) + for row in rows { + let entry = NSMenuItem(title: row.title, action: #selector(activateDockProfile(_:)), keyEquivalent: "") + entry.target = self + entry.representedObject = row.id + entry.state = row.isActive ? .on : .off + entry.image = NSImage(systemSymbolName: row.symbol, accessibilityDescription: nil) + submenu.addItem(entry) + } + if !rows.isEmpty { submenu.addItem(.separator()) } + let settings = NSMenuItem(title: AppMenu.dockSettingsTitle, action: #selector(openDockSettings(_:)), keyEquivalent: "") + settings.target = self + submenu.addItem(settings) + item.submenu = submenu + return item + } + + @objc private func activateDockProfile(_ sender: NSMenuItem) { + guard let id = sender.representedObject as? String else { return } + DockStore.shared.activate(profileID: id) + } + + @objc func openDockSettings(_ sender: Any?) { + perform(.dock, fromPopup: false) + } + @objc private func toggleMenuBarWidget(_ sender: NSMenuItem) { guard let widgetID = sender.representedObject as? String else { return } AppMenu.toggleMenuBar(widgetID: widgetID, runtime: shelfRuntime) diff --git a/Sources/MenubucketApp/Automation/AutomationEngine.swift b/Sources/MenubucketApp/Automation/AutomationEngine.swift index 6dbbf13..f3c0107 100644 --- a/Sources/MenubucketApp/Automation/AutomationEngine.swift +++ b/Sources/MenubucketApp/Automation/AutomationEngine.swift @@ -73,6 +73,11 @@ final class AutomationEngine: AutomationRunning { guard !active else { return } guard AXIsProcessTrusted() else { throw AutomationFailure(String(localized: "Allow BarShelf in System Settings → Privacy & Security → Accessibility, then enable the extension again.")) } do { + // Take these keys from other BarShelf features (the dock's ⌃⌥ + // profile keys) before registering, rather than failing on them. + InAppHotkeys.shared.setAutomationKeys(Set(script.bindings.map { + InAppHotkeys.Key(keyCode: $0.combination.keyCode, modifiers: $0.combination.modifiers) + })) if !script.bindings.isEmpty { try registerHotkeys() } if script.remap != nil { try installTap() } active = true @@ -102,6 +107,7 @@ final class AutomationEngine: AutomationRunning { releaseHeldKeys() for key in hotkeys { UnregisterEventHotKey(key) } hotkeys.removeAll() + InAppHotkeys.shared.setAutomationKeys([]) if let handler { RemoveEventHandler(handler) } handler = nil } diff --git a/Sources/MenubucketApp/Dock/DockActions.swift b/Sources/MenubucketApp/Dock/DockActions.swift new file mode 100644 index 0000000..5213c48 --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockActions.swift @@ -0,0 +1,360 @@ +import AppKit +import Combine +import MenubucketCore +import UniformTypeIdentifiers + +/// Apps with a Dock presence that are running now, in launch order. Feeds the +/// running dots and the section of apps that are open but not in the profile. +final class RunningApps: ObservableObject { + /// One per app: the dock and the actions that launch from it share it. + static let shared = RunningApps() + struct App: Identifiable, Equatable { + let path: String + let bundleID: String? + let processID: pid_t + var id: String { path } + } + + @Published private(set) var apps: [App] = [] + @Published private(set) var frontmostPath: String? + /// Apps opened from the dock that have not finished launching; their + /// icons bounce until they have, as on the Apple Dock. + @Published private(set) var launching: Set = [] + /// Apps brought forward lately, most recent first, kept across launches + /// for the dock's recent-apps section. + @Published private(set) var recent: [String] = [] + private static let recentKey = "BarShelfDockRecentApps" + private static let recentKept = 12 + private let defaults: UserDefaults + /// Longest a bounce lasts if an app never reports that it launched. + static let launchTimeout: TimeInterval = 15 + + private var observers: [NSObjectProtocol] = [] + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + recent = defaults.stringArray(forKey: Self.recentKey) ?? [] + let center = NSWorkspace.shared.notificationCenter + for name in [ + NSWorkspace.didLaunchApplicationNotification, + NSWorkspace.didTerminateApplicationNotification, + NSWorkspace.didActivateApplicationNotification, + NSWorkspace.didHideApplicationNotification, + NSWorkspace.didUnhideApplicationNotification, + ] { + observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] note in + if name == NSWorkspace.didLaunchApplicationNotification, + let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + let url = app.bundleURL { + self?.launching.remove(Self.key(url.path)) + } + self?.reload() + }) + } + reload() + } + + deinit { + observers.forEach(NSWorkspace.shared.notificationCenter.removeObserver) + } + + private var hasLooked = false + + func reload() { + defer { hasLooked = true } + let running = NSWorkspace.shared.runningApplications + .filter { $0.activationPolicy == .regular && !$0.isTerminated } + .sorted { ($0.launchDate ?? .distantPast) < ($1.launchDate ?? .distantPast) } + .compactMap { app -> App? in + guard let url = app.bundleURL else { return nil } + return App(path: Self.key(url.path), bundleID: app.bundleIdentifier, processID: app.processIdentifier) + } + if running != apps { apps = running } + let front = NSWorkspace.shared.frontmostApplication?.bundleURL.map { Self.key($0.path) } + if front != frontmostPath { + let isFirstLook = !hasLooked + frontmostPath = front + // Whatever is frontmost when BarShelf starts was not just used. + if !isFirstLook, let front, NSWorkspace.shared.frontmostApplication?.activationPolicy == .regular, + front != Bundle.main.bundleURL.standardizedFileURL.path { + noteUsed(front) + } + } + } + + /// Moves an app to the front of the recent list. + func noteUsed(_ path: String) { + let key = Self.key(path) + // Only app bundles: a bare executable with a window (a test tool, a + // script) has no icon or place in a dock. + guard key.hasSuffix(".app") else { return } + var list = recent.filter { $0 != key } + list.insert(key, at: 0) + list = Array(list.prefix(Self.recentKept)) + guard list != recent else { return } + recent = list + defaults.set(list, forKey: Self.recentKey) + } + + /// Starts an icon bouncing, unless the app is already up. + func markLaunching(path: String) { + let key = Self.key(path) + guard !isRunning(path: key) else { return } + launching.insert(key) + DispatchQueue.main.asyncAfter(deadline: .now() + Self.launchTimeout) { [weak self] in + self?.launching.remove(key) + } + } + + func isLaunching(path: String) -> Bool { + launching.contains(Self.key(path)) + } + + func isRunning(path: String) -> Bool { + let key = Self.key(path) + return apps.contains { $0.path == key } + } + + func runningApplication(path: String) -> NSRunningApplication? { + let key = Self.key(path) + guard let app = apps.first(where: { $0.path == key }) else { return nil } + return NSRunningApplication(processIdentifier: app.processID) + } + + /// Paths compare without a trailing slash or `..` noise. + static func key(_ path: String) -> String { + URL(fileURLWithPath: path).standardizedFileURL.path + } +} + +/// What clicking, dropping on, and right-clicking a dock tile does. +enum DockActions { + // Icons and names, once per path. The dock's body runs on every hover + // change while magnifying, and asking LaunchServices for every tile's + // icon each time stuttered the animation. + private static let iconCache = NSCache() + private static let nameCache = NSCache() + + static func fileIcon(at path: String) -> NSImage { + if let cached = iconCache.object(forKey: path as NSString) { return cached } + let icon = NSWorkspace.shared.icon(forFile: path) + iconCache.setObject(icon, forKey: path as NSString) + return icon + } + + static func fileDisplayName(at path: String) -> String { + if let cached = nameCache.object(forKey: path as NSString) { return cached as String } + let name = FileManager.default.displayName(atPath: path) + .replacingOccurrences(of: ".app", with: "", options: [.anchored, .backwards]) + nameCache.setObject(name as NSString, forKey: path as NSString) + return name + } + + static var trashURL: URL { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".Trash", isDirectory: true) + } + + // MARK: Open + + static func open(_ item: DockItem) { + switch item.kind { + case .app(let path): + openApp(path: path) + case .folder(let path, _, _), .file(let path): + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + case .link(let string, _): + if let url = URL(string: string) { NSWorkspace.shared.open(url) } + case .shortcut(let name): + runShortcut(named: name) + case .widget(let id): + if let url = URL(string: "barshelf://show?widget=\(id.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? id)") { + NSWorkspace.shared.open(url) + } + case .spacer, .separator: + break + } + } + + /// Launches the app, or brings it forward with a reopen event so an app + /// with no windows opens one — what clicking the Apple Dock does. + static func openApp(path: String) { + if DockStore.shared.configuration.animateOpening { + RunningApps.shared.markLaunching(path: path) + } + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + NSWorkspace.shared.openApplication(at: URL(fileURLWithPath: path), configuration: configuration) + } + + static func open(_ urls: [URL], withAppAt path: String) { + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + NSWorkspace.shared.open(urls, withApplicationAt: URL(fileURLWithPath: path), configuration: configuration) + } + + static func revealInFinder(path: String) { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + } + + static func openTrash() { + NSWorkspace.shared.open(trashURL) + } + + static func moveToTrash(_ urls: [URL]) { + for url in urls { + try? FileManager.default.trashItem(at: url, resultingItemURL: nil) + } + } + + // MARK: Shortcuts + + /// Runs a Shortcut through the `shortcuts` tool, off the main thread. + static func runShortcut(named name: String) { + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/shortcuts") + process.arguments = ["run", name] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try? process.run() + process.waitUntilExit() + } + } + + /// The user's Shortcuts by name, for the "Add Shortcut" picker. + static func listShortcuts(completion: @escaping ([String]) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/shortcuts") + process.arguments = ["list"] + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + var names: [String] = [] + if (try? process.run()) != nil { + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + names = String(decoding: data, as: UTF8.self) + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } + DispatchQueue.main.async { completion(names) } + } + } + + // MARK: Icons + + static func icon(for item: DockItem) -> NSImage? { + switch item.kind { + case .app(let path), .file(let path): + return fileIcon(at: path) + case .folder(let path, _, _): + return fileIcon(at: path) + case .shortcut: + return NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.shortcuts") + .map { fileIcon(at: $0.path) } + default: + return nil + } + } + + static func displayName(for item: DockItem) -> String { + switch item.kind { + case .app(let path), .file(let path): + return fileDisplayName(at: path) + default: + return item.fallbackTitle + } + } + + // MARK: Folder menu (a "list" stack) + + /// The folder's contents as a menu, subfolders opening as submenus — + /// the Apple Dock's list view of a stack. + static func folderMenu(path: String) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + let url = URL(fileURLWithPath: path, isDirectory: true) + let open = NSMenuItem( + title: String(localized: "Open in Finder"), + action: #selector(FolderMenuTarget.openItem(_:)), keyEquivalent: "" + ) + open.target = FolderMenuTarget.shared + open.representedObject = url + menu.addItem(open) + menu.addItem(.separator()) + FolderMenuTarget.fill(menu, with: url) + return menu + } +} + +/// Target and lazy filler for folder menus; subfolders fill when opened. +final class FolderMenuTarget: NSObject, NSMenuDelegate { + static let shared = FolderMenuTarget() + /// Enough to find something in Downloads without a menu taller than the + /// screen; "Open in Finder" covers the rest. + static let limit = 80 + + static func fill(_ menu: NSMenu, with folder: URL) { + let keys: [URLResourceKey] = [.isDirectoryKey, .isPackageKey, .addedToDirectoryDateKey] + let entries = ((try? FileManager.default.contentsOfDirectory( + at: folder, includingPropertiesForKeys: keys, options: [.skipsHiddenFiles] + )) ?? []) + .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } + if entries.isEmpty { + let empty = NSMenuItem(title: String(localized: "Empty Folder"), action: nil, keyEquivalent: "") + empty.isEnabled = false + menu.addItem(empty) + return + } + for entry in entries.prefix(limit) { + let item = NSMenuItem( + title: FileManager.default.displayName(atPath: entry.path), + action: #selector(openItem(_:)), keyEquivalent: "" + ) + item.target = shared + item.representedObject = entry + let icon = NSWorkspace.shared.icon(forFile: entry.path) + icon.size = NSSize(width: 16, height: 16) + item.image = icon + let values = try? entry.resourceValues(forKeys: Set(keys)) + if values?.isDirectory == true, values?.isPackage != true { + let submenu = NSMenu() + submenu.autoenablesItems = false + submenu.delegate = shared + submenu.title = entry.path + item.submenu = submenu + } + menu.addItem(item) + } + if entries.count > limit { + let more = NSMenuItem( + title: String(localized: "\(entries.count - limit) more…"), + action: #selector(openItem(_:)), keyEquivalent: "" + ) + more.target = shared + more.representedObject = folder + menu.addItem(more) + } + } + + func menuNeedsUpdate(_ menu: NSMenu) { + guard menu.items.isEmpty else { return } + let folder = URL(fileURLWithPath: menu.title, isDirectory: true) + let open = NSMenuItem( + title: String(localized: "Open in Finder"), action: #selector(openItem(_:)), keyEquivalent: "" + ) + open.target = self + open.representedObject = folder + menu.addItem(open) + menu.addItem(.separator()) + Self.fill(menu, with: folder) + } + + @objc func openItem(_ sender: NSMenuItem) { + guard let url = sender.representedObject as? URL else { return } + NSWorkspace.shared.open(url) + } +} diff --git a/Sources/MenubucketApp/Dock/DockFolderStack.swift b/Sources/MenubucketApp/Dock/DockFolderStack.swift new file mode 100644 index 0000000..80d4ef9 --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockFolderStack.swift @@ -0,0 +1,153 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +/// A folder opened from the dock as a grid of its contents, the way the Apple +/// Dock shows a stack: newest first by date added, subfolders open in place +/// with a way back, and files can be dragged out. +struct DockFolderStack: View { + let root: URL + let onClose: () -> Void + + @State private var trail: [URL] = [] + @State private var entries: [Entry] + + init(root: URL, onClose: @escaping () -> Void) { + self.root = root + self.onClose = onClose + // Read before the popover opens: it is placed for its first size, and + // contents arriving later made it grow down over the dock. + _entries = State(initialValue: Self.entries(in: root)) + } + + /// Enough to find a recent download; the rest is a click away in Finder. + static let limit = 60 + static let columns = 4 + static let cellWidth: CGFloat = 96 + static let rowHeight: CGFloat = 92 + static let rowSpacing: CGFloat = 10 + + /// Every row when they fit, else four and a half, so it reads as more. + static func gridHeight(count: Int) -> CGFloat { + let rows = max(1, (count + columns - 1) / columns) + let shown = min(CGFloat(rows), 4.5) + return shown * rowHeight + (shown.rounded(.up) - 1) * rowSpacing + 24 + } + + struct Entry: Identifiable, Equatable { + let url: URL + let name: String + let opensInPlace: Bool + var id: URL { url } + } + + private var folder: URL { trail.last ?? root } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + if entries.isEmpty { + Text("Empty Folder") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 120) + } else { + ScrollView { + LazyVGrid(columns: Array(repeating: GridItem(.fixed(Self.cellWidth), spacing: 8), count: Self.columns), + spacing: Self.rowSpacing) { + ForEach(entries) { entry in cell(entry) } + } + .padding(12) + } + // A scroll view has no height of its own, and the popover + // took the smallest it could: one row. Rows decide it, up to + // a screenful. + .frame(height: Self.gridHeight(count: entries.count)) + } + } + .frame(width: CGFloat(Self.columns) * Self.cellWidth + CGFloat(Self.columns - 1) * 8 + 24) + .onChange(of: trail) { _, _ in load() } + } + + private var header: some View { + HStack(spacing: Spacing.xs) { + if !trail.isEmpty { + Button { + trail.removeLast() + } label: { + Image(systemName: "chevron.left") + } + .buttonStyle(.borderless) + .help("Back") + .accessibilityLabel(Text("Back")) + } + Text(FileManager.default.displayName(atPath: folder.path)) + .font(.headline) + .lineLimit(1) + Spacer() + Button("Open in Finder") { + NSWorkspace.shared.open(folder) + onClose() + } + .controlSize(.small) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private func cell(_ entry: Entry) -> some View { + Button { + if entry.opensInPlace { + trail.append(entry.url) + } else { + NSWorkspace.shared.open(entry.url) + onClose() + } + } label: { + VStack(spacing: 4) { + Image(nsImage: DockActions.fileIcon(at: entry.url.path)) + .resizable() + .interpolation(.high) + .frame(width: 52, height: 52) + Text(entry.name) + .font(.caption) + .lineLimit(2) + .multilineTextAlignment(.center) + .truncationMode(.middle) + .frame(width: Self.cellWidth - 8) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onDrag { NSItemProvider(object: entry.url as NSURL) } + .help(entry.name) + .accessibilityLabel(Text(entry.name)) + } + + private func load() { + entries = Self.entries(in: folder) + } + + /// The folder's visible contents, newest first by date added (then + /// modified), as many as `limit`. + static func entries(in folder: URL) -> [Entry] { + let keys: [URLResourceKey] = [.isDirectoryKey, .isPackageKey, .addedToDirectoryDateKey, .contentModificationDateKey] + let urls = (try? FileManager.default.contentsOfDirectory( + at: folder, includingPropertiesForKeys: keys, options: [.skipsHiddenFiles] + )) ?? [] + let dated = urls.map { url -> (URL, Date, URLResourceValues?) in + let values = try? url.resourceValues(forKeys: Set(keys)) + return (url, values?.addedToDirectoryDate ?? values?.contentModificationDate ?? .distantPast, values) + } + return dated + .sorted { $0.1 > $1.1 } + .prefix(limit) + .map { url, _, values in + Entry( + url: url, + name: FileManager.default.displayName(atPath: url.path), + opensInPlace: values?.isDirectory == true && values?.isPackage != true + ) + } + } +} diff --git a/Sources/MenubucketApp/Dock/DockHotkeys.swift b/Sources/MenubucketApp/Dock/DockHotkeys.swift new file mode 100644 index 0000000..faa0ade --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockHotkeys.swift @@ -0,0 +1,133 @@ +import Carbon.HIToolbox +import Combine +import Foundation +import MenubucketCore + +/// Which ⌃⌥ numbers the dock could not take, for settings to show. +/// Positions are 1-based. +final class DockHotkeyStatus: ObservableObject { + static let shared = DockHotkeyStatus() + /// Owned by another app. + @Published fileprivate(set) var unavailable: Set = [] + /// Used by the Automation script, which wins. + @Published fileprivate(set) var heldByAutomation: Set = [] +} + +/// ⌃⌥1…9: switch to the first nine dock profiles (R15). Carbon hot keys, like +/// the popup shortcut, so no Accessibility permission is needed. Its own +/// signature keeps its presses apart from the popup shortcut's handler. +final class DockHotkeys { + static let signature: OSType = 0x4253_444B // 'BSDK' + static let modifiers = UInt32(controlKey | optionKey) + static let digitKeyCodes: [UInt32] = [ + UInt32(kVK_ANSI_1), UInt32(kVK_ANSI_2), UInt32(kVK_ANSI_3), + UInt32(kVK_ANSI_4), UInt32(kVK_ANSI_5), UInt32(kVK_ANSI_6), + UInt32(kVK_ANSI_7), UInt32(kVK_ANSI_8), UInt32(kVK_ANSI_9), + ] + + private let store: DockStore + private var refs: [EventHotKeyRef] = [] + private var handler: EventHandlerRef? + private var desiredCount = 0 + private var unavailable: Set = [] { + didSet { DockHotkeyStatus.shared.unavailable = unavailable } + } + private var heldByAutomation: Set = [] { + didSet { DockHotkeyStatus.shared.heldByAutomation = heldByAutomation } + } + private var cancellable: AnyCancellable? + private var automationObserver: UUID? + + init(store: DockStore) { + self.store = store + // Automation claiming or releasing keys: register again around them. + automationObserver = InAppHotkeys.shared.observe { [weak self] in + guard let self else { return } + self.register(count: self.desiredCount) + } + cancellable = store.$configuration + .map { $0.profileHotkeysEnabled ? min($0.profiles.count, DockConfiguration.hotkeyProfileLimit) : 0 } + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] count in self?.register(count: count) } + } + + deinit { + if let automationObserver { InAppHotkeys.shared.removeObserver(automationObserver) } + unregisterAll() + if let handler { RemoveEventHandler(handler) } + } + + /// Shown in settings beside each profile: "⌃⌥1". + static func label(forPosition position: Int) -> String? { + guard (1...DockConfiguration.hotkeyProfileLimit).contains(position) else { return nil } + return "⌃⌥\(position)" + } + + /// Registers the first `count` ⌃⌥ numbers, leaving out the ones the + /// Automation script uses. Called again whenever either side changes; a + /// key another app held is retried each time. + private func register(count: Int) { + desiredCount = count + unregisterAll() + guard count > 0 else { return } + installHandlerIfNeeded() + let automation = InAppHotkeys.shared.automation + var failed: Set = [] + var held: Set = [] + for (index, keyCode) in Self.digitKeyCodes.prefix(count).enumerated() { + let position = index + 1 + if automation.contains(InAppHotkeys.Key(keyCode: keyCode, modifiers: Self.modifiers)) { + held.insert(position) + continue + } + var ref: EventHotKeyRef? + let id = EventHotKeyID(signature: Self.signature, id: UInt32(position)) + if RegisterEventHotKey(keyCode, Self.modifiers, id, GetApplicationEventTarget(), 0, &ref) == noErr, + let ref { + refs.append(ref) + } else { + failed.insert(position) + } + } + unavailable = failed + heldByAutomation = held + } + + private func unregisterAll() { + refs.forEach { UnregisterEventHotKey($0) } + refs.removeAll() + unavailable = [] + heldByAutomation = [] + } + + private func installHandlerIfNeeded() { + guard handler == nil else { return } + var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) + let selfPtr = Unmanaged.passUnretained(self).toOpaque() + InstallEventHandler( + GetApplicationEventTarget(), + { _, event, userData -> OSStatus in + guard let userData, let event else { return OSStatus(eventNotHandledErr) } + var id = EventHotKeyID() + guard GetEventParameter( + event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), + nil, MemoryLayout.size, nil, &id + ) == noErr, id.signature == DockHotkeys.signature else { + return OSStatus(eventNotHandledErr) + } + let hotkeys = Unmanaged.fromOpaque(userData).takeUnretainedValue() + let position = Int(id.id) + DispatchQueue.main.async { hotkeys.pressed(position: position) } + return noErr + }, + 1, &eventType, selfPtr, &handler + ) + } + + private func pressed(position: Int) { + let profiles = store.configuration.profiles + guard profiles.indices.contains(position - 1) else { return } + store.activate(profileID: profiles[position - 1].id) + } +} diff --git a/Sources/MenubucketApp/Dock/DockMenus.swift b/Sources/MenubucketApp/Dock/DockMenus.swift new file mode 100644 index 0000000..dbe66b1 --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockMenus.swift @@ -0,0 +1,165 @@ +import AppKit +import SwiftUI + +/// One row of a dock tile's menu. A tile's menu is defined once, as these, +/// and offered two ways: as an `NSMenu` on right-click, and as named +/// accessibility actions. SwiftUI's context menu could not be opened through +/// accessibility in the dock's non-activating panel (AXShowMenu reached +/// neither it nor an action of our own), which left VoiceOver with no way to +/// reach these commands. +struct DockMenuEntry: Identifiable { + enum Kind { + case action(() -> Void) + case submenu([DockMenuEntry]) + case divider + } + + let id = UUID() + let title: String + var symbol: String? + var isChecked = false + var isEnabled = true + var isDestructive = false + let kind: Kind + + static func action( + _ title: String, symbol: String? = nil, checked: Bool = false, + enabled: Bool = true, destructive: Bool = false, _ run: @escaping () -> Void + ) -> DockMenuEntry { + DockMenuEntry( + title: title, symbol: symbol, isChecked: checked, isEnabled: enabled, + isDestructive: destructive, kind: .action(run) + ) + } + + static func submenu(_ title: String, _ entries: [DockMenuEntry]) -> DockMenuEntry { + DockMenuEntry(title: title, kind: .submenu(entries)) + } + + static var divider: DockMenuEntry { DockMenuEntry(title: "", kind: .divider) } + + /// Every enabled command, submenus spelled out ("Profile: Work"), for + /// the accessibility actions list. + struct Flat: Identifiable { + let id = UUID() + let title: String + let run: () -> Void + } + + static func flattened(_ entries: [DockMenuEntry], prefix: String? = nil) -> [Flat] { + entries.flatMap { entry -> [Flat] in + switch entry.kind { + case .divider: + return [] + case .submenu(let children): + return flattened(children, prefix: entry.title) + case .action(let run): + guard entry.isEnabled else { return [] } + return [Flat(title: prefix.map { "\($0): \(entry.title)" } ?? entry.title, run: run)] + } + } + } + + /// Leading, trailing, and doubled dividers dropped, so optional groups can + /// be appended without bookkeeping. + static func tidy(_ entries: [DockMenuEntry]) -> [DockMenuEntry] { + var out: [DockMenuEntry] = [] + for entry in entries { + if case .divider = entry.kind { + if out.isEmpty { continue } + if case .divider = out.last!.kind { continue } + } + out.append(entry) + } + if let last = out.last, case .divider = last.kind { out.removeLast() } + return out + } +} + +/// The menu itself, for a right-click. +enum DockMenuPresenter { + /// Keeps the closures alive while the menu is open. + private final class Target: NSObject { + let run: () -> Void + init(_ run: @escaping () -> Void) { self.run = run } + @objc func fire(_ sender: Any?) { run() } + } + + static func makeMenu(_ entries: [DockMenuEntry]) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + for entry in entries { + switch entry.kind { + case .divider: + menu.addItem(.separator()) + case .submenu(let children): + let item = NSMenuItem(title: entry.title, action: nil, keyEquivalent: "") + item.submenu = makeMenu(children) + menu.addItem(item) + case .action(let run): + let target = Target(run) + let item = NSMenuItem(title: entry.title, action: #selector(Target.fire(_:)), keyEquivalent: "") + item.target = target + item.representedObject = target + item.isEnabled = entry.isEnabled + item.state = entry.isChecked ? .on : .off + if let symbol = entry.symbol { + item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) + } + menu.addItem(item) + } + } + return menu + } +} + +/// Which menu a right-click opens: the tile under the pointer, else the +/// bar's. The dock opens its menus itself (`DockHostingView`) rather than +/// through SwiftUI's context menu. +final class DockMenuRouter { + static let shared = DockMenuRouter() + + private var hovered: (id: String, entries: () -> [DockMenuEntry])? + var bar: (() -> [DockMenuEntry])? + + func enter(_ id: String, entries: @escaping () -> [DockMenuEntry]) { + hovered = (id, entries) + } + + func exit(_ id: String) { + if hovered?.id == id { hovered = nil } + } + + /// Forgets a hovered tile that is gone. SwiftUI does not always say the + /// pointer left a view that was removed under it (Remove from Dock, an + /// app quitting), which left a right-click showing the gone tile's menu. + func keep(only ids: Set) { + if let hovered, !ids.contains(hovered.id) { self.hovered = nil } + } + + /// The entries for a right-click right now. + var current: [DockMenuEntry]? { + if let hovered { return hovered.entries() } + return bar?() + } +} + +extension View { + /// A tile's menu: on right-click (through `DockMenuRouter`), and as named + /// accessibility actions, the way VoiceOver offers a SwiftUI view's + /// commands (VO-Command-Space). + func dockMenu(id: String, _ entries: @escaping () -> [DockMenuEntry]) -> some View { + accessibilityActions { + ForEach(DockMenuEntry.flattened(entries())) { entry in + Button(entry.title) { entry.run() } + } + } + .onHover { inside in + if inside { + DockMenuRouter.shared.enter(id, entries: entries) + } else { + DockMenuRouter.shared.exit(id) + } + } + } +} diff --git a/Sources/MenubucketApp/Dock/DockPanelController.swift b/Sources/MenubucketApp/Dock/DockPanelController.swift new file mode 100644 index 0000000..24d2611 --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockPanelController.swift @@ -0,0 +1,561 @@ +import AppKit +import Combine +import MenubucketCore +import SwiftUI + +/// A borderless panel at the Dock's window level. Never activates BarShelf, +/// so clicking an icon leaves the frontmost app frontmost until the click +/// itself switches apps. +final class DockPanel: NSPanel { + init() { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 100, height: 60), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + isOpaque = false + backgroundColor = .clear + hasShadow = false + level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.dockWindow))) + // Every Space, never in Mission Control's window list or ⌘` cycling; + // full-screen apps hide it, as they hide the Apple Dock. + collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] + isMovable = false + hidesOnDeactivate = false + becomesKeyOnlyIfNeeded = true + isReleasedWhenClosed = false + animationBehavior = .none + } + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} + +/// First clicks act right away (the panel is never key when they arrive), and +/// a two-finger swipe or ⌘-scroll over the dock switches profiles. +final class DockHostingView: NSHostingView { + var onSwipe: ((Int) -> Void)? + /// The content's ideal size changed (items, sizes, running apps). + var onIdealSizeChange: (() -> Void)? + private var accumulated: CGFloat = 0 + private var firedThisGesture = false + private var commandScrollFired = false + private var lastCommandScroll: TimeInterval = 0 + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override func rightMouseDown(with event: NSEvent) { + showMenu(for: event) + } + + override func mouseDown(with event: NSEvent) { + // Control-click is a right-click, as everywhere on the Mac. + if event.modifierFlags.contains(.control) { + showMenu(for: event) + } else { + super.mouseDown(with: event) + } + } + + private func showMenu(for event: NSEvent) { + guard let entries = DockMenuRouter.shared.current, !entries.isEmpty else { return } + NSMenu.popUpContextMenu(DockMenuPresenter.makeMenu(entries), with: event, for: self) + } + + override func invalidateIntrinsicContentSize() { + super.invalidateIntrinsicContentSize() + onIdealSizeChange?() + } + + override func scrollWheel(with event: NSEvent) { + // Momentum after the fingers lift would switch a second time. + guard event.momentumPhase.isEmpty else { return } + if event.modifierFlags.contains(.command) { + // One profile per gesture or wheel notch: a trackpad flick sends + // a run of events, and each switch may restart the Apple Dock. + let delta = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY / 20 : event.scrollingDeltaY + let now = ProcessInfo.processInfo.systemUptime + if event.phase == .began { commandScrollFired = false } + if abs(delta) >= 1, !commandScrollFired, now - lastCommandScroll > 0.35 { + commandScrollFired = event.hasPreciseScrollingDeltas + lastCommandScroll = now + onSwipe?(delta > 0 ? -1 : 1) + } + if event.phase == .ended || event.phase == .cancelled { commandScrollFired = false } + return + } + guard event.hasPreciseScrollingDeltas, abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) else { + super.scrollWheel(with: event) + return + } + if event.phase == .began { + accumulated = 0 + firedThisGesture = false + } + accumulated += event.scrollingDeltaX + if !firedThisGesture, abs(accumulated) > 60 { + firedThisGesture = true + // Content follows the fingers: swiping left brings the next one. + onSwipe?(accumulated < 0 ? 1 : -1) + } + if event.phase == .ended || event.phase == .cancelled { + accumulated = 0 + firedThisGesture = false + } + } +} + +/// Shows the BarShelf Dock on its screen edge, hides it when auto-hide says +/// so, and tells the runtime which dock widgets are on screen (R15). +final class DockPanelController { + private let store: DockStore + private let runtime: WidgetRuntime + private let running = RunningApps.shared + private let panel = DockPanel() + private var hostingView: DockHostingView! + private var cancellables: Set = [] + private var mouseMonitors: [Any] = [] + private var restingSize: CGSize = .zero + private var isHovering = false + private var measurePending = false + /// Whether the dock is out (not slid away by auto-hide). + private var isRevealed = true + private var hideWorkItem: DispatchWorkItem? + private var revealWorkItem: DispatchWorkItem? + private var moveWorkItem: DispatchWorkItem? + + /// The edge band, in points, that brings an auto-hidden dock back. + static let revealBand: CGFloat = 2 + /// Gap between the dock and the screen edge. + static let edgeGap: CGFloat = 4 + + var onOpenSettings: (() -> Void)? + + + init(store: DockStore, runtime: WidgetRuntime) { + self.store = store + self.runtime = runtime + let view = DockView( + store: store, runtime: runtime, running: running, + onHoverChange: { [weak self] hovering in self?.hoverChanged(hovering) }, + onOpenSettings: { [weak self] in self?.onOpenSettings?() } + ) + hostingView = DockHostingView(rootView: view) + // The ideal size is read, not obeyed: the hosting view sits in a + // plain container that the panel's frame drives, so the bar's size + // can be measured without the window snapping to it. + hostingView.sizingOptions = [.intrinsicContentSize] + hostingView.translatesAutoresizingMaskIntoConstraints = true + hostingView.autoresizingMask = [.width, .height] + hostingView.onSwipe = { [weak store] offset in store?.activate(offset: offset) } + hostingView.onIdealSizeChange = { [weak self] in self?.setNeedsMeasure() } + let container = NSView(frame: panel.contentLayoutRect) + hostingView.frame = container.bounds + container.addSubview(hostingView) + panel.contentView = container + + store.$configuration + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.configurationChanged() } + .store(in: &cancellables) + runtime.$widgets + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.reportVisibleWidgets() + self?.setNeedsMeasure() + } + .store(in: &cancellables) + running.$apps + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.setNeedsMeasure() } + .store(in: &cancellables) + for name in [NSWorkspace.activeSpaceDidChangeNotification, NSWorkspace.didActivateApplicationNotification] { + NSWorkspace.shared.notificationCenter.publisher(for: name) + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.scheduleFullScreenCheck() } + .store(in: &cancellables) + } + NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification) + .receive(on: RunLoop.main) + // Displays coming and going change whether the pointer matters. + .sink { [weak self] _ in self?.configurationChanged() } + .store(in: &cancellables) + configurationChanged() + store.syncSizeWithAppleDock() + } + + deinit { + removeMouseMonitors() + } + + private var config: DockConfiguration { store.configuration } + + // MARK: Showing + + private func configurationChanged() { + guard config.mode.showsDock else { + panel.orderOut(nil) + removeMouseMonitors() + runtime.setDockWidgetIDs([]) + return + } + if needsPointerTracking { + installMouseMonitorsIfNeeded() + } else { + removeMouseMonitors() + } + panel.collectionBehavior = config.showInFullScreen + ? [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary] + : [.canJoinAllSpaces, .stationary, .ignoresCycle] + if !hidesAutomatically { isRevealed = true } + setNeedsMeasure() + placePanel(animated: false) + if isRevealed { panel.orderFrontRegardless() } + reportVisibleWidgets() + } + + // MARK: Full-screen Spaces + + /// The dock's display is showing a full-screen app. + private var inFullScreenSpace = false + + /// Auto-hide by choice, or because a full-screen app has the display. + private var hidesAutomatically: Bool { + config.autoHide || (inFullScreenSpace && config.showInFullScreen) + } + + /// Looks again after a Space switch or app change settles. + private func scheduleFullScreenCheck() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + self?.checkFullScreen() + self?.store.syncSizeWithAppleDock() + } + } + + private func checkFullScreen() { + guard config.mode.showsDock, let screen else { return } + let windows = (CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) + as? [[String: Any]]) ?? [] + let primaryHeight = NSScreen.screens.first?.frame.height ?? screen.frame.height + let full = Self.isFullScreen( + windows: windows, + screenFrame: Self.windowServerFrame(of: screen.frame, primaryHeight: primaryHeight), + topInset: screen.safeAreaInsets.top, + ownPID: ProcessInfo.processInfo.processIdentifier + ) + guard full != inFullScreenSpace else { return } + inFullScreenSpace = full + if full, config.showInFullScreen { + setRevealed(false) + } + configurationChanged() + } + + /// A screen frame in window-server coordinates (origin at the top left + /// of the main display, y down), the space window bounds come in. + static func windowServerFrame(of frame: NSRect, primaryHeight: CGFloat) -> CGRect { + CGRect(x: frame.minX, y: primaryHeight - frame.maxY, width: frame.width, height: frame.height) + } + + /// Whether another app has an ordinary window covering the whole screen, + /// menu bar area included, which only a full-screen window does: zoomed + /// and tiled windows stop below the menu bar. On a display with a camera + /// housing a full-screen window starts below it instead (`topInset`, the + /// screen's top safe-area inset), which a zoomed window still does not + /// reach, since the menu bar there is taller than the housing area it + /// shares. Reads window bounds only, which needs no permission. + static func isFullScreen( + windows: [[String: Any]], screenFrame: CGRect, topInset: CGFloat = 0, ownPID: pid_t + ) -> Bool { + var targets = [screenFrame] + if topInset > 0 { + targets.append(CGRect( + x: screenFrame.minX, y: screenFrame.minY + topInset, + width: screenFrame.width, height: screenFrame.height - topInset + )) + } + return windows.contains { window in + guard (window[kCGWindowLayer as String] as? Int) == 0, + (window[kCGWindowOwnerPID as String] as? pid_t) != ownPID, + let dict = window[kCGWindowBounds as String] as? NSDictionary, + let bounds = CGRect(dictionaryRepresentation: dict) + else { return false } + return targets.contains { target in + abs(bounds.minX - target.minX) < 1 && abs(bounds.minY - target.minY) < 1 + && abs(bounds.width - target.width) < 1 && abs(bounds.height - target.height) < 1 + } + } + } + + private func hoverChanged(_ hovering: Bool) { + isHovering = hovering + if !hovering { setNeedsMeasure() } + } + + /// Measures the bar once SwiftUI has caught up with the change that asked + /// for it (`@Published` fires before the new value is stored). + private func setNeedsMeasure() { + guard !measurePending else { return } + measurePending = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.measurePending = false + self.measure() + } + } + + private func measure() { + guard config.mode.showsDock, !isHovering else { return } + let fitting = hostingView.intrinsicContentSize + // The edge gap is part of the SwiftUI padding; the bar is the rest. + let size = config.edge.isVertical + ? CGSize(width: fitting.width - Self.edgeGap, height: fitting.height) + : CGSize(width: fitting.width, height: fitting.height - Self.edgeGap) + guard size.width > 0, size.height > 0, size != restingSize else { return } + restingSize = size + placePanel(animated: false) + } + + /// The headroom around the bar that magnified icons and their labels + /// draw into. Transparent, so clicks there reach the windows below. + private var headroom: (along: CGFloat, across: CGFloat) { + let tile = CGFloat(config.tileSize) + let magnifies = config.style == .classic && config.magnification + let grow = tile * DockView.magnification(for: config) + // Labels sit past the grown icon; the profile banner past the bar. + return (along: magnifies ? tile * 2.5 : tile, across: grow + 48) + } + + /// The display the dock is on: the main one, or (following the pointer) + /// the one it last moved to. + private var screen: NSScreen? { + let main = NSScreen.screens.first ?? NSScreen.main + guard config.display == .pointer, let pointerDisplayID else { return main } + return NSScreen.screens.first { Self.displayID(of: $0) == pointerDisplayID } ?? main + } + + private var pointerDisplayID: CGDirectDisplayID? + + private static func displayID(of screen: NSScreen) -> CGDirectDisplayID? { + (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value + } + + /// Whether the pointer rests in the reveal band of the dock's edge of + /// `screen`. + private func isAtEdge(_ point: NSPoint, of screen: NSScreen) -> Bool { + let full = screen.frame + return switch config.edge { + case .bottom: point.y <= full.minY + Self.revealBand && point.x >= full.minX && point.x <= full.maxX + case .left: point.x <= full.minX + Self.revealBand && point.y >= full.minY && point.y <= full.maxY + case .right: point.x >= full.maxX - Self.revealBand - 1 && point.y >= full.minY && point.y <= full.maxY + } + } + + /// The panel frame for the current size and edge, out on screen or slid + /// past the edge (the current state unless `revealed` says otherwise). + private func targetFrame(revealed: Bool? = nil) -> NSRect? { + let isRevealed = revealed ?? self.isRevealed + guard let screen, restingSize != .zero else { return nil } + let visible = screen.visibleFrame + let full = screen.frame + let room = headroom + switch config.edge { + case .bottom: + let width = min(restingSize.width + room.along, full.width) + let height = restingSize.height + room.across + Self.edgeGap + // Above the Apple Dock when it shares the edge and stays up. + var y = visible.minY + if !isRevealed { y = full.minY - height } + return NSRect(x: visible.midX - width / 2, y: y, width: width, height: height) + case .left, .right: + let width = restingSize.width + room.across + Self.edgeGap + let height = min(restingSize.height + room.along, visible.height) + let y = visible.midY - height / 2 + if config.edge == .left { + let x = isRevealed ? visible.minX : full.minX - width + return NSRect(x: x, y: y, width: width, height: height) + } + let x = isRevealed ? visible.maxX - width : full.maxX + return NSRect(x: x, y: y, width: width, height: height) + } + } + + private func placePanel(animated: Bool, completion: (() -> Void)? = nil) { + guard let frame = targetFrame() else { return } + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().setFrame(frame, display: true) + } completionHandler: { + completion?() + } + } else { + panel.setFrame(frame, display: true) + completion?() + } + } + + // MARK: Auto-hide + + /// Watching every mouse move system-wide is only worth it for what + /// needs the pointer: auto-hide, following it between displays, and + /// stepping aside when the Apple Dock moves onto this display (which + /// only happens with more than one display). + private var needsPointerTracking: Bool { + hidesAutomatically || config.display == .pointer + || (config.mode == .alongside && NSScreen.screens.count > 1) + } + + private func installMouseMonitorsIfNeeded() { + guard mouseMonitors.isEmpty else { return } + let handler: (NSEvent) -> Void = { [weak self] _ in self?.mouseMoved() } + if let global = NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged], handler: handler) { + mouseMonitors.append(global) + } + if let local = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged], handler: { event in + handler(event) + return event + }) { + mouseMonitors.append(local) + } + } + + private func removeMouseMonitors() { + mouseMonitors.forEach(NSEvent.removeMonitor) + mouseMonitors.removeAll() + } + + private func mouseMoved() { + guard config.mode.showsDock else { return } + let point = NSEvent.mouseLocation + followPointerIfNeeded(point) + replaceIfAppleDockMoved() + guard hidesAutomatically, let screen else { return } + let atEdge = isAtEdge(point, of: screen) + if !isRevealed { + // Out only after the pointer has rested at the edge a moment, so + // passing over the edge (or onto a display below) does not pull + // the dock out. + if atEdge { + if revealWorkItem == nil { + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.revealWorkItem = nil + if let screen = self.screen, self.isAtEdge(NSEvent.mouseLocation, of: screen) { + self.setRevealed(true) + } + } + revealWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + config.autoHideDelay, execute: work) + } + } else { + revealWorkItem?.cancel() + revealWorkItem = nil + } + return + } + let inside = barFrame().insetBy(dx: -12, dy: -12).contains(point) || atEdge + if inside { + hideWorkItem?.cancel() + hideWorkItem = nil + } else if hideWorkItem == nil { + let work = DispatchWorkItem { [weak self] in + self?.hideWorkItem = nil + self?.setRevealed(false) + } + hideWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work) + } + } + + /// The Apple Dock moves between displays with the pointer, which changes + /// a display's visible frame without any notification. Sitting on a + /// stale frame put the BarShelf Dock under it, so the frame is + /// re-checked as the pointer moves (it is the pointer that moves it). + private func replaceIfAppleDockMoved() { + guard let visible = screen?.visibleFrame, visible != lastVisibleFrame else { return } + lastVisibleFrame = visible + placePanel(animated: true) + } + + private var lastVisibleFrame: NSRect? + + /// Following the pointer: resting at the dock's edge of another display + /// moves the dock there, as the Apple Dock does. + private func followPointerIfNeeded(_ point: NSPoint) { + guard config.display == .pointer, + let target = NSScreen.screens.first(where: { NSMouseInRect(point, $0.frame, false) }), + let targetID = Self.displayID(of: target), + targetID != screen.flatMap(Self.displayID(of:)), + isAtEdge(point, of: target) + else { return } + // One pending move at a time; it re-checks the pointer when it fires. + guard moveWorkItem == nil else { return } + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.moveWorkItem = nil + guard NSMouseInRect(NSEvent.mouseLocation, target.frame, false), + self.isAtEdge(NSEvent.mouseLocation, of: target) else { return } + self.pointerDisplayID = targetID + self.placePanel(animated: false) + if self.hidesAutomatically { self.setRevealed(true) } + } + moveWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + max(0.3, config.autoHideDelay), execute: work) + } + + /// The bar's own frame on screen, without the headroom. + private func barFrame() -> NSRect { + let frame = panel.frame + switch config.edge { + case .bottom: + return NSRect( + x: frame.midX - restingSize.width / 2, y: frame.minY, + width: restingSize.width, height: restingSize.height + Self.edgeGap + ) + case .left: + return NSRect( + x: frame.minX, y: frame.midY - restingSize.height / 2, + width: restingSize.width + Self.edgeGap, height: restingSize.height + ) + case .right: + return NSRect( + x: frame.maxX - restingSize.width - Self.edgeGap, y: frame.midY - restingSize.height / 2, + width: restingSize.width + Self.edgeGap, height: restingSize.height + ) + } + } + + private func setRevealed(_ revealed: Bool) { + guard revealed != isRevealed else { return } + // A menu or a drag in flight keeps the dock out. + if !revealed, NSEvent.pressedMouseButtons != 0 { return } + isRevealed = revealed + if revealed { + // Start just past the edge, then slide in. + if let hidden = targetFrame(revealed: false) { panel.setFrame(hidden, display: false) } + panel.orderFrontRegardless() + placePanel(animated: true) + } else { + // Slide out, then leave the screen for real: past the edge could + // be another display. + placePanel(animated: true) { [weak self] in + guard let self, !self.isRevealed else { return } + self.panel.orderOut(nil) + } + } + reportVisibleWidgets() + } + + // MARK: Widgets on screen + + private func reportVisibleWidgets() { + guard config.mode.showsDock, isRevealed else { + runtime.setDockWidgetIDs([]) + return + } + runtime.setDockWidgetIDs(Set(config.activeWidgetIDs)) + } +} diff --git a/Sources/MenubucketApp/Dock/DockSettingsPage.swift b/Sources/MenubucketApp/Dock/DockSettingsPage.swift new file mode 100644 index 0000000..4b61c1a --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockSettingsPage.swift @@ -0,0 +1,600 @@ +import AppKit +import MenubucketCore +import SwiftUI +import UniformTypeIdentifiers + +/// BarShelf window ▸ Dock (R15): whether there is a BarShelf Dock and how it +/// relates to the Apple Dock, how it looks, and its profiles. +struct DockSettingsPage: View { + @ObservedObject var store: DockStore + @ObservedObject var runtime: WidgetRuntime + + @State private var selectedProfileID: String? + @State private var shortcuts: [String]? + @State private var linkDraft = "" + @State private var showLinkPrompt = false + @State private var newProfileName = "" + @State private var showNewProfilePrompt = false + @State private var profileToDelete: DockProfile? + @ObservedObject private var hotkeyStatus = DockHotkeyStatus.shared + @State private var nameDraft = "" + @FocusState private var nameFocused: Bool + + static let symbolPresets = [ + "square.grid.2x2", "briefcase", "house", "laptopcomputer", "hammer", + "paintbrush", "book", "gamecontroller", "music.note", "airplane", + "moon", "sun.max", "person.2", "graduationcap", "cup.and.saucer", + ] + + private var config: DockConfiguration { store.configuration } + + private var selectedProfile: DockProfile { + config.profiles.first { $0.id == selectedProfileID } ?? config.activeProfile + } + + var body: some View { + SettingsPage { + modeSection + if let error = store.lastError { + Section { + StatusBanner(tone: .critical, message: String(localized: "Couldn't save dock settings: \(error)")) + } + } + appearanceSection + profilesSection + itemsSection + appleDockSection + switchingSection + } + .alert("Add Link", isPresented: $showLinkPrompt) { + TextField("https://example.com", text: $linkDraft) + Button("Cancel", role: .cancel) { linkDraft = "" } + Button("Add") { addLink() } + } message: { + Text("A web page to open from the dock.") + } + .alert("New Profile", isPresented: $showNewProfilePrompt) { + TextField("Profile name", text: $newProfileName) + Button("Cancel", role: .cancel) { newProfileName = "" } + Button("Add") { + let name = newProfileName.trimmingCharacters(in: .whitespacesAndNewlines) + newProfileName = "" + guard !name.isEmpty else { return } + selectedProfileID = store.addProfile(named: name) + } + } message: { + Text("Starts empty. Add apps, folders, and widgets below.") + } + .alert( + "Delete \(profileToDelete?.name ?? "")?", + isPresented: Binding(get: { profileToDelete != nil }, set: { if !$0 { profileToDelete = nil } }) + ) { + Button("Cancel", role: .cancel) { profileToDelete = nil } + Button("Delete", role: .destructive) { + if let profile = profileToDelete { + store.removeProfile(profile.id) + selectedProfileID = nil + } + profileToDelete = nil + } + } message: { + Text("Its items and saved Apple Dock layout are removed. Nothing on disk is touched.") + } + } + + // MARK: Mode + + private var modeSection: some View { + Section { + Picker("BarShelf Dock", selection: Binding( + get: { config.mode }, + set: { mode in store.update { $0.mode = mode } } + )) { + Text("Off").tag(DockConfiguration.Mode.off) + Text("Alongside the Apple Dock").tag(DockConfiguration.Mode.alongside) + Text("Instead of the Apple Dock").tag(DockConfiguration.Mode.replace) + } + .pickerStyle(.radioGroup) + if config.mode == .replace { + StatusBanner( + tone: .info, + message: String(localized: "The Apple Dock is hidden while BarShelf runs and comes back when you quit BarShelf or turn this off. If it ever stays hidden, run barshelf dock restore-apple-dock in Terminal.") + ) + } else if config.mode == .alongside, sharesEdgeWithAppleDock { + StatusBanner( + tone: .warning, + message: String(localized: "The Apple Dock is on the same edge. The BarShelf Dock sits just above it; another position, or auto-hide, keeps them apart.") + ) + } + } header: { + Text("Dock") + } footer: { + Text("Profiles below also work with the BarShelf Dock off: they can switch the Apple Dock's apps.") + } + } + + /// Only said with one display: with several, the Apple Dock moves to + /// whichever the pointer rests at, so "the same edge" may not be. + private var sharesEdgeWithAppleDock: Bool { + NSScreen.screens.count == 1 + && store.appleDock.orientation == config.edge.rawValue + && store.appleDock.visibility.autohide != true + } + + // MARK: Appearance + + private var appearanceSection: some View { + Section("Appearance") { + Picker("Style", selection: binding(\.style)) { + Text("Classic").tag(DockConfiguration.Style.classic) + Text("Shelf").tag(DockConfiguration.Style.shelf) + } + .pickerStyle(.segmented) + Text(config.style == .classic + ? "Like the Apple Dock: icons on glass, names on hover. Widgets show their main reading at icon height." + : "A sturdier bar with names under icons, and widgets as full cards.") + .font(.caption) + .foregroundStyle(.secondary) + Picker("Position on screen", selection: binding(\.edge)) { + Text("Left").tag(DockConfiguration.Edge.left) + Text("Bottom").tag(DockConfiguration.Edge.bottom) + Text("Right").tag(DockConfiguration.Edge.right) + } + .pickerStyle(.segmented) + Toggle("Same size as the Apple Dock", isOn: Binding( + get: { config.matchAppleDockSize }, + set: { on in + store.update { $0.matchAppleDockSize = on } + if on { store.syncSizeWithAppleDock() } + } + )) + LabeledContent("Size") { + // Moving the slider is choosing a size of one's own. + Slider(value: Binding( + get: { config.tileSize }, + set: { size in store.update { $0.tileSize = size; $0.matchAppleDockSize = false } } + ), in: DockConfiguration.tileSizeRange) { + Text("Size") + } minimumValueLabel: { + Text("Small").font(.caption) + } maximumValueLabel: { + Text("Large").font(.caption) + } + .labelsHidden() + .frame(maxWidth: 260) + } + if config.style == .shelf { + LabeledContent("Widget size") { + Slider(value: binding(\.widgetSize), in: DockConfiguration.widgetSizeRange) { + Text("Widget size") + } minimumValueLabel: { + Text("Small").font(.caption) + } maximumValueLabel: { + Text("Large").font(.caption) + } + .labelsHidden() + .frame(maxWidth: 260) + } + } + if config.style == .classic { + Toggle("Magnification", isOn: binding(\.magnification)) + LabeledContent("Magnification amount") { + Slider(value: binding(\.magnificationAmount), in: DockConfiguration.magnificationAmountRange) { + Text("Magnification amount") + } minimumValueLabel: { + Text("Small").font(.caption) + } maximumValueLabel: { + Text("Large").font(.caption) + } + .labelsHidden() + .frame(maxWidth: 260) + } + .disabled(!config.magnification) + } + Picker("Show on", selection: binding(\.display)) { + Text("Main display").tag(DockConfiguration.Display.main) + Text("Display with the pointer").tag(DockConfiguration.Display.pointer) + } + Toggle("Automatically hide and show the dock", isOn: binding(\.autoHide)) + if config.autoHide { + LabeledContent("Show after") { + HStack { + Slider(value: binding(\.autoHideDelay), in: DockConfiguration.autoHideDelayRange) + .frame(maxWidth: 200) + Text(config.autoHideDelay < 0.05 + ? String(localized: "Immediately") + : String(localized: "\(config.autoHideDelay, format: .number.precision(.fractionLength(1))) s")) + .monospacedDigit() + .frame(minWidth: 70, alignment: .trailing) + } + } + } + Toggle("Show in full-screen apps", isOn: binding(\.showInFullScreen)) + .help("In a full-screen app the dock waits at the edge and slides out when the pointer rests there.") + Toggle("Animate opening apps", isOn: binding(\.animateOpening)) + Toggle("Show indicators for open apps", isOn: binding(\.showIndicators)) + Toggle("Show open apps that aren't in the dock", isOn: binding(\.showRunningApps)) + Toggle("Show recent apps", isOn: binding(\.showRecentApps)) + .help("Up to three apps you used lately that aren't in the dock or open.") + Picker("Open folders as", selection: binding(\.folderView)) { + Text("Grid").tag(DockConfiguration.FolderView.grid) + Text("List").tag(DockConfiguration.FolderView.list) + } + Toggle("Show Trash", isOn: binding(\.showTrash)) + } + .disabled(!config.mode.showsDock) + } + + private func binding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { store.configuration[keyPath: keyPath] }, + set: { value in store.update { $0[keyPath: keyPath] = value } } + ) + } + + // MARK: Profiles + + private var profilesSection: some View { + Section { + ForEach(Array(config.profiles.enumerated()), id: \.element.id) { index, profile in + profileRow(profile, position: index + 1) + } + .onMove { from, to in + store.update { $0.profiles.move(fromOffsets: from, toOffset: to) } + } + HStack { + Button("Add Profile…") { showNewProfilePrompt = true } + Button("Duplicate") { + selectedProfileID = store.addProfile( + named: String(localized: "\(selectedProfile.name) Copy"), copying: selectedProfile + ) + } + Spacer() + Button("Delete…", role: .destructive) { profileToDelete = selectedProfile } + .disabled(config.profiles.count < 2) + } + } header: { + Text("Profiles") + } footer: { + Text("Each profile has its own dock items, and can carry an Apple Dock layout and a popup page. Drag to reorder; the first nine get ⌃⌥1–9.") + } + } + + private func profileRow(_ profile: DockProfile, position: Int) -> some View { + let isSelected = profile.id == selectedProfile.id + let isActive = profile.id == config.activeProfileID + return HStack(spacing: Spacing.xs) { + Image(systemName: profile.symbol) + .frame(width: 20) + .foregroundStyle(isSelected ? Color.accentColor : .secondary) + Text(profile.name) + .fontWeight(isSelected ? .semibold : .regular) + if isActive { + Text("Active") + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Color.accentColor.opacity(0.15), in: Capsule()) + } + Spacer() + if config.profileHotkeysEnabled, let label = DockHotkeys.label(forPosition: position) { + if hotkeyStatus.heldByAutomation.contains(position) { + Label("\(label) used by Automation", systemImage: "keyboard") + .font(.caption) + .foregroundStyle(.secondary) + .help("Your Automation script uses \(label), so it goes there instead of switching profiles.") + } else if hotkeyStatus.unavailable.contains(position) { + Label("\(label) is taken", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + .help("Another app or an Automation shortcut already uses \(label). Free it, then turn profile shortcuts off and on.") + } else { + Text(label).font(.caption.monospaced()).foregroundStyle(.secondary) + } + } + if !isActive { + Button("Switch") { store.activate(profileID: profile.id) } + .controlSize(.small) + } + } + .contentShape(Rectangle()) + .onTapGesture { selectedProfileID = profile.id } + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + // MARK: Items of the selected profile + + private var itemsSection: some View { + let profile = selectedProfile + return Section { + // Saved on Return or leaving the field: written per keystroke, + // the name was trimmed while typing ("Work Mode" lost its space). + TextField("Name", text: $nameDraft) + .focused($nameFocused) + .onSubmit { commitName(profileID: profile.id) } + .onChange(of: nameFocused) { _, focused in + if !focused { commitName(profileID: profile.id) } + } + .onChange(of: profile.id, initial: true) { previous, current in + // Switching profiles mid-edit keeps the edit, on the + // profile it was made to. + if previous != current { commitName(profileID: previous) } + nameDraft = profile.name + } + .onChange(of: profile.name) { _, name in + if !nameFocused { nameDraft = name } + } + Picker("Symbol", selection: Binding( + get: { profile.symbol }, + set: { symbol in store.updateProfile(profile.id) { $0.symbol = symbol } } + )) { + ForEach(Self.symbolPresets, id: \.self) { symbol in + Label(symbol, systemImage: symbol).labelStyle(.iconOnly).tag(symbol) + } + if !Self.symbolPresets.contains(profile.symbol) { + Label(profile.symbol, systemImage: profile.symbol).tag(profile.symbol) + } + } + Picker("Popup page", selection: Binding( + get: { profile.popupPage ?? "" }, + set: { page in store.updateProfile(profile.id) { $0.popupPage = page.isEmpty ? nil : page } } + )) { + Text("Don't change").tag("") + ForEach(runtime.allGroups, id: \.self) { Text($0).tag($0) } + } + if profile.items.isEmpty { + Text("No items yet. Add some below, or drop apps, folders, and files on the dock.") + .foregroundStyle(.secondary) + } + ForEach(profile.items) { item in + itemRow(item, profileID: profile.id) + } + .onMove { from, to in + store.updateProfile(profile.id) { $0.items.move(fromOffsets: from, toOffset: to) } + } + addMenu(profileID: profile.id) + } header: { + Text("Items in \(profile.name)") + } + } + + private func commitName(profileID: String) { + let name = nameDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + nameDraft = config.profiles.first { $0.id == profileID }?.name ?? "" + return + } + guard config.profiles.first(where: { $0.id == profileID })?.name != name else { return } + store.updateProfile(profileID) { $0.name = name } + } + + private func itemRow(_ item: DockItem, profileID: String) -> some View { + HStack(spacing: Spacing.xs) { + itemIcon(item) + .frame(width: 22, height: 22) + VStack(alignment: .leading, spacing: 0) { + Text(itemTitle(item)).lineLimit(1) + Text(itemKind(item)).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + if case .folder(let path, let color, let label) = item.kind { + Picker("Color", selection: Binding( + get: { color }, + set: { store.replaceItem(DockItem(id: item.id, kind: .folder(path: path, color: $0, label: label)), profileID: profileID) } + )) { + Text("Folder icon").tag(DockItem.FolderColor?.none) + ForEach(DockItem.FolderColor.allCases, id: \.self) { option in + Text(FolderBadge.name(option)).tag(Optional(option)) + } + } + .labelsHidden() + .fixedSize() + } + Button { + store.removeItem(item.id, profileID: profileID) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .help("Remove from the dock") + .accessibilityLabel(Text("Remove \(itemTitle(item))")) + } + } + + @ViewBuilder + private func itemIcon(_ item: DockItem) -> some View { + switch item.kind { + case .widget: + Image(systemName: "square.grid.2x2").foregroundStyle(Color.accentColor) + case .spacer: + Image(systemName: "arrow.left.and.right").foregroundStyle(.secondary) + case .separator: + Image(systemName: "line.diagonal").foregroundStyle(.secondary) + case .link: + Image(systemName: "globe").foregroundStyle(.teal) + case .folder(_, let color?, let label): + FolderBadge(color: color, label: label ?? itemTitle(item)) + default: + if let icon = DockActions.icon(for: item) { + Image(nsImage: icon).resizable() + } else { + Image(systemName: "questionmark.app.dashed") + } + } + } + + private func itemTitle(_ item: DockItem) -> String { + if case .widget(let id) = item.kind { + return runtime.widgets.first { $0.id == id }?.displayName ?? id + } + return DockActions.displayName(for: item) + } + + private func itemKind(_ item: DockItem) -> String { + switch item.kind { + case .app: return String(localized: "App") + case .folder(let path, _, _): return path.abbreviatingWithTildeInPath + case .file(let path): return path.abbreviatingWithTildeInPath + case .link(let url, _): return url + case .shortcut: return String(localized: "Shortcut") + case .widget: return String(localized: "Widget") + case .spacer: return String(localized: "Space") + case .separator: return String(localized: "Divider") + } + } + + private func addMenu(profileID: String) -> some View { + HStack { + Menu("Add") { + Button("App…") { choose(profileID: profileID, apps: true) } + Button("Folder or File…") { choose(profileID: profileID, apps: false) } + Button("Link…") { + selectedProfileID = profileID + showLinkPrompt = true + } + Menu("Shortcut") { + if let shortcuts { + if shortcuts.isEmpty { Text("No Shortcuts found") } + ForEach(shortcuts, id: \.self) { name in + Button(name) { store.addItems([DockItem(kind: .shortcut(name: name))], profileID: profileID) } + } + } else { + Text("Loading…") + } + } + Menu("Widget") { + ForEach(runtime.widgets) { widget in + Button(widget.displayName) { + store.addItems([DockItem(kind: .widget(id: widget.id))], profileID: profileID) + } + } + } + Divider() + Button("Space") { store.addItems([DockItem(kind: .spacer)], profileID: profileID) } + Button("Divider") { store.addItems([DockItem(kind: .separator)], profileID: profileID) } + } + .fixedSize() + .onAppear { + guard shortcuts == nil else { return } + DockActions.listShortcuts { shortcuts = $0 } + } + Button("Copy from Apple Dock") { + store.addItems(store.appleDockItems(), profileID: profileID) + } + .help("Adds the Apple Dock's apps, folders, and files to this profile.") + Spacer() + } + } + + private func choose(profileID: String, apps: Bool) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = !apps + panel.canChooseFiles = true + panel.treatsFilePackagesAsDirectories = false + if apps { + panel.allowedContentTypes = [.application] + panel.directoryURL = URL(fileURLWithPath: "/Applications") + } + panel.prompt = String(localized: "Add to Dock") + guard panel.runModal() == .OK else { return } + let items = panel.urls.map { url in + DockItem.forFile(at: url, isDirectory: (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory ?? false) + } + store.addItems(items, profileID: profileID) + } + + private func addLink() { + var text = linkDraft.trimmingCharacters(in: .whitespacesAndNewlines) + linkDraft = "" + guard !text.isEmpty else { return } + if !text.contains("://") { text = "https://" + text } + guard let url = URL(string: text), url.host != nil else { return } + store.addItems([DockItem(kind: .link(url: url.absoluteString, title: url.host))], profileID: selectedProfile.id) + } + + // MARK: Apple Dock layouts + + private var appleDockSection: some View { + let profile = selectedProfile + return Section { + Toggle("Switch the Apple Dock's apps with the profile", isOn: binding(\.appleDockLayouts)) + if let layout = profile.appleDock { + LabeledContent("Saved in \(profile.name)") { + VStack(alignment: .trailing, spacing: 2) { + Text(Self.summary(layout.appNames + layout.otherNames)) + .lineLimit(2) + .multilineTextAlignment(.trailing) + Text(layout.capturedAt, format: .dateTime.year().month().day().hour().minute()) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } else { + Text("\(profile.name) has no Apple Dock layout saved; switching to it leaves the Apple Dock as it is.") + .foregroundStyle(.secondary) + } + HStack { + Button(profile.appleDock == nil ? "Save Current Apple Dock" : "Replace with Current Apple Dock") { + store.captureAppleDock(into: profile.id) + } + Button("Apply Now") { store.applyAppleDockLayout(of: profile.id) } + .disabled(profile.appleDock == nil) + Spacer() + Button("Forget") { store.updateProfile(profile.id) { $0.appleDock = nil } } + .disabled(profile.appleDock == nil) + } + } header: { + Text("Apple Dock Layout") + } footer: { + Text("Arrange the Apple Dock the way you want it for this profile, then save. Switching restarts the Dock briefly; open apps and windows stay as they are. The previous layout is backed up first.") + } + } + + static func summary(_ names: [String]) -> String { + let shown = names.prefix(6).joined(separator: ", ") + return names.count > 6 ? String(localized: "\(shown), and \(names.count - 6) more") : shown + } + + // MARK: Switching + + private var switchingSection: some View { + let profile = selectedProfile + let url = Self.switchURL(for: profile) + return Section { + Toggle("Switch profiles with ⌃⌥1–9", isOn: binding(\.profileHotkeysEnabled)) + Text("You can also swipe sideways with two fingers on the dock, or scroll over it holding ⌘, or pick a profile from the BarShelf menu.") + .font(.caption) + .foregroundStyle(.secondary) + LabeledContent("Link to \(profile.name)") { + HStack { + Text(url) + .font(.caption.monospaced()) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url, forType: .string) + } + .controlSize(.small) + } + } + } header: { + Text("Switching") + } footer: { + Text("To follow a Focus: in Shortcuts, add a personal automation for when the Focus turns on, with the Open URLs action and this link. From Terminal: barshelf dock use \"\(profile.name)\".") + } + } + + /// By id, which survives a rename; a name link would quietly stop + /// working in a Focus automation once the profile is renamed. + static func switchURL(for profile: DockProfile) -> String { + let value = profile.id.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&=+"))) ?? profile.id + return "barshelf://dock?profile=\(value)" + } +} + +private extension String { + var abbreviatingWithTildeInPath: String { (self as NSString).abbreviatingWithTildeInPath } +} diff --git a/Sources/MenubucketApp/Dock/DockStore.swift b/Sources/MenubucketApp/Dock/DockStore.swift new file mode 100644 index 0000000..f2f396a --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockStore.swift @@ -0,0 +1,280 @@ +import AppKit +import Combine +import MenubucketCore + +/// The BarShelf Dock's settings and profiles (R15), and the one place that +/// changes the Apple Dock: hiding it for `replace`, putting it back, and +/// rewriting its layout when a profile carries one. +final class DockStore: ObservableObject { + static let shared = DockStore() + + static var defaultFileURL: URL { + WidgetRuntime.applicationSupportDirectory.appendingPathComponent("dock.json") + } + + static var backupDirectory: URL { + WidgetRuntime.applicationSupportDirectory.appendingPathComponent("dock-backups", isDirectory: true) + } + + @Published private(set) var configuration: DockConfiguration + @Published private(set) var lastError: String? + /// The profile just switched to, for the dock's brief name banner. + @Published private(set) var announcedProfile: DockProfile? + + /// Runs after a profile becomes active (popup page, and so on). + var onProfileActivated: ((DockProfile) -> Void)? + + let appleDock: AppleDock + private let fileURL: URL + private var announceTask: Task? + + init( + fileURL: URL = DockStore.defaultFileURL, + appleDock: AppleDock = AppleDock(backupDirectory: DockStore.backupDirectory) + ) { + self.fileURL = fileURL + self.appleDock = appleDock + var configuration = DockConfiguration.load(from: fileURL) + // A first dock starts at the Apple Dock's own icon size. + if !FileManager.default.fileExists(atPath: fileURL.path), let size = appleDock.tileSize { + configuration.tileSize = size + configuration.normalize() + } + self.configuration = configuration + } + + var activeProfile: DockProfile { configuration.activeProfile } + + // MARK: Editing + + /// Applies an edit, saves it, and carries out what a mode change means + /// for the Apple Dock. + func update(_ change: (inout DockConfiguration) -> Void) { + var copy = configuration + change(©) + copy.normalize() + let oldMode = configuration.mode + configuration = copy + if oldMode != .replace, copy.mode == .replace { + hideAppleDock() + } else if oldMode == .replace, copy.mode != .replace { + restoreAppleDock() + } + save() + } + + func updateProfile(_ id: String, _ change: (inout DockProfile) -> Void) { + update { config in + guard let index = config.profiles.firstIndex(where: { $0.id == id }) else { return } + change(&config.profiles[index]) + } + } + + /// Adds items to the active profile, before `beforeID` or at the end. + func addItems(_ items: [DockItem], before beforeID: String? = nil, profileID: String? = nil) { + guard !items.isEmpty else { return } + updateProfile(profileID ?? configuration.activeProfileID) { profile in + let index = beforeID.flatMap { id in profile.items.firstIndex { $0.id == id } } ?? profile.items.count + profile.items.insert(contentsOf: items, at: index) + } + } + + func removeItem(_ itemID: String, profileID: String? = nil) { + updateProfile(profileID ?? configuration.activeProfileID) { profile in + profile.items.removeAll { $0.id == itemID } + } + } + + /// Moves an item in front of another (or to the end with nil). + func moveItem(_ itemID: String, before targetID: String?, profileID: String? = nil) { + guard itemID != targetID else { return } + updateProfile(profileID ?? configuration.activeProfileID) { profile in + guard let from = profile.items.firstIndex(where: { $0.id == itemID }) else { return } + let item = profile.items.remove(at: from) + let to = targetID.flatMap { id in profile.items.firstIndex { $0.id == id } } ?? profile.items.count + profile.items.insert(item, at: to) + } + } + + func replaceItem(_ item: DockItem, profileID: String? = nil) { + updateProfile(profileID ?? configuration.activeProfileID) { profile in + guard let index = profile.items.firstIndex(where: { $0.id == item.id }) else { return } + profile.items[index] = item + } + } + + @discardableResult + func addProfile(named name: String, copying source: DockProfile? = nil) -> String { + var profile = DockProfile(name: name) + if let source { + profile.symbol = source.symbol + profile.items = source.items.map { DockItem(kind: $0.kind) } + profile.appleDock = source.appleDock + profile.popupPage = source.popupPage + } + update { $0.profiles.append(profile) } + return profile.id + } + + func removeProfile(_ id: String) { + let wasActive = configuration.activeProfileID == id + update { config in + guard config.profiles.count > 1 else { return } + config.profiles.removeAll { $0.id == id } + } + // The profile that takes over is switched to properly: its Apple + // Dock layout and popup page, not just its items. + if wasActive, configuration.activeProfileID != id { + activate(profileID: configuration.activeProfileID) + } + } + + // MARK: Switching + + /// Makes a profile active: the BarShelf Dock shows its items, the Apple + /// Dock takes its saved layout (when that is turned on), and the popup + /// moves to its page. + func activate(profileID: String) { + guard let profile = configuration.profiles.first(where: { $0.id == profileID }) else { return } + if configuration.activeProfileID != profileID { + update { $0.activeProfileID = profileID } + } + if configuration.appleDockLayouts, let layout = profile.appleDock { + appleDock.apply(layout) + } + onProfileActivated?(profile) + announce(profile) + } + + /// For URLs, the CLI, and Shortcuts: an id, a name, or a 1-based number. + @discardableResult + func activate(matching query: String) -> Bool { + guard let profile = configuration.profile(matching: query) else { return false } + activate(profileID: profile.id) + return true + } + + func activate(offset: Int) { + guard configuration.profiles.count > 1 else { return } + activate(profileID: configuration.profile(offsetFromActive: offset).id) + } + + private func announce(_ profile: DockProfile) { + announcedProfile = profile + announceTask?.cancel() + announceTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 1_400_000_000) + guard !Task.isCancelled else { return } + self?.announcedProfile = nil + } + } + + // MARK: Apple Dock layouts + + /// Saves what the Apple Dock shows now into a profile. False when its + /// layout could not be read; an empty save would unpin everything later. + @discardableResult + func captureAppleDock(into profileID: String) -> Bool { + let layout = appleDock.currentLayout() + guard !layout.isEmpty else { + lastError = String(localized: "Couldn't read the Apple Dock's layout, so nothing was saved.") + return false + } + updateProfile(profileID) { $0.appleDock = layout } + return true + } + + /// `barshelf dock restore-apple-dock` while BarShelf runs: the app owns + /// dock.json then, so the CLI asks it instead of editing the file. + func restoreAppleDockOnRequest() { + if configuration.mode == .replace { + update { $0.mode = .alongside } + } else if configuration.appleDockBackup != nil { + restoreAppleDock() + save() + } else { + appleDock.unhideWithoutBackup() + } + } + + /// Puts a profile's saved layout into the Apple Dock now. + func applyAppleDockLayout(of profileID: String) { + guard let layout = configuration.profiles.first(where: { $0.id == profileID })?.appleDock else { return } + appleDock.apply(layout) + } + + /// The Apple Dock's apps, folders, and files as BarShelf Dock items. + func appleDockItems() -> [DockItem] { + let layout = appleDock.currentLayout() + func items(_ tiles: [Any]) -> [DockItem] { + tiles.compactMap { tile in + if AppleDockTiles.isSpacer(tile) { return DockItem(kind: .spacer) } + guard let url = AppleDockTiles.fileURL(of: tile) else { return nil } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { return nil } + return DockItem.forFile(at: url, isDirectory: isDirectory.boolValue) + } + } + let apps = items(layout.appTiles) + let others = items(layout.otherTiles) + return others.isEmpty ? apps : apps + [DockItem(kind: .separator)] + others + } + + /// Follows the Apple Dock's icon size when that is turned on. Its size + /// changes in System Settings without telling anyone, so this is called + /// at launch and whenever apps or Spaces change. + func syncSizeWithAppleDock() { + guard configuration.matchAppleDockSize, let size = appleDock.tileSize, + abs(size - configuration.tileSize) >= 0.5 else { return } + update { $0.tileSize = size } + } + + // MARK: Hiding the Apple Dock (`replace`) + + private func hideAppleDock() { + guard let before = appleDock.hide() else { return } + // Only the first backup counts: it holds the user's own settings. + if configuration.appleDockBackup == nil { + configuration.appleDockBackup = before + } + } + + private func restoreAppleDock() { + guard let backup = configuration.appleDockBackup else { return } + appleDock.restore(backup) + configuration.appleDockBackup = nil + } + + /// At launch: `replace` hides the Apple Dock again (it was shown at quit, + /// or something put it back); anything else restores a backup left + /// behind by a crash. + func reconcileAppleDockAtLaunch() { + let before = configuration + if configuration.mode == .replace { + hideAppleDock() + } else { + restoreAppleDock() + } + // Someone who never opened the Dock page gets no dock.json. + if configuration != before { save() } + } + + /// At quit the Apple Dock comes back, so the Mac is never left without a + /// dock while BarShelf is not running. Launch hides it again. + func restoreAppleDockForTermination() { + guard configuration.mode == .replace, configuration.appleDockBackup != nil else { return } + restoreAppleDock() + save() + } + + // MARK: Persistence + + private func save() { + do { + try configuration.save(to: fileURL) + lastError = nil + } catch { + lastError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } + } +} diff --git a/Sources/MenubucketApp/Dock/DockView.swift b/Sources/MenubucketApp/Dock/DockView.swift new file mode 100644 index 0000000..2fd1a52 --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockView.swift @@ -0,0 +1,883 @@ +import AppKit +import MenubucketCore +import SwiftUI +import UniformTypeIdentifiers + +/// One thing drawn in the dock: a profile item, or something the dock adds +/// on its own (running apps, the Trash). +enum DockTile: Identifiable, Equatable { + case item(DockItem) + case running(RunningApps.App) + /// Used lately, not in the profile and not open. + case recent(String) + case divider(String) + case trash + + var id: String { + switch self { + case .item(let item): return item.id + case .running(let app): return "running:\(app.path)" + case .recent(let path): return "recent:\(path)" + case .divider(let id): return "divider:\(id)" + case .trash: return "trash" + } + } + + /// Icons grow under the pointer; widgets, spacers, and dividers do not. + var magnifies: Bool { + switch self { + case .item(let item): + switch item.kind { + case .widget, .spacer, .separator: return false + default: return true + } + case .running, .recent, .trash: return true + case .divider: return false + } + } +} + +/// The BarShelf Dock's content (R15). +struct DockView: View { + @ObservedObject var store: DockStore + @ObservedObject var runtime: WidgetRuntime + @ObservedObject var running: RunningApps + /// The pointer came onto or left the icons; the panel measures the bar + /// only at rest, since magnified sizes would make it chase the pointer. + let onHoverChange: (Bool) -> Void + let onOpenSettings: () -> Void + + @State private var hoveredID: String? + @State private var dropTargetID: String? + /// The folder whose grid is open above the dock. + @State private var openFolderID: String? + + static let dragPrefix = "barshelf-dock-item:" + + private var config: DockConfiguration { store.configuration } + private var edge: DockConfiguration.Edge { config.edge } + private var tileSize: CGFloat { CGFloat(config.tileSize) } + private var isClassic: Bool { config.style == .classic } + private var spacing: CGFloat { isClassic ? max(2, tileSize * 0.08) : max(6, tileSize * 0.16) } + + // MARK: Tiles + + var tiles: [DockTile] { + Self.tiles(for: store.configuration, running: running.apps, recent: running.recent) + } + + /// The profile's items, then running apps not among them, then the Trash. + static func tiles( + for config: DockConfiguration, + running: [RunningApps.App], + recent: [String] = [], + exists: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) } + ) -> [DockTile] { + let items = config.activeProfile.items + var tiles = items.map(DockTile.item) + var shownApps = Set(items.compactMap { item -> String? in + if case .app(let path) = item.kind { return RunningApps.key(path) } + return nil + }) + if config.showRunningApps { + let pinned = Set(items.compactMap { item -> String? in + if case .app(let path) = item.kind { return RunningApps.key(path) } + return nil + }) + let extra = running.filter { !pinned.contains($0.path) && $0.bundleID != Bundle.main.bundleIdentifier } + if !extra.isEmpty { + if !tiles.isEmpty { tiles.append(.divider("running")) } + tiles += extra.map(DockTile.running) + } + } + shownApps.formUnion(running.map(\.path)) + if config.showRecentApps { + let recents = recent + .filter { !shownApps.contains($0) && exists($0) } + .prefix(DockConfiguration.recentAppLimit) + if !recents.isEmpty { + if !tiles.isEmpty { tiles.append(.divider("recent")) } + tiles += recents.map { DockTile.recent($0) } + } + } + if config.showTrash { + if !tiles.isEmpty { tiles.append(.divider("trash")) } + tiles.append(.trash) + } + return tiles + } + + private func scale(at index: Int, in tiles: [DockTile]) -> CGFloat { + guard isClassic, config.magnification, let hoveredID, + let hovered = tiles.firstIndex(where: { $0.id == hoveredID }), + tiles[hovered].magnifies, tiles[index].magnifies + else { return 1 } + let distance = CGFloat(abs(index - hovered)) + return 1 + Self.magnification(for: config) * max(0, 1 - distance / 2.5) + } + + /// How much the hovered icon grows, as a fraction of its size; 0 when + /// this dock does not magnify. + static func magnification(for config: DockConfiguration) -> CGFloat { + guard config.style == .classic, config.magnification else { return 0 } + return CGFloat(config.magnificationAmount) + } + + // MARK: Body + + var body: some View { + let tiles = self.tiles + let layout = edge.isVertical + ? AnyLayout(VStackLayout(alignment: edge == .left ? .leading : .trailing, spacing: spacing)) + : AnyLayout(HStackLayout(alignment: .bottom, spacing: spacing)) + layout { + ForEach(Array(tiles.enumerated()), id: \.element.id) { index, tile in + tileView(tile, scale: scale(at: index, in: tiles)) + } + if tiles.isEmpty { emptyHint } + } + .padding(barPadding) + .background { barBackground } + .contentShape(Rectangle()) + .onAppear { DockMenuRouter.shared.bar = { barMenu } } + .onDrop(of: DockDrop.acceptedTypes, isTargeted: nil) { providers in + DockDrop.receive(providers, store: store, before: nil) + } + .overlay(alignment: edge == .bottom ? .top : .center) { profileBanner } + .fixedSize() + .onChange(of: hoveredID == nil) { _, resting in onHoverChange(!resting) } + .animation(.spring(response: 0.22, dampingFraction: 0.82), value: hoveredID) + .animation(.easeInOut(duration: 0.2), value: tiles.map(\.id)) + .onChange(of: tiles.map(\.id)) { _, ids in + DockMenuRouter.shared.keep(only: Set(ids)) + if let hoveredID, !ids.contains(hoveredID) { self.hoveredID = nil } + } + // The panel is larger than the bar (room for magnified icons and + // labels); the bar sits on the screen edge inside it. + .padding(edgeInsets) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: edgeAlignment) + } + + private var edgeAlignment: Alignment { + switch edge { + case .bottom: return .bottom + case .left: return .leading + case .right: return .trailing + } + } + + private var edgeInsets: EdgeInsets { + let gap = DockPanelController.edgeGap + switch edge { + case .bottom: return EdgeInsets(top: 0, leading: 0, bottom: gap, trailing: 0) + case .left: return EdgeInsets(top: 0, leading: gap, bottom: 0, trailing: 0) + case .right: return EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: gap) + } + } + + /// Measured off the Apple Dock (macOS 26–27): about a fifth of an icon + /// around the icons, and corners a fifth of the bar's thickness. + private var barPadding: CGFloat { isClassic ? max(4, tileSize * 0.19) : max(8, tileSize * 0.18) } + private var barCornerRadius: CGFloat { isClassic ? (tileSize + barPadding * 2) * 0.2 : 18 } + + @ViewBuilder + private var barBackground: some View { + let shape = RoundedRectangle(cornerRadius: barCornerRadius, style: .continuous) + if #available(macOS 26.0, *), isClassic { + // AppKit's clear glass, the see-through Liquid Glass the Apple + // Dock shows. Regular glass and the window materials came out as + // a flat light-grey slab in this never-active panel. + DockGlass(cornerRadius: barCornerRadius) + } else { + shape + .fill(isClassic ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(.regularMaterial)) + .overlay(shape.strokeBorder(Color.primary.opacity(0.12), lineWidth: 0.5)) + .shadow(color: .black.opacity(0.18), radius: 10, y: 2) + } + } + + private var emptyHint: some View { + Text("Drop apps, folders, or files here") + .font(.callout) + .foregroundStyle(.secondary) + .frame(height: tileSize) + .padding(.horizontal, Spacing.s) + } + + @ViewBuilder + private var profileBanner: some View { + if let profile = store.announcedProfile { + Label(profile.name, systemImage: profile.symbol) + .font(.headline) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(.regularMaterial, in: Capsule()) + .offset(y: edge == .bottom ? -44 : 0) + .transition(.opacity.combined(with: .scale(scale: 0.9))) + .allowsHitTesting(false) + .accessibilityLabel(Text("Dock profile: \(profile.name)")) + } + } + + // MARK: Tile views + + @ViewBuilder + private func tileView(_ tile: DockTile, scale: CGFloat) -> some View { + switch tile { + case .item(let item): + itemView(item, scale: scale) + case .running(let app): + iconTile( + id: tile.id, + title: DockActions.fileDisplayName(at: app.path), + image: DockActions.fileIcon(at: app.path), + isRunning: true, scale: scale, + action: { DockActions.openApp(path: app.path) }, + dropFiles: { urls in DockActions.open(urls, withAppAt: app.path) }, + menu: { runningAppMenu(app) } + ) + case .recent(let path): + iconTile( + id: tile.id, + title: DockActions.fileDisplayName(at: path), + image: DockActions.fileIcon(at: path), + isRunning: false, scale: scale, + isLaunching: running.isLaunching(path: path), + action: { DockActions.openApp(path: path) }, + dropFiles: { urls in DockActions.open(urls, withAppAt: path) }, + menu: { recentAppMenu(path) } + ) + case .divider: + divider + case .trash: + iconTile( + id: tile.id, title: String(localized: "Trash"), + image: NSImage(named: NSImage.trashEmptyName), isRunning: false, scale: scale, + action: DockActions.openTrash, + dropFiles: DockActions.moveToTrash, + menu: { trashMenu } + ) + } + } + + @ViewBuilder + private func itemView(_ item: DockItem, scale: CGFloat) -> some View { + Group { + switch item.kind { + case .widget(let id): + widgetTile(item: item, widgetID: id) + case .spacer: + Color.clear + .frame( + width: edge.isVertical ? tileSize : tileSize * 0.5, + height: edge.isVertical ? tileSize * 0.5 : tileSize + ) + .contentShape(Rectangle()) + case .separator: + divider + case .app(let path): + iconTile( + id: item.id, title: DockActions.displayName(for: item), + image: DockActions.icon(for: item), + isRunning: running.isRunning(path: path), scale: scale, + isLaunching: running.isLaunching(path: path), + action: { DockActions.open(item) }, + dropFiles: { urls in DockActions.open(urls, withAppAt: path) }, + menu: { itemMenu(item) } + ) + case .folder(let path, let color, let label): + iconTile( + id: item.id, title: DockActions.displayName(for: item), + image: color == nil ? DockActions.icon(for: item) : nil, + isRunning: false, scale: scale, + action: { + if config.folderView == .grid { + openFolderID = openFolderID == item.id ? nil : item.id + } else { + Self.showFolderMenu(path: path) + } + }, + dropFiles: nil, + custom: color.map { color in + AnyView(FolderBadge(color: color, label: label ?? DockActions.displayName(for: item))) + }, + menu: { itemMenu(item) } + ) + .popover( + isPresented: Binding( + get: { openFolderID == item.id }, + set: { if !$0, openFolderID == item.id { openFolderID = nil } } + ), + arrowEdge: edge == .bottom ? .top : edge == .left ? .trailing : .leading + ) { + DockFolderStack(root: URL(fileURLWithPath: path, isDirectory: true)) { + openFolderID = nil + } + } + case .link(let url, _): + iconTile( + id: item.id, title: DockActions.displayName(for: item), + image: nil, isRunning: false, scale: scale, + action: { DockActions.open(item) }, dropFiles: nil, + custom: AnyView(LinkBadge(url: url)), + menu: { itemMenu(item) } + ) + case .file, .shortcut: + iconTile( + id: item.id, title: DockActions.displayName(for: item), + image: DockActions.icon(for: item), isRunning: false, scale: scale, + action: { DockActions.open(item) }, dropFiles: nil, + menu: { itemMenu(item) } + ) + } + } + .modifier(OuterItemMenu(item: item, menu: { itemMenu(item) })) + .onDrag { + NSItemProvider(object: "\(Self.dragPrefix)\(item.id)" as NSString) + } + .onDrop(of: DockDrop.acceptedTypes, isTargeted: Binding( + get: { dropTargetID == item.id }, + set: { dropTargetID = $0 ? item.id : (dropTargetID == item.id ? nil : dropTargetID) } + )) { providers in + DockDrop.receive(providers, store: store, before: item.id) + } + .overlay(alignment: edge.isVertical ? .top : .leading) { + if dropTargetID == item.id { + RoundedRectangle(cornerRadius: 1.5) + .fill(Color.accentColor) + .frame( + width: edge.isVertical ? nil : 3, + height: edge.isVertical ? 3 : nil + ) + .offset(x: edge.isVertical ? 0 : -spacing / 2 - 1.5, y: edge.isVertical ? -spacing / 2 - 1.5 : 0) + .allowsHitTesting(false) + } + } + } + + private var divider: some View { + Rectangle() + .fill(Color.primary.opacity(0.22)) + .frame( + width: edge.isVertical ? tileSize * 0.8 : 1, + height: edge.isVertical ? 1 : tileSize * 0.8 + ) + .padding(edge.isVertical ? .vertical : .horizontal, 2) + .frame(maxHeight: edge.isVertical ? nil : tileSize, alignment: .center) + } + + /// An app, folder, file, link, Shortcut, or the Trash. + private func iconTile( + id: String, + title: String, + image: NSImage?, + isRunning: Bool, + scale: CGFloat, + isLaunching: Bool = false, + action: @escaping () -> Void, + dropFiles: (([URL]) -> Void)?, + custom: AnyView? = nil, + menu: @escaping () -> [DockMenuEntry] + ) -> some View { + let grown = tileSize * scale + let anchor: UnitPoint = switch edge { + case .bottom: .bottom + case .left: .leading + case .right: .trailing + } + return Button(action: action) { + VStack(spacing: 3) { + Group { + if let custom { + custom + } else if let image { + Image(nsImage: image).resizable().interpolation(.high) + } else { + Image(systemName: "questionmark.app.dashed").resizable().foregroundStyle(.secondary) + } + } + .frame(width: tileSize, height: tileSize) + .scaleEffect(scale, anchor: anchor) + .modifier(LaunchBounce(isActive: isLaunching, edge: edge, height: tileSize * 0.45)) + .frame( + width: edge.isVertical ? tileSize : grown, + height: edge.isVertical ? grown : tileSize, + alignment: Alignment(horizontal: edge == .left ? .leading : edge == .right ? .trailing : .center, + vertical: edge == .bottom ? .bottom : .center) + ) + if !isClassic { + Text(title) + .font(.system(size: max(9, tileSize * 0.2))) + .lineLimit(1) + .truncationMode(.middle) + .frame(width: tileSize * 1.3) + .foregroundStyle(.secondary) + } + } + .overlay(alignment: runningDotAlignment) { + if isRunning, config.showIndicators { + Circle() + .fill(Color.primary.opacity(0.7)) + .frame(width: isClassic ? 3 : 4, height: isClassic ? 3 : 4) + .offset(runningDotOffset) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(DockPressStyle()) + // On the button itself: it is the accessibility element. + .dockMenu(id: id, menu) + .overlay(alignment: labelAlignment) { + if isClassic, hoveredID == id { + HoverLabel(title: title) + .fixedSize() + .offset(labelOffset(grown: grown)) + .allowsHitTesting(false) + } + } + .onHover { inside in + if inside { hoveredID = id } else if hoveredID == id { hoveredID = nil } + } + .modifier(FileDropModifier(store: store, addsFolders: id != DockTile.trash.id, dropFiles: dropFiles)) + .accessibilityLabel(Text(title)) + .accessibilityValue(isRunning ? Text("Running") : Text("")) + } + + private var runningDotAlignment: Alignment { + switch edge { + case .bottom: return .bottom + case .left: return .leading + case .right: return .trailing + } + } + + private var runningDotOffset: CGSize { + // In the middle of the bar's padding, as on the Apple Dock. + let gap = isClassic ? barPadding * 0.55 + 1.5 : 2 + switch edge { + case .bottom: return CGSize(width: 0, height: isClassic ? gap : gap + 2) + case .left: return CGSize(width: -gap, height: 0) + case .right: return CGSize(width: gap, height: 0) + } + } + + private var labelAlignment: Alignment { + switch edge { + case .bottom: return .top + case .left: return .trailing + case .right: return .leading + } + } + + private func labelOffset(grown: CGFloat) -> CGSize { + let lift = grown - tileSize + 30 + switch edge { + case .bottom: return CGSize(width: 0, height: -lift) + case .left: return CGSize(width: lift + 40, height: 0) + case .right: return CGSize(width: -lift - 40, height: 0) + } + } + + // MARK: Widgets + + private func widgetFrame(for widgetID: String) -> CGSize { + let thickness = CGFloat(config.widgetSize) + let size = runtime.effectiveSize(for: widgetID) + if edge.isVertical { + let factor: CGFloat = switch size { + case "XS": 0.6 + case "S": 0.8 + case "L": 1.5 + default: 1.0 + } + return CGSize(width: thickness * 2, height: thickness * factor) + } + let factor: CGFloat = switch size { + case "XS", "S": 1.6 + case "L": 3.0 + default: 2.4 + } + return CGSize(width: thickness * factor, height: thickness) + } + + @ViewBuilder + private func widgetTile(item: DockItem, widgetID: String) -> some View { + let frame = widgetFrame(for: widgetID) + let shape = RoundedRectangle(cornerRadius: 12, style: .continuous) + if isClassic, let widget = runtime.widgets.first(where: { $0.id == widgetID }) { + // Classic keeps the Apple Dock's height: a glance, not a card. + DockWidgetGlance(widget: widget, runtime: runtime, height: tileSize) + .frame(width: tileSize * DockWidgetGlance.widthFactor(size: runtime.effectiveSize(for: widgetID))) + .contentShape(Rectangle()) + .onTapGesture { DockActions.open(item) } + .help(widget.displayName) + } else if let widget = runtime.widgets.first(where: { $0.id == widgetID }) { + WidgetCardView(widget: widget, runtime: runtime, compactHeight: frame.height, placement: .single) + .frame(width: frame.width, height: frame.height) + .clipShape(shape) + .overlay(shape.strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)) + } else { + VStack(spacing: Spacing.xxs) { + Image(systemName: "questionmark.square.dashed").font(.title2) + Text("Widget not installed").font(.caption) + } + .foregroundStyle(.secondary) + .frame(width: frame.width, height: frame.height) + .background(Color.primary.opacity(0.05), in: shape) + } + } + + // MARK: Menus + + private func itemMenu(_ item: DockItem) -> [DockMenuEntry] { + var entries: [DockMenuEntry] = [] + switch item.kind { + case .app(let path): + entries.append(.action(String(localized: "Open")) { DockActions.open(item) }) + if let app = running.runningApplication(path: path) { + entries.append(.action(String(localized: "Hide")) { app.hide() }) + entries.append(.action(String(localized: "Quit")) { app.terminate() }) + } + entries.append(.action(String(localized: "Show in Finder")) { DockActions.revealInFinder(path: path) }) + case .folder(let path, let color, let label): + entries.append(.action(String(localized: "Open")) { DockActions.open(item) }) + entries.append(.action(String(localized: "Show in Finder")) { DockActions.revealInFinder(path: path) }) + let recolor: (DockItem.FolderColor?) -> Void = { option in + store.replaceItem(DockItem(id: item.id, kind: .folder(path: path, color: option, label: label))) + } + entries.append(.submenu( + String(localized: "Color"), + [.action(String(localized: "None"), checked: color == nil) { recolor(nil) }] + + DockItem.FolderColor.allCases.map { option in + .action(FolderBadge.name(option), checked: option == color) { recolor(option) } + } + )) + case .file(let path): + entries.append(.action(String(localized: "Open")) { DockActions.open(item) }) + entries.append(.action(String(localized: "Show in Finder")) { DockActions.revealInFinder(path: path) }) + case .link(let url, _): + entries.append(.action(String(localized: "Open")) { DockActions.open(item) }) + entries.append(.action(String(localized: "Copy Link")) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url, forType: .string) + }) + case .shortcut: + entries.append(.action(String(localized: "Run Shortcut")) { DockActions.open(item) }) + entries.append(.action(String(localized: "Open Shortcuts")) { + if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.shortcuts") { + DockActions.openApp(path: url.path) + } + }) + case .widget(let id): + entries.append(.action(String(localized: "Show in BarShelf")) { DockActions.open(item) }) + entries.append(.action(String(localized: "Refresh")) { runtime.refresh(widgetID: id) }) + case .spacer, .separator: + break + } + entries.append(.divider) + entries.append(.action(String(localized: "Remove from Dock"), destructive: true) { store.removeItem(item.id) }) + entries.append(.divider) + return DockMenuEntry.tidy(entries + barMenu) + } + + private func runningAppMenu(_ app: RunningApps.App) -> [DockMenuEntry] { + var entries: [DockMenuEntry] = [ + .action(String(localized: "Keep in Dock")) { store.addItems([DockItem(kind: .app(path: app.path))]) }, + ] + if let running = NSRunningApplication(processIdentifier: app.processID) { + entries.append(.action(String(localized: "Hide")) { running.hide() }) + entries.append(.action(String(localized: "Quit")) { running.terminate() }) + } + entries.append(.action(String(localized: "Show in Finder")) { DockActions.revealInFinder(path: app.path) }) + return DockMenuEntry.tidy(entries + [.divider] + barMenu) + } + + private func recentAppMenu(_ path: String) -> [DockMenuEntry] { + DockMenuEntry.tidy([ + .action(String(localized: "Open")) { DockActions.openApp(path: path) }, + .action(String(localized: "Keep in Dock")) { store.addItems([DockItem(kind: .app(path: path))]) }, + .action(String(localized: "Show in Finder")) { DockActions.revealInFinder(path: path) }, + .divider, + ] + barMenu) + } + + private var trashMenu: [DockMenuEntry] { + DockMenuEntry.tidy([.action(String(localized: "Open")) { DockActions.openTrash() }, .divider] + barMenu) + } + + /// Profiles (when there is more than one) and the dock's settings. + private var barMenu: [DockMenuEntry] { + var entries: [DockMenuEntry] = [] + if config.profiles.count > 1 { + entries.append(.submenu( + String(localized: "Profile"), + config.profiles.map { profile in + .action(profile.name, symbol: profile.symbol, checked: profile.id == config.activeProfileID) { + store.activate(profileID: profile.id) + } + } + )) + } + entries.append(.action(String(localized: "Dock Settings…")) { onOpenSettings() }) + return entries + } + + static func showFolderMenu(path: String) { + DockActions.folderMenu(path: path).popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil) + } +} + +// MARK: - Pieces + +private struct DockPressStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .brightness(configuration.isPressed ? -0.25 : 0) + } +} + +private struct HoverLabel: View { + let title: String + + var body: some View { + Text(title) + .font(.system(size: 13)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(.regularMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.primary.opacity(0.1), lineWidth: 0.5)) + } +} + +/// A folder drawn as a coloured tile with a letter or two. +struct FolderBadge: View { + let color: DockItem.FolderColor + let label: String + + static func color(_ color: DockItem.FolderColor) -> Color { + switch color { + case .blue: return .blue + case .purple: return .purple + case .pink: return .pink + case .red: return .red + case .orange: return .orange + case .yellow: return .yellow + case .green: return .green + case .gray: return .gray + } + } + + static func name(_ color: DockItem.FolderColor) -> String { + switch color { + case .blue: return String(localized: "Blue") + case .purple: return String(localized: "Purple") + case .pink: return String(localized: "Pink") + case .red: return String(localized: "Red") + case .orange: return String(localized: "Orange") + case .yellow: return String(localized: "Yellow") + case .green: return String(localized: "Green") + case .gray: return String(localized: "Gray") + } + } + + var body: some View { + GeometryReader { proxy in + let side = min(proxy.size.width, proxy.size.height) + let base = Self.color(color) + RoundedRectangle(cornerRadius: side * 0.24, style: .continuous) + .fill(LinearGradient(colors: [base.opacity(0.85), base], startPoint: .top, endPoint: .bottom)) + .overlay(alignment: .center) { + Text(String(label.prefix(2)).uppercased()) + .font(.system(size: side * 0.42, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .minimumScaleFactor(0.5) + .padding(side * 0.08) + } + .overlay(alignment: .topLeading) { + Image(systemName: "folder.fill") + .font(.system(size: side * 0.2)) + .foregroundStyle(.white.opacity(0.75)) + .padding(side * 0.1) + } + .padding(side * 0.06) + } + } +} + +/// A web link drawn as a globe tile with the site's first letter. +private struct LinkBadge: View { + let url: String + + var body: some View { + GeometryReader { proxy in + let side = min(proxy.size.width, proxy.size.height) + let host = URL(string: url)?.host?.replacingOccurrences(of: "www.", with: "") ?? url + RoundedRectangle(cornerRadius: side * 0.24, style: .continuous) + .fill(LinearGradient(colors: [.teal, .blue], startPoint: .topLeading, endPoint: .bottomTrailing)) + .overlay { + Text(String(host.prefix(1)).uppercased()) + .font(.system(size: side * 0.46, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + } + .overlay(alignment: .bottomTrailing) { + Image(systemName: "globe") + .font(.system(size: side * 0.2, weight: .semibold)) + .foregroundStyle(.white.opacity(0.85)) + .padding(side * 0.1) + } + .padding(side * 0.06) + } + } +} + +// MARK: - Drag and drop + +/// Files dropped on an app open in it, or on the Trash go to it. Only tiles +/// that take files get the handler, so the rest pass drops to the dock. +private struct FileDropModifier: ViewModifier { + let store: DockStore + /// Apps and folders dropped on an app are being added, not opened; on + /// the Trash they are thrown away like anything else. + let addsFolders: Bool + let dropFiles: (([URL]) -> Void)? + + func body(content: Content) -> some View { + if let dropFiles { + content.onDrop(of: [.fileURL], isTargeted: nil) { providers in + DockDrop.loadFileURLs(providers) { urls in + if addsFolders, urls.allSatisfy(\.hasDirectoryPath) { + store.addItems(urls.map { DockItem.forFile(at: $0, isDirectory: true) }) + } else { + dropFiles(urls) + } + } + } + } else { + content + } + } +} + +enum DockDrop { + static let acceptedTypes: [UTType] = [.fileURL, .url, .text] + + /// Files become items, web links become link items, and a dragged dock + /// item moves. `before` is the item the drop landed on, if any. + static func receive(_ providers: [NSItemProvider], store: DockStore, before: String?) -> Bool { + if providers.contains(where: { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) }) { + return loadFileURLs(providers) { urls in + let items = urls.map { url -> DockItem in + var isDirectory: ObjCBool = false + FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) + return DockItem.forFile(at: url, isDirectory: isDirectory.boolValue) + } + store.addItems(items, before: before) + } + } + if let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.url.identifier) }) { + _ = provider.loadObject(ofClass: URL.self) { url, _ in + guard let url, let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme) else { return } + DispatchQueue.main.async { + store.addItems([DockItem(kind: .link(url: url.absoluteString, title: url.host))], before: before) + } + } + return true + } + if let provider = providers.first(where: { $0.canLoadObject(ofClass: NSString.self) }) { + _ = provider.loadObject(ofClass: NSString.self) { value, _ in + guard let text = value as? String, text.hasPrefix(DockView.dragPrefix) else { return } + let id = String(text.dropFirst(DockView.dragPrefix.count)) + DispatchQueue.main.async { store.moveItem(id, before: before) } + } + return true + } + return false + } + + /// Loads every file URL among the providers, then calls back on main. + @discardableResult + static func loadFileURLs(_ providers: [NSItemProvider], completion: @escaping ([URL]) -> Void) -> Bool { + let fileProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) } + guard !fileProviders.isEmpty else { return false } + let group = DispatchGroup() + let lock = NSLock() + var urls: [(Int, URL)] = [] + for (index, provider) in fileProviders.enumerated() { + group.enter() + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { value, _ in + defer { group.leave() } + let url: URL? = if let data = value as? Data { + URL(dataRepresentation: data, relativeTo: nil) + } else { + value as? URL + } + guard let url else { return } + lock.lock() + urls.append((index, url)) + lock.unlock() + } + } + group.notify(queue: .main) { + completion(urls.sorted { $0.0 < $1.0 }.map(\.1)) + } + return true + } +} + +/// The system's Liquid Glass as an AppKit view, for the Classic bar. +@available(macOS 26.0, *) +private struct DockGlass: NSViewRepresentable { + let cornerRadius: CGFloat + + func makeNSView(context: Context) -> NSGlassEffectView { + let view = NSGlassEffectView() + view.style = .clear + view.cornerRadius = cornerRadius + return view + } + + func updateNSView(_ view: NSGlassEffectView, context: Context) { + view.cornerRadius = cornerRadius + } +} + + +/// Widgets, spaces, and dividers have no button of their own, so their menu +/// hangs on the item; icon tiles carry theirs on the button. +private struct OuterItemMenu: ViewModifier { + let item: DockItem + let menu: () -> [DockMenuEntry] + + func body(content: Content) -> some View { + switch item.kind { + case .widget, .spacer, .separator: + content.dockMenu(id: item.id, menu) + default: + content + } + } +} + +/// An icon hopping off the dock while its app launches. +private struct LaunchBounce: ViewModifier { + let isActive: Bool + let edge: DockConfiguration.Edge + let height: CGFloat + + func body(content: Content) -> some View { + if isActive { + TimelineView(.animation) { context in + // Hops of 0.6 s, easing at the top like a thrown ball. + let phase = context.date.timeIntervalSinceReferenceDate + .truncatingRemainder(dividingBy: 0.6) / 0.6 + let lift = height * CGFloat(sin(phase * .pi)) + content.offset( + x: edge == .left ? lift : edge == .right ? -lift : 0, + y: edge == .bottom ? -lift : 0 + ) + } + } else { + content + } + } +} diff --git a/Sources/MenubucketApp/Dock/DockWidgetGlance.swift b/Sources/MenubucketApp/Dock/DockWidgetGlance.swift new file mode 100644 index 0000000..d835f4b --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockWidgetGlance.swift @@ -0,0 +1,97 @@ +import MenubucketCore +import SwiftUI + +/// A widget at icon height, for the Classic dock: its name, its main value, +/// and up to two bars, the way the desktop widgets boil a view down (R14-B +/// `SharedShelf.summarize`). The full card lives in the Shelf style and the +/// popup; squeezing it to icon height would only crop it. +struct DockWidgetGlance: View { + let widget: LoadedWidget + let height: CGFloat + @ObservedObject private var model: WidgetCardModel + + init(widget: LoadedWidget, runtime: WidgetRuntime, height: CGFloat) { + self.widget = widget + self.height = height + _model = ObservedObject(wrappedValue: runtime.cardModel(for: widget.id)) + } + + /// How wide a glance is for the widget's chosen size, in icon heights. + static func widthFactor(size: String) -> CGFloat { + switch size { + case "XS", "S": return 2.2 + case "L": return 3.6 + default: return 2.8 + } + } + + private var summary: SharedShelf.Summary? { + model.snapshot.viewTree.map { SharedShelf.summarize($0, fallbackTitle: widget.displayName) } + } + + var body: some View { + let summary = self.summary + let metrics = Array((summary?.metrics ?? []).prefix(2)) + VStack(alignment: .leading, spacing: height * 0.06) { + HStack(spacing: 4) { + if let symbol = summary?.symbol { + Image(systemName: symbol).imageScale(.small) + } + // The widget's own name: a summary's first text can be a + // reading (System's load average came out as its "title"). + Text(widget.displayName) + .lineLimit(1) + } + .font(.system(size: max(8, height * 0.2), weight: .medium)) + .foregroundStyle(.secondary) + + if model.snapshot.error != nil { + Label("Error", systemImage: "exclamationmark.triangle.fill") + .font(.system(size: max(9, height * 0.26), weight: .semibold)) + .foregroundStyle(.orange) + } else if !metrics.isEmpty { + ForEach(Array(metrics.enumerated()), id: \.offset) { _, metric in + meter(metric) + } + } else { + Text(summary?.value ?? "—") + .font(.system(size: max(10, height * 0.36), weight: .semibold, design: .rounded)) + .foregroundStyle(nodeColor(summary?.tone) ?? .primary) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + } + .padding(.horizontal, height * 0.16) + .frame(height: height, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } + + private func meter(_ metric: SharedShelf.Metric) -> some View { + let barHeight = max(3, height * 0.08) + return HStack(spacing: 4) { + if let label = metric.label { + Text(label) + .font(.system(size: max(8, height * 0.18))) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize() + } + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(Color.primary.opacity(0.12)) + Capsule() + .fill(nodeColor(metric.tone) ?? .accentColor) + .frame(width: proxy.size.width * CGFloat(min(max(metric.fraction ?? 0, 0), 1))) + } + } + .frame(height: barHeight) + if let value = metric.value { + Text(value) + .font(.system(size: max(8, height * 0.2), weight: .semibold).monospacedDigit()) + .lineLimit(1) + .fixedSize() + } + } + } +} diff --git a/Sources/MenubucketApp/Hub/HubView.swift b/Sources/MenubucketApp/Hub/HubView.swift index a1f489d..e98b34c 100644 --- a/Sources/MenubucketApp/Hub/HubView.swift +++ b/Sources/MenubucketApp/Hub/HubView.swift @@ -40,6 +40,10 @@ struct HubView: View { private func sidebarRow(_ tab: HubTab) -> some View { Label(tab.title, systemImage: tab.symbol) .tag(tab) + // A press as well as a selection, so accessibility clients that + // press rather than select (automation, Switch Control) can + // change pages; the row did not answer AXPress before. + .accessibilityAction { model.tab = tab } } private var sidebarHeader: some View { @@ -76,6 +80,8 @@ struct HubView: View { ShelfView(runtime: runtime, model: model) case .menuBar: MenuBarPage(appPrefs: appPrefs, runtime: runtime) + case .dock: + DockSettingsPage(store: DockStore.shared, runtime: runtime) case .gallery: GalleryView(model: galleryModel, runtime: runtime) .onAppear { galleryModel.onWindowShown() } diff --git a/Sources/MenubucketApp/Hub/HubWindowController.swift b/Sources/MenubucketApp/Hub/HubWindowController.swift index af39068..8f06a04 100644 --- a/Sources/MenubucketApp/Hub/HubWindowController.swift +++ b/Sources/MenubucketApp/Hub/HubWindowController.swift @@ -9,14 +9,14 @@ import SwiftUI /// `widgets` keeps the Shelf's historical one. enum HubTab: String, CaseIterable, Identifiable { case shelf = "widgets" - case menuBar, gallery, create, automation + case menuBar, dock, gallery, create, automation case general, shortcuts, updates, privacy, advanced /// Former names, kept so existing callers read naturally. static let widgets = HubTab.shelf static let settings = HubTab.general - static let workspace: [HubTab] = [.shelf, .menuBar, .gallery, .create, .automation] + static let workspace: [HubTab] = [.shelf, .menuBar, .dock, .gallery, .create, .automation] static let settingsPages: [HubTab] = [.general, .shortcuts, .updates, .privacy, .advanced] var id: String { rawValue } @@ -25,6 +25,7 @@ enum HubTab: String, CaseIterable, Identifiable { switch self { case .shelf: return String(localized: "Shelf", comment: "Hub page: the widget shelf") case .menuBar: return String(localized: "Menu Bar") + case .dock: return String(localized: "Dock", comment: "Hub page: the BarShelf Dock") case .gallery: return String(localized: "Gallery") case .create: return String(localized: "Create", comment: "Hub page: build a widget") case .automation: return String(localized: "Automation") @@ -40,6 +41,7 @@ enum HubTab: String, CaseIterable, Identifiable { switch self { case .shelf: return "square.grid.2x2" case .menuBar: return "menubar.rectangle" + case .dock: return "dock.rectangle" case .gallery: return "sparkles.rectangle.stack" case .create: return "wand.and.stars" case .automation: return "keyboard" @@ -55,6 +57,7 @@ enum HubTab: String, CaseIterable, Identifiable { switch self { case .shelf: return String(localized: "Arrange your pages and widgets.") case .menuBar: return String(localized: "Choose what shows in the menu bar and how it looks.") + case .dock: return String(localized: "A dock for apps, folders, and widgets, with profiles to switch between.") case .gallery: return String(localized: "Find and install widgets.") case .create: return String(localized: "Build a widget from a command, a URL, a folder, or text.") case .automation: return String(localized: "Keyboard shortcuts and window control, imported from Hammerspoon.") diff --git a/Sources/MenubucketApp/InAppHotkeys.swift b/Sources/MenubucketApp/InAppHotkeys.swift new file mode 100644 index 0000000..2e9c5a5 --- /dev/null +++ b/Sources/MenubucketApp/InAppHotkeys.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Global shortcuts BarShelf's own features have claimed, so two of them do +/// not fight over one key. Carbon refuses a second registration of a +/// combination, and Automation stopped its whole script when one of its keys +/// was already a dock profile key. +/// +/// The user's Automation script wins: while it runs, the dock leaves its keys +/// alone and shows them as taken. +final class InAppHotkeys { + static let shared = InAppHotkeys() + + struct Key: Hashable { + let keyCode: UInt32 + let modifiers: UInt32 + } + + private(set) var automation: Set = [] + private var observers: [UUID: () -> Void] = [:] + + /// Automation is about to register these (or, with none, has stopped). + /// Observers run synchronously, so a key the dock held is free by the + /// time this returns. + func setAutomationKeys(_ keys: Set) { + guard keys != automation else { return } + automation = keys + observers.values.forEach { $0() } + } + + @discardableResult + func observe(_ handler: @escaping () -> Void) -> UUID { + let id = UUID() + observers[id] = handler + return id + } + + func removeObserver(_ id: UUID) { + observers.removeValue(forKey: id) + } +} diff --git a/Sources/MenubucketApp/Popover/ShelfMenus.swift b/Sources/MenubucketApp/Popover/ShelfMenus.swift index b678686..95aecd5 100644 --- a/Sources/MenubucketApp/Popover/ShelfMenus.swift +++ b/Sources/MenubucketApp/Popover/ShelfMenus.swift @@ -8,6 +8,7 @@ struct ShelfMoreMenu: View { let onCommand: (AppMenuCommand) -> Void /// So "Check for Updates…" turns into "Update to BarShelf X…". @ObservedObject private var updates = UpdateStatus.shared + @ObservedObject private var dockStore = DockStore.shared var body: some View { Menu { @@ -44,6 +45,22 @@ struct ShelfMoreMenu: View { Label(command.title, systemImage: command.symbol) } } + } else if command == .dock { + Menu { + let rows = AppMenu.dockProfiles(store: dockStore) + ForEach(rows) { row in + Toggle(isOn: Binding( + get: { row.isActive }, + set: { _ in dockStore.activate(profileID: row.id) } + )) { + Label(row.title, systemImage: row.symbol) + } + } + if !rows.isEmpty { Divider() } + Button(AppMenu.dockSettingsTitle) { onCommand(.dock) } + } label: { + Label(command.title, systemImage: command.symbol) + } } else { Button { onCommand(command) } label: { Label(command.title, systemImage: command.symbol) diff --git a/Sources/MenubucketApp/StatusItemController.swift b/Sources/MenubucketApp/StatusItemController.swift index 3287d44..4b3f812 100644 --- a/Sources/MenubucketApp/StatusItemController.swift +++ b/Sources/MenubucketApp/StatusItemController.swift @@ -21,6 +21,9 @@ final class StatusItemController: NSObject { /// Draws the live strip into the main item and owns the extra status items /// of widgets the user split out. private var menuBar: MenuBarController! + /// The BarShelf Dock on the screen edge, and its profile hotkeys (R15). + private var dockPanel: DockPanelController! + private var dockHotkeys: DockHotkeys! private var keyboardMonitor: Any? private var scrollMonitor: Any? private var cancellables: Set = [] @@ -80,6 +83,20 @@ final class StatusItemController: NSObject { self?.runtime.handleURLRefreshTrigger(widgetID: widgetID) } + // barshelf://dock?profile=Work — Shortcuts' Focus automations use it. + WidgetInstaller.shared.onDockRequest = { [weak self] query in + self?.handleDockRequest(query) + } + let dockStore = DockStore.shared + dockStore.onProfileActivated = { [weak self] profile in + self?.showPopupPage(named: profile.popupPage) + } + dockPanel = DockPanelController(store: dockStore, runtime: runtime) + dockPanel.onOpenSettings = { + Task { @MainActor in HubWindowController.shared.show(tab: .dock) } + } + dockHotkeys = DockHotkeys(store: dockStore) + // Register the app's single runtime so runtime-less hub shims work. // Construction is guaranteed on the main thread (applicationDidFinishLaunching). MainActor.assumeIsolated { @@ -294,6 +311,10 @@ final class StatusItemController: NSObject { Task { @MainActor in HubWindowController.shared.show(tab: .gallery) } case .menuBar: break // a submenu, not an action + case .dock: + // The submenu's "Dock Settings…" row. + popup.hide() + Task { @MainActor in HubWindowController.shared.show(runtime: runtime, tab: .dock) } case .openBarShelf: openHub(nil) case .settings: @@ -442,6 +463,38 @@ final class StatusItemController: NSObject { runtime.updateMenuBarPlacement(for: widgetID) { $0.enabled = false } } + // MARK: - Dock (R15) + + private func handleDockRequest(_ query: [URLQueryItem]) { + let store = DockStore.shared + for item in query { + switch item.name.lowercased() { + case "profile": + if let value = item.value, !store.activate(matching: value) { + NSLog("BarShelf: no dock profile matches \(value)") + } + case "next": + store.activate(offset: 1) + case "previous", "prev": + store.activate(offset: -1) + case "restore-apple-dock": + store.restoreAppleDockOnRequest() + default: + continue + } + return + } + } + + /// A profile's popup page, selected for the next time the popup opens + /// (or right away while it is open). + private func showPopupPage(named page: String?) { + guard let page else { return } + let pages = runtime.pages + guard let index = pages.firstIndex(where: { $0.group == page }) else { return } + pager.jump(to: index, pageCount: pages.count) + } + // MARK: - Global hotkey (Carbon RegisterEventHotKey — no a11y permission) /// Synchronizes externally loaded preferences. Interactive edits go diff --git a/Sources/MenubucketApp/WidgetInstaller.swift b/Sources/MenubucketApp/WidgetInstaller.swift index ec78ca9..7f00a00 100644 --- a/Sources/MenubucketApp/WidgetInstaller.swift +++ b/Sources/MenubucketApp/WidgetInstaller.swift @@ -274,6 +274,8 @@ final class WidgetInstaller { /// argument is the optional `widget` query item (`nil` → refresh all /// url-trigger widgets). var onRefreshRequest: ((_ widgetID: String?) -> Void)? + /// `barshelf://dock?profile=`, `?next`, `?previous` (R15). + var onDockRequest: ((_ query: [URLQueryItem]) -> Void)? /// Menu entry point: "Install Widget from URL…". func promptForURL() { @@ -303,6 +305,10 @@ final class WidgetInstaller { } return } + if route == "dock" { + onDockRequest?(components?.queryItems ?? []) + return + } if route == "refresh" { let widgetID = components?.queryItems? .first { $0.name == "widget" }? diff --git a/Sources/MenubucketApp/WidgetRuntime.swift b/Sources/MenubucketApp/WidgetRuntime.swift index ec9cbef..bb59b01 100644 --- a/Sources/MenubucketApp/WidgetRuntime.swift +++ b/Sources/MenubucketApp/WidgetRuntime.swift @@ -917,6 +917,9 @@ final class WidgetRuntime: ObservableObject { scheduler.configure(widgets: enabled) scheduler.setVisibleWidgetIDs(visibleWidgetIDs) syncMenuBar() + // `configure` may have dropped dock widgets it did not know yet. + dockWidgetIDs.formIntersection(seenIDs) + scheduler.setMenuBarWidgetIDs(menuBarWidgetIDs.union(dockWidgetIDs)) publishDesktopWidgetIndex() } @@ -1195,6 +1198,7 @@ final class WidgetRuntime: ObservableObject { scheduler.configure(widgets: widgets.filter { !prefs.isDisabled($0.id) }) setVisibleWidgetIDs(visibleWidgetIDs) syncMenuBar() + scheduler.setMenuBarWidgetIDs(menuBarWidgetIDs.union(dockWidgetIDs)) updateAttention() objectWillChange.send() if !flag, scriptDisableStopTokens[id] == nil { @@ -1487,7 +1491,7 @@ final class WidgetRuntime: ObservableObject { scheduler.setIntervalOverrides(intervalOverrides) let promoted = menuBarWidgetIDs guard promoted != previous else { return } - scheduler.setMenuBarWidgetIDs(promoted) + scheduler.setMenuBarWidgetIDs(promoted.union(dockWidgetIDs)) for id in promoted.subtracting(previous) { let widget = widgets.first { $0.id == id } let staleAfter = effectiveStaleAfter(widget?.manifest.refresh?.staleAfterSec) @@ -1656,11 +1660,35 @@ final class WidgetRuntime: ObservableObject { refreshWidgetsAwaitingVisibility() } + /// Widgets showing in the BarShelf Dock (R15). Like menu bar items they + /// are on screen whether or not the popup is open, so they poll at their + /// own cadence; an auto-hidden dock reports none. + private(set) var dockWidgetIDs: Set = [] + + func setDockWidgetIDs(_ ids: Set) { + let normalized = ids.intersection(widgets.map(\.id)) + guard normalized != dockWidgetIDs else { return } + let added = normalized.subtracting(dockWidgetIDs) + dockWidgetIDs = normalized + scheduler.setMenuBarWidgetIDs(menuBarWidgetIDs.union(normalized)) + for id in added { + guard let widget = widgets.first(where: { $0.id == id }) else { continue } + let snapshot = snapshots[id] ?? WidgetSnapshot(widgetID: id) + if snapshot.isStale(after: effectiveStaleAfter(widget.manifest.refresh?.staleAfterSec)) { + refresh(widget, manual: false) + } else if visibilityAwareWidgetIDs.contains(id), lastRefreshVisibility[id] == false { + refresh(widget, manual: false) + } + } + } + /// Whether this widget's card is on screen: the shelf is open on its page, - /// or it is showing in its own menu bar popover. Workflows read it as - /// `widget.visible` and use it to skip work nobody can see. + /// it is showing in its own menu bar popover, or it is in the dock. + /// Workflows read it as `widget.visible` and use it to skip work nobody + /// can see. func isCardVisible(_ widgetID: String) -> Bool { if menuBarPopoverWidgetID == widgetID { return true } + if dockWidgetIDs.contains(widgetID) { return true } return scheduler.popupIsOpen && visibleWidgetIDs.contains(widgetID) } diff --git a/Sources/MenubucketApp/main.swift b/Sources/MenubucketApp/main.swift index a087894..ecfcee7 100644 --- a/Sources/MenubucketApp/main.swift +++ b/Sources/MenubucketApp/main.swift @@ -23,6 +23,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { OnboardingWindowController.shared.showIfNeeded(runtime: runtime) } AutomationController.shared.startAtLaunch() + // `replace` hides the Apple Dock again; a crash's leftover backup is + // put back otherwise. + DockStore.shared.reconcileAppleDockAtLaunch() // Record that this build genuinely came up — after the status item // exists, so the receipt attests to the thing a user would look for // rather than to a process having been created. An updater waits for @@ -40,6 +43,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { AutomationController.shared.stopForTermination() + // Never leave the Mac without a dock while BarShelf is not running. + DockStore.shared.restoreAppleDockForTermination() } /// Minimal main menu carrying only the standard Edit commands. Not shown diff --git a/Sources/MenubucketCore/AppleDock.swift b/Sources/MenubucketCore/AppleDock.swift new file mode 100644 index 0000000..dd9f0ba --- /dev/null +++ b/Sources/MenubucketCore/AppleDock.swift @@ -0,0 +1,327 @@ +import Foundation + +/// The Apple Dock's pinned items as saved into a dock profile (R15). +/// +/// The `persistent-apps` and `persistent-others` arrays are kept verbatim — +/// bookmark data and all — as binary property lists, so putting a layout back +/// loses nothing. The names are only for showing what a profile holds. +public struct AppleDockLayout: Codable, Equatable, Sendable { + public var apps: Data + public var others: Data + public var capturedAt: Date + public var appNames: [String] + public var otherNames: [String] + + public init(appTiles: [Any], otherTiles: [Any], capturedAt: Date = Date()) { + apps = Self.encode(appTiles) + others = Self.encode(otherTiles) + self.capturedAt = capturedAt + appNames = AppleDockTiles.labels(of: appTiles) + otherNames = AppleDockTiles.labels(of: otherTiles) + } + + public var appTiles: [Any] { Self.decode(apps) } + /// Nothing readable: the prefs could not be read when it was saved, or + /// the stored data is damaged. + public var isEmpty: Bool { appTiles.isEmpty && otherTiles.isEmpty } + public var otherTiles: [Any] { Self.decode(others) } + + static func encode(_ tiles: [Any]) -> Data { + (try? PropertyListSerialization.data( + fromPropertyList: tiles, format: .binary, options: 0 + )) ?? Data() + } + + static func decode(_ data: Data) -> [Any] { + guard !data.isEmpty, + let value = try? PropertyListSerialization.propertyList(from: data, format: nil) + else { return [] } + return value as? [Any] ?? [] + } +} + +/// The Apple Dock's hide settings, as found before BarShelf hid it. `nil` +/// means the key was not set, which is how it is put back. +public struct AppleDockVisibility: Codable, Equatable, Sendable { + public var autohide: Bool? + public var autohideDelay: Double? + + public init(autohide: Bool?, autohideDelay: Double?) { + self.autohide = autohide + self.autohideDelay = autohideDelay + } +} + +/// Reading and building the Dock's tile dictionaries. Pure; no I/O. +public enum AppleDockTiles { + /// What a tile is called: its label, else the last part of its URL. + public static func labels(of tiles: [Any]) -> [String] { + tiles.compactMap { tile in + guard let tile = tile as? [String: Any] else { return nil } + let type = tile["tile-type"] as? String ?? "" + if type.hasSuffix("spacer-tile") { return nil } + let data = tile["tile-data"] as? [String: Any] ?? [:] + if let label = data["file-label"] as? String, !label.isEmpty { return label } + if let label = data["label"] as? String, !label.isEmpty { return label } + if let url = fileURL(of: tile) { + return (url.lastPathComponent as NSString).deletingPathExtension + } + return nil + } + } + + /// The file a tile points at, for app, file, and folder tiles. + public static func fileURL(of tile: Any) -> URL? { + guard let tile = tile as? [String: Any], + let data = tile["tile-data"] as? [String: Any], + let fileData = data["file-data"] as? [String: Any], + let string = fileData["_CFURLString"] as? String + else { return nil } + if let url = URL(string: string), url.isFileURL { return url } + return URL(fileURLWithPath: string) + } + + /// Whether the tile is a spacer of any width. + public static func isSpacer(_ tile: Any) -> Bool { + ((tile as? [String: Any])?["tile-type"] as? String)?.hasSuffix("spacer-tile") == true + } + + /// What tells two layouts apart: each tile's type and target, in order. + /// Bookkeeping the Dock rewrites on its own (GUIDs, dates) is left out, so + /// putting back a layout that is already showing is a no-op. + public static func signature(of tiles: [Any]) -> [String] { + tiles.map { tile in + let type = (tile as? [String: Any])?["tile-type"] as? String ?? "?" + let url = fileURL(of: tile)?.standardizedFileURL.path + ?? ((tile as? [String: Any])?["tile-data"] as? [String: Any])?["url"] + .flatMap { ($0 as? [String: Any])?["_CFURLString"] as? String } + ?? "" + return "\(type)|\(url)" + } + } + + /// A minimal tile for an app bundle — what dockutil writes; the Dock fills + /// in the rest on its next launch. + public static func appTile(path: String) -> [String: Any] { + [ + "tile-type": "file-tile", + "tile-data": [ + "file-data": [ + "_CFURLString": URL(fileURLWithPath: path, isDirectory: true).absoluteString, + "_CFURLStringType": 15, + ], + ], + ] + } + + /// A minimal folder tile for the Dock's right-hand side. + public static func folderTile(path: String) -> [String: Any] { + [ + "tile-type": "directory-tile", + "tile-data": [ + "file-data": [ + "_CFURLString": URL(fileURLWithPath: path, isDirectory: true).absoluteString, + "_CFURLStringType": 15, + ], + // Fan/grid by kind, sorted by date added, shown as a folder. + "arrangement": 2, + "displayas": 1, + "showas": 0, + ], + ] + } +} + +/// Where the Apple Dock's preferences are read and written. The live store +/// is `CFPreferences` for `com.apple.dock`; tests use a dictionary. +public protocol AppleDockDefaults: AnyObject { + func value(forKey key: String) -> Any? + func set(_ value: Any?, forKey key: String) + func synchronize() +} + +public final class SystemAppleDockDefaults: AppleDockDefaults { + private let domain = "com.apple.dock" as CFString + + public init() {} + + public func value(forKey key: String) -> Any? { + CFPreferencesCopyAppValue(key as CFString, domain) + } + + public func set(_ value: Any?, forKey key: String) { + CFPreferencesSetAppValue(key as CFString, value as CFPropertyList?, domain) + } + + public func synchronize() { + CFPreferencesAppSynchronize(domain) + } +} + +/// Reads, rewrites, hides, and restores the Apple Dock (R15). The Dock reads +/// its preferences at launch, so every change ends with a Dock restart +/// (launchd brings it straight back). +public final class AppleDock { + public static let appsKey = "persistent-apps" + public static let othersKey = "persistent-others" + public static let autohideKey = "autohide" + public static let autohideDelayKey = "autohide-delay" + public static let orientationKey = "orientation" + /// Long enough that the Dock never comes up in practice. + public static let hiddenDelay: Double = 1000 + /// Layout backups kept before each rewrite. + public static let backupLimit = 10 + + private let defaults: AppleDockDefaults + private let restart: () -> Void + private let backupDirectory: URL? + + public init( + defaults: AppleDockDefaults = SystemAppleDockDefaults(), + backupDirectory: URL? = nil, + restart: @escaping () -> Void = AppleDock.restartDock + ) { + self.defaults = defaults + self.backupDirectory = backupDirectory + self.restart = restart + } + + // MARK: Layout + + public func currentLayout(now: Date = Date()) -> AppleDockLayout { + defaults.synchronize() + return AppleDockLayout( + appTiles: defaults.value(forKey: Self.appsKey) as? [Any] ?? [], + otherTiles: defaults.value(forKey: Self.othersKey) as? [Any] ?? [], + capturedAt: now + ) + } + + /// Puts `layout` into the Apple Dock. Returns false, without restarting + /// the Dock, when it is already showing that layout. + @discardableResult + public func apply(_ layout: AppleDockLayout) -> Bool { + let current = currentLayout() + let newApps = layout.appTiles + let newOthers = layout.otherTiles + // A layout with nothing in it is an unreadable save, not a wish for + // an empty Dock: writing it would unpin every app. + guard !newApps.isEmpty || !newOthers.isEmpty else { return false } + if AppleDockTiles.signature(of: current.appTiles) == AppleDockTiles.signature(of: newApps), + AppleDockTiles.signature(of: current.otherTiles) == AppleDockTiles.signature(of: newOthers) { + return false + } + writeBackup(current) + defaults.set(newApps, forKey: Self.appsKey) + defaults.set(newOthers, forKey: Self.othersKey) + defaults.synchronize() + restart() + return true + } + + /// Keeps the layout about to be replaced, newest last, at most + /// `backupLimit` files. + private func writeBackup(_ layout: AppleDockLayout) { + guard let backupDirectory else { return } + let fm = FileManager.default + try? fm.createDirectory(at: backupDirectory, withIntermediateDirectories: true) + let tiles: [String: Any] = [ + Self.appsKey: layout.appTiles, + Self.othersKey: layout.otherTiles, + ] + let stamp = Int(layout.capturedAt.timeIntervalSince1970 * 1000) + // The stamp sorts the files; the suffix keeps two rewrites in the same + // millisecond from overwriting each other. + let suffix = UUID().uuidString.prefix(8) + let file = backupDirectory.appendingPathComponent("apple-dock-\(stamp)-\(suffix).plist") + if let data = try? PropertyListSerialization.data(fromPropertyList: tiles, format: .xml, options: 0) { + try? data.write(to: file, options: .atomic) + } + let existing = ((try? fm.contentsOfDirectory(at: backupDirectory, includingPropertiesForKeys: nil)) ?? []) + .filter { $0.lastPathComponent.hasPrefix("apple-dock-") && $0.pathExtension == "plist" } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + for old in existing.dropLast(Self.backupLimit) { + try? fm.removeItem(at: old) + } + } + + // MARK: Visibility + + public var visibility: AppleDockVisibility { + defaults.synchronize() + return AppleDockVisibility( + autohide: Self.bool(defaults.value(forKey: Self.autohideKey)), + autohideDelay: Self.double(defaults.value(forKey: Self.autohideDelayKey)) + ) + } + + /// Whether the Dock is hidden the way `hide()` hides it. + public var isHidden: Bool { + let current = visibility + return current.autohide == true && (current.autohideDelay ?? 0) >= Self.hiddenDelay / 2 + } + + /// Hides the Apple Dock and returns its settings from before, to hand + /// back to `restore`. Already hidden → nil, nothing changes. + public func hide() -> AppleDockVisibility? { + guard !isHidden else { return nil } + let before = visibility + defaults.set(true, forKey: Self.autohideKey) + defaults.set(Self.hiddenDelay, forKey: Self.autohideDelayKey) + defaults.synchronize() + restart() + return before + } + + /// Puts back the hide settings `hide` found. + public func restore(_ backup: AppleDockVisibility) { + defaults.set(backup.autohide, forKey: Self.autohideKey) + defaults.set(backup.autohideDelay, forKey: Self.autohideDelayKey) + defaults.synchronize() + restart() + } + + /// Shows the Dock when there is no backup to go by: whatever hid it, the + /// long delay goes. Autohide itself stays as the user had it. + public func unhideWithoutBackup() { + guard isHidden else { return } + defaults.set(nil, forKey: Self.autohideDelayKey) + defaults.synchronize() + restart() + } + + /// The Apple Dock's icon size in points, when it has one set. + public var tileSize: Double? { + defaults.synchronize() + return Self.double(defaults.value(forKey: "tilesize")) + } + + /// "bottom", "left", or "right". + public var orientation: String { + defaults.synchronize() + return defaults.value(forKey: Self.orientationKey) as? String ?? "bottom" + } + + public static func restartDock() { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + process.arguments = ["Dock"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try? process.run() + process.waitUntilExit() + } + + private static func bool(_ value: Any?) -> Bool? { + if let bool = value as? Bool { return bool } + if let number = value as? NSNumber { return number.boolValue } + if let string = value as? String { return ["1", "true", "yes"].contains(string.lowercased()) } + return nil + } + + private static func double(_ value: Any?) -> Double? { + if let number = value as? NSNumber { return number.doubleValue } + if let string = value as? String { return Double(string) } + return nil + } +} diff --git a/Sources/MenubucketCore/Dock.swift b/Sources/MenubucketCore/Dock.swift new file mode 100644 index 0000000..e12320b --- /dev/null +++ b/Sources/MenubucketCore/Dock.swift @@ -0,0 +1,509 @@ +import Foundation + +/// The BarShelf Dock (R15): a bar on a screen edge carrying apps, folders, +/// files, links, Shortcuts, and BarShelf widgets, arranged in profiles the +/// user switches between. Persisted at +/// `~/Library/Application Support/barshelf/dock.json`. +/// +/// Pure Codable model (UI-free, unit-testable). Missing keys decode to their +/// defaults and unknown item types are dropped, so files written by older or +/// newer builds keep loading. +public struct DockConfiguration: Codable, Equatable, Sendable { + /// How the BarShelf Dock relates to the Apple Dock. + public enum Mode: String, Codable, CaseIterable, Sendable { + /// No BarShelf Dock. Profiles still switch the Apple Dock layout when + /// that is turned on. + case off + /// The BarShelf Dock alongside the Apple Dock. + case alongside + /// The BarShelf Dock instead of the Apple Dock, which is hidden. + case replace + + public var showsDock: Bool { self != .off } + } + + public enum Style: String, Codable, CaseIterable, Sendable { + /// Like the Apple Dock: glass bar, icons, magnification. + case classic + /// A sturdier bar with labels, suited to widgets. + case shelf + } + + public enum Edge: String, Codable, CaseIterable, Sendable { + case bottom, left, right + + public var isVertical: Bool { self != .bottom } + } + + public var mode: Mode + public var style: Style + public var edge: Edge + /// App icon size in points. + public var tileSize: Double + /// How thick a widget tile is (its height on the bottom edge, its width on + /// a side edge), in points. + public var widgetSize: Double + public var magnification: Bool + /// Slide away until the pointer reaches the screen edge. + public var autoHide: Bool + /// Apps that are running but not in the profile, after a separator. + public var showRunningApps: Bool + public var showTrash: Bool + /// How much a hovered icon grows: 0 is barely, 1 is double size. + public var magnificationAmount: Double = DockConfiguration.defaultMagnificationAmount + /// The dots under running apps. + public var showIndicators: Bool = true + /// An app bounces while it launches from the dock. + public var animateOpening: Bool = true + /// Seconds the pointer rests at the edge before an auto-hidden dock + /// comes out. + public var autoHideDelay: Double = DockConfiguration.defaultAutoHideDelay + /// Which display the dock is on. + public var display: Display = .main + /// How a folder opens: a grid of its contents, or a menu. + public var folderView: FolderView = .grid + /// Recently used apps that are neither in the profile nor open, after + /// the open ones — the Apple Dock's "suggested and recent apps". + public var showRecentApps: Bool = false + /// Icon size follows the Apple Dock's, even as it changes. + public var matchAppleDockSize: Bool = true + /// In a full-screen app the dock waits at the edge, as the Apple Dock + /// does, instead of staying out of the full-screen Space altogether. + public var showInFullScreen: Bool = true + /// How many recent apps that section shows. + public static let recentAppLimit = 3 + + public enum FolderView: String, Codable, CaseIterable, Sendable { + /// A grid of icons above the dock, like the Apple Dock's stacks. + case grid + /// A menu, with subfolders as submenus. + case list + } + + public enum Display: String, Codable, CaseIterable, Sendable { + /// The display with the menu bar. + case main + /// Follows the pointer to the display it rests at the edge of, as the + /// Apple Dock does. + case pointer + } + + public var profiles: [DockProfile] + public var activeProfileID: String + /// ⌃⌥1…9 switch to the first nine profiles. + public var profileHotkeysEnabled: Bool + /// Switching profiles also rewrites the Apple Dock's pinned items from + /// the profile's saved layout. + public var appleDockLayouts: Bool + /// The Apple Dock's own hide settings from before `replace` hid it. Kept + /// until they are put back, so a crash cannot strand the user without a + /// Dock. + public var appleDockBackup: AppleDockVisibility? + + /// The Apple Dock's own range. + public static let tileSizeRange: ClosedRange = 16...128 + public static let widgetSizeRange: ClosedRange = 72...220 + public static let defaultTileSize: Double = 52 + public static let defaultWidgetSize: Double = 110 + public static let magnificationAmountRange: ClosedRange = 0.1...1 + public static let defaultMagnificationAmount: Double = 0.5 + public static let autoHideDelayRange: ClosedRange = 0...2 + public static let defaultAutoHideDelay: Double = 0.2 + /// Profiles past this many cannot get a ⌃⌥ number. + public static let hotkeyProfileLimit = 9 + + public init( + mode: Mode = .off, + style: Style = .classic, + edge: Edge = .bottom, + tileSize: Double = DockConfiguration.defaultTileSize, + widgetSize: Double = DockConfiguration.defaultWidgetSize, + magnification: Bool = true, + autoHide: Bool = false, + showRunningApps: Bool = true, + showTrash: Bool = true, + profiles: [DockProfile] = [], + activeProfileID: String = "", + profileHotkeysEnabled: Bool = false, + appleDockLayouts: Bool = false, + appleDockBackup: AppleDockVisibility? = nil + ) { + self.mode = mode + self.style = style + self.edge = edge + self.tileSize = tileSize + self.widgetSize = widgetSize + self.magnification = magnification + self.autoHide = autoHide + self.showRunningApps = showRunningApps + self.showTrash = showTrash + self.profiles = profiles + self.activeProfileID = activeProfileID + self.profileHotkeysEnabled = profileHotkeysEnabled + self.appleDockLayouts = appleDockLayouts + self.appleDockBackup = appleDockBackup + normalize() + } + + /// Brings every field into its allowed range: sizes are clamped, there is + /// always at least one profile, ids are unique, and the active profile + /// exists. + public mutating func normalize() { + tileSize = Self.clamp(tileSize, to: Self.tileSizeRange, fallback: Self.defaultTileSize) + widgetSize = Self.clamp(widgetSize, to: Self.widgetSizeRange, fallback: Self.defaultWidgetSize) + magnificationAmount = Self.clamp( + magnificationAmount, to: Self.magnificationAmountRange, fallback: Self.defaultMagnificationAmount + ) + autoHideDelay = Self.clamp(autoHideDelay, to: Self.autoHideDelayRange, fallback: Self.defaultAutoHideDelay) + if profiles.isEmpty { + profiles = [DockProfile(id: DockProfile.defaultID, name: DockProfile.defaultName)] + } + var seenProfiles: Set = [] + for index in profiles.indices { + profiles[index].normalize() + while !seenProfiles.insert(profiles[index].id).inserted { + profiles[index].id = UUID().uuidString + } + } + if !profiles.contains(where: { $0.id == activeProfileID }) { + activeProfileID = profiles[0].id + } + } + + private static func clamp(_ value: Double, to range: ClosedRange, fallback: Double) -> Double { + guard value.isFinite else { return fallback } + return min(max(value, range.lowerBound), range.upperBound) + } + + public var activeProfile: DockProfile { + profiles.first { $0.id == activeProfileID } ?? profiles[0] + } + + public var activeProfileIndex: Int { + profiles.firstIndex { $0.id == activeProfileID } ?? 0 + } + + /// Finds a profile the way a person names it on the command line or in a + /// Shortcut: its id, its name (any case), or its 1-based position. + public func profile(matching query: String) -> DockProfile? { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let byID = profiles.first(where: { $0.id == trimmed }) { return byID } + if let byName = profiles.first(where: { + $0.name.compare(trimmed, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + }) { return byName } + if let position = Int(trimmed), profiles.indices.contains(position - 1) { + return profiles[position - 1] + } + return nil + } + + /// The profile `offset` steps from the active one, wrapping around. + public func profile(offsetFromActive offset: Int) -> DockProfile { + let count = profiles.count + let index = ((activeProfileIndex + offset) % count + count) % count + return profiles[index] + } + + /// The widgets the active profile places in the dock, in order. + public var activeWidgetIDs: [String] { + activeProfile.items.compactMap { item in + if case .widget(let id) = item.kind { return id } + return nil + } + } + + // MARK: Codable + + private enum CodingKeys: String, CodingKey { + case mode, style, edge, tileSize, widgetSize, magnification, autoHide + case showRunningApps, showTrash, profiles, activeProfileID + case profileHotkeysEnabled, appleDockLayouts, appleDockBackup + case magnificationAmount, showIndicators, animateOpening, autoHideDelay, display + case folderView, showRecentApps, showInFullScreen, matchAppleDockSize + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let defaults = DockConfiguration() + mode = (try? c.decodeIfPresent(Mode.self, forKey: .mode)) ?? defaults.mode + style = (try? c.decodeIfPresent(Style.self, forKey: .style)) ?? defaults.style + edge = (try? c.decodeIfPresent(Edge.self, forKey: .edge)) ?? defaults.edge + tileSize = (try? c.decodeIfPresent(Double.self, forKey: .tileSize)) ?? defaults.tileSize + widgetSize = (try? c.decodeIfPresent(Double.self, forKey: .widgetSize)) ?? defaults.widgetSize + magnification = (try? c.decodeIfPresent(Bool.self, forKey: .magnification)) ?? defaults.magnification + autoHide = (try? c.decodeIfPresent(Bool.self, forKey: .autoHide)) ?? defaults.autoHide + showRunningApps = (try? c.decodeIfPresent(Bool.self, forKey: .showRunningApps)) ?? defaults.showRunningApps + showTrash = (try? c.decodeIfPresent(Bool.self, forKey: .showTrash)) ?? defaults.showTrash + profiles = (try? c.decodeIfPresent(LenientArray.self, forKey: .profiles))?.elements ?? [] + activeProfileID = (try? c.decodeIfPresent(String.self, forKey: .activeProfileID)) ?? "" + profileHotkeysEnabled = (try? c.decodeIfPresent(Bool.self, forKey: .profileHotkeysEnabled)) ?? false + appleDockLayouts = (try? c.decodeIfPresent(Bool.self, forKey: .appleDockLayouts)) ?? false + appleDockBackup = (try? c.decodeIfPresent(AppleDockVisibility.self, forKey: .appleDockBackup)) ?? nil + magnificationAmount = (try? c.decodeIfPresent(Double.self, forKey: .magnificationAmount)) + ?? defaults.magnificationAmount + showIndicators = (try? c.decodeIfPresent(Bool.self, forKey: .showIndicators)) ?? defaults.showIndicators + animateOpening = (try? c.decodeIfPresent(Bool.self, forKey: .animateOpening)) ?? defaults.animateOpening + autoHideDelay = (try? c.decodeIfPresent(Double.self, forKey: .autoHideDelay)) ?? defaults.autoHideDelay + display = (try? c.decodeIfPresent(Display.self, forKey: .display)) ?? defaults.display + folderView = (try? c.decodeIfPresent(FolderView.self, forKey: .folderView)) ?? defaults.folderView + showRecentApps = (try? c.decodeIfPresent(Bool.self, forKey: .showRecentApps)) ?? defaults.showRecentApps + showInFullScreen = (try? c.decodeIfPresent(Bool.self, forKey: .showInFullScreen)) ?? defaults.showInFullScreen + matchAppleDockSize = (try? c.decodeIfPresent(Bool.self, forKey: .matchAppleDockSize)) + ?? defaults.matchAppleDockSize + normalize() + } + + // MARK: File persistence + + public static func load(from fileURL: URL) -> DockConfiguration { + guard let data = try? Data(contentsOf: fileURL), + let decoded = try? JSONDecoder().decode(DockConfiguration.self, from: data) + else { return DockConfiguration() } + return decoded + } + + /// Atomic write (creates the parent directory). + public func save(to fileURL: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(self) + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: fileURL, options: .atomic) + } +} + +/// One named arrangement: what the BarShelf Dock shows, and optionally the +/// Apple Dock layout and popup page that go with it. +public struct DockProfile: Codable, Equatable, Identifiable, Sendable { + public static let defaultID = "default" + public static let defaultName = "Default" + public static let defaultSymbol = "square.grid.2x2" + + public var id: String + public var name: String + /// SF Symbol shown in menus and the switcher. + public var symbol: String + public var items: [DockItem] + /// The Apple Dock's pinned items as saved into this profile. + public var appleDock: AppleDockLayout? + /// The popup page to show when this profile becomes active. + public var popupPage: String? + + public init( + id: String = UUID().uuidString, + name: String, + symbol: String = DockProfile.defaultSymbol, + items: [DockItem] = [], + appleDock: AppleDockLayout? = nil, + popupPage: String? = nil + ) { + self.id = id + self.name = name + self.symbol = symbol + self.items = items + self.appleDock = appleDock + self.popupPage = popupPage + normalize() + } + + public mutating func normalize() { + id = id.trimmingCharacters(in: .whitespacesAndNewlines) + if id.isEmpty { id = UUID().uuidString } + name = name.trimmingCharacters(in: .whitespacesAndNewlines) + if name.isEmpty { name = Self.defaultName } + symbol = symbol.trimmingCharacters(in: .whitespacesAndNewlines) + if symbol.isEmpty { symbol = Self.defaultSymbol } + let page = popupPage?.trimmingCharacters(in: .whitespacesAndNewlines) + popupPage = (page?.isEmpty == false) ? page : nil + var seen: Set = [] + for index in items.indices { + while !seen.insert(items[index].id).inserted { + items[index].id = UUID().uuidString + } + } + } + + private enum CodingKeys: String, CodingKey { + case id, name, symbol, items, appleDock, popupPage + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = (try? c.decodeIfPresent(String.self, forKey: .name)) ?? Self.defaultName + symbol = (try? c.decodeIfPresent(String.self, forKey: .symbol)) ?? Self.defaultSymbol + items = (try? c.decodeIfPresent(LenientArray.self, forKey: .items))?.elements ?? [] + appleDock = (try? c.decodeIfPresent(AppleDockLayout.self, forKey: .appleDock)) ?? nil + popupPage = try? c.decodeIfPresent(String.self, forKey: .popupPage) + normalize() + } +} + +/// Something in the BarShelf Dock. +public struct DockItem: Codable, Equatable, Identifiable, Sendable { + public enum Kind: Equatable, Sendable { + case app(path: String) + /// A folder; `color` and `label` draw it as a coloured tile with a + /// letter instead of the folder icon. + case folder(path: String, color: FolderColor?, label: String?) + case file(path: String) + case link(url: String, title: String?) + /// A Shortcut from the Shortcuts app, run by name. + case shortcut(name: String) + /// A BarShelf widget, by instance id. + case widget(id: String) + case spacer + case separator + } + + public enum FolderColor: String, Codable, CaseIterable, Sendable { + case blue, purple, pink, red, orange, yellow, green, gray + } + + public var id: String + public var kind: Kind + + public init(id: String = UUID().uuidString, kind: Kind) { + self.id = id + self.kind = kind + } + + /// The item a dropped or chosen file URL becomes: an app bundle, a folder, + /// or a plain file. + public static func forFile(at url: URL, isDirectory: Bool) -> DockItem { + let path = url.standardizedFileURL.path + if url.pathExtension.lowercased() == "app" { + return DockItem(kind: .app(path: path)) + } + if isDirectory { + return DockItem(kind: .folder(path: path, color: nil, label: nil)) + } + return DockItem(kind: .file(path: path)) + } + + /// The file this item stands for, if any. + public var filePath: String? { + switch kind { + case .app(let path), .file(let path): return path + case .folder(let path, _, _): return path + default: return nil + } + } + + /// A name for menus and accessibility, without touching the disk. + public var fallbackTitle: String { + switch kind { + case .app(let path): + return ((path as NSString).lastPathComponent as NSString).deletingPathExtension + case .folder(let path, _, let label): + if let label, !label.isEmpty { return label } + return (path as NSString).lastPathComponent + case .file(let path): + return (path as NSString).lastPathComponent + case .link(let url, let title): + if let title, !title.isEmpty { return title } + return URL(string: url)?.host ?? url + case .shortcut(let name): return name + case .widget(let id): return id + case .spacer: return "Spacer" + case .separator: return "Separator" + } + } + + // MARK: Codable — flat `{ "id", "type", … }` + + private enum CodingKeys: String, CodingKey { + case id, type, path, color, label, url, title, name, widget + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = (try? c.decodeIfPresent(String.self, forKey: .id)) ?? UUID().uuidString + let type = try c.decode(String.self, forKey: .type) + switch type { + case "app": + kind = .app(path: try c.decode(String.self, forKey: .path)) + case "folder": + kind = .folder( + path: try c.decode(String.self, forKey: .path), + color: try? c.decodeIfPresent(FolderColor.self, forKey: .color), + label: try? c.decodeIfPresent(String.self, forKey: .label) + ) + case "file": + kind = .file(path: try c.decode(String.self, forKey: .path)) + case "link": + kind = .link( + url: try c.decode(String.self, forKey: .url), + title: try? c.decodeIfPresent(String.self, forKey: .title) + ) + case "shortcut": + kind = .shortcut(name: try c.decode(String.self, forKey: .name)) + case "widget": + kind = .widget(id: try c.decode(String.self, forKey: .widget)) + case "spacer": + kind = .spacer + case "separator": + kind = .separator + default: + throw DecodingError.dataCorruptedError( + forKey: .type, in: c, debugDescription: "Unknown dock item type \(type)" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + switch kind { + case .app(let path): + try c.encode("app", forKey: .type) + try c.encode(path, forKey: .path) + case .folder(let path, let color, let label): + try c.encode("folder", forKey: .type) + try c.encode(path, forKey: .path) + try c.encodeIfPresent(color, forKey: .color) + try c.encodeIfPresent(label, forKey: .label) + case .file(let path): + try c.encode("file", forKey: .type) + try c.encode(path, forKey: .path) + case .link(let url, let title): + try c.encode("link", forKey: .type) + try c.encode(url, forKey: .url) + try c.encodeIfPresent(title, forKey: .title) + case .shortcut(let name): + try c.encode("shortcut", forKey: .type) + try c.encode(name, forKey: .name) + case .widget(let id): + try c.encode("widget", forKey: .type) + try c.encode(id, forKey: .widget) + case .spacer: + try c.encode("spacer", forKey: .type) + case .separator: + try c.encode("separator", forKey: .type) + } + } +} + +/// Decodes an array element by element, dropping the ones that fail, so one +/// item from a newer build does not cost the user the whole list. +struct LenientArray: Decodable { + let elements: [Element] + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + var elements: [Element] = [] + while !container.isAtEnd { + if let element = try? container.decode(Element.self) { + elements.append(element) + } else { + // Any JSON value: advances past the element whatever it is. + _ = try? container.decode(JSONValue.self) + } + } + self.elements = elements + } +} diff --git a/Tests/BarShelfCLITests/DockCommandTests.swift b/Tests/BarShelfCLITests/DockCommandTests.swift new file mode 100644 index 0000000..dbcbc4d --- /dev/null +++ b/Tests/BarShelfCLITests/DockCommandTests.swift @@ -0,0 +1,97 @@ +import XCTest +@testable import BarShelfKit +import MenubucketCore + +/// `barshelf dock` (R15). +final class DockCommandTests: XCTestCase { + private final class FakeDefaults: AppleDockDefaults { + var values: [String: Any] = [:] + func value(forKey key: String) -> Any? { values[key] } + func set(_ value: Any?, forKey key: String) { values[key] = value } + func synchronize() {} + } + + private var fileURL: URL! + + override func setUpWithError() throws { + fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("dock-cli-\(UUID().uuidString).json") + try DockConfiguration(profiles: [ + DockProfile(id: "w", name: "Work"), + DockProfile(id: "p", name: "Personal"), + ]).save(to: fileURL) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: fileURL) + } + + func testListMarksTheActiveProfile() { + let text = DockCommand.list(DockConfiguration.load(from: fileURL)) + XCTAssertTrue(text.hasPrefix("* 1. Work")) + XCTAssertTrue(text.contains(" 2. Personal")) + } + + func testUseSendsTheProfileIDToTheApp() { + var opened: [URL] = [] + let status = DockCommand.run( + arguments: ["use", "personal"], configurationURL: fileURL, + openURL: { opened.append($0); return true } + ) + XCTAssertEqual(status, 0) + XCTAssertEqual(opened, [URL(string: "barshelf://dock?profile=p")!]) + } + + func testUseRejectsAnUnknownProfileWithoutCallingTheApp() { + var opened = false + let status = DockCommand.run( + arguments: ["use", "Gaming"], configurationURL: fileURL, + openURL: { _ in opened = true; return true } + ) + XCTAssertEqual(status, 1) + XCTAssertFalse(opened) + } + + func testRestoreAppleDockUsesTheBackupAndLeavesReplaceMode() throws { + var config = DockConfiguration.load(from: fileURL) + config.mode = .replace + config.appleDockBackup = AppleDockVisibility(autohide: false, autohideDelay: 0.2) + try config.save(to: fileURL) + let defaults = FakeDefaults() + defaults.values[AppleDock.autohideKey] = true + defaults.values[AppleDock.autohideDelayKey] = AppleDock.hiddenDelay + + let status = DockCommand.run( + arguments: ["restore-apple-dock"], configurationURL: fileURL, + appleDock: { AppleDock(defaults: defaults) {} }, + appIsRunning: { false } + ) + XCTAssertEqual(status, 0) + XCTAssertEqual(defaults.values[AppleDock.autohideKey] as? Bool, false) + XCTAssertEqual(defaults.values[AppleDock.autohideDelayKey] as? Double, 0.2) + let saved = DockConfiguration.load(from: fileURL) + XCTAssertNil(saved.appleDockBackup) + XCTAssertEqual(saved.mode, .alongside) + } + + /// With BarShelf running the app is asked, and the file is left alone. + func testRestoreAsksARunningApp() throws { + var config = DockConfiguration.load(from: fileURL) + config.mode = .replace + try config.save(to: fileURL) + var opened: [URL] = [] + let status = DockCommand.run( + arguments: ["restore-apple-dock"], configurationURL: fileURL, + openURL: { opened.append($0); return true }, + appleDock: { XCTFail("the app does this"); return AppleDock(defaults: FakeDefaults()) {} }, + appIsRunning: { true } + ) + XCTAssertEqual(status, 0) + XCTAssertEqual(opened, [URL(string: "barshelf://dock?restore-apple-dock")!]) + XCTAssertEqual(DockConfiguration.load(from: fileURL).mode, .replace) + } + + func testUnknownSubcommandFails() { + XCTAssertEqual(DockCommand.run(arguments: ["fly"], configurationURL: fileURL), 1) + XCTAssertEqual(DockCommand.run(arguments: [], configurationURL: fileURL), 1) + } +} diff --git a/Tests/MenubucketAppTests/AppCommandsTests.swift b/Tests/MenubucketAppTests/AppCommandsTests.swift index df688d9..cc38516 100644 --- a/Tests/MenubucketAppTests/AppCommandsTests.swift +++ b/Tests/MenubucketAppTests/AppCommandsTests.swift @@ -61,7 +61,7 @@ final class AppCommandsTests: XCTestCase { func testTheAppMenuHasTheDocumentedCommandsAndShortcuts() { XCTAssertEqual( AppMenu.sections, - [[.editShelf, .addWidget, .menuBar], [.openBarShelf, .settings, .checkForUpdates], [.quit]] + [[.editShelf, .addWidget, .menuBar, .dock], [.openBarShelf, .settings, .checkForUpdates], [.quit]] ) XCTAssertEqual(AppMenuCommand.editShelf.keyEquivalent, "e") // ⌘, is Settings, as everywhere on macOS; Open BarShelf opens the Shelf. diff --git a/Tests/MenubucketAppTests/DockStoreTests.swift b/Tests/MenubucketAppTests/DockStoreTests.swift new file mode 100644 index 0000000..9e32686 --- /dev/null +++ b/Tests/MenubucketAppTests/DockStoreTests.swift @@ -0,0 +1,374 @@ +import XCTest +@testable import MenubucketApp +@testable import MenubucketCore + +/// The BarShelf Dock's store (R15): it is the only thing that hides, shows, +/// and rewrites the Apple Dock, so those transitions are pinned down here +/// against a fake `com.apple.dock`. +final class DockStoreTests: XCTestCase { + private final class FakeDefaults: AppleDockDefaults { + var values: [String: Any] = [:] + func value(forKey key: String) -> Any? { values[key] } + func set(_ value: Any?, forKey key: String) { values[key] = value } + func synchronize() {} + } + + private var fileURL: URL! + private var defaults: FakeDefaults! + private var restarts = 0 + + override func setUp() { + fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("dock-\(UUID().uuidString).json") + defaults = FakeDefaults() + restarts = 0 + } + + override func tearDown() { + try? FileManager.default.removeItem(at: fileURL) + } + + private func makeStore() -> DockStore { + DockStore(fileURL: fileURL, appleDock: AppleDock(defaults: defaults) { [weak self] in self?.restarts += 1 }) + } + + func testReplaceHidesTheAppleDockAndOffPutsItBack() { + defaults.values[AppleDock.autohideKey] = false + let store = makeStore() + store.update { $0.mode = .replace } + XCTAssertTrue(store.appleDock.isHidden) + XCTAssertEqual(store.configuration.appleDockBackup, AppleDockVisibility(autohide: false, autohideDelay: nil)) + + store.update { $0.mode = .off } + XCTAssertFalse(store.appleDock.isHidden) + XCTAssertEqual(defaults.values[AppleDock.autohideKey] as? Bool, false) + XCTAssertNil(defaults.values[AppleDock.autohideDelayKey]) + XCTAssertNil(store.configuration.appleDockBackup) + XCTAssertEqual(restarts, 2) + } + + func testSizeFollowsTheAppleDockUntilChosen() { + defaults.values["tilesize"] = 29.0 + let store = makeStore() + store.syncSizeWithAppleDock() + XCTAssertEqual(store.configuration.tileSize, 29) + defaults.values["tilesize"] = 48.0 + store.syncSizeWithAppleDock() + XCTAssertEqual(store.configuration.tileSize, 48) + store.update { $0.matchAppleDockSize = false; $0.tileSize = 60 } + defaults.values["tilesize"] = 20.0 + store.syncSizeWithAppleDock() + XCTAssertEqual(store.configuration.tileSize, 60) + } + + func testOtherEditsDoNotTouchTheAppleDock() { + let store = makeStore() + store.update { $0.mode = .alongside } + store.update { $0.tileSize = 60 } + XCTAssertEqual(restarts, 0) + XCTAssertNil(defaults.values[AppleDock.autohideKey]) + } + + /// A crash while hiding leaves a backup in dock.json; the next launch + /// without `replace` must put the user's Dock back. + func testLaunchRestoresALeftoverBackup() throws { + var config = DockConfiguration(mode: .alongside) + config.appleDockBackup = AppleDockVisibility(autohide: nil, autohideDelay: nil) + try config.save(to: fileURL) + defaults.values[AppleDock.autohideKey] = true + defaults.values[AppleDock.autohideDelayKey] = AppleDock.hiddenDelay + + let store = makeStore() + store.reconcileAppleDockAtLaunch() + XCTAssertFalse(store.appleDock.isHidden) + XCTAssertNil(store.configuration.appleDockBackup) + XCTAssertNil(DockConfiguration.load(from: fileURL).appleDockBackup, "the restore must be saved") + } + + func testQuitShowsTheAppleDockAndLaunchHidesItAgain() { + let store = makeStore() + store.update { $0.mode = .replace } + store.restoreAppleDockForTermination() + XCTAssertFalse(store.appleDock.isHidden) + XCTAssertEqual(store.configuration.mode, .replace, "quitting is not turning the mode off") + + let relaunched = makeStore() + relaunched.reconcileAppleDockAtLaunch() + XCTAssertTrue(relaunched.appleDock.isHidden) + } + + func testSwitchingAppliesTheSavedAppleDockLayoutOnlyWhenTurnedOn() { + defaults.values[AppleDock.appsKey] = [AppleDockTiles.appTile(path: "/Applications/Mail.app")] + let store = makeStore() + let work = store.addProfile(named: "Work") + store.captureAppleDock(into: work) + defaults.values[AppleDock.appsKey] = [AppleDockTiles.appTile(path: "/Applications/Music.app")] + + store.activate(profileID: work) + XCTAssertEqual(store.configuration.activeProfileID, work) + XCTAssertEqual(restarts, 0, "Apple Dock layouts are off by default") + + store.update { $0.appleDockLayouts = true } + store.activate(profileID: work) + XCTAssertEqual(restarts, 1) + XCTAssertEqual(AppleDockTiles.labels(of: defaults.values[AppleDock.appsKey] as! [Any]), ["Mail"]) + } + + func testActivateByNameNumberAndOffset() { + let store = makeStore() + let work = store.addProfile(named: "Work") + var activated: [String] = [] + store.onProfileActivated = { activated.append($0.name) } + XCTAssertTrue(store.activate(matching: "work")) + XCTAssertFalse(store.activate(matching: "nope")) + XCTAssertEqual(store.configuration.activeProfileID, work) + store.activate(offset: 1) + XCTAssertEqual(store.configuration.activeProfileID, DockProfile.defaultID) + XCTAssertTrue(store.activate(matching: "2")) + XCTAssertEqual(activated, ["Work", DockProfile.defaultName, "Work"]) + } + + func testItemEditing() { + let store = makeStore() + let a = DockItem(kind: .app(path: "/Applications/A.app")) + let b = DockItem(kind: .app(path: "/Applications/B.app")) + let c = DockItem(kind: .separator) + store.addItems([a, b]) + store.addItems([c], before: b.id) + XCTAssertEqual(store.activeProfile.items.map(\.id), [a.id, c.id, b.id]) + store.moveItem(a.id, before: nil) + XCTAssertEqual(store.activeProfile.items.map(\.id), [c.id, b.id, a.id]) + store.removeItem(c.id) + XCTAssertEqual(store.activeProfile.items.map(\.id), [b.id, a.id]) + XCTAssertEqual(DockConfiguration.load(from: fileURL).activeProfile.items.map(\.id), [b.id, a.id]) + } + + func testTheLastProfileCannotBeRemoved() { + let store = makeStore() + store.removeProfile(DockProfile.defaultID) + XCTAssertEqual(store.configuration.profiles.count, 1) + } + + // MARK: Tiles + + func testTilesAddRunningAppsNotInTheProfileThenTrash() { + var config = DockConfiguration(mode: .alongside) + config.profiles[0].items = [DockItem(id: "mail", kind: .app(path: "/Applications/Mail.app"))] + let running = [ + RunningApps.App(path: "/Applications/Mail.app", bundleID: "com.apple.mail", processID: 1), + RunningApps.App(path: "/Applications/Notes.app", bundleID: "com.apple.Notes", processID: 2), + ] + let ids = DockView.tiles(for: config, running: running).map(\.id) + XCTAssertEqual(ids, ["mail", "divider:running", "running:/Applications/Notes.app", "divider:trash", "trash"]) + + config.showRunningApps = false + config.showTrash = false + XCTAssertEqual(DockView.tiles(for: config, running: running).map(\.id), ["mail"]) + } + + func testRecentAppsSkipWhatIsAlreadyShown() { + var config = DockConfiguration(mode: .alongside) + config.profiles[0].items = [DockItem(id: "mail", kind: .app(path: "/Applications/Mail.app"))] + config.showTrash = false + config.showRecentApps = true + let running = [RunningApps.App(path: "/Applications/Notes.app", bundleID: "com.apple.Notes", processID: 1)] + let recent = ["/Applications/Mail.app", "/Applications/Notes.app", "/Applications/Gone.app", + "/Applications/A.app", "/Applications/B.app", "/Applications/C.app", "/Applications/D.app"] + let ids = DockView.tiles(for: config, running: running, recent: recent, exists: { !$0.contains("Gone") }).map(\.id) + XCTAssertEqual(ids, [ + "mail", "divider:running", "running:/Applications/Notes.app", "divider:recent", + "recent:/Applications/A.app", "recent:/Applications/B.app", "recent:/Applications/C.app", + ]) + } + + func testRecentListIsMostRecentFirstAndKept() throws { + let suite = "dock-recent-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let apps = RunningApps(defaults: defaults) + apps.noteUsed("/Applications/A.app") + apps.noteUsed("/Applications/B.app") + apps.noteUsed("/Applications/A.app") + // Real app switches may land in between; the order of these holds. + let a = try XCTUnwrap(apps.recent.firstIndex(of: "/Applications/A.app")) + let b = try XCTUnwrap(apps.recent.firstIndex(of: "/Applications/B.app")) + XCTAssertLessThan(a, b) + XCTAssertEqual(apps.recent.filter { $0 == "/Applications/A.app" }.count, 1) + XCTAssertEqual(RunningApps(defaults: defaults).recent, apps.recent) + apps.noteUsed("/tmp/scratch/fstest") + XCTAssertFalse(apps.recent.contains("/tmp/scratch/fstest"), "bare executables are not apps") + } + + func testFolderStackIsNewestFirstAndOpensFoldersInPlace() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("stack-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + try FileManager.default.createDirectory(at: dir.appendingPathComponent("Sub"), withIntermediateDirectories: true) + let old = dir.appendingPathComponent("old.txt") + let new = dir.appendingPathComponent("new.txt") + try Data().write(to: old) + try Data().write(to: new) + try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSince1970: 0)], ofItemAtPath: old.path) + let entries = DockFolderStack.entries(in: dir) + XCTAssertEqual(Set(entries.map(\.name)), ["old.txt", "new.txt", "Sub"]) + XCTAssertTrue(entries.first { $0.name == "Sub" }!.opensInPlace) + XCTAssertFalse(entries.first { $0.name == "new.txt" }!.opensInPlace) + } + + // MARK: Full screen + + func testFullScreenMeansAWindowCoveringTheWholeScreen() { + let main = DockPanelController.windowServerFrame(of: NSRect(x: 0, y: 0, width: 2560, height: 1440), primaryHeight: 1440) + let below = DockPanelController.windowServerFrame(of: NSRect(x: 0, y: -1440, width: 2560, height: 1440), primaryHeight: 1440) + XCTAssertEqual(below, CGRect(x: 0, y: 1440, width: 2560, height: 1440)) + func window(_ rect: CGRect, layer: Int = 0, pid: pid_t = 42) -> [String: Any] { + [kCGWindowLayer as String: layer, kCGWindowOwnerPID as String: pid, + kCGWindowBounds as String: rect.dictionaryRepresentation] + } + // A zoomed window stops below the menu bar. + XCTAssertFalse(DockPanelController.isFullScreen( + windows: [window(CGRect(x: 0, y: 30, width: 2560, height: 1410))], screenFrame: main, ownPID: 1)) + XCTAssertTrue(DockPanelController.isFullScreen( + windows: [window(main)], screenFrame: main, ownPID: 1)) + // Not on this display, not an ordinary window, or our own: no. + XCTAssertFalse(DockPanelController.isFullScreen(windows: [window(below)], screenFrame: main, ownPID: 1)) + XCTAssertFalse(DockPanelController.isFullScreen(windows: [window(main, layer: 25)], screenFrame: main, ownPID: 1)) + XCTAssertFalse(DockPanelController.isFullScreen(windows: [window(main, pid: 1)], screenFrame: main, ownPID: 1)) + + // A notched laptop: full screen starts below the camera housing (38 pt), + // and a zoomed window starts below the taller menu bar (49 pt here). + let laptop = CGRect(x: 0, y: 0, width: 1710, height: 1112) + XCTAssertTrue(DockPanelController.isFullScreen( + windows: [window(CGRect(x: 0, y: 38, width: 1710, height: 1074))], + screenFrame: laptop, topInset: 38, ownPID: 1)) + XCTAssertFalse(DockPanelController.isFullScreen( + windows: [window(CGRect(x: 0, y: 49, width: 1710, height: 1014))], + screenFrame: laptop, topInset: 38, ownPID: 1)) + } + + // MARK: Hotkeys + + /// The Automation script wins a ⌃⌥ number over the dock, and gives it + /// back when it stops. + func testAutomationKeysAreLeftToAutomation() { + let store = makeStore() + _ = store.addProfile(named: "Work") + store.update { $0.profileHotkeysEnabled = true } + let hotkeys = DockHotkeys(store: store) + let expectation = expectation(description: "registered") + DispatchQueue.main.async { expectation.fulfill() } + wait(for: [expectation], timeout: 2) + + let one = InAppHotkeys.Key(keyCode: DockHotkeys.digitKeyCodes[0], modifiers: DockHotkeys.modifiers) + InAppHotkeys.shared.setAutomationKeys([one]) + XCTAssertEqual(DockHotkeyStatus.shared.heldByAutomation, [1]) + InAppHotkeys.shared.setAutomationKeys([]) + XCTAssertEqual(DockHotkeyStatus.shared.heldByAutomation, []) + _ = hotkeys + } + + // MARK: URL + + func testDockDeepLinkRoutesToHook() { + let installer = WidgetInstaller() + var received: [[URLQueryItem]] = [] + installer.onDockRequest = { received.append($0) } + installer.handleDeepLink(URL(string: "barshelf://dock?profile=Work%20Mode")!) + installer.handleDeepLink(URL(string: "barshelf://dock?next")!) + XCTAssertEqual(received.count, 2) + XCTAssertEqual(received[0].first?.value, "Work Mode") + XCTAssertEqual(received[1].first?.name, "next") + } + + /// The copyable link names the profile by id, so renaming it does not + /// quietly break a Focus automation. + func testSwitchURLUsesTheStableID() { + let profile = DockProfile(id: "a&b", name: "Deep Work") + let url = URL(string: DockSettingsPage.switchURL(for: profile))! + let value = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first?.value + XCTAssertEqual(value, "a&b") + } + + func testDeletingTheActiveProfileSwitchesProperly() { + defaults.values[AppleDock.appsKey] = [AppleDockTiles.appTile(path: "/Applications/Mail.app")] + let store = makeStore() + store.captureAppleDock(into: DockProfile.defaultID) + let work = store.addProfile(named: "Work") + store.update { $0.appleDockLayouts = true } + store.activate(profileID: work) + defaults.values[AppleDock.appsKey] = [AppleDockTiles.appTile(path: "/Applications/Music.app")] + var activated: [String] = [] + store.onProfileActivated = { activated.append($0.id) } + + store.removeProfile(work) + XCTAssertEqual(store.configuration.activeProfileID, DockProfile.defaultID) + XCTAssertEqual(activated, [DockProfile.defaultID]) + XCTAssertEqual(AppleDockTiles.labels(of: defaults.values[AppleDock.appsKey] as! [Any]), ["Mail"]) + } + + func testAnUnreadableAppleDockIsNotSaved() { + let store = makeStore() + XCTAssertFalse(store.captureAppleDock(into: DockProfile.defaultID)) + XCTAssertNil(store.activeProfile.appleDock) + XCTAssertNotNil(store.lastError) + } + + func testRestoreRequestedByTheCLILeavesReplaceMode() { + let store = makeStore() + store.update { $0.mode = .replace } + store.restoreAppleDockOnRequest() + XCTAssertEqual(store.configuration.mode, .alongside) + XCTAssertFalse(store.appleDock.isHidden) + XCTAssertNil(DockConfiguration.load(from: fileURL).appleDockBackup) + } + + func testMenuRouterForgetsAGoneTile() { + let router = DockMenuRouter() + router.bar = { [.action("Dock Settings…") {}] } + router.enter("mail", entries: { [.action("Quit") {}] }) + XCTAssertEqual(router.current?.map(\.title), ["Quit"]) + router.keep(only: ["notes"]) + XCTAssertEqual(router.current?.map(\.title), ["Dock Settings…"]) + } +} + +/// Dock menus are defined once and offered as a right-click NSMenu and as +/// named accessibility actions; both must carry the same commands. +final class DockMenuTests: XCTestCase { + func testTidyDropsStrayDividers() { + let entries = DockMenuEntry.tidy([ + .divider, .action("A") {}, .divider, .divider, .action("B") {}, .divider, + ]) + XCTAssertEqual(entries.map(\.title), ["A", "", "B"]) + } + + /// VoiceOver gets every command as a named action, submenus spelled out. + func testFlattenedActionsSpellOutSubmenus() { + var ran: [String] = [] + let flat = DockMenuEntry.flattened([ + .action("Open") { ran.append("open") }, + .divider, + .action("Hidden", enabled: false) {}, + .submenu("Profile", [.action("Work") { ran.append("work") }]), + ]) + XCTAssertEqual(flat.map(\.title), ["Open", "Profile: Work"]) + flat.forEach { $0.run() } + XCTAssertEqual(ran, ["open", "work"]) + } + + func testNSMenuMatchesTheEntries() { + var ran: [String] = [] + let menu = DockMenuPresenter.makeMenu([ + .action("Open") { ran.append("open") }, + .divider, + .submenu("Profile", [.action("Work", checked: true) { ran.append("work") }]), + .action("Remove from Dock", destructive: true) { ran.append("remove") }, + ]) + XCTAssertEqual(menu.items.map(\.title), ["Open", "", "Profile", "Remove from Dock"]) + XCTAssertTrue(menu.items[1].isSeparatorItem) + guard let work = menu.items[2].submenu?.items.first else { return XCTFail("no submenu") } + XCTAssertEqual(work.state, .on) + for item in [menu.items[0], work, menu.items[3]] { + XCTAssertTrue(NSApplication.shared.sendAction(item.action!, to: item.target, from: item)) + } + XCTAssertEqual(ran, ["open", "work", "remove"]) + } +} diff --git a/Tests/MenubucketCoreTests/DockConfigurationTests.swift b/Tests/MenubucketCoreTests/DockConfigurationTests.swift new file mode 100644 index 0000000..3dcaa96 --- /dev/null +++ b/Tests/MenubucketCoreTests/DockConfigurationTests.swift @@ -0,0 +1,255 @@ +import XCTest +@testable import MenubucketCore + +final class DockConfigurationTests: XCTestCase { + private var tempDir: URL! + + override func setUpWithError() throws { + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("dock-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tempDir) + } + + func testDefaultsAreOffWithOneProfile() { + let config = DockConfiguration() + XCTAssertEqual(config.mode, .off) + XCTAssertEqual(config.profiles.count, 1) + XCTAssertEqual(config.activeProfileID, DockProfile.defaultID) + XCTAssertFalse(config.appleDockLayouts) + XCTAssertNil(config.appleDockBackup) + } + + func testRoundTripKeepsEveryItemKind() throws { + let items: [DockItem] = [ + DockItem(kind: .app(path: "/Applications/Safari.app")), + DockItem(kind: .folder(path: "/Users/me/Downloads", color: .orange, label: "D")), + DockItem(kind: .file(path: "/Users/me/notes.txt")), + DockItem(kind: .link(url: "https://example.com", title: "Example")), + DockItem(kind: .shortcut(name: "Start Day")), + DockItem(kind: .widget(id: "battery")), + DockItem(kind: .spacer), + DockItem(kind: .separator), + ] + let layout = AppleDockLayout( + appTiles: [AppleDockTiles.appTile(path: "/Applications/Safari.app")], + otherTiles: [], + capturedAt: Date(timeIntervalSince1970: 1_000) + ) + let config = DockConfiguration( + mode: .replace, style: .shelf, edge: .left, tileSize: 64, + profiles: [DockProfile(id: "work", name: "Work", items: items, appleDock: layout, popupPage: "Dev")], + activeProfileID: "work", profileHotkeysEnabled: true, appleDockLayouts: true, + appleDockBackup: AppleDockVisibility(autohide: false, autohideDelay: nil) + ) + let file = tempDir.appendingPathComponent("dock.json") + try config.save(to: file) + let loaded = DockConfiguration.load(from: file) + XCTAssertEqual(loaded, config) + XCTAssertEqual(loaded.activeProfile.appleDock?.appNames, ["Safari"]) + } + + func testUnknownItemTypesAreDroppedNotFatal() throws { + let json = """ + {"mode":"alongside","activeProfileID":"p","profiles":[{"id":"p","name":"P","items":[ + {"id":"a","type":"app","path":"/Applications/Mail.app"}, + {"id":"x","type":"hologram","path":"/nope"}, + "garbage", + {"id":"s","type":"separator"} + ]}]} + """ + let config = try JSONDecoder().decode(DockConfiguration.self, from: Data(json.utf8)) + XCTAssertEqual(config.mode, .alongside) + XCTAssertEqual(config.activeProfile.items.map(\.id), ["a", "s"]) + } + + func testAppleDockStyleSettingsRoundTripAndClamp() throws { + var config = DockConfiguration() + config.magnificationAmount = 7 + config.autoHideDelay = -1 + config.showIndicators = false + config.animateOpening = false + config.display = .pointer + config.folderView = .list + config.showRecentApps = true + config.normalize() + XCTAssertEqual(config.magnificationAmount, DockConfiguration.magnificationAmountRange.upperBound) + XCTAssertEqual(config.autoHideDelay, 0) + let file = tempDir.appendingPathComponent("dock.json") + try config.save(to: file) + XCTAssertEqual(DockConfiguration.load(from: file), config) + + // Files from before these settings get the Apple Dock's defaults. + let old = try JSONDecoder().decode(DockConfiguration.self, from: Data(#"{"mode":"alongside"}"#.utf8)) + XCTAssertTrue(old.showIndicators) + XCTAssertTrue(old.animateOpening) + XCTAssertEqual(old.display, .main) + XCTAssertEqual(old.folderView, .grid) + XCTAssertFalse(old.showRecentApps) + XCTAssertTrue(old.showInFullScreen) + XCTAssertEqual(old.magnificationAmount, DockConfiguration.defaultMagnificationAmount) + } + + func testUnknownModeFallsBackAndSizesClamp() throws { + let json = #"{"mode":"teleport","tileSize":4000,"widgetSize":-3}"# + let config = try JSONDecoder().decode(DockConfiguration.self, from: Data(json.utf8)) + XCTAssertEqual(config.mode, .off) + XCTAssertEqual(config.tileSize, DockConfiguration.tileSizeRange.upperBound) + XCTAssertEqual(config.widgetSize, DockConfiguration.widgetSizeRange.lowerBound) + } + + func testNormalizeFixesDuplicateIDsAndMissingActiveProfile() { + let config = DockConfiguration( + profiles: [ + DockProfile(id: "a", name: "One", items: [DockItem(id: "i", kind: .spacer), DockItem(id: "i", kind: .separator)]), + DockProfile(id: "a", name: "Two"), + ], + activeProfileID: "missing" + ) + XCTAssertEqual(Set(config.profiles.map(\.id)).count, 2) + XCTAssertEqual(Set(config.profiles[0].items.map(\.id)).count, 2) + XCTAssertEqual(config.activeProfileID, "a") + } + + func testProfileMatchingByIDNameOrPosition() { + let config = DockConfiguration(profiles: [ + DockProfile(id: "w", name: "Work"), + DockProfile(id: "p", name: "Personal"), + ]) + XCTAssertEqual(config.profile(matching: "p")?.name, "Personal") + XCTAssertEqual(config.profile(matching: "work")?.id, "w") + XCTAssertEqual(config.profile(matching: "2")?.id, "p") + XCTAssertNil(config.profile(matching: "3")) + XCTAssertNil(config.profile(matching: " ")) + } + + func testProfileOffsetWraps() { + var config = DockConfiguration(profiles: [ + DockProfile(id: "a", name: "A"), DockProfile(id: "b", name: "B"), DockProfile(id: "c", name: "C"), + ]) + XCTAssertEqual(config.profile(offsetFromActive: -1).id, "c") + config.activeProfileID = "c" + XCTAssertEqual(config.profile(offsetFromActive: 1).id, "a") + } + + func testItemForFile() { + XCTAssertEqual( + DockItem.forFile(at: URL(fileURLWithPath: "/Applications/Notes.app"), isDirectory: true).kind, + .app(path: "/Applications/Notes.app") + ) + XCTAssertEqual( + DockItem.forFile(at: URL(fileURLWithPath: "/tmp/dir"), isDirectory: true).kind, + .folder(path: "/tmp/dir", color: nil, label: nil) + ) + XCTAssertEqual( + DockItem.forFile(at: URL(fileURLWithPath: "/tmp/a.txt"), isDirectory: false).kind, + .file(path: "/tmp/a.txt") + ) + } +} + +final class AppleDockTests: XCTestCase { + private final class FakeDefaults: AppleDockDefaults { + var values: [String: Any] = [:] + func value(forKey key: String) -> Any? { values[key] } + func set(_ value: Any?, forKey key: String) { values[key] = value } + func synchronize() {} + } + + private func tile(_ path: String, guid: Int) -> [String: Any] { + var tile = AppleDockTiles.appTile(path: path) + tile["GUID"] = guid + var data = tile["tile-data"] as! [String: Any] + data["file-label"] = ((path as NSString).lastPathComponent as NSString).deletingPathExtension + tile["tile-data"] = data + return tile + } + + func testCaptureAndApplyRestartsOnlyWhenDifferent() { + let defaults = FakeDefaults() + var restarts = 0 + let dock = AppleDock(defaults: defaults) { restarts += 1 } + defaults.values[AppleDock.appsKey] = [tile("/Applications/Mail.app", guid: 1)] + let work = dock.currentLayout() + XCTAssertEqual(work.appNames, ["Mail"]) + + // Same items with fresh bookkeeping: nothing to do. + defaults.values[AppleDock.appsKey] = [tile("/Applications/Mail.app", guid: 99)] + XCTAssertFalse(dock.apply(work)) + XCTAssertEqual(restarts, 0) + + defaults.values[AppleDock.appsKey] = [tile("/Applications/Music.app", guid: 2)] + XCTAssertTrue(dock.apply(work)) + XCTAssertEqual(restarts, 1) + XCTAssertEqual(AppleDockTiles.labels(of: defaults.values[AppleDock.appsKey] as! [Any]), ["Mail"]) + } + + func testApplyKeepsBoundedBackups() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("dock-backups-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let defaults = FakeDefaults() + let dock = AppleDock(defaults: defaults, backupDirectory: dir) {} + for index in 0..<(AppleDock.backupLimit + 3) { + let layout = AppleDockLayout( + appTiles: [tile("/Applications/App\(index).app", guid: index)], otherTiles: [], + capturedAt: Date(timeIntervalSince1970: Double(index)) + ) + dock.apply(layout) + } + let files = try FileManager.default.contentsOfDirectory(atPath: dir.path) + XCTAssertEqual(files.count, AppleDock.backupLimit) + } + + func testHideAndRestorePutBackWhatWasThere() { + let defaults = FakeDefaults() + var restarts = 0 + let dock = AppleDock(defaults: defaults) { restarts += 1 } + XCTAssertFalse(dock.isHidden) + let backup = dock.hide() + XCTAssertEqual(backup, AppleDockVisibility(autohide: nil, autohideDelay: nil)) + XCTAssertTrue(dock.isHidden) + XCTAssertNil(dock.hide(), "hiding twice must not overwrite the real backup") + dock.restore(backup!) + XCTAssertNil(defaults.values[AppleDock.autohideKey]) + XCTAssertNil(defaults.values[AppleDock.autohideDelayKey]) + XCTAssertFalse(dock.isHidden) + XCTAssertEqual(restarts, 2) + } + + func testUnhideWithoutBackupKeepsAutohide() { + let defaults = FakeDefaults() + defaults.values[AppleDock.autohideKey] = true + defaults.values[AppleDock.autohideDelayKey] = 1000.0 + let dock = AppleDock(defaults: defaults) {} + dock.unhideWithoutBackup() + XCTAssertEqual(defaults.values[AppleDock.autohideKey] as? Bool, true) + XCTAssertNil(defaults.values[AppleDock.autohideDelayKey]) + } + + /// An empty layout is an unreadable save; writing it would unpin + /// every app in the Dock. + func testAnEmptyLayoutIsNeverWritten() { + let defaults = FakeDefaults() + defaults.values[AppleDock.appsKey] = [tile("/Applications/Mail.app", guid: 1)] + var restarts = 0 + let dock = AppleDock(defaults: defaults) { restarts += 1 } + XCTAssertFalse(dock.apply(AppleDockLayout(appTiles: [], otherTiles: []))) + XCTAssertEqual(restarts, 0) + XCTAssertEqual((defaults.values[AppleDock.appsKey] as? [Any])?.count, 1) + XCTAssertTrue(AppleDockLayout(appTiles: [], otherTiles: []).isEmpty) + } + + func testLabelsSkipSpacers() { + let tiles: [Any] = [ + tile("/Applications/Mail.app", guid: 1), + ["tile-type": "small-spacer-tile", "tile-data": [String: Any]()], + AppleDockTiles.folderTile(path: "/Users/me/Downloads"), + ] + XCTAssertEqual(AppleDockTiles.labels(of: tiles), ["Mail", "Downloads"]) + XCTAssertTrue(AppleDockTiles.isSpacer(tiles[1])) + XCTAssertEqual(AppleDockTiles.fileURL(of: tiles[2])?.path, "/Users/me/Downloads") + } +} diff --git a/docs/CLI.md b/docs/CLI.md index 6924f0b..794bd0b 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -18,6 +18,7 @@ barshelf new [--kind exec|workflow|script] [--dir ] # 기본 kind barshelf validate # widget.json(+workflow.json 있으면) Core 디코더로 검증, 오류를 파일:필드 단위로 출력 barshelf pack [-o .mbw] # zip(.mbw) 생성 + 아카이브에 manifest.sha256 포함(widget.json의 sha256) barshelf list # 설치된 위젯 나열 (id, name, version, kind) +barshelf dock list|use |next|previous|restore-apple-dock # 독 프로필 전환, Apple Dock 복구 barshelf agent-spec # 위젯 작성 스펙(docs/AGENTS.md)을 stdout으로 출력 (LLM 에이전트용) barshelf upgrade [--check] [--yes] [--restart] [--app ] # CLI와 BarShelf.app을 최신 릴리스로 갱신 barshelf --version / --help @@ -32,6 +33,7 @@ barshelf --version / --help | `barshelf validate ` | 위젯 디렉터리 또는 pack된 `.mbw` 파일 | `widget.json`(그리고 `workflow.json`이 있으면 함께)을 Core 디코더로 검증하고, 오류를 파일:필드 단위로 출력한다. `.mbw`를 받으면 안전 추출 후 검증한다. | | `barshelf pack ` | `-o .mbw` (출력 파일명) | 위젯 디렉터리를 zip(`.mbw`)으로 패키징하고, 아카이브에 `manifest.sha256`(widget.json의 sha256)을 포함한다. | | `barshelf list` | — | 설치된 위젯을 id, name, version, kind로 나열한다. | +| `barshelf dock` | `list`, `use <프로필>`, `next`, `previous`, `restore-apple-dock` | 독 프로필([`docs/DOCK.md`](DOCK.md))을 나열하고 전환한다. `use`는 이름·id·번호를 받아 `barshelf://dock?profile=`로 실행 중인 앱에 전달한다. `restore-apple-dock`은 앱 없이도 숨겨진 Apple Dock을 되돌린다. | | `barshelf agent-spec` | — | 위젯 작성 스펙([`docs/AGENTS.md`](AGENTS.md))을 stdout으로 출력한다. LLM 에이전트에게 위젯 제작 계약 전체를 한 번에 넘길 때 쓴다. 개발 체크아웃에서는 디스크의 `docs/AGENTS.md`를, 단독 배포 바이너리에서는 빌드시 내장된 사본을 출력한다(내용 동일). | | `barshelf upgrade` | `--check`(확인만), `--yes`(확인 프롬프트 생략), `--restart`(앱 재시작), `--app ` | CLI(`barshelf`·`bsf`)와 `BarShelf.app`을 최신 GitHub 릴리스로 갱신한다. 자세한 내용은 아래 [자가 업데이트](#자가-업데이트). | | `barshelf --version` / `barshelf --help` | — | 버전/도움말 출력. | diff --git a/docs/DOCK.md b/docs/DOCK.md new file mode 100644 index 0000000..8a9298a --- /dev/null +++ b/docs/DOCK.md @@ -0,0 +1,114 @@ +# BarShelf Dock + +BarShelf is menu-bar-first; the dock is an option, off by default. Open the +BarShelf window's **Dock** page to turn it on. + +## Modes + +| Mode | What happens | +| --- | --- | +| **Off** | No BarShelf Dock. Profiles can still switch the Apple Dock's apps (below). | +| **Alongside the Apple Dock** | The BarShelf Dock sits on its edge next to the Apple Dock. On the same edge it sits just above it. | +| **Instead of the Apple Dock** | The Apple Dock is hidden while BarShelf runs. | + +Hiding the Apple Dock uses the only method macOS allows: BarShelf turns on its +auto-hide with a very long delay, then restarts the Dock. First it saves your +own `autohide` and `autohide-delay` settings to `dock.json`, and it puts them +back when you switch modes or quit BarShelf. If BarShelf crashes, the next +launch puts them back too. If the Apple Dock ever stays hidden anyway: + +```bash +barshelf dock restore-apple-dock +``` + +With BarShelf running, this asks the app to put the Apple Dock back and leave +"Instead of the Apple Dock"; otherwise it restores the saved settings itself. + +In a full-screen app the dock waits at the edge and slides out when the +pointer rests there, as the Apple Dock does (**Show in full-screen apps**). + +macOS has no public way to make other apps' windows stay clear of a dock that +isn't Apple's. Windows can go under the BarShelf Dock, as they do under an +auto-hidden Apple Dock. Turn on **Automatically hide and show the dock** if +that gets in the way. The dock then slides in when the pointer reaches the +screen edge. + +## What goes in the dock + +- **Apps**: click to open or bring forward. Drop files on an app to open them + with it. A dot means the app is running. Apps that are open but not in the + profile come after a divider (optional), and up to three recently used + apps after those (**Show recent apps**). +- **Folders**: click for a grid of the folder's contents, newest first, as + the Apple Dock shows a stack: subfolders open in place, and files drag out. + **Open folders as ▸ List** shows a menu instead. A folder can be a coloured + tile with a letter or two. +- **Files** and **links**: open on click. +- **Shortcuts**: run on click, through the `shortcuts` tool. +- **Widgets**: any BarShelf widget, live. A dock widget refreshes on its own + schedule even while the popup is closed, as a menu bar item does. An + auto-hidden dock stops refreshing until it shows again. +- **Spaces** and **dividers**, and the **Trash** (drop files on it to throw them away). + +Drag apps, folders, files, or web links onto the dock to add them. Drag an +item onto another to move it. Right-click any item for its menu, including +**Remove from Dock**. + +The Apple Dock's own settings are there too: size (by default the same as +the Apple Dock's, following it when it changes), magnification and how +much, which display (the main one, or the one the pointer rests at the edge +of), auto-hide and how long the pointer waits at the edge, opening +animation, and indicators for open apps. + +**Classic** looks like the Apple Dock: its icon size by default, icons on +clear Liquid Glass (macOS 26 and later), names on hover, optional +magnification. Widgets keep to icon height there, showing their name and main +reading (or two small bars). **Shelf** is a sturdier bar with names under +icons and widgets as full cards. + +## Profiles + +Each profile has its own items. It can also carry: + +- **An Apple Dock layout.** Arrange the Apple Dock, then click **Save Current + Apple Dock**. With **Switch the Apple Dock's apps with the profile** on, + switching profiles rewrites the Apple Dock's pinned apps and folders and + restarts the Dock for a moment. Open apps and windows aren't affected. The + layout being replaced goes to `dock-backups/` first (the last ten are kept). +- **A popup page**, which the BarShelf popup turns to. + +Ways to switch: + +- **⌃⌥1–9** for the first nine profiles (turn on in **Switching**). A number + another app already uses is marked as taken; one your Automation script uses + stays with Automation, and comes back to the dock when Automation stops. +- A **two-finger sideways swipe** on the dock, or **⌘-scroll** over it. +- **BarShelf menu ▸ Dock**, or right-click the dock. +- A link: `barshelf://dock?profile=Work` (name, id, or number; the link + **Dock ▸ Switching** copies uses the id, so renaming the profile does not + break it), + `barshelf://dock?next`, `barshelf://dock?previous`. +- Terminal: `barshelf dock use Work`, `barshelf dock next`, `barshelf dock list`. + +### Following a Focus + +macOS doesn't tell other apps which Focus is on, so this goes through Shortcuts: + +1. Open **Shortcuts ▸ Automation ▸ New Automation ▸ Focus**, pick the Focus, + and choose **When Turning On** and **Run Immediately**. +2. Add the **Open URLs** action with the profile's link. **Dock ▸ Switching** + shows it, with a Copy button. +3. To go back when the Focus ends, add a **When Turning Off** automation with + the other profile's link. + +## Files + +| Path (under `~/Library/Application Support/barshelf/`) | Contents | +| --- | --- | +| `dock.json` | Mode, look, profiles, and the Apple Dock backup while it is hidden | +| `dock-backups/apple-dock-*.plist` | The Apple Dock layouts replaced by profile switches | + +## Not possible (no public API) + +Badges on app icons (unread counts), window previews, and minimised windows in +the dock.