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.