diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 199c1ea13003..e47967d9a938 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -218,28 +218,41 @@ jobs: # The display churn regression creates its own virtual display above, # so recording must start after that harness is ready. if [ "$RECORD_VIDEO" = "true" ]; then - DEVLIST=$( ffmpeg -f avfoundation -list_devices true -i "" 2>&1 || true ) - echo "Available devices:" - echo "$DEVLIST" | grep -E "AVFoundation|Capture screen" - - SCREEN_INDEX=$( echo "$DEVLIST" | grep "Capture screen" | head -1 \ - | sed 's/.*\[\([0-9]*\)\].*/\1/' ) - SCREEN_INDEX="${SCREEN_INDEX:-0}" - echo "Using screen device index: $SCREEN_INDEX" - - ffmpeg -f avfoundation -framerate 10 -capture_cursor 1 \ - -i "${SCREEN_INDEX}:none" \ - -c:v libx264 -preset ultrafast -pix_fmt yuv420p \ - /tmp/test-recording-raw.mp4 /tmp/ffmpeg.log 2>&1 & - RECORD_PID=$! + if [ -x /usr/sbin/screencapture ]; then + RAW_RECORDING=/tmp/test-recording-raw.mov + /usr/sbin/screencapture -v -k -D 1 "$RAW_RECORDING" >/tmp/screencapture.log 2>&1 & + RECORD_PID=$! + echo "RECORD_BACKEND=screencapture" >> "$GITHUB_ENV" + echo "RECORD_RAW=$RAW_RECORDING" >> "$GITHUB_ENV" + echo "Started screencapture video recording" + else + DEVLIST=$( ffmpeg -f avfoundation -list_devices true -i "" 2>&1 || true ) + echo "Available devices:" + echo "$DEVLIST" | grep -E "AVFoundation|Capture screen" + + SCREEN_INDEX=$( echo "$DEVLIST" | grep "Capture screen" | head -1 \ + | sed 's/.*\[\([0-9]*\)\].*/\1/' ) + SCREEN_INDEX="${SCREEN_INDEX:-0}" + echo "Using screen device index: $SCREEN_INDEX" + + RAW_RECORDING=/tmp/test-recording-raw.mp4 + ffmpeg -f avfoundation -framerate 10 -capture_cursor 1 \ + -i "${SCREEN_INDEX}:none" \ + -c:v libx264 -preset ultrafast -pix_fmt yuv420p \ + "$RAW_RECORDING" /tmp/ffmpeg.log 2>&1 & + RECORD_PID=$! + echo "RECORD_BACKEND=ffmpeg" >> "$GITHUB_ENV" + echo "RECORD_RAW=$RAW_RECORDING" >> "$GITHUB_ENV" + fi echo "RECORD_PID=$RECORD_PID" >> "$GITHUB_ENV" sleep 2 if kill -0 "$RECORD_PID" 2>/dev/null; then echo "Recording started (PID $RECORD_PID)" else - echo "::warning::ffmpeg screen recording failed to start" - cat /tmp/ffmpeg.log + echo "::warning::screen recording failed to start" + cat /tmp/screencapture.log 2>/dev/null || true + cat /tmp/ffmpeg.log 2>/dev/null || true fi fi @@ -297,13 +310,50 @@ jobs: fi sleep 1 done + if kill -0 "$RECORD_PID" 2>/dev/null; then + kill -TERM "$RECORD_PID" 2>/dev/null || true + for i in $(seq 1 5); do + if ! kill -0 "$RECORD_PID" 2>/dev/null; then + echo "Recording terminated after ${i}s" + break + fi + sleep 1 + done + fi kill -9 "$RECORD_PID" 2>/dev/null || true echo "=== raw recording ===" - ls -lh /tmp/test-recording-raw.mp4 2>/dev/null || { echo "No recording file"; exit 0; } + RAW_RECORDING="${RECORD_RAW:-/tmp/test-recording-raw.mp4}" + ls -lh "$RAW_RECORDING" 2>/dev/null || { + echo "No recording file" + cat /tmp/screencapture.log 2>/dev/null || true + cat /tmp/ffmpeg.log 2>/dev/null || true + SCREENSHOT_ROOTS=("$HOME" "/Users/runner" /var/folders /tmp) + BEFORE_SCREENSHOT=$(find "${SCREENSHOT_ROOTS[@]}" -type f -name 'cmux-configured-menubar-demo-before.png' -print 2>/dev/null | head -1 || true) + OPEN_SCREENSHOT=$(find "${SCREENSHOT_ROOTS[@]}" -type f -name 'cmux-configured-menubar-demo-open.png' -print 2>/dev/null | head -1 || true) + find "${SCREENSHOT_ROOTS[@]}" -type f -name 'cmux-configured-menubar-demo-*.png' -print 2>/dev/null || true + if [ -n "$BEFORE_SCREENSHOT" ]; then + ls -lh "$BEFORE_SCREENSHOT" + fi + if [ -n "$OPEN_SCREENSHOT" ]; then + ls -lh "$OPEN_SCREENSHOT" + fi + if [ -f "$BEFORE_SCREENSHOT" ] && [ -f "$OPEN_SCREENSHOT" ]; then + echo "Creating fallback recording from UI test screenshots" + ffmpeg -y \ + -loop 1 -t 1 -i "$BEFORE_SCREENSHOT" \ + -loop 1 -t 4 -i "$OPEN_SCREENSHOT" \ + -filter_complex "[0:v]fps=10,format=yuv420p[v0];[1:v]fps=10,format=yuv420p[v1];[v0][v1]concat=n=2:v=1:a=0[v]" \ + -map "[v]" /tmp/test-recording.mp4 2>/tmp/fallback-recording.log + cat /tmp/fallback-recording.log 2>/dev/null || true + ls -lh /tmp/test-recording.mp4 + exit 0 + fi + exit 0 + } # Trim: detect first non-black frame and cut from there - BLACK_END=$(ffmpeg -i /tmp/test-recording-raw.mp4 \ + BLACK_END=$(ffmpeg -i "$RAW_RECORDING" \ -vf "blackdetect=d=0.3:pic_th=0.95:pix_th=0.1" \ -an -f null - 2>&1 \ | grep "black_end" | tail -1 \ @@ -311,12 +361,14 @@ jobs: if [ -n "$BLACK_END" ] && [ "$BLACK_END" != "0" ]; then echo "Trimming ${BLACK_END}s of black frames from start" - ffmpeg -y -i /tmp/test-recording-raw.mp4 -ss "$BLACK_END" \ + ffmpeg -y -i "$RAW_RECORDING" -ss "$BLACK_END" \ -c:v libx264 -preset ultrafast -pix_fmt yuv420p \ /tmp/test-recording.mp4 2>/dev/null else - echo "No black frames detected, using raw recording" - mv /tmp/test-recording-raw.mp4 /tmp/test-recording.mp4 + echo "No black frames detected, transcoding raw recording" + ffmpeg -y -i "$RAW_RECORDING" \ + -c:v libx264 -preset ultrafast -pix_fmt yuv420p \ + /tmp/test-recording.mp4 2>/dev/null fi echo "=== final recording ===" diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 17546bed9fe9..8c03ef539a73 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ C0DE31390000000000000105 /* CMUXCLIErrorOutputRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000106 /* CMUXCLIErrorOutputRegressionTests.swift */; }; C0DE31390000000000000103 /* FilePreviewReviewFeedbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000104 /* FilePreviewReviewFeedbackTests.swift */; }; C0DEF0A10000000000000001 /* CmuxConfigUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A10000000000000002 /* CmuxConfigUI.swift */; }; + C0DEF0C10000000000000001 /* ConfiguredMenuBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0C10000000000000002 /* ConfiguredMenuBarController.swift */; }; C0DEF0A30000000000000001 /* SidebarPortDisplayText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */; }; C0DEF0B10000000000000001 /* JSONCParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0B10000000000000002 /* JSONCParser.swift */; }; C0DEF0B10000000000000003 /* JSONCParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0B10000000000000002 /* JSONCParser.swift */; }; @@ -318,6 +319,7 @@ B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */; }; B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; }; C0DE34020000000000000005 /* HelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE34020000000000000006 /* HelpMenuUITests.swift */; }; + C0DE40520000000000000005 /* ConfiguredMenuBarDemoUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE40520000000000000006 /* ConfiguredMenuBarDemoUITests.swift */; }; B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; }; C0DE32470000000000000005 /* CommandPaletteIdentifierClipboardUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE32470000000000000006 /* CommandPaletteIdentifierClipboardUITests.swift */; }; B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */; }; @@ -812,6 +814,7 @@ B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnibarAndToolsTests.swift; sourceTree = ""; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = ""; }; C0DE34020000000000000006 /* HelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpMenuUITests.swift; sourceTree = ""; }; + C0DE40520000000000000006 /* ConfiguredMenuBarDemoUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfiguredMenuBarDemoUITests.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; C0DE32470000000000000006 /* CommandPaletteIdentifierClipboardUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIdentifierClipboardUITests.swift; sourceTree = ""; }; B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayResolutionRegressionUITests.swift; sourceTree = ""; }; @@ -856,6 +859,7 @@ C0DE31390000000000000106 /* CMUXCLIErrorOutputRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXCLIErrorOutputRegressionTests.swift; sourceTree = ""; }; C0DE31390000000000000104 /* FilePreviewReviewFeedbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewReviewFeedbackTests.swift; sourceTree = ""; }; C0DEF0A10000000000000002 /* CmuxConfigUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigUI.swift; sourceTree = ""; }; + C0DEF0C10000000000000002 /* ConfiguredMenuBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfiguredMenuBarController.swift; sourceTree = ""; }; C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPortDisplayText.swift; sourceTree = ""; }; C0DEF0A40000000000000002 /* CmuxConfigContextMenuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigContextMenuTests.swift; sourceTree = ""; }; A5001435A5001435A5001435 /* FilePreviewPDFThumbnailSidebarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewPDFThumbnailSidebarTests.swift; sourceTree = ""; }; @@ -1041,6 +1045,7 @@ B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */, 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, C0DE34020000000000000006 /* HelpMenuUITests.swift */, + C0DE40520000000000000006 /* ConfiguredMenuBarDemoUITests.swift */, B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, C0DE32470000000000000006 /* CommandPaletteIdentifierClipboardUITests.swift */, B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */, @@ -1293,6 +1298,7 @@ A5001651 /* CmuxConfig.swift */, C10D00040000000000000004 /* CmuxSurfaceTabBarBuiltInAction.swift */, C0DEF0A10000000000000002 /* CmuxConfigUI.swift */, + C0DEF0C10000000000000002 /* ConfiguredMenuBarController.swift */, E30750000000000000000003 /* CmuxWorkspaceDefinition.swift */, A5001653 /* CmuxConfigExecutor.swift */, A5001655 /* CmuxActionTrust.swift */, @@ -1933,6 +1939,7 @@ A5001650 /* CmuxConfig.swift in Sources */, C10D00030000000000000003 /* CmuxSurfaceTabBarBuiltInAction.swift in Sources */, C0DEF0A10000000000000001 /* CmuxConfigUI.swift in Sources */, + C0DEF0C10000000000000001 /* ConfiguredMenuBarController.swift in Sources */, E30750000000000000000004 /* CmuxWorkspaceDefinition.swift in Sources */, A5001652 /* CmuxConfigExecutor.swift in Sources */, A5001654 /* CmuxActionTrust.swift in Sources */, @@ -2027,6 +2034,7 @@ F0F0A001A1B2C3D4E5F60718 /* WorkspaceSidebarScrollUITests.swift in Sources */, B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */, C0DE34020000000000000005 /* HelpMenuUITests.swift in Sources */, + C0DE40520000000000000005 /* ConfiguredMenuBarDemoUITests.swift in Sources */, B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */, C0DE32470000000000000005 /* CommandPaletteIdentifierClipboardUITests.swift in Sources */, B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 350b08281806..ecc955071192 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -111212,6 +111212,34 @@ "rightSidebar.remote.error.unavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Right sidebar not available" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 右サイドバーを利用できません" } } } }, "rightSidebar.remote.error.focusFailed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Failed to focus right sidebar" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 右サイドバーにフォーカスできませんでした" } } } }, "rightSidebar.remote.error.modeUnavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: Right sidebar mode '%@' is not available" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: 右サイドバーのモード '%@' は利用できません" } } } }, - "rightSidebar.remote.error.appDelegateUnavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: App delegate not available" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: アプリデリゲートを利用できません" } } } } + "rightSidebar.remote.error.appDelegateUnavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "ERROR: App delegate not available" } }, "ja": { "stringUnit": { "state": "translated", "value": "ERROR: アプリデリゲートを利用できません" } } } }, + "dialog.cmuxConfig.confirmDynamicMenu.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Run Dynamic Menu Source: %@" } }, "ja": { "stringUnit": { "state": "translated", "value": "動的メニューソースを実行: %@" } } } }, + "menu.edit.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Edit" } }, "ja": { "stringUnit": { "state": "translated", "value": "編集" } } } }, + "menu.file.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "File" } }, "ja": { "stringUnit": { "state": "translated", "value": "ファイル" } } } }, + "menu.help.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Help" } }, "ja": { "stringUnit": { "state": "translated", "value": "ヘルプ" } } } }, + "menu.view.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "View" } }, "ja": { "stringUnit": { "state": "translated", "value": "表示" } } } }, + "menu.window.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Window" } }, "ja": { "stringUnit": { "state": "translated", "value": "ウインドウ" } } } }, + "menuBar.dynamic.copyError": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Copy Error" } }, "ja": { "stringUnit": { "state": "translated", "value": "エラーをコピー" } } } }, + "menuBar.dynamic.error.exitStatus": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Command exited with status %d." } }, "ja": { "stringUnit": { "state": "translated", "value": "コマンドはステータス %d で終了しました。" } } } }, + "menuBar.dynamic.error.outputLimit": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Command output exceeded %d bytes." } }, "ja": { "stringUnit": { "state": "translated", "value": "コマンド出力が %d バイトを超えました。" } } } }, + "menuBar.dynamic.error.timeout": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Command timed out after %.1f seconds." } }, "ja": { "stringUnit": { "state": "translated", "value": "コマンドは %.1f 秒でタイムアウトしました。" } } } }, + "menuBar.dynamic.failed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Dynamic menu failed" } }, "ja": { "stringUnit": { "state": "translated", "value": "動的メニューが失敗しました" } } } }, + "menuBar.dynamic.failure.notification.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Dynamic menu failed" } }, "ja": { "stringUnit": { "state": "translated", "value": "動的メニューが失敗しました" } } } }, + "menuBar.dynamic.load": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Load Dynamic Menu" } }, "ja": { "stringUnit": { "state": "translated", "value": "動的メニューを読み込む" } } } }, + "menuBar.dynamic.loading": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Loading..." } }, "ja": { "stringUnit": { "state": "translated", "value": "読み込み中..." } } } }, + "menuBar.dynamic.noCachedItems": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "No cached items" } }, "ja": { "stringUnit": { "state": "translated", "value": "キャッシュ項目なし" } } } }, + "menuBar.dynamic.noItems": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "No items" } }, "ja": { "stringUnit": { "state": "translated", "value": "項目なし" } } } }, + "menuBar.dynamic.notLoaded": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Not loaded" } }, "ja": { "stringUnit": { "state": "translated", "value": "未読み込み" } } } }, + "menuBar.dynamic.reload": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Reload" } }, "ja": { "stringUnit": { "state": "translated", "value": "再読み込み" } } } }, + "menuBar.dynamic.running": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Loading..." } }, "ja": { "stringUnit": { "state": "translated", "value": "読み込み中..." } } } }, + "taskManager.dynamicMenu.duration": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "%d ms" } }, "ja": { "stringUnit": { "state": "translated", "value": "%d ms" } } } }, + "taskManager.dynamicMenu.failed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Failed" } }, "ja": { "stringUnit": { "state": "translated", "value": "失敗" } } } }, + "taskManager.dynamicMenu.idle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Idle" } }, "ja": { "stringUnit": { "state": "translated", "value": "アイドル" } } } }, + "taskManager.dynamicMenu.items": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "%d items" } }, "ja": { "stringUnit": { "state": "translated", "value": "%d 項目" } } } }, + "taskManager.dynamicMenu.loaded": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Loaded" } }, "ja": { "stringUnit": { "state": "translated", "value": "読み込み済み" } } } }, + "taskManager.dynamicMenu.running": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Running" } }, "ja": { "stringUnit": { "state": "translated", "value": "実行中" } } } }, + "taskManager.row.dynamicMenu": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Dynamic Menu" } }, "ja": { "stringUnit": { "state": "translated", "value": "動的メニュー" } } } }, + "taskManager.row.dynamicMenus": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Dynamic Menus" } }, "ja": { "stringUnit": { "state": "translated", "value": "動的メニュー" } } } }, + "taskManager.row.dynamicMenus.count": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "%d sources" } }, "ja": { "stringUnit": { "state": "translated", "value": "%d 個のソース" } } } } } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a36f373a905c..1ea266b97214 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -688,6 +688,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var shortcutMonitor: Any? private var shortcutDefaultsObserver: NSObjectProtocol? private var menuBarVisibilityObserver: NSObjectProtocol? + private var configuredMenuBarController: ConfiguredMenuBarController? private var splitButtonTooltipRefreshScheduled = false private struct PendingConfiguredShortcutChord { let firstStroke: ShortcutStroke @@ -1595,6 +1596,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installLifecycleSnapshotObserversIfNeeded() prepareStartupSessionSnapshotIfNeeded() startSessionAutosaveTimerIfNeeded() + installConfiguredMenuBarObserversIfNeeded() #if DEBUG setupJumpUnreadUITestIfNeeded() setupTerminalCmdClickUITestIfNeeded() @@ -6236,6 +6238,82 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + func configuredMenuBarRuntimeContext(preferredWindow: NSWindow? = nil) -> ConfiguredMenuBarRuntimeContext { + let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow) + let store = context?.cmuxConfigStore + let cwd = context?.tabManager.selectedWorkspace?.currentDirectory + let workingDirectory = (cwd?.isEmpty == false) ? cwd! + : FileManager.default.homeDirectoryForCurrentUser.path + return ConfiguredMenuBarRuntimeContext( + menus: store?.menuBarMenus ?? [], + extensions: store?.menuBarExtensions ?? [], + configStore: store, + workingDirectory: workingDirectory + ) + } + + @discardableResult + func performConfiguredMenuBarAction( + _ action: CmuxResolvedConfigAction, + preferredWindow: NSWindow? = nil + ) -> Bool { + let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow) + ?? createMainWindowContextForConfiguredMenuBarAction() + guard let context else { + NSSound.beep() + return false + } + let preferredWindow = resolvedWindow(for: context) ?? preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + guard executeConfiguredCmuxAction(action, context: context, preferredWindow: preferredWindow) else { + NSSound.beep() + return false + } + return true + } + + func notifyConfiguredMenuBarDynamicFailure( + source: CmuxResolvedMenuBarDynamicSource, + error: String, + preferredWindow: NSWindow? + ) { + let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow) + guard let workspace = context?.tabManager.selectedWorkspace ?? tabManager?.selectedWorkspace else { return } + notificationStore?.addNotification( + tabId: workspace.id, + surfaceId: nil, + title: String(localized: "menuBar.dynamic.failure.notification.title", defaultValue: "Dynamic menu failed"), + subtitle: source.title, + body: error, + cooldownKey: "dynamicMenu.\(source.id).\(error.hashValue)", + cooldownInterval: 60 + ) + } + + func configuredDynamicMenuTaskManagerPayload() -> [[String: Any]] { + configuredMenuBarController?.taskManagerPayload() ?? [] + } + + private func createMainWindowContextForConfiguredMenuBarAction() -> MainWindowContext? { + guard mainWindowContexts.isEmpty else { + return preferredMainWindowContextForWorkspaceCreation(debugSource: "menuBar.configured") + } + let windowId = createMainWindow() + return mainWindowContexts.values.first { $0.windowId == windowId } + } + + private func configuredMenuBarControllerForApp() -> ConfiguredMenuBarController { + if let configuredMenuBarController { + return configuredMenuBarController + } + let controller = ConfiguredMenuBarController(owner: self) + configuredMenuBarController = controller + return controller + } + + private func installConfiguredMenuBarObserversIfNeeded() { + configuredMenuBarControllerForApp().installObserversIfNeeded() + } + /// Shows the "Open Folder" panel and creates a workspace for the selected directory. /// Called from both the SwiftUI menu and `handleCustomShortcut`. func showOpenFolderPanel() { diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index 11842b68fc98..61ad47e1aae2 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -7,6 +7,10 @@ extension CodingUserInfoKey { static let cmuxWorkspaceColorDefaults = CodingUserInfoKey(rawValue: "cmuxWorkspaceColorDefaults")! } +extension Notification.Name { + static let cmuxConfigStoreDidChange = Notification.Name("cmux.configStoreDidChange") +} + struct CmuxConfigFile: Codable, Sendable { var actions: [String: CmuxConfigActionDefinition] var ui: CmuxConfigUIDefinition? @@ -1643,6 +1647,7 @@ struct CmuxConfigIssue: Identifiable, Equatable, Sendable { case newWorkspaceActionNotFound case newWorkspaceCommandNotFound case newWorkspaceCommandRequiresWorkspace + case menuBarInvalidMenu case schemaError } @@ -1684,6 +1689,8 @@ struct CmuxConfigIssue: Identifiable, Equatable, Sendable { return "\(settingName) '\(commandName ?? "")' does not match any loaded command" case .newWorkspaceCommandRequiresWorkspace: return "\(settingName) '\(commandName ?? "")' must reference a workspace command" + case .menuBarInvalidMenu: + return "\(settingName) has an invalid menuBar menu: \(message ?? "unknown error")" case .schemaError: return "\(settingName) has a schema error: \(message ?? "unknown error")" } @@ -1702,6 +1709,8 @@ final class CmuxConfigStore: ObservableObject { @Published private(set) var newWorkspaceCommandName: String? @Published private(set) var newWorkspaceActionID: String? @Published private(set) var newWorkspaceContextMenuItems: [CmuxResolvedConfigContextMenuItem] = [] + @Published private(set) var menuBarMenus: [CmuxResolvedMenuBarMenu] = [] + @Published private(set) var menuBarExtensions: [CmuxResolvedMenuBarExtension] = [] @Published private(set) var surfaceTabBarButtons: [CmuxSurfaceTabBarButton] = CmuxSurfaceTabBarButton.defaults @Published private(set) var configurationIssues: [CmuxConfigIssue] = [] @Published private(set) var configRevision: UInt64 = 0 @@ -1719,6 +1728,14 @@ final class CmuxConfigStore: ObservableObject { private let fileWatchingEnabled: Bool nonisolated private static func defaultGlobalConfigPath() -> String { +#if DEBUG + let environment = ProcessInfo.processInfo.environment + if environment["CMUX_UI_TEST_MODE"] == "1", + let uiTestConfigPath = environment["CMUX_UI_TEST_CMUX_CONFIG_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !uiTestConfigPath.isEmpty { + return uiTestConfigPath + } +#endif let home = FileManager.default.homeDirectoryForCurrentUser.path return (home as NSString).appendingPathComponent(".config/cmux/cmux.json") } @@ -1728,6 +1745,12 @@ final class CmuxConfigStore: ObservableObject { let sourcePath: String? } + private struct MenuBarConfigGroup { + let menus: [CmuxConfigMenuDefinition] + let sourcePath: String? + let settingName: String + } + private struct ResolvedSurfaceTabBarButtonEntry { let button: CmuxSurfaceTabBarButton let terminalCommandSourcePath: String? @@ -1743,6 +1766,17 @@ final class CmuxConfigStore: ObservableObject { let issues: [CmuxConfigIssue] } + private struct ResolvedMenuBarMenus { + let menus: [CmuxResolvedMenuBarMenu] + let extensions: [CmuxResolvedMenuBarExtension] + let issues: [CmuxConfigIssue] + } + + private struct ResolvedMenuBarItems { + let items: [CmuxResolvedMenuBarItem] + let issues: [CmuxConfigIssue] + } + private struct NewWorkspaceCommandResolution { let command: CmuxResolvedCommand? let issue: CmuxConfigIssue? @@ -1914,6 +1948,7 @@ final class CmuxConfigStore: ObservableObject { var configuredNewWorkspaceActionSourcePath: String? var configuredNewWorkspaceContextMenu: [CmuxConfigContextMenuItem]? var configuredNewWorkspaceContextMenuSourcePath: String? + var configuredMenuBarGroups: [MenuBarConfigGroup] = [] var configuredSurfaceTabBarButtons: [CmuxSurfaceTabBarButton]? var configuredSurfaceTabBarButtonSourcePath: String? let localPath = localConfigPath @@ -1941,6 +1976,13 @@ final class CmuxConfigStore: ObservableObject { configuredNewWorkspaceContextMenu = contextMenu configuredNewWorkspaceContextMenuSourcePath = localPath } + if let menus = localConfig.ui?.menuBar?.menus { + configuredMenuBarGroups.append(MenuBarConfigGroup( + menus: menus, + sourcePath: localPath, + settingName: "ui.menuBar.menus" + )) + } if configuredNewWorkspaceActionID == nil, let newWorkspaceCommand = localConfig.newWorkspaceCommand { configuredNewWorkspaceCommandName = newWorkspaceCommand @@ -1974,6 +2016,16 @@ final class CmuxConfigStore: ObservableObject { configuredNewWorkspaceContextMenu = contextMenu configuredNewWorkspaceContextMenuSourcePath = globalConfigPath } + if let menus = globalConfig.ui?.menuBar?.menus { + configuredMenuBarGroups.insert( + MenuBarConfigGroup( + menus: menus, + sourcePath: globalConfigPath, + settingName: "ui.menuBar.menus" + ), + at: 0 + ) + } if configuredNewWorkspaceActionID == nil, configuredNewWorkspaceCommandName == nil, let newWorkspaceCommand = globalConfig.newWorkspaceCommand { @@ -2040,6 +2092,12 @@ final class CmuxConfigStore: ObservableObject { settingName: "ui.newWorkspace.contextMenu", settingSourcePath: configuredNewWorkspaceContextMenuSourcePath ) + let resolvedMenuBarMenus = resolvedMenuBarMenus( + configuredMenuBarGroups, + actions: resolvedActionLookup, + commands: commands, + sourcePaths: sourcePaths + ) loadedCommands = commands loadedActions = resolvedActions @@ -2049,6 +2107,8 @@ final class CmuxConfigStore: ObservableObject { newWorkspaceActionSourcePath = configuredNewWorkspaceActionSourcePath newWorkspaceCommandName = configuredNewWorkspaceCommandName newWorkspaceContextMenuItems = resolvedNewWorkspaceContextMenuItems.items + menuBarMenus = resolvedMenuBarMenus.menus + menuBarExtensions = resolvedMenuBarMenus.extensions surfaceTabBarButtonSourcePath = configuredSurfaceTabBarButtonSourcePath surfaceTabBarCommandSourcePaths = resolvedButtons.terminalCommandSourcePaths surfaceTabBarWorkspaceCommands = resolvedWorkspaceButtons.workspaceCommands @@ -2059,9 +2119,29 @@ final class CmuxConfigStore: ObservableObject { issues.append(issue) } issues.append(contentsOf: resolvedNewWorkspaceContextMenuItems.issues) + issues.append(contentsOf: resolvedMenuBarMenus.issues) configurationIssues = issues applySurfaceTabBarButtonsToCurrentManager() configRevision &+= 1 + NotificationCenter.default.post(name: .cmuxConfigStoreDidChange, object: self) + } + + func resolveGeneratedMenuBarItems( + _ configuredItems: [CmuxConfigMenuBarItem], + settingName: String, + settingSourcePath: String? + ) -> (items: [CmuxResolvedMenuBarItem], issues: [CmuxConfigIssue]) { + let resolved = resolvedMenuBarItems( + configuredItems, + actions: actionLookup, + commands: loadedCommands, + sourcePaths: commandSourcePaths, + settingName: settingName, + settingSourcePath: settingSourcePath, + identityName: settingName, + allowDynamicSources: false + ) + return (resolved.items, resolved.issues) } private func actionEntries( @@ -2303,8 +2383,12 @@ final class CmuxConfigStore: ObservableObject { func shortcutActions() -> [CmuxResolvedConfigAction] { let builtInIDs = Set(CmuxSurfaceTabBarBuiltInAction.allCases.map(\.configID)) - return loadedActions.filter { action in - action.shortcut != nil && (builtInIDs.contains(action.id) || action.actionSourcePath != nil) + let menuBarActions = menuBarShortcutActions() + let menuBarActionIDs = Set(menuBarActions.map(\.id)) + let configuredActions = loadedActions.filter { action in + action.shortcut != nil && + !menuBarActionIDs.contains(action.id) && + (builtInIDs.contains(action.id) || action.actionSourcePath != nil) }.sorted { lhs, rhs in let lhsPriority = builtInIDs.contains(lhs.id) ? 0 : 1 let rhsPriority = builtInIDs.contains(rhs.id) ? 0 : 1 @@ -2313,6 +2397,40 @@ final class CmuxConfigStore: ObservableObject { } return lhs.id.localizedStandardCompare(rhs.id) == .orderedAscending } + return menuBarActions + configuredActions + } + + private func menuBarShortcutActions() -> [CmuxResolvedConfigAction] { + var actions: [CmuxResolvedConfigAction] = [] + var seen = Set() + for menu in menuBarMenus { + appendMenuBarShortcutActions(from: menu.items, to: &actions, seen: &seen) + } + for menuExtension in menuBarExtensions { + appendMenuBarShortcutActions(from: menuExtension.items, to: &actions, seen: &seen) + } + return actions + } + + private func appendMenuBarShortcutActions( + from items: [CmuxResolvedMenuBarItem], + to actions: inout [CmuxResolvedConfigAction], + seen: inout Set + ) { + for item in items { + switch item { + case .action(let menuAction): + if let shortcut = menuAction.action.shortcut { + let key = "\(menuAction.action.id)\u{1F}\(shortcut.configIdentifier)" + guard seen.insert(key).inserted else { continue } + actions.append(menuAction.action) + } + case .submenu(let submenu): + appendMenuBarShortcutActions(from: submenu.items, to: &actions, seen: &seen) + case .dynamicSource, .separator: + continue + } + } } private func resolvedConfiguredNewWorkspaceAction( @@ -2468,6 +2586,303 @@ final class CmuxConfigStore: ObservableObject { return ResolvedContextMenuItems(items: resolvedItems, issues: issues) } + private func resolvedMenuBarMenus( + _ configuredGroups: [MenuBarConfigGroup], + actions: [String: CmuxResolvedConfigAction], + commands: [CmuxCommandDefinition], + sourcePaths: [String: String] + ) -> ResolvedMenuBarMenus { + guard !configuredGroups.isEmpty else { + return ResolvedMenuBarMenus(menus: [], extensions: [], issues: []) + } + + var menus: [CmuxResolvedMenuBarMenu] = [] + var extensions: [CmuxResolvedMenuBarExtension] = [] + var issues: [CmuxConfigIssue] = [] + + for (groupIndex, group) in configuredGroups.enumerated() { + for (index, menu) in group.menus.enumerated() { + let menuSettingName = "\(group.settingName)[\(index)]" + let menuIdentityName = "\(group.settingName).source\(groupIndex)[\(index)]" + let resolvedItems = resolvedMenuBarItems( + menu.items, + actions: actions, + commands: commands, + sourcePaths: sourcePaths, + settingName: "\(menuSettingName).items", + settingSourcePath: group.sourcePath, + identityName: "\(menuIdentityName).items" + ) + issues.append(contentsOf: resolvedItems.issues) + guard !resolvedItems.items.isEmpty else { continue } + + if let targetID = menu.extends { + if menu.before != nil || menu.after != nil { + issues.append(CmuxConfigIssue( + kind: .menuBarInvalidMenu, + settingName: menuSettingName, + sourcePath: group.sourcePath, + message: "menuBar extensions cannot define before or after" + )) + } + let sanitizedTargetID = sanitizeConfigText(targetID, fallback: targetID) + extensions.append( + CmuxResolvedMenuBarExtension( + id: "\(menuIdentityName).extends.\(sanitizedTargetID)", + targetID: sanitizedTargetID, + items: resolvedItems.items + ) + ) + continue + } + + let before = menu.before.map { sanitizeConfigText($0, fallback: $0) } + let after = menu.after.map { sanitizeConfigText($0, fallback: $0) } + let resolvedBefore: String? + let resolvedAfter: String? + if before != nil, after != nil { + issues.append(CmuxConfigIssue( + kind: .menuBarInvalidMenu, + settingName: menuSettingName, + sourcePath: group.sourcePath, + message: "menuBar menu must define at most one of before or after" + )) + resolvedBefore = nil + resolvedAfter = nil + } else { + resolvedBefore = before + resolvedAfter = after + } + + guard let title = menu.title else { + issues.append(CmuxConfigIssue( + kind: .menuBarInvalidMenu, + settingName: menuSettingName, + sourcePath: group.sourcePath, + message: "menuBar menu must define title or extends" + )) + continue + } + + let fallbackID = menu.id ?? Self.generatedMenuID(title: title, index: index) + let configID = sanitizeConfigText(fallbackID, fallback: String(index)) + menus.append( + CmuxResolvedMenuBarMenu( + id: "\(menuIdentityName).\(configID)", + configID: configID, + title: sanitizeConfigText(title, fallback: fallbackID), + before: resolvedBefore, + after: resolvedAfter, + items: resolvedItems.items + ) + ) + } + } + + return ResolvedMenuBarMenus(menus: menus, extensions: extensions, issues: issues) + } + + private func resolvedMenuBarItems( + _ configuredItems: [CmuxConfigMenuBarItem], + actions: [String: CmuxResolvedConfigAction], + commands: [CmuxCommandDefinition], + sourcePaths: [String: String], + settingName: String, + settingSourcePath: String?, + identityName: String? = nil, + allowDynamicSources: Bool = true + ) -> ResolvedMenuBarItems { + guard !configuredItems.isEmpty else { + return ResolvedMenuBarItems(items: [], issues: []) + } + + var resolvedItems: [CmuxResolvedMenuBarItem] = [] + var issues: [CmuxConfigIssue] = [] + resolvedItems.reserveCapacity(configuredItems.count) + let itemIdentityPrefix = identityName ?? settingName + + for (index, configuredItem) in configuredItems.enumerated() { + let itemSettingName = "\(settingName)[\(index)]" + let itemIdentityName = "\(itemIdentityPrefix)[\(index)]" + switch configuredItem { + case .separator: + appendMenuBarSeparatorIfNeeded(to: &resolvedItems, id: "\(itemIdentityName).separator") + case .submenu(let submenu): + guard let submenuTitle = submenu.title, + submenu.extends == nil, + submenu.before == nil, + submenu.after == nil else { + issues.append(CmuxConfigIssue( + kind: .menuBarInvalidMenu, + settingName: itemSettingName, + sourcePath: settingSourcePath, + message: "nested menuBar menus must define title and cannot define extends, before, or after" + )) + continue + } + let nested = resolvedMenuBarItems( + submenu.items, + actions: actions, + commands: commands, + sourcePaths: sourcePaths, + settingName: "\(itemSettingName).items", + settingSourcePath: settingSourcePath, + identityName: "\(itemIdentityName).items", + allowDynamicSources: allowDynamicSources + ) + issues.append(contentsOf: nested.issues) + guard !nested.items.isEmpty else { continue } + let fallbackID = submenu.id ?? Self.generatedMenuID(title: submenuTitle, index: index) + let configID = sanitizeConfigText(fallbackID, fallback: String(index)) + resolvedItems.append( + .submenu( + CmuxResolvedMenuBarMenu( + id: "\(itemIdentityName).\(configID)", + configID: configID, + title: sanitizeConfigText(submenuTitle, fallback: fallbackID), + before: nil, + after: nil, + items: nested.items + ) + ) + ) + case .dynamic(let dynamicItem): + guard allowDynamicSources else { + issues.append(CmuxConfigIssue( + kind: .menuBarInvalidMenu, + settingName: itemSettingName, + sourcePath: settingSourcePath, + message: "dynamic menu sources cannot return another dynamic source" + )) + continue + } + let fallbackID = dynamicItem.id ?? Self.generatedMenuID(title: dynamicItem.title, index: index) + let configID = sanitizeConfigText(fallbackID, fallback: String(index)) + resolvedItems.append(.dynamicSource(CmuxResolvedMenuBarDynamicSource( + id: "\(itemIdentityName).\(configID)", + title: sanitizeConfigText(dynamicItem.title, fallback: fallbackID), + icon: dynamicItem.icon, + tooltip: dynamicItem.tooltip.map(sanitizeConfigText), + source: dynamicItem.source, + settingName: itemSettingName, + settingSourcePath: settingSourcePath + ))) + case .action(let item): + let resolved = resolvedMenuBarAction( + item, + actions: actions, + commands: commands, + sourcePaths: sourcePaths, + settingName: itemSettingName, + settingSourcePath: settingSourcePath + ) + if let issue = resolved.issue { + issues.append(issue) + continue + } + guard var action = resolved.action else { continue } + action.shortcut = item.shortcut ?? action.shortcut + resolvedItems.append( + .action( + CmuxResolvedConfigMenuAction( + id: "\(itemIdentityName).\(action.id)", + title: sanitizeConfigText(item.title ?? action.title, fallback: action.id), + icon: item.icon ?? action.icon, + tooltip: (item.tooltip ?? action.tooltip).map(sanitizeConfigText), + action: action + ) + ) + ) + } + } + + if let last = resolvedItems.last, case .separator = last { + resolvedItems.removeLast() + } + return ResolvedMenuBarItems(items: resolvedItems, issues: issues) + } + + private func appendMenuBarSeparatorIfNeeded( + to items: inout [CmuxResolvedMenuBarItem], + id: String + ) { + guard !items.isEmpty else { return } + if let last = items.last, case .separator = last { + return + } + items.append(.separator(id: id)) + } + + private func resolvedMenuBarAction( + _ item: CmuxConfigMenuBarActionItem, + actions: [String: CmuxResolvedConfigAction], + commands: [CmuxCommandDefinition], + sourcePaths: [String: String], + settingName: String, + settingSourcePath: String? + ) -> (action: CmuxResolvedConfigAction?, issue: CmuxConfigIssue?) { + let action: CmuxResolvedConfigAction + + if let actionReference = item.action { + let resolvedActionID = canonicalActionID(actionReference) + guard let resolved = actions[resolvedActionID] else { + let issue = CmuxConfigIssue( + kind: .newWorkspaceActionNotFound, + settingName: settingName, + commandName: actionReference, + sourcePath: settingSourcePath + ) + return (nil, issue) + } + action = resolved + } else if let inlineAction = item.inlineAction { + let id = "cmux.menuBar." + Self.generatedMenuID( + title: [settingName, item.title].compactMap { $0 }.joined(separator: "."), + index: 0 + ) + guard let resolved = CmuxResolvedConfigAction.fromDefinition( + id: id, + definition: inlineAction, + sourcePath: settingSourcePath + ) else { + let issue = CmuxConfigIssue( + kind: .newWorkspaceActionNotFound, + settingName: settingName, + commandName: item.title, + sourcePath: settingSourcePath + ) + return (nil, issue) + } + action = resolved + } else { + return (nil, nil) + } + + if let actionCommandName = action.workspaceCommandName { + let commandResolution = resolvedConfiguredNewWorkspaceCommand( + named: actionCommandName, + settingName: settingName, + settingSourcePath: action.actionSourcePath ?? settingSourcePath, + commands: commands, + sourcePaths: sourcePaths + ) + if let issue = commandResolution.issue { + return (nil, issue) + } + guard commandResolution.command != nil else { + return (nil, nil) + } + } + + return (action, nil) + } + + private static func generatedMenuID(title: String, index: Int) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + let encoded = title.addingPercentEncoding(withAllowedCharacters: allowed) ?? title + return encoded.isEmpty ? "menu.\(index)" : encoded + } + private func canonicalActionID(_ id: String) -> String { CmuxSurfaceTabBarBuiltInAction(configID: id)?.configID ?? id } diff --git a/Sources/CmuxConfigExecutor.swift b/Sources/CmuxConfigExecutor.swift index 9a8e84e95368..4620e466fa44 100644 --- a/Sources/CmuxConfigExecutor.swift +++ b/Sources/CmuxConfigExecutor.swift @@ -160,6 +160,55 @@ struct CmuxConfigExecutor { } } + @discardableResult + static func authorizeDynamicMenuSourceIfNeeded( + command rawCommand: String, + sourceID: String, + configSourcePath: String?, + globalConfigPath: String, + displayTitle: String?, + presentingWindow: NSWindow? = nil, + onAuthorized: @escaping (String) -> Void + ) -> Bool { + let shellCommand = sanitizeForDisplay(rawCommand) + guard !shellCommand.isEmpty else { return false } + + return authorizeProjectActionIfNeeded( + descriptor: dynamicMenuSourceTrustDescriptor( + command: shellCommand, + sourceID: sourceID, + configSourcePath: configSourcePath + ), + confirm: false, + configSourcePath: configSourcePath, + globalConfigPath: globalConfigPath, + displayCommand: shellCommand, + displayTitle: displayTitle, + presentingWindow: presentingWindow + ) { + onAuthorized(shellCommand) + } + } + + static func isTrustedDynamicMenuSource( + command rawCommand: String, + sourceID: String, + configSourcePath: String?, + globalConfigPath: String + ) -> Bool { + let shellCommand = sanitizeForDisplay(rawCommand) + guard !shellCommand.isEmpty else { return false } + guard let sourcePath = configSourcePath.map(canonicalPath), + sourcePath != canonicalPath(globalConfigPath) else { + return true + } + return CmuxActionTrust.shared.isTrusted(dynamicMenuSourceTrustDescriptor( + command: shellCommand, + sourceID: sourceID, + configSourcePath: configSourcePath + )) + } + @discardableResult private static func authorizeProjectActionIfNeeded( descriptor: CmuxActionTrustDescriptor, @@ -342,6 +391,23 @@ struct CmuxConfigExecutor { ) } + private static func dynamicMenuSourceTrustDescriptor( + command: String, + sourceID: String, + configSourcePath: String? + ) -> CmuxActionTrustDescriptor { + CmuxActionTrustDescriptor( + actionID: sourceID, + kind: "dynamicMenuSource", + command: command, + target: nil, + workspaceCommand: nil, + configPath: configSourcePath.map(canonicalPath), + projectRoot: configSourcePath.map { canonicalPath(CmuxButtonIcon.projectRoot(forConfigPath: $0)) }, + iconFingerprint: nil + ) + } + static func isTrustedSurfaceButton( _ button: CmuxSurfaceTabBarButton, workspaceCommand: CmuxResolvedCommand?, diff --git a/Sources/CmuxConfigUI.swift b/Sources/CmuxConfigUI.swift index 9be9805f1c99..5993b2b257a0 100644 --- a/Sources/CmuxConfigUI.swift +++ b/Sources/CmuxConfigUI.swift @@ -3,12 +3,605 @@ import AppKit struct CmuxConfigUIDefinition: Codable, Sendable, Hashable { var newWorkspace: CmuxConfigButtonPlacement? var surfaceTabBar: CmuxSurfaceTabBarUIDefinition? + var menuBar: CmuxMenuBarUIDefinition? } struct CmuxSurfaceTabBarUIDefinition: Codable, Sendable, Hashable { var buttons: [CmuxSurfaceTabBarButton]? } +struct CmuxMenuBarUIDefinition: Codable, Sendable, Hashable { + var menus: [CmuxConfigMenuDefinition] + + private enum CodingKeys: String, CodingKey { + case menus + } + + init(menus: [CmuxConfigMenuDefinition] = []) { + self.menus = menus + } + + init(from decoder: Decoder) throws { + if let menus = try? decoder.singleValueContainer().decode([CmuxConfigMenuDefinition].self) { + self.menus = menus + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + menus = try container.decodeIfPresent([CmuxConfigMenuDefinition].self, forKey: .menus) ?? [] + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(menus, forKey: .menus) + } +} + +struct CmuxConfigMenuDefinition: Codable, Sendable, Hashable { + var id: String? + var title: String? + var extends: String? + var before: String? + var after: String? + var items: [CmuxConfigMenuBarItem] + + private enum CodingKeys: String, CodingKey { + case id + case title + case extends + case before + case after + case items + } + + init( + id: String? = nil, + title: String, + before: String? = nil, + after: String? = nil, + items: [CmuxConfigMenuBarItem] = [] + ) { + self.id = id + self.title = title + self.extends = nil + self.before = before + self.after = after + self.items = items + } + + init(id: String? = nil, extends: String, items: [CmuxConfigMenuBarItem] = []) { + self.id = id + self.title = nil + self.extends = extends + self.before = nil + self.after = nil + self.items = items + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try Self.trimmedString(forKey: .id, in: container, allowBlankAsNil: true) + title = try Self.trimmedString(forKey: .title, in: container, allowBlankAsNil: true) + extends = try Self.trimmedString(forKey: .extends, in: container, allowBlankAsNil: true) + before = try Self.trimmedString(forKey: .before, in: container, allowBlankAsNil: true) + after = try Self.trimmedString(forKey: .after, in: container, allowBlankAsNil: true) + guard title != nil || extends != nil else { + throw DecodingError.keyNotFound( + CodingKeys.title, + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "menuBar menus require either title or extends" + ) + ) + } + items = try container.decodeIfPresent([CmuxConfigMenuBarItem].self, forKey: .items) ?? [] + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(id, forKey: .id) + try container.encodeIfPresent(title, forKey: .title) + try container.encodeIfPresent(extends, forKey: .extends) + try container.encodeIfPresent(before, forKey: .before) + try container.encodeIfPresent(after, forKey: .after) + try container.encode(items, forKey: .items) + } + + private static func trimmedString( + forKey key: CodingKeys, + in container: KeyedDecodingContainer, + allowBlankAsNil: Bool = false + ) throws -> String? { + guard container.contains(key) else { return nil } + let raw = try container.decode(String.self, forKey: key) + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + if allowBlankAsNil { return nil } + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "\(key.stringValue) must not be blank" + ) + } + return trimmed + } +} + +enum CmuxConfigMenuBarSourceRefresh: String, Codable, Sendable, Hashable { + case onOpen + case manual + case onConfigReload + case interval +} + +struct CmuxConfigMenuBarSourceDefinition: Codable, Sendable, Hashable { + var type: String + var command: String + var refresh: CmuxConfigMenuBarSourceRefresh? + var timeoutSeconds: Double? + var intervalSeconds: Double? + + private enum CodingKeys: String, CodingKey { + case type + case command + case refresh + case timeoutSeconds + case intervalSeconds + } + + init( + type: String = "command", + command: String, + refresh: CmuxConfigMenuBarSourceRefresh? = nil, + timeoutSeconds: Double? = nil, + intervalSeconds: Double? = nil + ) { + self.type = type + self.command = command + self.refresh = refresh + self.timeoutSeconds = timeoutSeconds + self.intervalSeconds = intervalSeconds + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try Self.trimmedString(forKey: .type, in: container, allowBlankAsNil: true) ?? "command" + guard type == "command" else { + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "menuBar source type must be 'command'" + ) + } + command = try Self.requiredTrimmedString(forKey: .command, in: container) + refresh = try container.decodeIfPresent(CmuxConfigMenuBarSourceRefresh.self, forKey: .refresh) + timeoutSeconds = try container.decodeIfPresent(Double.self, forKey: .timeoutSeconds) + intervalSeconds = try container.decodeIfPresent(Double.self, forKey: .intervalSeconds) + if let timeoutSeconds, !timeoutSeconds.isFinite || timeoutSeconds <= 0 { + throw DecodingError.dataCorruptedError( + forKey: .timeoutSeconds, + in: container, + debugDescription: "menuBar source timeoutSeconds must be greater than zero" + ) + } + if let intervalSeconds, !intervalSeconds.isFinite || intervalSeconds < 10 { + throw DecodingError.dataCorruptedError( + forKey: .intervalSeconds, + in: container, + debugDescription: "menuBar source intervalSeconds must be at least 10" + ) + } + if refresh == .interval, intervalSeconds == nil { + throw DecodingError.keyNotFound( + CodingKeys.intervalSeconds, + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "menuBar interval sources require intervalSeconds" + ) + ) + } + } + + private static func requiredTrimmedString( + forKey key: CodingKeys, + in container: KeyedDecodingContainer + ) throws -> String { + guard let value = try trimmedString(forKey: key, in: container) else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "\(key.stringValue) is required" + ) + ) + } + return value + } + + private static func trimmedString( + forKey key: CodingKeys, + in container: KeyedDecodingContainer, + allowBlankAsNil: Bool = false + ) throws -> String? { + guard container.contains(key) else { return nil } + let raw = try container.decode(String.self, forKey: key) + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + if allowBlankAsNil { return nil } + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "\(key.stringValue) must not be blank" + ) + } + return trimmed + } +} + +struct CmuxConfigMenuBarDynamicItem: Codable, Sendable, Hashable { + var id: String? + var title: String + var source: CmuxConfigMenuBarSourceDefinition + var icon: CmuxButtonIcon? + var tooltip: String? + + private enum CodingKeys: String, CodingKey { + case id + case title + case source + case icon + case tooltip + } + + init( + id: String? = nil, + title: String, + source: CmuxConfigMenuBarSourceDefinition, + icon: CmuxButtonIcon? = nil, + tooltip: String? = nil + ) { + self.id = id + self.title = title + self.source = source + self.icon = icon + self.tooltip = tooltip + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try Self.trimmedString(forKey: .id, in: container, allowBlankAsNil: true) + title = try Self.requiredTrimmedString(forKey: .title, in: container) + source = try container.decode(CmuxConfigMenuBarSourceDefinition.self, forKey: .source) + icon = try container.decodeIfPresent(CmuxButtonIcon.self, forKey: .icon) + tooltip = try Self.trimmedString(forKey: .tooltip, in: container, allowBlankAsNil: true) + } + + private static func requiredTrimmedString( + forKey key: CodingKeys, + in container: KeyedDecodingContainer + ) throws -> String { + guard let value = try trimmedString(forKey: key, in: container) else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "\(key.stringValue) is required" + ) + ) + } + return value + } + + private static func trimmedString( + forKey key: CodingKeys, + in container: KeyedDecodingContainer, + allowBlankAsNil: Bool = false + ) throws -> String? { + guard container.contains(key) else { return nil } + let raw = try container.decode(String.self, forKey: key) + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + if allowBlankAsNil { return nil } + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "\(key.stringValue) must not be blank" + ) + } + return trimmed + } +} + +struct CmuxConfigMenuBarActionItem: Codable, Sendable, Hashable { + var action: String? + var inlineAction: CmuxConfigActionDefinition? + var title: String? + var icon: CmuxButtonIcon? + var tooltip: String? + var shortcut: StoredShortcut? + + private enum CodingKeys: String, CodingKey { + case action + case title + case icon + case tooltip + case type + case builtin + case command + case commandName + case name + case agent + case args + case subtitle + case description + case keywords + case palette + case shortcut + case confirm + case target + } + + init( + action: String, + title: String? = nil, + icon: CmuxButtonIcon? = nil, + tooltip: String? = nil, + shortcut: StoredShortcut? = nil + ) { + self.action = action + self.inlineAction = nil + self.title = title + self.icon = icon + self.tooltip = tooltip + self.shortcut = shortcut + } + + init( + inlineAction: CmuxConfigActionDefinition, + title: String? = nil, + icon: CmuxButtonIcon? = nil, + tooltip: String? = nil, + shortcut: StoredShortcut? = nil + ) { + self.action = nil + self.inlineAction = inlineAction + self.title = title + self.icon = icon + self.tooltip = tooltip + self.shortcut = shortcut ?? inlineAction.shortcut + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + title = try Self.trimmedString(forKey: .title, in: container, allowBlankAsNil: true) + icon = try container.decodeIfPresent(CmuxButtonIcon.self, forKey: .icon) + tooltip = try Self.trimmedString(forKey: .tooltip, in: container, allowBlankAsNil: true) + shortcut = try Self.decodeShortcut(forKey: .shortcut, in: container) + + let actionReference = try Self.trimmedString(forKey: .action, in: container) + let hasInlineAction = [ + container.contains(.type), + container.contains(.builtin), + container.contains(.command), + container.contains(.commandName), + container.contains(.agent) + ].contains(true) + + if actionReference != nil && hasInlineAction { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "menuBar items must define either 'action' or an inline action, not both" + ) + ) + } + + if let actionReference { + action = actionReference + inlineAction = nil + return + } + + if hasInlineAction { + let definition = try CmuxConfigActionDefinition(from: decoder) + guard definition.action != nil else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "menuBar inline actions must be runnable" + ) + ) + } + action = nil + inlineAction = definition + shortcut = shortcut ?? definition.shortcut + return + } + + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "menuBar action items must define 'action', 'command', 'agent', 'builtin', or 'type'" + ) + ) + } + + func encode(to encoder: Encoder) throws { + if let action { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(action, forKey: .action) + try container.encodeIfPresent(title, forKey: .title) + try container.encodeIfPresent(icon, forKey: .icon) + try container.encodeIfPresent(tooltip, forKey: .tooltip) + try Self.encodeShortcut(shortcut, forKey: .shortcut, in: &container) + return + } + + var encodedInlineAction = inlineAction + if let shortcut { + encodedInlineAction?.shortcut = shortcut + } + try encodedInlineAction?.encode(to: encoder) + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(title, forKey: .title) + try container.encodeIfPresent(icon, forKey: .icon) + try container.encodeIfPresent(tooltip, forKey: .tooltip) + } + + private static func trimmedString( + forKey key: CodingKeys, + in container: KeyedDecodingContainer, + allowBlankAsNil: Bool = false + ) throws -> String? { + guard container.contains(key) else { return nil } + let raw = try container.decode(String.self, forKey: key) + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + if allowBlankAsNil { return nil } + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "\(key.stringValue) must not be blank" + ) + } + return trimmed + } + + private static func decodeShortcut( + forKey key: CodingKeys, + in container: KeyedDecodingContainer + ) throws -> StoredShortcut? { + guard container.contains(key) else { return nil } + if let rawShortcut = try? container.decode(String.self, forKey: key) { + guard let shortcut = StoredShortcut.parseConfig(rawShortcut) else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "shortcut must use modifier+key syntax like 'cmd+shift+t' or be empty to unbind" + ) + } + return shortcut + } + if let rawShortcut = try? container.decode([String].self, forKey: key) { + guard let shortcut = StoredShortcut.parseConfig(strokes: rawShortcut) else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "shortcut chords must be one or two non-empty strokes" + ) + } + return shortcut + } + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "shortcut must be a string or array of one or two strings" + ) + } + + private static func encodeShortcut( + _ shortcut: StoredShortcut?, + forKey key: CodingKeys, + in container: inout KeyedEncodingContainer + ) throws { + guard let shortcut else { return } + if shortcut.isUnbound { + try container.encode("", forKey: key) + return + } + if let secondStroke = shortcut.secondStroke { + try container.encode( + [shortcut.firstStroke.configString(), secondStroke.configString()], + forKey: key + ) + } else { + try container.encode(shortcut.firstStroke.configString(), forKey: key) + } + } +} + +indirect enum CmuxConfigMenuBarItem: Codable, Sendable, Hashable { + case action(CmuxConfigMenuBarActionItem) + case submenu(CmuxConfigMenuDefinition) + case dynamic(CmuxConfigMenuBarDynamicItem) + case separator + + private enum CodingKeys: String, CodingKey { + case type + case title + case items + case source + } + + init(from decoder: Decoder) throws { + if let container = try? decoder.singleValueContainer(), + let rawAction = try? container.decode(String.self) { + let trimmed = rawAction.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == "-" || trimmed == "separator" { + self = .separator + return + } + guard !trimmed.isEmpty else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "menuBar action must not be blank" + ) + ) + } + self = .action(CmuxConfigMenuBarActionItem(action: trimmed)) + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawType = try Self.trimmedString(forKey: .type, in: container) + if rawType == "separator" { + self = .separator + return + } + if container.contains(.source) || rawType == "source" || rawType == "dynamic" { + self = .dynamic(try CmuxConfigMenuBarDynamicItem(from: decoder)) + return + } + if container.contains(.items) || rawType == "menu" || rawType == "submenu" { + self = .submenu(try CmuxConfigMenuDefinition(from: decoder)) + return + } + self = .action(try CmuxConfigMenuBarActionItem(from: decoder)) + } + + func encode(to encoder: Encoder) throws { + switch self { + case .action(let item): + try item.encode(to: encoder) + case .submenu(let menu): + try menu.encode(to: encoder) + case .dynamic(let item): + try item.encode(to: encoder) + case .separator: + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("separator", forKey: .type) + } + } + + private static func trimmedString( + forKey key: CodingKeys, + in container: KeyedDecodingContainer + ) throws -> String? { + guard container.contains(key) else { return nil } + let raw = try container.decode(String.self, forKey: key) + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "\(key.stringValue) must not be blank" + ) + } + return trimmed + } +} + struct CmuxConfigButtonPlacement: Codable, Sendable, Hashable { var action: String? var icon: CmuxButtonIcon? @@ -224,6 +817,51 @@ enum CmuxResolvedConfigContextMenuItem: Identifiable, Sendable, Hashable { } } +struct CmuxResolvedMenuBarMenu: Identifiable, Sendable, Hashable { + var id: String + var configID: String + var title: String + var before: String? + var after: String? + var items: [CmuxResolvedMenuBarItem] +} + +struct CmuxResolvedMenuBarExtension: Identifiable, Sendable, Hashable { + var id: String + var targetID: String + var items: [CmuxResolvedMenuBarItem] +} + +struct CmuxResolvedMenuBarDynamicSource: Identifiable, Sendable, Hashable { + var id: String + var title: String + var icon: CmuxButtonIcon? + var tooltip: String? + var source: CmuxConfigMenuBarSourceDefinition + var settingName: String + var settingSourcePath: String? +} + +indirect enum CmuxResolvedMenuBarItem: Identifiable, Sendable, Hashable { + case action(CmuxResolvedConfigMenuAction) + case submenu(CmuxResolvedMenuBarMenu) + case dynamicSource(CmuxResolvedMenuBarDynamicSource) + case separator(id: String) + + var id: String { + switch self { + case .action(let action): + return action.id + case .submenu(let menu): + return menu.id + case .dynamicSource(let source): + return source.id + case .separator(let id): + return id + } + } +} + enum CmuxRestartBehavior: String, Codable, Sendable { case new case recreate diff --git a/Sources/ConfiguredMenuBarController.swift b/Sources/ConfiguredMenuBarController.swift new file mode 100644 index 000000000000..04e411330f21 --- /dev/null +++ b/Sources/ConfiguredMenuBarController.swift @@ -0,0 +1,1129 @@ +import AppKit +import Foundation + +struct ConfiguredMenuBarRuntimeContext { + var menus: [CmuxResolvedMenuBarMenu] + var extensions: [CmuxResolvedMenuBarExtension] + var configStore: CmuxConfigStore? + var workingDirectory: String +} + +private enum ConfiguredMenuBarDynamicPhase: String, Sendable { + case idle + case running + case loaded + case failed +} + +private struct ConfiguredMenuBarDynamicState: Sendable { + var phase: ConfiguredMenuBarDynamicPhase = .idle + var items: [CmuxResolvedMenuBarItem] = [] + var error: String? + var lastRunAt: Date? + var durationMS: Int? + var exitStatus: Int32? + var activePID: Int32? + var activeRunID: UUID? + var generatedItemCount: Int? + var command: String? + var lastConfigRevisionRun: UInt64? +} + +private struct ConfiguredMenuBarDynamicCommandResult: Sendable { + var stdout: String + var stderr: String + var exitStatus: Int32? + var durationMS: Int + var errorMessage: String? +} + +private final class ConfiguredMenuBarOutputCollector: @unchecked Sendable { + private let lock = NSLock() + private let limit: Int + private var data = Data() + private var exceededLimit = false + + init(limit: Int) { + self.limit = limit + } + + func append(_ chunk: Data) { + guard !chunk.isEmpty else { return } + lock.lock() + defer { lock.unlock() } + if data.count + chunk.count > limit { + exceededLimit = true + let remaining = max(0, limit - data.count) + if remaining > 0 { + data.append(chunk.prefix(remaining)) + } + return + } + data.append(chunk) + } + + func string() -> String { + lock.lock() + let snapshot = data + lock.unlock() + return String(data: snapshot, encoding: .utf8) + ?? String(decoding: snapshot, as: UTF8.self) + } + + func didExceedLimit() -> Bool { + lock.lock() + let exceeded = exceededLimit + lock.unlock() + return exceeded + } +} + +private enum ConfiguredMenuBarDynamicRunner { + static let defaultTimeoutSeconds: Double = 5 + static let outputLimitBytes = 256 * 1024 + + static func run( + command: String, + cwd: String, + timeoutSeconds: Double, + onStarted: @escaping (Int32) -> Void + ) async -> ConfiguredMenuBarDynamicCommandResult { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + runBlocking( + command: command, + cwd: cwd, + timeoutSeconds: timeoutSeconds, + onStarted: onStarted, + continuation: continuation + ) + } + } + } + + private static func runBlocking( + command: String, + cwd: String, + timeoutSeconds: Double, + onStarted: @escaping (Int32) -> Void, + continuation: CheckedContinuation + ) { + let startedAt = Date() + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = ["-lc", command] + process.currentDirectoryURL = URL(fileURLWithPath: cwd, isDirectory: true) + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let stdout = ConfiguredMenuBarOutputCollector(limit: outputLimitBytes) + let stderr = ConfiguredMenuBarOutputCollector(limit: outputLimitBytes) + stdoutPipe.fileHandleForReading.readabilityHandler = { handle in + stdout.append(handle.availableData) + } + stderrPipe.fileHandleForReading.readabilityHandler = { handle in + stderr.append(handle.availableData) + } + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let finishLock = NSLock() + var didFinish = false + var didTimeOut = false + let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility)) + + func finish(_ result: ConfiguredMenuBarDynamicCommandResult) { + finishLock.lock() + guard !didFinish else { + finishLock.unlock() + return + } + didFinish = true + finishLock.unlock() + timer.cancel() + continuation.resume(returning: result) + } + + process.terminationHandler = { terminatedProcess in + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + stdout.append(stdoutPipe.fileHandleForReading.readDataToEndOfFile()) + stderr.append(stderrPipe.fileHandleForReading.readDataToEndOfFile()) + let durationMS = max(0, Int(Date().timeIntervalSince(startedAt) * 1000)) + finishLock.lock() + let timedOut = didTimeOut + finishLock.unlock() + + let stdoutText = stdout.string() + let stderrText = stderr.string() + let errorMessage: String? + if timedOut { + errorMessage = String( + format: String( + localized: "menuBar.dynamic.error.timeout", + defaultValue: "Command timed out after %.1f seconds." + ), + timeoutSeconds + ) + } else if stdout.didExceedLimit() || stderr.didExceedLimit() { + errorMessage = String( + format: String( + localized: "menuBar.dynamic.error.outputLimit", + defaultValue: "Command output exceeded %d bytes." + ), + outputLimitBytes + ) + } else if terminatedProcess.terminationStatus != 0 { + let detail = stderrText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? stdoutText.trimmingCharacters(in: .whitespacesAndNewlines) + : stderrText.trimmingCharacters(in: .whitespacesAndNewlines) + errorMessage = [ + String( + format: String( + localized: "menuBar.dynamic.error.exitStatus", + defaultValue: "Command exited with status %d." + ), + Int(terminatedProcess.terminationStatus) + ), + detail + ].filter { !$0.isEmpty }.joined(separator: "\n") + } else { + errorMessage = nil + } + + finish(ConfiguredMenuBarDynamicCommandResult( + stdout: stdoutText, + stderr: stderrText, + exitStatus: terminatedProcess.terminationStatus, + durationMS: durationMS, + errorMessage: errorMessage + )) + } + + do { + try process.run() + onStarted(process.processIdentifier) + } catch { + let durationMS = max(0, Int(Date().timeIntervalSince(startedAt) * 1000)) + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + finish(ConfiguredMenuBarDynamicCommandResult( + stdout: "", + stderr: "", + exitStatus: nil, + durationMS: durationMS, + errorMessage: error.localizedDescription + )) + return + } + + timer.schedule(deadline: .now() + max(0.1, timeoutSeconds)) + timer.setEventHandler { + finishLock.lock() + guard !didFinish else { + finishLock.unlock() + return + } + didTimeOut = true + finishLock.unlock() + process.terminate() + } + timer.resume() + } +} + +@MainActor +final class ConfiguredMenuBarController: NSObject { + private final class ActionBox: NSObject { + let action: CmuxResolvedConfigAction + + init(action: CmuxResolvedConfigAction) { + self.action = action + } + } + + private final class DynamicSourceBox: NSObject { + let sourceID: String + + init(sourceID: String) { + self.sourceID = sourceID + } + } + + private final class DynamicErrorBox: NSObject { + let error: String + + init(error: String) { + self.error = error + } + } + + private final class DynamicMenuDelegate: NSObject, NSMenuDelegate { + weak var owner: ConfiguredMenuBarController? + let sourceID: String + weak var preferredWindow: NSWindow? + + init(owner: ConfiguredMenuBarController, sourceID: String, preferredWindow: NSWindow?) { + self.owner = owner + self.sourceID = sourceID + self.preferredWindow = preferredWindow + } + + func menuWillOpen(_ menu: NSMenu) { + owner?.dynamicMenuWillOpen(sourceID: sourceID, preferredWindow: preferredWindow) + } + } + + private enum DynamicRefreshReason { + case open + case manual + case configReload + case interval + } + + private enum TopLevelMenuPlacement { + case before + case after + } + + private weak var owner: AppDelegate? + private let notificationCenter: NotificationCenter + private var observerTokens: [NSObjectProtocol] = [] + private var topLevelItems: [NSMenuItem] = [] + private var extensionItems: [NSMenuItem] = [] + private var actionBoxes: [ActionBox] = [] + private var dynamicSourceBoxes: [DynamicSourceBox] = [] + private var dynamicErrorBoxes: [DynamicErrorBox] = [] + private var menuDelegates: [DynamicMenuDelegate] = [] + private var dynamicStates: [String: ConfiguredMenuBarDynamicState] = [:] + private var dynamicSourceByID: [String: CmuxResolvedMenuBarDynamicSource] = [:] + private var dynamicMenus: [String: NSMenu] = [:] + private var dynamicTimers: [String: DispatchSourceTimer] = [:] + private var dynamicTimerKeys: [String: String] = [:] + private var dynamicTasks: [String: Task] = [:] + private var refreshScheduled = false + private var isApplyingMenuChanges = false + + init(owner: AppDelegate, notificationCenter: NotificationCenter = .default) { + self.owner = owner + self.notificationCenter = notificationCenter + super.init() + } + + deinit { + for token in observerTokens { + notificationCenter.removeObserver(token) + } + for timer in dynamicTimers.values { + timer.cancel() + } + for task in dynamicTasks.values { + task.cancel() + } + } + + func installObserversIfNeeded() { + guard observerTokens.isEmpty else { return } + let names: [Notification.Name] = [ + .cmuxConfigStoreDidChange, + .mainWindowContextsDidChange, + NSWindow.didBecomeKeyNotification, + NSWindow.didBecomeMainNotification, + ] + observerTokens = names.map { name in + notificationCenter.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleRefresh() + } + } + } + let menuMutationNames: [Notification.Name] = [ + NSMenu.didAddItemNotification, + NSMenu.didRemoveItemNotification, + NSMenu.didChangeItemNotification, + ] + observerTokens.append(contentsOf: menuMutationNames.map { name in + notificationCenter.addObserver(forName: name, object: nil, queue: .main) { [weak self] notification in + MainActor.assumeIsolated { + guard let self, + !self.isApplyingMenuChanges, + let menu = notification.object as? NSMenu, + menu === NSApp.mainMenu else { + return + } + self.scheduleRefresh() + } + } + }) + scheduleRefresh() + } + + func taskManagerPayload() -> [[String: Any]] { + dynamicSourceByID.values + .sorted { lhs, rhs in + if lhs.title == rhs.title { return lhs.id < rhs.id } + return lhs.title < rhs.title + } + .map { source in + let state = dynamicStates[source.id] ?? ConfiguredMenuBarDynamicState() + var detailParts: [String] = [phaseLabel(state.phase)] + if let generatedItemCount = state.generatedItemCount { + detailParts.append(String( + format: String( + localized: "taskManager.dynamicMenu.items", + defaultValue: "%d items" + ), + generatedItemCount + )) + } + if let durationMS = state.durationMS { + detailParts.append(String( + format: String( + localized: "taskManager.dynamicMenu.duration", + defaultValue: "%d ms" + ), + durationMS + )) + } + let pids = state.activePID.map { [Int($0)] } ?? [] + return [ + "id": source.id, + "title": source.title, + "detail": detailParts.joined(separator: " / "), + "state": state.phase.rawValue, + "active_pid": state.activePID.map { Int($0) } as Any? ?? NSNull(), + "root_pids": pids, + "pids": pids, + "source_path": source.settingSourcePath as Any? ?? NSNull(), + "resources": CmuxTaskManagerResources.zeroPayload + ] + } + } + + private func scheduleRefresh() { + guard !refreshScheduled else { return } + refreshScheduled = true + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + self.refreshScheduled = false + self.refresh() + } + } + } + + private func refresh() { + guard let mainMenu = NSApp.mainMenu else { return } + + isApplyingMenuChanges = true + defer { isApplyingMenuChanges = false } + + removeConfiguredItems(from: mainMenu) + + let preferredWindow = NSApp.keyWindow ?? NSApp.mainWindow + let runtime = owner?.configuredMenuBarRuntimeContext(preferredWindow: preferredWindow) + let menus = runtime?.menus ?? [] + let extensions = runtime?.extensions ?? [] + guard !menus.isEmpty || !extensions.isEmpty else { + resetDynamicSources() + return + } + + var customMenusByConfigID: [String: NSMenu] = [:] + var customMenuItemsByConfigID: [String: NSMenuItem] = [:] + var insertionIndex = defaultInsertionIndex(in: mainMenu) + for menu in menus { + let item = NSMenuItem(title: menu.title, action: nil, keyEquivalent: "") + let submenu = configuredMenu(from: menu, preferredWindow: preferredWindow) + item.submenu = submenu + mainMenu.insertItem(item, at: insertionIndex) + topLevelItems.append(item) + customMenusByConfigID[menu.configID] = submenu + customMenuItemsByConfigID[menu.configID] = item + insertionIndex += 1 + } + applyMenuPlacements( + menus, + in: mainMenu, + customMenuItemsByConfigID: customMenuItemsByConfigID + ) + + for menuExtension in extensions { + guard let targetMenu = targetMenu( + for: menuExtension.targetID, + mainMenu: mainMenu, + customMenusByConfigID: customMenusByConfigID + ) else { + continue + } + let items = menuItems(from: menuExtension.items, preferredWindow: preferredWindow) + guard !items.isEmpty else { continue } + if !targetMenu.items.isEmpty, items.first?.isSeparatorItem == false { + let separator = NSMenuItem.separator() + targetMenu.addItem(separator) + extensionItems.append(separator) + } + for item in items { + targetMenu.addItem(item) + extensionItems.append(item) + } + } + + dynamicStates = dynamicStates.filter { + dynamicSourceByID[$0.key] != nil + } + cancelRemovedDynamicTimers() + cancelRemovedDynamicTasks() + scheduleIntervalDynamicSourcesIfNeeded(store: runtime?.configStore, preferredWindow: preferredWindow) + runConfigReloadDynamicSourcesIfNeeded(store: runtime?.configStore, preferredWindow: preferredWindow) + } + + private func removeConfiguredItems(from mainMenu: NSMenu) { + for item in extensionItems { + let menu = item.menu + let index = menu?.index(of: item) ?? -1 + if let menu, index >= 0 { + menu.removeItem(at: index) + } + } + extensionItems.removeAll() + + for item in topLevelItems { + let index = mainMenu.index(of: item) + if index >= 0 { + mainMenu.removeItem(at: index) + } + } + topLevelItems.removeAll() + actionBoxes.removeAll() + dynamicSourceBoxes.removeAll() + dynamicErrorBoxes.removeAll() + menuDelegates.removeAll() + dynamicSourceByID.removeAll() + dynamicMenus.removeAll() + } + + private func resetDynamicSources() { + dynamicStates.removeAll() + for sourceID in Array(dynamicTimers.keys) { + cancelDynamicTimer(sourceID: sourceID) + } + for sourceID in Array(dynamicTasks.keys) { + cancelDynamicTask(sourceID: sourceID) + } + } + + private func defaultInsertionIndex(in mainMenu: NSMenu) -> Int { + let notificationsTitle = String(localized: "menu.notifications.title", defaultValue: "Notifications") + if let notificationsIndex = mainMenu.items.lastIndex(where: { $0.title == notificationsTitle }) { + return notificationsIndex + 1 + } +#if DEBUG + if let debugIndex = mainMenu.items.lastIndex(where: { $0.title == "Debug" }) { + return debugIndex + } +#endif + return min(mainMenu.items.count, max(1, mainMenu.items.count - 1)) + } + + private func applyMenuPlacements( + _ menus: [CmuxResolvedMenuBarMenu], + in mainMenu: NSMenu, + customMenuItemsByConfigID: [String: NSMenuItem] + ) { + for menu in menus { + guard let item = customMenuItemsByConfigID[menu.configID] else { continue } + if let before = menu.before { + moveTopLevelMenuItem( + item, + placement: .before, + targetID: before, + in: mainMenu, + customMenuItemsByConfigID: customMenuItemsByConfigID + ) + } else if let after = menu.after { + moveTopLevelMenuItem( + item, + placement: .after, + targetID: after, + in: mainMenu, + customMenuItemsByConfigID: customMenuItemsByConfigID + ) + } + } + } + + private func moveTopLevelMenuItem( + _ item: NSMenuItem, + placement: TopLevelMenuPlacement, + targetID: String, + in mainMenu: NSMenu, + customMenuItemsByConfigID: [String: NSMenuItem] + ) { + guard mainMenu.index(of: item) >= 0, + let targetItem = topLevelMenuItem( + for: targetID, + in: mainMenu, + customMenuItemsByConfigID: customMenuItemsByConfigID + ), + targetItem !== item else { + return + } + + let currentIndex = mainMenu.index(of: item) + guard currentIndex >= 0 else { return } + mainMenu.removeItem(at: currentIndex) + let targetIndex = mainMenu.index(of: targetItem) + guard targetIndex >= 0 else { + mainMenu.insertItem(item, at: min(currentIndex, mainMenu.items.count)) + return + } + + let rawInsertionIndex: Int + switch placement { + case .before: + rawInsertionIndex = targetIndex + case .after: + rawInsertionIndex = targetIndex + 1 + } + let insertionIndex = min(max(1, rawInsertionIndex), mainMenu.items.count) + mainMenu.insertItem(item, at: insertionIndex) + } + + private func topLevelMenuItem( + for targetID: String, + in mainMenu: NSMenu, + customMenuItemsByConfigID: [String: NSMenuItem] + ) -> NSMenuItem? { + if let customItem = customMenuItemsByConfigID[targetID] { + return customItem + } + let normalized = normalizedTargetID(targetID) + if let customItem = customMenuItemsByConfigID.first(where: { + normalizedTargetID($0.key) == normalized + })?.value { + return customItem + } + + if ["application", "app", "cmux"].contains(normalized) { + return mainMenu.items.first + } + + let builtinTitles: [String: String] = [ + "file": String(localized: "menu.file.title", defaultValue: "File"), + "edit": String(localized: "menu.edit.title", defaultValue: "Edit"), + "view": String(localized: "menu.view.title", defaultValue: "View"), + "update": "Update Pill", + "updatepill": "Update Pill", + "notifications": String(localized: "menu.notifications.title", defaultValue: "Notifications"), + "debug": "Debug", + "window": String(localized: "menu.window.title", defaultValue: "Window"), + "help": String(localized: "menu.help.title", defaultValue: "Help"), + ] + if let title = builtinTitles[normalized], + let item = mainMenu.items.first(where: { $0.title == title }) { + return item + } + + return mainMenu.items.first(where: { normalizedTargetID($0.title) == normalized }) + } + + private func configuredMenu( + from menu: CmuxResolvedMenuBarMenu, + preferredWindow: NSWindow? + ) -> NSMenu { + let nsMenu = NSMenu(title: menu.title) + for item in menuItems(from: menu.items, preferredWindow: preferredWindow) { + nsMenu.addItem(item) + } + return nsMenu + } + + private func menuItems( + from items: [CmuxResolvedMenuBarItem], + preferredWindow: NSWindow? + ) -> [NSMenuItem] { + var nsItems: [NSMenuItem] = [] + for item in items { + switch item { + case .separator: + if !nsItems.isEmpty, nsItems.last?.isSeparatorItem == false { + nsItems.append(.separator()) + } + case .submenu(let submenu): + let item = NSMenuItem(title: submenu.title, action: nil, keyEquivalent: "") + item.submenu = configuredMenu(from: submenu, preferredWindow: preferredWindow) + nsItems.append(item) + case .dynamicSource(let source): + nsItems.append(dynamicSourceMenuItem(for: source, preferredWindow: preferredWindow)) + case .action(let menuAction): + let item = NSMenuItem( + title: menuAction.title, + action: #selector(performMenuItem(_:)), + keyEquivalent: "" + ) + item.target = self + let box = ActionBox(action: menuAction.action) + actionBoxes.append(box) + item.representedObject = box + item.toolTip = menuAction.tooltip + item.image = menuImage(for: menuAction.icon ?? menuAction.action.icon) + applyShortcut(menuAction.action.shortcut, to: item) + nsItems.append(item) + } + } + + while nsItems.last?.isSeparatorItem == true { + nsItems.removeLast() + } + return nsItems + } + + private func applyShortcut(_ shortcut: StoredShortcut?, to item: NSMenuItem) { + guard let keyEquivalent = shortcut?.menuItemKeyEquivalent else { + item.keyEquivalent = "" + item.keyEquivalentModifierMask = [] + return + } + item.keyEquivalent = keyEquivalent + item.keyEquivalentModifierMask = shortcut?.modifierFlags ?? [] + } + + private func menuImage(for icon: CmuxButtonIcon?) -> NSImage? { + guard case .some(.symbol(let symbolName)) = icon else { return nil } + return NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) + } + + private func dynamicSourceMenuItem( + for source: CmuxResolvedMenuBarDynamicSource, + preferredWindow: NSWindow? + ) -> NSMenuItem { + var state = dynamicStates[source.id] ?? ConfiguredMenuBarDynamicState() + if state.command != nil, state.command != source.source.command { + state = ConfiguredMenuBarDynamicState() + cancelDynamicTimer(sourceID: source.id) + cancelDynamicTask(sourceID: source.id) + } + state.command = source.source.command + dynamicStates[source.id] = state + dynamicSourceByID[source.id] = source + + let item = NSMenuItem(title: source.title, action: nil, keyEquivalent: "") + item.image = menuImage(for: source.icon) + item.toolTip = source.tooltip + let submenu = NSMenu(title: source.title) + let delegate = DynamicMenuDelegate(owner: self, sourceID: source.id, preferredWindow: preferredWindow) + submenu.delegate = delegate + menuDelegates.append(delegate) + dynamicMenus[source.id] = submenu + item.submenu = submenu + renderDynamicSourceMenu(sourceID: source.id, preferredWindow: preferredWindow) + return item + } + + private func targetMenu( + for targetID: String, + mainMenu: NSMenu, + customMenusByConfigID: [String: NSMenu] + ) -> NSMenu? { + if let customMenu = customMenusByConfigID[targetID] { + return customMenu + } + let normalized = normalizedTargetID(targetID) + if let customMenu = customMenusByConfigID.first(where: { + normalizedTargetID($0.key) == normalized + })?.value { + return customMenu + } + + let builtinTitles: [String: String] = [ + "application": "", + "app": "", + "cmux": "", + "file": String(localized: "menu.file.title", defaultValue: "File"), + "edit": String(localized: "menu.edit.title", defaultValue: "Edit"), + "view": String(localized: "menu.view.title", defaultValue: "View"), + "notifications": String(localized: "menu.notifications.title", defaultValue: "Notifications"), + "window": String(localized: "menu.window.title", defaultValue: "Window"), + "help": String(localized: "menu.help.title", defaultValue: "Help"), + ] + if ["application", "app", "cmux"].contains(normalized) { + return mainMenu.items.first?.submenu + } + guard let title = builtinTitles[normalized] else { return nil } + return mainMenu.items.first(where: { $0.title == title })?.submenu + } + + private func normalizedTargetID(_ raw: String) -> String { + raw.lowercased().filter { $0.isLetter || $0.isNumber } + } + + private func runConfigReloadDynamicSourcesIfNeeded(store: CmuxConfigStore?, preferredWindow: NSWindow?) { + guard let store else { return } + for source in dynamicSourceByID.values { + guard source.source.refresh == .onConfigReload else { continue } + let lastRevision = dynamicStates[source.id]?.lastConfigRevisionRun + guard lastRevision != store.configRevision else { continue } + dynamicStates[source.id, default: ConfiguredMenuBarDynamicState()].lastConfigRevisionRun = store.configRevision + refreshDynamicSource(sourceID: source.id, preferredWindow: preferredWindow, reason: .configReload) + } + } + + private func scheduleIntervalDynamicSourcesIfNeeded(store: CmuxConfigStore?, preferredWindow: NSWindow?) { + guard let store else { return } + for source in dynamicSourceByID.values { + guard source.source.refresh == .interval, + let interval = source.source.intervalSeconds else { + cancelDynamicTimer(sourceID: source.id) + continue + } + let timerKey = [ + source.source.command, + String(interval), + source.settingSourcePath ?? "", + ].joined(separator: "\u{1F}") + guard dynamicTimerKeys[source.id] != timerKey else { continue } + cancelDynamicTimer(sourceID: source.id) + + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + interval, repeating: interval) + timer.setEventHandler { [weak self, weak store] in + MainActor.assumeIsolated { + guard let self, let store else { return } + guard let currentSource = self.dynamicSourceByID[source.id] else { + self.cancelDynamicTimer(sourceID: source.id) + return + } + guard CmuxConfigExecutor.isTrustedDynamicMenuSource( + command: currentSource.source.command, + sourceID: currentSource.id, + configSourcePath: currentSource.settingSourcePath, + globalConfigPath: store.globalConfigPath + ) else { + return + } + self.refreshDynamicSource( + sourceID: currentSource.id, + preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow, + reason: .interval + ) + } + } + dynamicTimers[source.id] = timer + dynamicTimerKeys[source.id] = timerKey + timer.resume() + } + } + + private func cancelRemovedDynamicTimers() { + let activeSourceIDs = Set(dynamicSourceByID.keys) + for sourceID in Array(dynamicTimers.keys) where !activeSourceIDs.contains(sourceID) { + cancelDynamicTimer(sourceID: sourceID) + } + } + + private func cancelRemovedDynamicTasks() { + let activeSourceIDs = Set(dynamicSourceByID.keys) + for sourceID in Array(dynamicTasks.keys) where !activeSourceIDs.contains(sourceID) { + cancelDynamicTask(sourceID: sourceID) + } + } + + private func cancelDynamicTimer(sourceID: String) { + dynamicTimers[sourceID]?.cancel() + dynamicTimers.removeValue(forKey: sourceID) + dynamicTimerKeys.removeValue(forKey: sourceID) + } + + private func cancelDynamicTask(sourceID: String) { + dynamicTasks[sourceID]?.cancel() + dynamicTasks.removeValue(forKey: sourceID) + dynamicStates[sourceID]?.activeRunID = nil + dynamicStates[sourceID]?.activePID = nil + } + + private func dynamicMenuWillOpen(sourceID: String, preferredWindow: NSWindow?) { + guard let source = dynamicSourceByID[sourceID] else { return } + renderDynamicSourceMenu(sourceID: sourceID, preferredWindow: preferredWindow) + if (source.source.refresh ?? .onOpen) == .onOpen { + refreshDynamicSource(sourceID: sourceID, preferredWindow: preferredWindow, reason: .open) + } + } + + private func renderDynamicSourceMenu(sourceID: String, preferredWindow: NSWindow?) { + guard let menu = dynamicMenus[sourceID], + let source = dynamicSourceByID[sourceID] else { return } + let state = dynamicStates[sourceID] ?? ConfiguredMenuBarDynamicState() + menu.removeAllItems() + + let cachedItems = menuItems(from: state.items, preferredWindow: preferredWindow) + for item in cachedItems { + menu.addItem(item) + } + if cachedItems.isEmpty { + menu.addItem(disabledItem(title: dynamicMenuEmptyTitle(for: state))) + } + + if state.phase == .running { + addSeparatorIfNeeded(to: menu) + menu.addItem(disabledItem(title: String( + localized: "menuBar.dynamic.running", + defaultValue: "Loading..." + ))) + } + + if let error = state.error, !error.isEmpty { + addSeparatorIfNeeded(to: menu) + menu.addItem(disabledItem(title: String( + localized: "menuBar.dynamic.failed", + defaultValue: "Dynamic menu failed" + ))) + let copyItem = NSMenuItem( + title: String(localized: "menuBar.dynamic.copyError", defaultValue: "Copy Error"), + action: #selector(copyDynamicSourceError(_:)), + keyEquivalent: "" + ) + copyItem.target = self + let box = DynamicErrorBox(error: error) + dynamicErrorBoxes.append(box) + copyItem.representedObject = box + menu.addItem(copyItem) + } + + addSeparatorIfNeeded(to: menu) + let reloadTitle = state.phase == .idle && (source.source.refresh ?? .onOpen) == .manual + ? String(localized: "menuBar.dynamic.load", defaultValue: "Load Dynamic Menu") + : String(localized: "menuBar.dynamic.reload", defaultValue: "Reload") + let reloadItem = NSMenuItem( + title: reloadTitle, + action: #selector(reloadDynamicSource(_:)), + keyEquivalent: "" + ) + reloadItem.target = self + reloadItem.isEnabled = state.phase != .running + let box = DynamicSourceBox(sourceID: sourceID) + dynamicSourceBoxes.append(box) + reloadItem.representedObject = box + menu.addItem(reloadItem) + } + + private func disabledItem(title: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + return item + } + + private func dynamicMenuEmptyTitle(for state: ConfiguredMenuBarDynamicState) -> String { + switch state.phase { + case .running: + return String(localized: "menuBar.dynamic.loading", defaultValue: "Loading...") + case .failed where state.items.isEmpty: + return String(localized: "menuBar.dynamic.noCachedItems", defaultValue: "No cached items") + case .loaded: + return String(localized: "menuBar.dynamic.noItems", defaultValue: "No items") + case .idle, .failed: + return String(localized: "menuBar.dynamic.notLoaded", defaultValue: "Not loaded") + } + } + + private func addSeparatorIfNeeded(to menu: NSMenu) { + if !menu.items.isEmpty, menu.items.last?.isSeparatorItem == false { + menu.addItem(.separator()) + } + } + + private func refreshDynamicSource( + sourceID: String, + preferredWindow: NSWindow?, + reason: DynamicRefreshReason + ) { + guard let source = dynamicSourceByID[sourceID] else { return } + guard dynamicStates[sourceID]?.phase != .running else { return } + guard let store = owner?.configuredMenuBarRuntimeContext(preferredWindow: preferredWindow).configStore else { return } + + let title = String( + format: String( + localized: "dialog.cmuxConfig.confirmDynamicMenu.title", + defaultValue: "Run Dynamic Menu Source: %@" + ), + source.title + ) + let authorized = CmuxConfigExecutor.authorizeDynamicMenuSourceIfNeeded( + command: source.source.command, + sourceID: source.id, + configSourcePath: source.settingSourcePath, + globalConfigPath: store.globalConfigPath, + displayTitle: title, + presentingWindow: preferredWindow + ) { [weak self, weak store, weak preferredWindow] command in + guard let self, let store else { return } + self.startDynamicSource( + source, + command: command, + store: store, + preferredWindow: preferredWindow, + reason: reason + ) + } + if !authorized { + var state = dynamicStates[sourceID] ?? ConfiguredMenuBarDynamicState() + state.phase = state.items.isEmpty ? .idle : .loaded + dynamicStates[sourceID] = state + renderDynamicSourceMenu(sourceID: sourceID, preferredWindow: preferredWindow) + } + } + + private func startDynamicSource( + _ source: CmuxResolvedMenuBarDynamicSource, + command: String, + store: CmuxConfigStore, + preferredWindow: NSWindow?, + reason: DynamicRefreshReason + ) { + let runID = UUID() + var state = dynamicStates[source.id] ?? ConfiguredMenuBarDynamicState() + state.phase = .running + state.error = nil + state.command = command + state.lastRunAt = Date() + state.activeRunID = runID + state.activePID = nil + dynamicStates[source.id] = state + renderDynamicSourceMenu(sourceID: source.id, preferredWindow: preferredWindow) + + let runtime = owner?.configuredMenuBarRuntimeContext(preferredWindow: preferredWindow) + let workingDirectory = runtime?.workingDirectory ?? FileManager.default.homeDirectoryForCurrentUser.path + let timeout = source.source.timeoutSeconds ?? ConfiguredMenuBarDynamicRunner.defaultTimeoutSeconds + + cancelDynamicTask(sourceID: source.id) + dynamicStates[source.id, default: ConfiguredMenuBarDynamicState()].activeRunID = runID + dynamicStates[source.id, default: ConfiguredMenuBarDynamicState()].phase = .running + + dynamicTasks[source.id] = Task { @MainActor [weak self, weak store, weak preferredWindow] in + let sourceID = source.id + let result = await ConfiguredMenuBarDynamicRunner.run( + command: command, + cwd: workingDirectory, + timeoutSeconds: timeout + ) { [weak self, sourceID, runID, weak preferredWindow] pid in + DispatchQueue.main.async { + MainActor.assumeIsolated { + guard let self else { return } + guard self.dynamicStates[sourceID]?.activeRunID == runID else { return } + self.dynamicStates[sourceID, default: ConfiguredMenuBarDynamicState()].activePID = pid + self.renderDynamicSourceMenu(sourceID: sourceID, preferredWindow: preferredWindow) + } + } + } + guard !Task.isCancelled else { return } + guard let self, let store else { return } + guard self.dynamicStates[sourceID]?.activeRunID == runID else { return } + self.finishDynamicSource( + source, + runID: runID, + result: result, + store: store, + preferredWindow: preferredWindow + ) + self.dynamicTasks.removeValue(forKey: sourceID) + } + } + + private func finishDynamicSource( + _ source: CmuxResolvedMenuBarDynamicSource, + runID: UUID, + result: ConfiguredMenuBarDynamicCommandResult, + store: CmuxConfigStore, + preferredWindow: NSWindow? + ) { + guard dynamicStates[source.id]?.activeRunID == runID else { return } + var state = dynamicStates[source.id] ?? ConfiguredMenuBarDynamicState() + state.activeRunID = nil + state.activePID = nil + state.durationMS = result.durationMS + state.exitStatus = result.exitStatus + + if let error = result.errorMessage { + state.phase = .failed + state.error = error + dynamicStates[source.id] = state + renderDynamicSourceMenu(sourceID: source.id, preferredWindow: preferredWindow) + owner?.notifyConfiguredMenuBarDynamicFailure(source: source, error: error, preferredWindow: preferredWindow) + return + } + + do { + let data = Data(result.stdout.utf8) + let generatedItems = try JSONDecoder().decode([CmuxConfigMenuBarItem].self, from: data) + let resolved = store.resolveGeneratedMenuBarItems( + generatedItems, + settingName: "\(source.settingName).generated", + settingSourcePath: source.settingSourcePath + ) + if let issue = resolved.issues.first { + throw NSError(domain: "CmuxDynamicMenu", code: 1, userInfo: [ + NSLocalizedDescriptionKey: issue.logMessage + ]) + } + state.phase = .loaded + state.items = resolved.items + state.error = nil + state.generatedItemCount = resolved.items.count + } catch { + state.phase = .failed + state.error = error.localizedDescription + owner?.notifyConfiguredMenuBarDynamicFailure( + source: source, + error: error.localizedDescription, + preferredWindow: preferredWindow + ) + } + dynamicStates[source.id] = state + renderDynamicSourceMenu(sourceID: source.id, preferredWindow: preferredWindow) + } + + private func phaseLabel(_ phase: ConfiguredMenuBarDynamicPhase) -> String { + switch phase { + case .idle: + return String(localized: "taskManager.dynamicMenu.idle", defaultValue: "Idle") + case .running: + return String(localized: "taskManager.dynamicMenu.running", defaultValue: "Running") + case .loaded: + return String(localized: "taskManager.dynamicMenu.loaded", defaultValue: "Loaded") + case .failed: + return String(localized: "taskManager.dynamicMenu.failed", defaultValue: "Failed") + } + } + + @objc private func performMenuItem(_ sender: NSMenuItem) { + guard let box = sender.representedObject as? ActionBox else { + NSSound.beep() + return + } + guard owner?.performConfiguredMenuBarAction(box.action, preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) == true else { + NSSound.beep() + return + } + } + + @objc private func reloadDynamicSource(_ sender: NSMenuItem) { + guard let box = sender.representedObject as? DynamicSourceBox else { + NSSound.beep() + return + } + refreshDynamicSource( + sourceID: box.sourceID, + preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow, + reason: .manual + ) + } + + @objc private func copyDynamicSourceError(_ sender: NSMenuItem) { + guard let box = sender.representedObject as? DynamicErrorBox else { + NSSound.beep() + return + } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(box.error, forType: .string) + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4a4058a883d4..84f10fa1fa41 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7078,6 +7078,12 @@ struct ContentView: View { defaultValue: "%@ '%@' must reference a workspace command" ) return String(format: format, issue.settingName, issue.commandName ?? "") + case .menuBarInvalidMenu: + let format = String( + localized: "command.cmuxConfig.issue.menuBarInvalidMenu.detail", + defaultValue: "%@: %@" + ) + return String(format: format, issue.settingName, issue.message ?? "") } } diff --git a/Sources/TaskManagerSnapshot.swift b/Sources/TaskManagerSnapshot.swift index 2014cb580026..85e82eb4c092 100644 --- a/Sources/TaskManagerSnapshot.swift +++ b/Sources/TaskManagerSnapshot.swift @@ -34,6 +34,10 @@ struct CmuxTaskManagerSnapshot { for window in windows { Self.appendWindow(window, to: &rows) } + let dynamicMenus = payload["dynamic_menus"] as? [[String: Any]] ?? [] + if !dynamicMenus.isEmpty { + Self.appendDynamicMenus(dynamicMenus, to: &rows) + } self.rows = rows } @@ -60,6 +64,52 @@ struct CmuxTaskManagerSnapshot { } } + private static func appendDynamicMenus(_ dynamicMenus: [[String: Any]], to rows: inout [CmuxTaskManagerRow]) { + let totalResources = dynamicMenus.reduce(CmuxTaskManagerResources.zero) { partial, menu in + let resources = CmuxTaskManagerResources(menu["resources"] as? [String: Any] ?? [:]) + return CmuxTaskManagerResources( + cpuPercent: partial.cpuPercent + resources.cpuPercent, + residentBytes: partial.residentBytes + resources.residentBytes, + processCount: partial.processCount + resources.processCount, + processIds: partial.processIds + resources.processIds + ) + } + rows.append(CmuxTaskManagerRow( + id: "dynamicMenuSection:configured", + kind: .dynamicMenuSection, + level: 0, + title: String(localized: "taskManager.row.dynamicMenus", defaultValue: "Dynamic Menus"), + detail: String( + format: String(localized: "taskManager.row.dynamicMenus.count", defaultValue: "%d sources"), + dynamicMenus.count + ), + resources: totalResources, + isDimmed: false, + workspaceId: nil, + surfaceId: nil, + terminalSurfaceId: nil, + processId: nil, + rootProcessIds: dynamicMenus.flatMap { intArray($0["root_pids"]) }, + foregroundProcessGroupIds: [], + agentAssetName: nil + )) + + for menu in dynamicMenus { + let title = nonEmptyString(menu["title"]) + ?? String(localized: "taskManager.row.dynamicMenu", defaultValue: "Dynamic Menu") + rows.append(row( + menu, + kind: .dynamicMenuSource, + level: 1, + title: title, + detail: nonEmptyString(menu["detail"]) ?? "", + isDimmed: nonEmptyString(menu["state"]) != "running", + processId: int(menu["active_pid"]), + rootProcessIds: intArray(menu["root_pids"]) + )) + } + } + private static func appendWorkspace(_ workspace: [String: Any], to rows: inout [CmuxTaskManagerRow]) { let workspaceId = uuid(workspace["id"]) let title = nonEmptyString(workspace["title"]) ?? displayHandle(workspace) diff --git a/Sources/TaskManagerTypes.swift b/Sources/TaskManagerTypes.swift index a4d7873a7968..089f23e4961f 100644 --- a/Sources/TaskManagerTypes.swift +++ b/Sources/TaskManagerTypes.swift @@ -11,6 +11,8 @@ struct CmuxTaskManagerRow: Identifiable { case terminalSurface case browserSurface case webview + case dynamicMenuSection + case dynamicMenuSource case process var systemImage: String { @@ -22,6 +24,8 @@ struct CmuxTaskManagerRow: Identifiable { case .terminalSurface: return "terminal" case .browserSurface: return "globe" case .webview: return "network" + case .dynamicMenuSection: return "menubar.rectangle" + case .dynamicMenuSource: return "point.3.connected.trianglepath.dotted" case .process: return "gearshape" } } @@ -35,6 +39,8 @@ struct CmuxTaskManagerRow: Identifiable { case .terminalSurface: return .green case .browserSurface: return .blue case .webview: return .purple + case .dynamicMenuSection: return .indigo + case .dynamicMenuSource: return .mint case .process: return .secondary } } @@ -106,6 +112,12 @@ struct CmuxTaskManagerRow: Identifiable { struct CmuxTaskManagerResources { static let zero = CmuxTaskManagerResources(cpuPercent: 0, residentBytes: 0, processCount: 0) + static let zeroPayload: [String: Any] = [ + "cpu_percent": 0, + "resident_bytes": 0, + "process_count": 0, + "pids": [] + ] let cpuPercent: Double let residentBytes: Int64 @@ -119,6 +131,13 @@ struct CmuxTaskManagerResources { self.processIds = [] } + init(cpuPercent: Double, residentBytes: Int64, processCount: Int, processIds: [Int]) { + self.cpuPercent = cpuPercent + self.residentBytes = residentBytes + self.processCount = processCount + self.processIds = processIds + } + init(_ payload: [String: Any]) { self.cpuPercent = Self.double(payload["cpu_percent"]) self.residentBytes = Self.int64(payload["resident_bytes"]) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 4f2a966c7a22..f9dd164b42b2 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3342,9 +3342,11 @@ class TerminalController { let identifyPayload = v2Identify(params: [:]) let focused = identifyPayload["focused"] as? [String: Any] ?? [:] var windowNodes: [[String: Any]] = [] + var dynamicMenus: [[String: Any]] = [] if let app = AppDelegate.shared { let summaries = app.listMainWindowSummaries() + dynamicMenus = app.configuredDynamicMenuTaskManagerPayload() for (windowIndex, summary) in summaries.enumerated() { guard let manager = app.tabManagerFor(windowId: summary.windowId) else { continue } @@ -3371,19 +3373,25 @@ class TerminalController { }.value let browserPIDOccurrences = v2TopBrowserPIDOccurrences(in: windowNodes) var annotatedWindows = windowNodes - let totalPIDs = v2AnnotateTopWindows( + var totalPIDs = v2AnnotateTopWindows( &annotatedWindows, processSnapshot: processSnapshot, browserPIDOccurrences: browserPIDOccurrences, includeProcesses: includeProcesses ) + totalPIDs.formUnion(v2AnnotateTopDynamicMenus( + &dynamicMenus, + processSnapshot: processSnapshot, + includeProcesses: includeProcesses + )) return [ "active": focused.isEmpty ? (NSNull() as Any) : focused, "caller": NSNull(), "sample": processSnapshot.samplePayload(), "totals": processSnapshot.summaryPayload(for: totalPIDs), - "windows": annotatedWindows + "windows": annotatedWindows, + "dynamic_menus": dynamicMenus ] } @@ -3395,21 +3403,28 @@ class TerminalController { guard case .ok(let value) = base else { return base } guard var payload = value as? [String: Any], let includeProcesses = payload.removeValue(forKey: "include_processes") as? Bool, - var windowNodes = payload.removeValue(forKey: "windows") as? [[String: Any]] else { + var windowNodes = payload.removeValue(forKey: "windows") as? [[String: Any]], + var dynamicMenus = payload.removeValue(forKey: "dynamic_menus") as? [[String: Any]] else { return .err(code: "internal_error", message: "Invalid system.top payload", data: nil) } let processSnapshot = CmuxTopProcessSnapshot.capture(includeProcessDetails: includeProcesses) let browserPIDOccurrences = v2TopBrowserPIDOccurrences(in: windowNodes) - let totalPIDs = v2AnnotateTopWindows( + var totalPIDs = v2AnnotateTopWindows( &windowNodes, processSnapshot: processSnapshot, browserPIDOccurrences: browserPIDOccurrences, includeProcesses: includeProcesses ) + totalPIDs.formUnion(v2AnnotateTopDynamicMenus( + &dynamicMenus, + processSnapshot: processSnapshot, + includeProcesses: includeProcesses + )) payload["sample"] = processSnapshot.samplePayload() payload["totals"] = processSnapshot.summaryPayload(for: totalPIDs) payload["windows"] = windowNodes + payload["dynamic_menus"] = dynamicMenus return .ok(payload) } @@ -3434,10 +3449,14 @@ class TerminalController { var windowNodes: [[String: Any]] = [] var workspaceFound = (workspaceFilter == nil) + var dynamicMenus: [[String: Any]] = [] if let app = AppDelegate.shared { let summaries = app.listMainWindowSummaries() let defaultWindowId = focusedWindowId ?? summaries.first?.windowId + if workspaceFilter == nil { + dynamicMenus = app.configuredDynamicMenuTaskManagerPayload() + } for (windowIndex, summary) in summaries.enumerated() { guard let manager = app.tabManagerFor(windowId: summary.windowId) else { continue } @@ -3504,7 +3523,8 @@ class TerminalController { "active": focused.isEmpty ? (NSNull() as Any) : focused, "caller": caller.isEmpty ? (NSNull() as Any) : caller, "include_processes": includeProcesses, - "windows": windowNodes + "windows": windowNodes, + "dynamic_menus": dynamicMenus ]) } diff --git a/Sources/TerminalControllerTopSupport.swift b/Sources/TerminalControllerTopSupport.swift index b68c1ab308ab..9423b8633c1c 100644 --- a/Sources/TerminalControllerTopSupport.swift +++ b/Sources/TerminalControllerTopSupport.swift @@ -73,6 +73,28 @@ extension TerminalController { return allPIDs } + nonisolated func v2AnnotateTopDynamicMenus( + _ dynamicMenus: inout [[String: Any]], + processSnapshot: CmuxTopProcessSnapshot, + includeProcesses: Bool + ) -> Set { + var allPIDs: Set = [] + for index in dynamicMenus.indices { + let rootPIDs = Set(v2TopIntArray(dynamicMenus[index]["root_pids"])) + let pids = processSnapshot.expandedPIDs(rootPIDs: rootPIDs) + dynamicMenus[index]["root_pids"] = rootPIDs.sorted() + dynamicMenus[index]["top_level_pids"] = processSnapshot.topLevelPIDs(for: pids).sorted() + dynamicMenus[index]["foreground_pgids"] = processSnapshot.foregroundProcessGroupIDs(for: pids).sorted() + dynamicMenus[index]["resources"] = processSnapshot.summaryPayload(for: pids, rootPIDs: rootPIDs) + dynamicMenus[index]["processes"] = includeProcesses ? processSnapshot.processTreePayload( + for: pids, + rootPIDs: rootPIDs + ) : [] + allPIDs.formUnion(pids) + } + return allPIDs + } + nonisolated func v2AnnotateTopWorkspace( _ workspace: inout [String: Any], processSnapshot: CmuxTopProcessSnapshot, diff --git a/cmuxTests/CmuxConfigContextMenuTests.swift b/cmuxTests/CmuxConfigContextMenuTests.swift index 0ea1578e6e06..9e608b57cd87 100644 --- a/cmuxTests/CmuxConfigContextMenuTests.swift +++ b/cmuxTests/CmuxConfigContextMenuTests.swift @@ -12,7 +12,7 @@ final class CmuxConfigContextMenuTests: XCTestCase { } @MainActor - private func loadStore(localJSON: String? = nil) throws -> CmuxConfigStore { + private func loadStore(localJSON: String? = nil, globalJSON: String? = nil) throws -> CmuxConfigStore { let root = FileManager.default.temporaryDirectory.appendingPathComponent( "cmux-config-store-\(UUID().uuidString)", isDirectory: true @@ -26,9 +26,15 @@ final class CmuxConfigContextMenuTests: XCTestCase { if let localJSON { try localJSON.write(to: localConfigURL, atomically: true, encoding: .utf8) } + let globalConfigURL = root.appendingPathComponent("global.json") + if let globalJSON { + try globalJSON.write(to: globalConfigURL, atomically: true, encoding: .utf8) + } let store = CmuxConfigStore( - globalConfigPath: root.appendingPathComponent("missing-global.json").path, + globalConfigPath: globalJSON == nil + ? root.appendingPathComponent("missing-global.json").path + : globalConfigURL.path, localConfigPath: localJSON == nil ? nil : localConfigURL.path, startFileWatchers: false ) @@ -83,6 +89,192 @@ final class CmuxConfigContextMenuTests: XCTestCase { } } + func testDecodeMenuBarSupportsActionRefsInlineCommandsAndSubmenus() throws { + let json = """ + { + "actions": { + "run-tests": { "type": "command", "command": "npm test" } + }, + "ui": { + "menuBar": { + "menus": [ + { + "title": "Project", + "items": [ + "run-tests", + { "type": "separator" }, + { + "title": "Tools", + "items": [ + { + "title": "Lint", + "command": "npm run lint", + "target": "currentTerminal" + } + ] + } + ] + } + ] + } + } + } + """ + let config = try decode(json) + let menu = try XCTUnwrap(config.ui?.menuBar?.menus.first) + XCTAssertEqual(menu.title, "Project") + XCTAssertEqual(menu.items.count, 3) + if case .action(let first) = menu.items[0] { + XCTAssertEqual(first.action, "run-tests") + } else { + XCTFail("Expected first menu item to be an action reference.") + } + if case .separator = menu.items[1] { + } else { + XCTFail("Expected second menu item to be a separator.") + } + if case .submenu(let submenu) = menu.items[2] { + XCTAssertEqual(submenu.title, "Tools") + guard case .action(let item) = submenu.items.first else { + return XCTFail("Expected nested inline command.") + } + XCTAssertEqual(item.title, "Lint") + XCTAssertEqual(item.inlineAction?.action?.terminalCommand, "npm run lint") + XCTAssertEqual(item.inlineAction?.terminalCommandTarget, .currentTerminal) + } else { + XCTFail("Expected third menu item to be a submenu.") + } + } + + func testEncodeMenuBarInlineActionPreservesPresentationFields() throws { + let item = try JSONDecoder().decode(CmuxConfigMenuBarItem.self, from: Data(""" + { + "title": "Lint", + "icon": { "type": "symbol", "name": "sparkles" }, + "tooltip": "Run lint", + "shortcut": "cmd+shift+l", + "command": "npm run lint", + "target": "currentTerminal" + } + """.utf8)) + + let data = try JSONEncoder().encode(item) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(object["title"] as? String, "Lint") + XCTAssertEqual(object["tooltip"] as? String, "Run lint") + let icon = try XCTUnwrap(object["icon"] as? [String: Any]) + XCTAssertEqual(icon["type"] as? String, "symbol") + XCTAssertEqual(icon["name"] as? String, "sparkles") + XCTAssertEqual(object["shortcut"] as? String, "cmd+shift+l") + XCTAssertEqual(object["command"] as? String, "npm run lint") + XCTAssertEqual(object["target"] as? String, "currentTerminal") + } + + func testEncodeMenuBarActionReferencePreservesShortcut() throws { + let item = try JSONDecoder().decode(CmuxConfigMenuBarItem.self, from: Data(""" + { + "action": "run-tests", + "title": "Run Tests", + "shortcut": ["ctrl+b", "t"] + } + """.utf8)) + + let data = try JSONEncoder().encode(item) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(object["action"] as? String, "run-tests") + XCTAssertEqual(object["title"] as? String, "Run Tests") + XCTAssertEqual(object["shortcut"] as? [String], ["ctrl+b", "t"]) + } + + func testDecodeMenuBarAcceptsArrayShorthand() throws { + let json = """ + { + "ui": { + "menuBar": [ + { + "title": "Project", + "items": [ + { "title": "Format", "command": "npm run format" } + ] + } + ] + } + } + """ + let config = try decode(json) + XCTAssertEqual(config.ui?.menuBar?.menus.first?.title, "Project") + } + + func testDecodeMenuBarSupportsTopLevelPlacementAnchors() throws { + let json = """ + { + "ui": { + "menuBar": [ + { + "id": "tools", + "title": "Tools", + "before": "notifications", + "items": [ + { "title": "Format", "command": "npm run format" } + ] + }, + { + "id": "deploy", + "title": "Deploy", + "after": "tools", + "items": [ + { "title": "Ship", "command": "npm run deploy" } + ] + } + ] + } + } + """ + let config = try decode(json) + let menus = try XCTUnwrap(config.ui?.menuBar?.menus) + XCTAssertEqual(menus.map(\.title), ["Tools", "Deploy"]) + XCTAssertEqual(menus[0].before, "notifications") + XCTAssertNil(menus[0].after) + XCTAssertEqual(menus[1].after, "tools") + XCTAssertNil(menus[1].before) + } + + func testDecodeMenuBarSupportsExtendsAndDynamicSource() throws { + let json = """ + { + "ui": { + "menuBar": [ + { + "extends": "notifications", + "items": [ + { + "title": "Recent Branches", + "source": { + "command": "printf '[]'", + "refresh": "interval", + "timeoutSeconds": 2, + "intervalSeconds": 10 + } + } + ] + } + ] + } + } + """ + let config = try decode(json) + let menu = try XCTUnwrap(config.ui?.menuBar?.menus.first) + XCTAssertEqual(menu.extends, "notifications") + guard case .dynamic(let item) = menu.items.first else { + return XCTFail("Expected dynamic menu source.") + } + XCTAssertEqual(item.title, "Recent Branches") + XCTAssertEqual(item.source.command, "printf '[]'") + XCTAssertEqual(item.source.refresh, .interval) + XCTAssertEqual(item.source.timeoutSeconds, 2) + XCTAssertEqual(item.source.intervalSeconds, 10) + } + @MainActor func testDefaultNewWorkspaceContextMenuIncludesCloudVM() throws { let store = try loadStore() @@ -416,4 +608,325 @@ final class CmuxConfigContextMenuTests: XCTestCase { XCTAssertEqual(item.title, "Menu") XCTAssertEqual(item.tooltip, "Tip") } + + @MainActor + func testResolvedMenuBarSupportsActionRefsInlineCommandsAndSubmenus() throws { + let store = try loadStore(localJSON: """ + { + "actions": { + "run-tests": { + "type": "command", + "title": "Run Tests", + "command": "npm test", + "target": "newTabInCurrentPane" + } + }, + "ui": { + "menuBar": { + "menus": [ + { + "title": "Project", + "items": [ + "run-tests", + { "type": "separator" }, + { + "title": "Tools", + "items": [ + { + "title": "Lint", + "command": "npm run lint", + "target": "currentTerminal" + } + ] + } + ] + } + ] + } + } + } + """) + + XCTAssertEqual(store.menuBarMenus.count, 1) + let menu = try XCTUnwrap(store.menuBarMenus.first) + XCTAssertEqual(menu.title, "Project") + XCTAssertEqual(menu.items.count, 3) + guard case .action(let first) = menu.items[0] else { + return XCTFail("Expected first menu item to resolve to an action.") + } + XCTAssertEqual(first.title, "Run Tests") + XCTAssertEqual(first.action.terminalCommand, "npm test") + guard case .separator = menu.items[1] else { + return XCTFail("Expected second menu item to be a separator.") + } + guard case .submenu(let submenu) = menu.items[2] else { + return XCTFail("Expected third menu item to resolve to a submenu.") + } + XCTAssertEqual(submenu.title, "Tools") + guard case .action(let nested) = submenu.items.first else { + return XCTFail("Expected nested menu item to resolve to an action.") + } + XCTAssertEqual(nested.title, "Lint") + XCTAssertEqual(nested.action.terminalCommand, "npm run lint") + XCTAssertEqual(nested.action.terminalCommandTarget, .currentTerminal) + XCTAssertTrue(store.configurationIssues.isEmpty) + } + + @MainActor + func testResolvedMenuBarAppliesShortcutsAndIncludesInlineMenuActionsInShortcutActions() throws { + let store = try loadStore(localJSON: """ + { + "actions": { + "run-tests": { + "type": "command", + "title": "Run Tests", + "command": "npm test", + "shortcut": "cmd+shift+t" + } + }, + "ui": { + "menuBar": [ + { + "title": "Project", + "items": [ + { + "action": "run-tests", + "title": "Run Visible Tests", + "shortcut": "cmd+shift+r" + }, + { + "title": "Lint", + "command": "npm run lint", + "target": "currentTerminal", + "shortcut": ["ctrl+b", "l"] + } + ] + } + ] + } + } + """) + + let menu = try XCTUnwrap(store.menuBarMenus.first) + guard case .action(let runTests) = menu.items[0], + case .action(let lint) = menu.items[1] else { + return XCTFail("Expected resolved menu actions.") + } + + XCTAssertEqual(runTests.title, "Run Visible Tests") + XCTAssertEqual(runTests.action.shortcut, StoredShortcut.parseConfig("cmd+shift+r")) + XCTAssertEqual(lint.action.shortcut, StoredShortcut.parseConfig(strokes: ["ctrl+b", "l"])) + + let shortcutActions = store.shortcutActions() + let runTestsShortcut = try XCTUnwrap(StoredShortcut.parseConfig("cmd+shift+r")) + let originalRunTestsShortcut = try XCTUnwrap(StoredShortcut.parseConfig("cmd+shift+t")) + XCTAssertEqual( + shortcutActions.filter { $0.id == "run-tests" }.compactMap(\.shortcut), + [runTestsShortcut] + ) + XCTAssertFalse(shortcutActions.contains { action in + action.id == "run-tests" && action.shortcut == originalRunTestsShortcut + }) + XCTAssertTrue(shortcutActions.contains { action in + action.terminalCommand == "npm test" && + action.shortcut == runTestsShortcut + }) + XCTAssertTrue(shortcutActions.contains { action in + action.terminalCommand == "npm run lint" && + action.shortcut == StoredShortcut.parseConfig(strokes: ["ctrl+b", "l"]) + }) + XCTAssertTrue(store.configurationIssues.isEmpty) + } + + @MainActor + func testResolvedMenuBarKeepsDuplicateTitlesSeparateAndSupportsExtends() throws { + let store = try loadStore( + localJSON: """ + { + "ui": { + "menuBar": [ + { + "id": "local-project", + "title": "Project", + "items": [{ "title": "Local", "command": "echo local" }] + }, + { + "extends": "notifications", + "items": [{ "title": "Open Logs", "command": "echo logs" }] + } + ] + } + } + """, + globalJSON: """ + { + "ui": { + "menuBar": [ + { + "id": "global-project", + "title": "Project", + "items": [{ "title": "Global", "command": "echo global" }] + } + ] + } + } + """ + ) + + XCTAssertEqual(store.menuBarMenus.map(\.configID), ["global-project", "local-project"]) + XCTAssertEqual(store.menuBarMenus.map(\.title), ["Project", "Project"]) + XCTAssertEqual(store.menuBarExtensions.count, 1) + XCTAssertEqual(store.menuBarExtensions.first?.targetID, "notifications") + XCTAssertTrue(store.configurationIssues.isEmpty) + } + + @MainActor + func testResolvedMenuBarCarriesTopLevelPlacementAnchors() throws { + let store = try loadStore(localJSON: """ + { + "ui": { + "menuBar": [ + { + "id": "tools", + "title": "Tools", + "before": "notifications", + "items": [{ "title": "Local", "command": "echo local" }] + }, + { + "id": "deploy", + "title": "Deploy", + "after": "tools", + "items": [{ "title": "Deploy", "command": "echo deploy" }] + } + ] + } + } + """) + + XCTAssertEqual(store.menuBarMenus.map(\.configID), ["tools", "deploy"]) + XCTAssertEqual(store.menuBarMenus[0].before, "notifications") + XCTAssertNil(store.menuBarMenus[0].after) + XCTAssertEqual(store.menuBarMenus[1].after, "tools") + XCTAssertNil(store.menuBarMenus[1].before) + XCTAssertTrue(store.configurationIssues.isEmpty) + } + + @MainActor + func testResolvedMenuBarIgnoresInvalidPlacementAnchorsWithIssue() throws { + let store = try loadStore(localJSON: """ + { + "ui": { + "menuBar": [ + { + "id": "tools", + "title": "Tools", + "before": "view", + "after": "notifications", + "items": [{ "title": "Local", "command": "echo local" }] + }, + { + "extends": "notifications", + "before": "help", + "items": [{ "title": "Open Logs", "command": "echo logs" }] + } + ] + } + } + """) + + let menu = try XCTUnwrap(store.menuBarMenus.first) + XCTAssertNil(menu.before) + XCTAssertNil(menu.after) + XCTAssertEqual(store.menuBarExtensions.count, 1) + XCTAssertEqual( + store.configurationIssues.map(\.message), + [ + "menuBar menu must define at most one of before or after", + "menuBar extensions cannot define before or after" + ] + ) + } + + @MainActor + func testResolvedMenuBarSupportsDynamicSourcesAndRejectsGeneratedNestedSources() throws { + let store = try loadStore(localJSON: """ + { + "ui": { + "menuBar": [ + { + "title": "Project", + "items": [ + { + "id": "recent-branches", + "title": "Recent Branches", + "source": { + "command": "printf '[]'", + "refresh": "onOpen", + "timeoutSeconds": 3 + } + } + ] + } + ] + } + } + """) + + let menu = try XCTUnwrap(store.menuBarMenus.first) + guard case .dynamicSource(let source) = menu.items.first else { + return XCTFail("Expected dynamic source.") + } + XCTAssertEqual(source.title, "Recent Branches") + XCTAssertEqual(source.source.command, "printf '[]'") + XCTAssertEqual(source.source.refresh, .onOpen) + + let generated = try JSONDecoder().decode([CmuxConfigMenuBarItem].self, from: Data(""" + [ + { + "title": "Nested Dynamic", + "source": { "command": "printf '[]'" } + } + ] + """.utf8)) + let resolved = store.resolveGeneratedMenuBarItems( + generated, + settingName: "ui.menuBar.menus[0].items[0].generated", + settingSourcePath: nil + ) + XCTAssertTrue(resolved.items.isEmpty) + XCTAssertEqual(resolved.issues.first?.kind, .menuBarInvalidMenu) + } + + @MainActor + func testResolvedMenuBarFiltersMissingActions() throws { + let store = try loadStore(localJSON: """ + { + "actions": { + "run-tests": { "type": "command", "command": "npm test" } + }, + "ui": { + "menuBar": [ + { + "title": "Project", + "items": [ + "missing-action", + "run-tests" + ] + } + ] + } + } + """) + + XCTAssertEqual(store.menuBarMenus.count, 1) + let menu = try XCTUnwrap(store.menuBarMenus.first) + XCTAssertEqual(menu.items.count, 1) + guard case .action(let item) = menu.items.first else { + return XCTFail("Expected valid action to remain.") + } + XCTAssertEqual(item.action.terminalCommand, "npm test") + XCTAssertEqual(store.configurationIssues.first?.kind, .newWorkspaceActionNotFound) + XCTAssertEqual(store.configurationIssues.first?.settingName, "ui.menuBar.menus[0].items[0]") + XCTAssertEqual(store.configurationIssues.first?.commandName, "missing-action") + } } diff --git a/cmuxUITests/ConfiguredMenuBarDemoUITests.swift b/cmuxUITests/ConfiguredMenuBarDemoUITests.swift new file mode 100644 index 000000000000..3fbacc1e66f7 --- /dev/null +++ b/cmuxUITests/ConfiguredMenuBarDemoUITests.swift @@ -0,0 +1,249 @@ +import XCTest + +private func configuredMenuBarPollUntil( + timeout: TimeInterval, + pollInterval: TimeInterval = 0.05, + condition: () -> Bool +) -> Bool { + let start = ProcessInfo.processInfo.systemUptime + while true { + if condition() { + return true + } + if (ProcessInfo.processInfo.systemUptime - start) >= timeout { + return false + } + RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) + } +} + +private func configuredMenuBarResetMenuBarOnlyDefault() { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/defaults") + process.arguments = ["write", "com.cmuxterm.app.debug", "menuBarOnly", "-bool", "false"] + do { + try process.run() + process.waitUntilExit() + } catch { + return + } +} + +private let configuredMenuBarLaunchArguments = [ + "-AppleLanguages", "(en)", + "-AppleLocale", "en_US", + "-ApplePersistenceIgnoreState", "YES", + "-NSQuitAlwaysKeepsWindows", "NO", + "-menuBarOnly", "false", +] + +private let configuredMenuBarDemoDirectoryName = "cmux-configured-menubar-demo" +private let configuredMenuBarDemoBeforeScreenshotName = "cmux-configured-menubar-demo-before.png" +private let configuredMenuBarDemoOpenScreenshotName = "cmux-configured-menubar-demo-open.png" + +final class ConfiguredMenuBarDemoUITests: XCTestCase { + private var app: XCUIApplication? + private var configURL: URL? + private var beforeScreenshotURL: URL? + private var openScreenshotURL: URL? + + override func setUpWithError() throws { + try super.setUpWithError() + continueAfterFailure = false + configuredMenuBarResetMenuBarOnlyDefault() + try writeConfiguredMenuBarDemoConfig() + } + + override func tearDownWithError() throws { + app?.terminate() + restoreOriginalConfig() + configuredMenuBarResetMenuBarOnlyDefault() + try super.tearDownWithError() + } + + func testConfiguredToolsMenuOpensForDemoRecording() throws { + let app = XCUIApplication() + self.app = app + app.launchArguments += configuredMenuBarLaunchArguments + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CMUX_CONFIG_PATH"] = try XCTUnwrap(configURL?.path) + launchAndActivate(app) + + XCTAssertTrue( + configuredMenuBarPollUntil(timeout: 8.0) { + app.windows.count >= 1 + }, + "Expected a cmux window before opening the configured menu" + ) + try saveDemoScreenshot(url: XCTUnwrap(beforeScreenshotURL)) + + XCTAssertTrue( + openConfiguredToolsMenu(in: app, timeout: 12.0), + configuredToolsMenuFailureDetails(app: app) + ) + + XCTAssertTrue(menuItemExists(in: app, title: "Run Static Demo Command", timeout: 3.0)) + XCTAssertTrue(app.menuItems["Nested Commands"].waitForExistence(timeout: 3.0)) + XCTAssertTrue(app.menuItems["Live Bash Items"].waitForExistence(timeout: 3.0)) + try saveDemoScreenshot(url: XCTUnwrap(openScreenshotURL)) + + RunLoop.current.run(until: Date().addingTimeInterval(3.0)) + } + + private func writeConfiguredMenuBarDemoConfig() throws { + let fileManager = FileManager.default + let configDirectory = fileManager.temporaryDirectory + .appendingPathComponent("\(configuredMenuBarDemoDirectoryName)-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: configDirectory, withIntermediateDirectories: true) + + let configURL = configDirectory.appendingPathComponent("cmux.json", isDirectory: false) + self.configURL = configURL + beforeScreenshotURL = configDirectory.appendingPathComponent(configuredMenuBarDemoBeforeScreenshotName, isDirectory: false) + openScreenshotURL = configDirectory.appendingPathComponent(configuredMenuBarDemoOpenScreenshotName, isDirectory: false) + + let config = #""" + { + "ui": { + "menuBar": [ + { + "id": "tools", + "title": "Tools", + "before": "notifications", + "items": [ + { + "title": "Run Static Demo Command", + "command": "printf 'static menu action from cmux.json\\\\n'", + "target": "currentTerminal", + "shortcut": "cmd+shift+y" + }, + { "type": "separator" }, + { + "title": "Nested Commands", + "items": [ + { + "title": "Nested Echo", + "command": "printf 'nested menu action\\\\n'", + "target": "currentTerminal" + } + ] + }, + { + "title": "Live Bash Items", + "source": { + "type": "command", + "command": "printf '[]'", + "refresh": "manual", + "timeoutSeconds": 3 + } + } + ] + } + ] + } + } + """# + try config.write(to: configURL, atomically: true, encoding: .utf8) + } + + private func openConfiguredToolsMenu(in app: XCUIApplication, timeout: TimeInterval) -> Bool { + let labeledCandidates = [ + app.menuBars.menuBarItems["Tools"], + app.menuBars.menuItems["Tools"], + ] + for candidate in labeledCandidates where candidate.waitForExistence(timeout: 0.5) { + candidate.click() + if menuItemExists(in: app, title: "Run Static Demo Command", timeout: 1.0) { + return true + } + } + + let start = ProcessInfo.processInfo.systemUptime + while (ProcessInfo.processInfo.systemUptime - start) < timeout { + let items = app.menuBars.menuBarItems.allElementsBoundByIndex + if items.isEmpty { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + continue + } + for item in items where item.exists { + if item.isHittable { + item.click() + } else { + item.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() + } + if menuItemExists(in: app, title: "Run Static Demo Command", timeout: 0.4) { + return true + } + } + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + return false + } + + private func menuItemExists(in app: XCUIApplication, title: String, timeout: TimeInterval) -> Bool { + if app.menuItems[title].waitForExistence(timeout: min(0.3, timeout)) { + return true + } + let predicate = NSPredicate(format: "label BEGINSWITH %@", title) + return app.menuItems.matching(predicate).firstMatch.waitForExistence(timeout: max(0.0, timeout - 0.3)) + } + + private func configuredToolsMenuFailureDetails(app: XCUIApplication) -> String { + let items = app.menuBars.menuBarItems.allElementsBoundByIndex + let titles = items.map(\.label).joined(separator: ", ") + let frames = items.map { NSStringFromRect($0.frame) }.joined(separator: ", ") + return "Expected configured Tools menu to open. Visible menu count: \(items.count). Titles: \(titles). Frames: \(frames)" + } + + private func saveDemoScreenshot(url: URL) throws { + do { + try XCUIScreen.main.screenshot().pngRepresentation.write(to: url, options: .atomic) + XCTAssertTrue( + FileManager.default.fileExists(atPath: url.path), + "Expected demo screenshot to exist at \(url.path)" + ) + print("Saved configured menu bar demo screenshot: \(url.path)") + } catch { + XCTFail("Failed to save configured menu bar demo screenshot at \(url.path): \(error)") + throw error + } + } + + private func restoreOriginalConfig() { + print("Preserving configured menu bar demo artifacts at \(configURL?.deletingLastPathComponent().path ?? "")") + } + + private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) { + let options = XCTExpectedFailure.Options() + options.isStrict = false + XCTExpectFailure("Headless CI may launch the app without foreground activation", options: options) { + app.launch() + } + + XCTAssertTrue( + configuredMenuBarPollUntil(timeout: 10.0) { + app.state == .runningForeground || app.state == .runningBackground + }, + "App failed to launch. state=\(app.state.rawValue)" + ) + + if app.state != .runningForeground { + let activated = configuredMenuBarPollUntil(timeout: activateTimeout) { + guard app.state != .runningForeground else { + return true + } + app.activate() + return app.state == .runningForeground + } + if !activated { + app.activate() + } + } + + XCTAssertTrue( + configuredMenuBarPollUntil(timeout: 6.0) { + app.state == .runningForeground + }, + "App did not become foreground before menu interactions. state=\(app.state.rawValue)" + ) + } +} diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 22b9bd91915c..c3bfd83eb7ed 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -69,6 +69,34 @@ const settingsFileExample = `{ // "hostsToOpenInEmbeddedBrowser": ["localhost", "*.internal.example"] // }, + // "actions": { + // "run-tests": { + // "type": "command", + // "title": "Run Tests", + // "command": "npm test", + // "target": "newTabInCurrentPane" + // } + // }, + // + // "ui": { + // "menuBar": [ + // { + // "title": "Project", + // "items": [ + // "run-tests", + // { "type": "separator" }, + // { "title": "Lint", "command": "npm run lint", "target": "currentTerminal", "shortcut": "cmd+shift+l" }, + // { + // "title": "Deploy", + // "items": [ + // { "title": "Staging", "command": "./scripts/deploy staging", "confirm": true } + // ] + // } + // ] + // } + // ] + // }, + // "workspaceColors": { // "colors": { // "Red": "#C0392B", @@ -349,6 +377,77 @@ working-directory = ~/code`} }`} )} + {sectionName === "ui" && ( + <> +

+ ui.menuBar adds custom top-level macOS menus. Menu items can + reference entries from actions, define inline shell commands, use{" "} + {`{ "type": "separator" }`}, or nest submenus with{" "} + items. Duplicate top-level titles stay separate. Use{" "} + extends when you want to append to an existing menu. Custom menus + appear in array order by default, and top-level menus can use before{" "} + or after to place themselves around built-in menus or another custom + menu id. Add shortcut to a menu action to bind it from + the keyboard. Single-stroke shortcuts render as native macOS menu hints; chord + shortcuts still trigger while cmux has focus. +

+

+ Dynamic menu sources run Bash and read a JSON array from stdout. They run on + menu open by default, can also run manually, after config reload, or on a bounded + interval, keep their last good items after failures, show a copyable error row, + and appear in Task Manager while running. +

+ {`{ + "actions": { + "run-tests": { + "type": "command", + "title": "Run Tests", + "command": "npm test" + } + }, + "ui": { + "menuBar": [ + { + "id": "project", + "title": "Project", + "before": "notifications", + "items": [ + "run-tests", + { "type": "separator" }, + { "title": "Lint", "command": "npm run lint", "target": "currentTerminal", "shortcut": "cmd+shift+l" }, + { + "title": "Recent Branches", + "source": { + "type": "command", + "command": "git branch --format='%(refname:short)' | head -10 | jq -R -s 'split(\\"\\\\n\\")[:-1] | map({ title: ., command: (\\"git switch \\" + .), target: \\"currentTerminal\\" })'", + "refresh": "interval", + "intervalSeconds": 60, + "timeoutSeconds": 3 + } + } + ] + }, + { + "extends": "notifications", + "items": [ + { "title": "Open Logs", "command": "open ~/Library/Logs/cmux", "target": "newTabInCurrentPane" } + ] + } + ] + } +}`} +

+ Built-in extension targets are app, file,{" "} + edit, view, updatePill,{" "} + notifications, debug, window, and{" "} + help. A custom menu can be targeted by its id. Use ids + for placement when duplicate custom menu titles exist. Generated dynamic output + can include actions, separators, submenus, and inline commands, but not another + dynamic source. Interval sources from project configs start running automatically + only after the source command is trusted. +

+ + )} ); })} diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 6476c9081112..4040880d60b4 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux.schema.json", "title": "cmux.json", - "description": "cmux configuration. Global cmux.json supports app settings, shortcuts, actions, custom commands, and workspace layouts. Project-local .cmux/cmux.json supports actions, commands, and UI action wiring.", + "description": "cmux configuration. Global cmux.json supports app settings, shortcuts, actions, custom commands, workspace layouts, and custom menu bar menus. Project-local .cmux/cmux.json supports actions, commands, and UI action wiring.", "type": "object", "additionalProperties": false, "properties": { @@ -20,15 +20,45 @@ }, "actions": { "title": "actions", - "description": "Action registry used by the surface tab bar, Command Palette, shortcuts, and plus-button menu.", + "description": "Action registry used by the surface tab bar, Command Palette, shortcuts, plus-button menu, and custom menu bar menus.", "type": "object", "additionalProperties": true }, "ui": { "title": "ui", - "description": "UI action wiring, including surface tab bar buttons and plus-button behavior.", + "description": "UI action wiring, including surface tab bar buttons, plus-button behavior, and custom menu bar menus.", "type": "object", - "additionalProperties": true + "additionalProperties": true, + "properties": { + "menuBar": { + "title": "ui.menuBar", + "description": "Custom top-level macOS menu bar menus. Menus can reference actions, define inline shell commands, include separators, and nest submenus.", + "oneOf": [ + { + "$ref": "#/$defs/menuBarMenus" + }, + { + "type": "object", + "additionalProperties": true, + "properties": { + "menus": { + "$ref": "#/$defs/menuBarMenus" + } + } + } + ] + }, + "newWorkspace": { + "type": "object", + "additionalProperties": true, + "description": "Action and context menu wiring for the new-workspace button." + }, + "surfaceTabBar": { + "type": "object", + "additionalProperties": true, + "description": "Action button wiring for surface tab bars." + } + } }, "commands": { "title": "commands", @@ -806,6 +836,197 @@ } ] }, + "configIcon": { + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["symbol", "sfSymbol", "systemImage", "emoji", "image", "file"], + "description": "Icon kind. Menu bar items render SF Symbols in the macOS menu bar." + }, + "name": { + "type": "string", + "description": "SF Symbol name for symbol, sfSymbol, or systemImage icons." + }, + "value": { + "type": "string", + "description": "Emoji value for emoji icons." + }, + "path": { + "type": "string", + "description": "Image path for image or file icons." + }, + "scale": { + "type": "number", + "description": "Optional emoji scale." + } + } + }, + "menuBarMenus": { + "type": "array", + "items": { + "$ref": "#/$defs/menuBarMenu" + }, + "description": "Configured macOS menu bar menus. Menus with title create custom top-level menus. Menus with extends append items to an existing built-in or custom menu." + }, + "menuBarMenu": { + "type": "object", + "additionalProperties": false, + "anyOf": [ + { "required": ["title"] }, + { "required": ["extends"] } + ], + "not": { + "required": ["before", "after"] + }, + "properties": { + "id": { + "type": "string", + "description": "Optional stable identifier for this menu." + }, + "title": { + "type": "string", + "description": "Menu title shown in the macOS menu bar." + }, + "extends": { + "type": "string", + "description": "Optional built-in menu id (file, edit, view, notifications, window, help, app) or custom menu id to append items to. Duplicate titles do not merge automatically." + }, + "before": { + "type": "string", + "description": "Optional top-level placement anchor. Creates this custom menu before a built-in menu id (file, edit, view, updatePill, notifications, debug, window, help, app) or a custom menu id. Only top-level menus use placement anchors." + }, + "after": { + "type": "string", + "description": "Optional top-level placement anchor. Creates this custom menu after a built-in menu id (file, edit, view, updatePill, notifications, debug, window, help, app) or a custom menu id. Only top-level menus use placement anchors." + }, + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/menuBarItem" + }, + "default": [], + "description": "Menu items. Strings reference actions. Use \"-\" or { \"type\": \"separator\" } for dividers." + } + } + }, + "menuBarItem": { + "oneOf": [ + { + "type": "string", + "description": "Action id, \"separator\", or \"-\"." + }, + { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "string", + "enum": ["separator", "menu", "submenu", "source", "dynamic", "command", "agent", "builtin", "workspaceCommand"], + "description": "separator creates a divider, menu/submenu creates a nested menu, source/dynamic creates a dynamic submenu, and action types define inline actions." + }, + "title": { + "type": "string", + "description": "Menu item or submenu title." + }, + "icon": { + "$ref": "#/$defs/configIcon", + "description": "Optional icon metadata. Menu bar items currently render SF Symbol icons." + }, + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/menuBarItem" + }, + "description": "Nested submenu items." + }, + "source": { + "$ref": "#/$defs/menuBarSource", + "description": "Dynamic menu source. The command must print a JSON array of menuBarItem objects. Generated output cannot contain another source." + }, + "action": { + "type": "string", + "description": "Action id from actions, built-in cmux action id, or generated command id." + }, + "shortcut": { + "$ref": "#/$defs/shortcutBinding", + "description": "Keyboard shortcut for this menu item. Single-stroke shortcuts render as native macOS menu hints. Chords work while cmux has focus but macOS menus only show one-stroke hints." + }, + "command": { + "type": "string", + "description": "Inline shell command. cmux inserts it into the selected terminal or a new terminal tab based on target." + }, + "target": { + "type": "string", + "enum": ["currentTerminal", "newTabInCurrentPane"], + "default": "newTabInCurrentPane", + "description": "Where an inline shell command runs." + }, + "agent": { + "type": "string", + "enum": ["codex", "claude", "claudeCode", "claude-code"], + "description": "Inline agent action shorthand." + }, + "args": { + "type": "string", + "description": "Arguments for an inline agent action." + }, + "builtin": { + "type": "string", + "description": "Built-in cmux action id such as cmux.newTerminal." + }, + "commandName": { + "type": "string", + "description": "Workspace command name for inline workspaceCommand actions." + }, + "confirm": { + "type": "boolean", + "description": "Ask before running this inline action." + }, + "tooltip": { + "type": "string", + "description": "Optional tooltip metadata." + } + } + } + ] + }, + "menuBarSource": { + "type": "object", + "additionalProperties": false, + "required": ["command"], + "properties": { + "type": { + "type": "string", + "enum": ["command"], + "default": "command", + "description": "Dynamic source kind. Only command is supported." + }, + "command": { + "type": "string", + "description": "Bash command run from the selected workspace directory. stdout must be a JSON array of menuBarItem values." + }, + "refresh": { + "type": "string", + "enum": ["onOpen", "manual", "onConfigReload", "interval"], + "default": "onOpen", + "description": "When cmux runs this source." + }, + "timeoutSeconds": { + "type": "number", + "exclusiveMinimum": 0, + "default": 5, + "description": "Maximum runtime before cmux terminates the command." + }, + "intervalSeconds": { + "type": "number", + "minimum": 10, + "description": "Required when refresh is interval. cmux only runs interval sources automatically after the source has already been trusted or when it comes from global config." + } + } + }, "shortcutBinding": { "oneOf": [ {