Skip to content

Commit f70c429

Browse files
author
cw
committed
feat: add menubar app, browser automation scripts, and web UI
- Add macOS menubar application with Swift implementations - Add browser automation scripts with Node.js dependencies - Add web UI components - Update .gitignore to exclude build artifacts and dependencies - Configure auto-approved permissions for git operations and OpenCLI domain
1 parent 46fa0ea commit f70c429

File tree

12 files changed

+6308
-1
lines changed

12 files changed

+6308
-1
lines changed

.claude/settings.local.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@
103103
"mcp__flutter-skill__enter_text",
104104
"mcp__flutter-skill__tap",
105105
"mcp__flutter-skill__screenshot",
106-
"mcp__flutter-skill__wait_for_element"
106+
"mcp__flutter-skill__wait_for_element",
107+
"Bash(FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch:*)",
108+
"WebFetch(domain:opencli.ai)"
107109
]
108110
}
109111
}

.gitignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Dependencies
2+
node_modules/
3+
4+
# Build outputs
5+
.dart_tool/
6+
*.app
7+
build/
8+
dist/
9+
10+
# Test files
11+
test_audio.aiff
12+
*.aiff
13+
14+
# IDE
15+
.vscode/
16+
.idea/
17+
18+
# OS
19+
.DS_Store

.idea/libraries/Dart_Packages.xml

Lines changed: 220 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

menubar-app/OpenCLIMenuBar.swift

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
#!/usr/bin/env swift
2+
3+
import Cocoa
4+
import Foundation
5+
import UserNotifications
6+
7+
class StatusBarController: NSObject {
8+
private var statusBar: NSStatusBar
9+
private var statusItem: NSStatusItem
10+
private var menu: NSMenu
11+
private var webSocket: URLSessionWebSocketTask?
12+
private var notificationsEnabled: Bool {
13+
get { UserDefaults.standard.bool(forKey: "notificationsEnabled") }
14+
set { UserDefaults.standard.set(newValue, forKey: "notificationsEnabled") }
15+
}
16+
17+
override init() {
18+
statusBar = NSStatusBar.system
19+
statusItem = statusBar.statusItem(withLength: NSStatusItem.variableLength)
20+
menu = NSMenu()
21+
22+
super.init()
23+
24+
// Default to notifications enabled
25+
if UserDefaults.standard.object(forKey: "notificationsEnabled") == nil {
26+
notificationsEnabled = true
27+
}
28+
29+
setupMenuBar()
30+
setupNotifications()
31+
connectWebSocket()
32+
updateStatus()
33+
34+
// Update status every 30 seconds (减少频率)
35+
Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { _ in
36+
self.updateStatus()
37+
}
38+
}
39+
40+
func setupMenuBar() {
41+
// Set icon
42+
if let button = statusItem.button {
43+
button.image = NSImage(systemSymbolName: "terminal.fill", accessibilityDescription: "OpenCLI")
44+
button.image?.isTemplate = true
45+
}
46+
47+
statusItem.menu = menu
48+
}
49+
50+
func setupNotifications() {
51+
let center = UNUserNotificationCenter.current()
52+
center.requestAuthorization(options: [.alert, .sound]) { granted, error in
53+
if let error = error {
54+
print("⚠️ Notification authorization error: \(error)")
55+
}
56+
}
57+
}
58+
59+
// WebSocket 连接监听任务完成事件
60+
func connectWebSocket() {
61+
let url = URL(string: "ws://localhost:9876")!
62+
let session = URLSession(configuration: .default)
63+
webSocket = session.webSocketTask(with: url)
64+
webSocket?.resume()
65+
66+
receiveMessage()
67+
}
68+
69+
func receiveMessage() {
70+
webSocket?.receive { [weak self] result in
71+
switch result {
72+
case .success(let message):
73+
switch message {
74+
case .string(let text):
75+
self?.handleWebSocketMessage(text)
76+
case .data(let data):
77+
if let text = String(data: data, encoding: .utf8) {
78+
self?.handleWebSocketMessage(text)
79+
}
80+
@unknown default:
81+
break
82+
}
83+
// Continue receiving
84+
self?.receiveMessage()
85+
86+
case .failure(let error):
87+
print("⚠️ WebSocket error: \(error)")
88+
// Reconnect after 5 seconds
89+
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
90+
self?.connectWebSocket()
91+
}
92+
}
93+
}
94+
}
95+
96+
func handleWebSocketMessage(_ text: String) {
97+
guard let data = text.data(using: .utf8),
98+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
99+
let type = json["type"] as? String else {
100+
return
101+
}
102+
103+
// 只在任务更新时发送通知
104+
if type == "task_update" {
105+
let status = json["status"] as? String ?? "unknown"
106+
let taskType = json["task_type"] as? String ?? "task"
107+
108+
if status == "completed" || status == "failed" {
109+
sendNotification(
110+
title: status == "completed" ? "✅ 任务完成" : "❌ 任务失败",
111+
body: "任务类型: \(taskType)"
112+
)
113+
}
114+
}
115+
}
116+
117+
func sendNotification(title: String, body: String) {
118+
guard notificationsEnabled else { return }
119+
120+
let content = UNMutableNotificationContent()
121+
content.title = title
122+
content.body = body
123+
content.sound = .default
124+
125+
let request = UNNotificationRequest(
126+
identifier: UUID().uuidString,
127+
content: content,
128+
trigger: nil
129+
)
130+
131+
UNUserNotificationCenter.current().add(request) { error in
132+
if let error = error {
133+
print("⚠️ Failed to send notification: \(error)")
134+
}
135+
}
136+
}
137+
138+
func updateStatus() {
139+
menu.removeAllItems()
140+
141+
// Fetch status from daemon
142+
let url = URL(string: "http://localhost:9875/status")!
143+
let task = URLSession.shared.dataTask(with: url) { data, response, error in
144+
DispatchQueue.main.async {
145+
if let data = data,
146+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
147+
let daemon = json["daemon"] as? [String: Any],
148+
let mobile = json["mobile"] as? [String: Any] {
149+
self.buildMenu(daemon: daemon, mobile: mobile)
150+
} else {
151+
self.buildOfflineMenu()
152+
}
153+
}
154+
}
155+
task.resume()
156+
}
157+
158+
func buildMenu(daemon: [String: Any], mobile: [String: Any]) {
159+
// Status header
160+
let statusItem = NSMenuItem(title: "🟢 OpenCLI is running", action: nil, keyEquivalent: "")
161+
statusItem.isEnabled = false
162+
menu.addItem(statusItem)
163+
164+
menu.addItem(NSMenuItem.separator())
165+
166+
// Stats
167+
if let version = daemon["version"] as? String {
168+
let versionItem = NSMenuItem(title: "Version: \(version)", action: nil, keyEquivalent: "")
169+
versionItem.isEnabled = false
170+
menu.addItem(versionItem)
171+
}
172+
173+
if let uptime = daemon["uptime_seconds"] as? Int {
174+
let hours = uptime / 3600
175+
let minutes = (uptime % 3600) / 60
176+
let uptimeItem = NSMenuItem(title: "Uptime: \(hours)h \(minutes)m", action: nil, keyEquivalent: "")
177+
uptimeItem.isEnabled = false
178+
menu.addItem(uptimeItem)
179+
}
180+
181+
if let memory = daemon["memory_mb"] as? Double {
182+
let memoryItem = NSMenuItem(title: String(format: "Memory: %.1f MB", memory), action: nil, keyEquivalent: "")
183+
memoryItem.isEnabled = false
184+
menu.addItem(memoryItem)
185+
}
186+
187+
if let clients = mobile["connected_clients"] as? Int {
188+
let clientsItem = NSMenuItem(title: "Mobile Clients: \(clients)", action: nil, keyEquivalent: "")
189+
clientsItem.isEnabled = false
190+
menu.addItem(clientsItem)
191+
}
192+
193+
menu.addItem(NSMenuItem.separator())
194+
195+
// 通知开关
196+
let notifItem = NSMenuItem(
197+
title: notificationsEnabled ? "🔔 通知: 开启" : "🔕 通知: 关闭",
198+
action: #selector(toggleNotifications),
199+
keyEquivalent: "n"
200+
)
201+
notifItem.target = self
202+
notifItem.state = notificationsEnabled ? .on : .off
203+
menu.addItem(notifItem)
204+
205+
menu.addItem(NSMenuItem.separator())
206+
207+
// Actions
208+
let dashboardItem = NSMenuItem(title: "📊 Open Dashboard", action: #selector(openDashboard), keyEquivalent: "d")
209+
dashboardItem.target = self
210+
menu.addItem(dashboardItem)
211+
212+
let webUIItem = NSMenuItem(title: "🌐 Open Web UI", action: #selector(openWebUI), keyEquivalent: "w")
213+
webUIItem.target = self
214+
menu.addItem(webUIItem)
215+
216+
menu.addItem(NSMenuItem.separator())
217+
218+
let refreshItem = NSMenuItem(title: "🔄 Refresh", action: #selector(refreshStatus), keyEquivalent: "r")
219+
refreshItem.target = self
220+
menu.addItem(refreshItem)
221+
222+
menu.addItem(NSMenuItem.separator())
223+
224+
let quitItem = NSMenuItem(title: "Quit OpenCLI Menu", action: #selector(quit), keyEquivalent: "q")
225+
quitItem.target = self
226+
menu.addItem(quitItem)
227+
}
228+
229+
func buildOfflineMenu() {
230+
let statusItem = NSMenuItem(title: "🔴 OpenCLI is offline", action: nil, keyEquivalent: "")
231+
statusItem.isEnabled = false
232+
menu.addItem(statusItem)
233+
234+
menu.addItem(NSMenuItem.separator())
235+
236+
// 通知开关(即使离线也可以切换)
237+
let notifItem = NSMenuItem(
238+
title: notificationsEnabled ? "🔔 通知: 开启" : "🔕 通知: 关闭",
239+
action: #selector(toggleNotifications),
240+
keyEquivalent: "n"
241+
)
242+
notifItem.target = self
243+
notifItem.state = notificationsEnabled ? .on : .off
244+
menu.addItem(notifItem)
245+
246+
menu.addItem(NSMenuItem.separator())
247+
248+
let refreshItem = NSMenuItem(title: "🔄 Refresh", action: #selector(refreshStatus), keyEquivalent: "r")
249+
refreshItem.target = self
250+
menu.addItem(refreshItem)
251+
252+
menu.addItem(NSMenuItem.separator())
253+
254+
let quitItem = NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q")
255+
quitItem.target = self
256+
menu.addItem(quitItem)
257+
}
258+
259+
@objc func toggleNotifications() {
260+
notificationsEnabled.toggle()
261+
updateStatus() // 刷新菜单显示
262+
263+
// 显示确认提示
264+
sendNotification(
265+
title: "通知设置已更新",
266+
body: notificationsEnabled ? "任务完成通知已开启" : "任务完成通知已关闭"
267+
)
268+
}
269+
270+
@objc func openDashboard() {
271+
NSWorkspace.shared.open(URL(string: "http://localhost:9875/status")!)
272+
}
273+
274+
@objc func openWebUI() {
275+
NSWorkspace.shared.open(URL(string: "http://localhost:3000")!)
276+
}
277+
278+
@objc func refreshStatus() {
279+
updateStatus()
280+
}
281+
282+
@objc func quit() {
283+
webSocket?.cancel(with: .goingAway, reason: nil)
284+
NSApplication.shared.terminate(self)
285+
}
286+
}
287+
288+
class AppDelegate: NSObject, NSApplicationDelegate {
289+
var statusBarController: StatusBarController?
290+
291+
func applicationDidFinishLaunching(_ notification: Notification) {
292+
statusBarController = StatusBarController()
293+
}
294+
}
295+
296+
// Main
297+
let app = NSApplication.shared
298+
let delegate = AppDelegate()
299+
app.delegate = delegate
300+
app.run()

0 commit comments

Comments
 (0)