a2acalling 0.6.44 → 0.6.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1660 @@
1
+ # A2A Callbook — Native macOS App (v1) Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Deliver a native macOS Tauri app that wraps the existing A2A dashboard SPA, adding native notifications, deep links, menu bar status, and proper macOS app lifecycle.
6
+
7
+ **Architecture:** Tauri v2 shell with system WebKit WebView loading the existing SPA from the running Express server (`http://localhost:{port}/api/a2a/dashboard/`). The Rust backend handles port discovery, server lifecycle spawning, macOS notifications (via polling), and `a2a://` URL scheme registration. No server code changes — the app is a client of the existing HTTP API.
8
+
9
+ **Tech Stack:** Tauri v2, Rust, existing vanilla JS/HTML/CSS SPA, macOS WebKit, cargo for Rust build
10
+
11
+ **Linear ticket:** A2A-20
12
+
13
+ ---
14
+
15
+ ## Phase 1: Scaffold Tauri Project
16
+
17
+ ### Task 1: Initialize Tauri v2 project structure
18
+
19
+ **Files:**
20
+ - Create: `native/macos/Cargo.toml`
21
+ - Create: `native/macos/src-tauri/Cargo.toml`
22
+ - Create: `native/macos/src-tauri/src/main.rs`
23
+ - Create: `native/macos/src-tauri/src/lib.rs`
24
+ - Create: `native/macos/src-tauri/tauri.conf.json`
25
+ - Create: `native/macos/src-tauri/capabilities/default.json`
26
+ - Create: `native/macos/src-tauri/icons/` (placeholder)
27
+ - Create: `native/macos/package.json` (for Tauri CLI)
28
+ - Create: `native/macos/index.html` (minimal loader page)
29
+
30
+ **Step 1: Create directory structure**
31
+
32
+ ```bash
33
+ mkdir -p native/macos/src-tauri/src
34
+ mkdir -p native/macos/src-tauri/icons
35
+ mkdir -p native/macos/src-tauri/capabilities
36
+ ```
37
+
38
+ **Step 2: Create `native/macos/package.json`**
39
+
40
+ This is just for the Tauri CLI tooling — not a Node app.
41
+
42
+ ```json
43
+ {
44
+ "name": "a2a-callbook-macos",
45
+ "version": "0.1.0",
46
+ "private": true,
47
+ "scripts": {
48
+ "tauri": "cargo tauri"
49
+ }
50
+ }
51
+ ```
52
+
53
+ **Step 3: Create `native/macos/index.html`**
54
+
55
+ This is the loading/fallback page shown before the server is detected. Once the server is found, the WebView navigates to the live SPA URL.
56
+
57
+ ```html
58
+ <!DOCTYPE html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="UTF-8">
62
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
63
+ <title>A2A Callbook</title>
64
+ <style>
65
+ * { margin: 0; padding: 0; box-sizing: border-box; }
66
+ body {
67
+ font-family: -apple-system, BlinkMacSystemFont, 'IBM Plex Sans', sans-serif;
68
+ background: linear-gradient(180deg, #eef3f8 0%, #f8f9fb 100%);
69
+ color: #1a1a2e;
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: center;
73
+ min-height: 100vh;
74
+ }
75
+ .status-card {
76
+ background: #fff;
77
+ border: 1px solid #d0d7de;
78
+ border-radius: 12px;
79
+ padding: 48px;
80
+ text-align: center;
81
+ max-width: 420px;
82
+ box-shadow: 0 2px 8px rgba(0,0,0,0.06);
83
+ }
84
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
85
+ .subtitle { color: #666; font-size: 14px; margin-bottom: 24px; }
86
+ .status-indicator {
87
+ display: inline-block;
88
+ width: 10px; height: 10px;
89
+ border-radius: 50%;
90
+ margin-right: 8px;
91
+ vertical-align: middle;
92
+ }
93
+ .status-indicator.searching { background: #f59e0b; animation: pulse 1.5s infinite; }
94
+ .status-indicator.disconnected { background: #ef4444; }
95
+ .status-indicator.connected { background: #22c55e; }
96
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
97
+ .status-text { font-size: 14px; margin-bottom: 24px; color: #444; }
98
+ .port-info { font-size: 12px; color: #888; margin-bottom: 16px; font-family: monospace; }
99
+ button {
100
+ background: #1466c1; color: #fff; border: none; border-radius: 8px;
101
+ padding: 10px 24px; font-size: 14px; cursor: pointer; margin: 4px;
102
+ font-family: inherit;
103
+ }
104
+ button:hover { background: #1052a0; }
105
+ button.secondary {
106
+ background: transparent; color: #1466c1; border: 1px solid #1466c1;
107
+ }
108
+ button.secondary:hover { background: #eef3f8; }
109
+ #error-detail { color: #ef4444; font-size: 12px; margin-top: 12px; display: none; }
110
+ </style>
111
+ </head>
112
+ <body>
113
+ <div class="status-card">
114
+ <h1>A2A Callbook</h1>
115
+ <p class="subtitle">Agent-to-agent communication dashboard</p>
116
+
117
+ <div id="status-searching">
118
+ <p class="status-text">
119
+ <span class="status-indicator searching"></span>
120
+ Looking for a2a server...
121
+ </p>
122
+ <p class="port-info" id="port-info">Scanning ports: 3001, 80, 8080, 8443, 9001</p>
123
+ </div>
124
+
125
+ <div id="status-not-found" style="display:none;">
126
+ <p class="status-text">
127
+ <span class="status-indicator disconnected"></span>
128
+ Server not running
129
+ </p>
130
+ <p class="port-info" id="last-port">No a2a server found on common ports</p>
131
+ <button id="btn-start">Start Server</button>
132
+ <button id="btn-retry" class="secondary">Retry</button>
133
+ <p id="error-detail"></p>
134
+ </div>
135
+
136
+ <div id="status-connected" style="display:none;">
137
+ <p class="status-text">
138
+ <span class="status-indicator connected"></span>
139
+ Connected to server
140
+ </p>
141
+ <p class="port-info" id="connected-port"></p>
142
+ </div>
143
+ </div>
144
+
145
+ <script>
146
+ const { invoke } = window.__TAURI__.core;
147
+
148
+ async function checkServer() {
149
+ show('status-searching');
150
+ try {
151
+ const result = await invoke('discover_server');
152
+ if (result.port) {
153
+ show('status-connected');
154
+ document.getElementById('connected-port').textContent =
155
+ `localhost:${result.port}`;
156
+ // Navigate to live SPA
157
+ setTimeout(() => {
158
+ window.location.href =
159
+ `http://127.0.0.1:${result.port}/api/a2a/dashboard/` +
160
+ (window.__TAB_HASH || '');
161
+ }, 400);
162
+ } else {
163
+ show('status-not-found');
164
+ }
165
+ } catch (err) {
166
+ show('status-not-found');
167
+ const detail = document.getElementById('error-detail');
168
+ detail.textContent = err;
169
+ detail.style.display = 'block';
170
+ }
171
+ }
172
+
173
+ function show(id) {
174
+ ['status-searching', 'status-not-found', 'status-connected']
175
+ .forEach(s => document.getElementById(s).style.display = 'none');
176
+ document.getElementById(id).style.display = 'block';
177
+ }
178
+
179
+ document.getElementById('btn-start')?.addEventListener('click', async () => {
180
+ try {
181
+ await invoke('start_server');
182
+ // Wait for server to boot, then retry
183
+ setTimeout(checkServer, 2000);
184
+ } catch (err) {
185
+ const detail = document.getElementById('error-detail');
186
+ detail.textContent = `Failed to start: ${err}`;
187
+ detail.style.display = 'block';
188
+ }
189
+ });
190
+
191
+ document.getElementById('btn-retry')?.addEventListener('click', checkServer);
192
+
193
+ // Start discovery on load
194
+ checkServer();
195
+ </script>
196
+ </body>
197
+ </html>
198
+ ```
199
+
200
+ **Step 4: Create `native/macos/src-tauri/tauri.conf.json`**
201
+
202
+ ```json
203
+ {
204
+ "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
205
+ "productName": "A2A Callbook",
206
+ "version": "0.1.0",
207
+ "identifier": "com.openclaw.a2a-callbook",
208
+ "build": {
209
+ "frontendDist": "../index.html"
210
+ },
211
+ "app": {
212
+ "windows": [
213
+ {
214
+ "title": "A2A Callbook",
215
+ "width": 1024,
216
+ "height": 720,
217
+ "minWidth": 480,
218
+ "minHeight": 600,
219
+ "resizable": true,
220
+ "titleBarStyle": "Visible",
221
+ "hiddenTitle": false
222
+ }
223
+ ],
224
+ "security": {
225
+ "dangerousRemoteUrlAccess": [
226
+ { "url": "http://127.0.0.1:**" },
227
+ { "url": "http://localhost:**" }
228
+ ]
229
+ }
230
+ },
231
+ "bundle": {
232
+ "active": true,
233
+ "targets": ["dmg", "app"],
234
+ "icon": [
235
+ "icons/32x32.png",
236
+ "icons/128x128.png",
237
+ "icons/128x128@2x.png",
238
+ "icons/icon.icns"
239
+ ],
240
+ "macOS": {
241
+ "minimumSystemVersion": "12.0",
242
+ "frameworks": []
243
+ }
244
+ }
245
+ }
246
+ ```
247
+
248
+ **Step 5: Create `native/macos/src-tauri/Cargo.toml`**
249
+
250
+ ```toml
251
+ [package]
252
+ name = "a2a-callbook"
253
+ version = "0.1.0"
254
+ edition = "2021"
255
+
256
+ [lib]
257
+ name = "a2a_callbook_lib"
258
+ crate-type = ["lib", "cdylib", "staticlib"]
259
+
260
+ [build-dependencies]
261
+ tauri-build = { version = "2", features = [] }
262
+
263
+ [dependencies]
264
+ tauri = { version = "2", features = [] }
265
+ tauri-plugin-shell = "2"
266
+ tauri-plugin-notification = "2"
267
+ tauri-plugin-deep-link = "2"
268
+ tauri-plugin-window-state = "2"
269
+ serde = { version = "1", features = ["derive"] }
270
+ serde_json = "1"
271
+ reqwest = { version = "0.12", features = ["json"], default-features = false, features = ["rustls-tls"] }
272
+ tokio = { version = "1", features = ["full"] }
273
+ dirs = "6"
274
+ ```
275
+
276
+ **Step 6: Create `native/macos/src-tauri/capabilities/default.json`**
277
+
278
+ ```json
279
+ {
280
+ "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
281
+ "identifier": "default",
282
+ "description": "Default capabilities for A2A Callbook",
283
+ "windows": ["main"],
284
+ "permissions": [
285
+ "core:default",
286
+ "shell:allow-open",
287
+ "shell:allow-execute",
288
+ "notification:default",
289
+ "notification:allow-is-permission-granted",
290
+ "notification:allow-request-permission",
291
+ "notification:allow-notify",
292
+ "deep-link:default",
293
+ "window-state:default"
294
+ ]
295
+ }
296
+ ```
297
+
298
+ **Step 7: Create `native/macos/src-tauri/src/main.rs`**
299
+
300
+ ```rust
301
+ // Prevents additional console window on Windows in release
302
+ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
303
+
304
+ fn main() {
305
+ a2a_callbook_lib::run()
306
+ }
307
+ ```
308
+
309
+ **Step 8: Create `native/macos/src-tauri/src/lib.rs`**
310
+
311
+ Minimal skeleton — just opens the window with the loader page.
312
+
313
+ ```rust
314
+ use tauri::Manager;
315
+
316
+ #[cfg_attr(mobile, tauri::mobile_entry_point)]
317
+ pub fn run() {
318
+ tauri::Builder::default()
319
+ .plugin(tauri_plugin_shell::init())
320
+ .plugin(tauri_plugin_notification::init())
321
+ .plugin(tauri_plugin_deep_link::init())
322
+ .plugin(tauri_plugin_window_state::Builder::new().build())
323
+ .run(tauri::generate_context!())
324
+ .expect("error while running A2A Callbook");
325
+ }
326
+ ```
327
+
328
+ **Step 9: Create `native/macos/src-tauri/build.rs`**
329
+
330
+ ```rust
331
+ fn main() {
332
+ tauri_build::build()
333
+ }
334
+ ```
335
+
336
+ **Step 10: Verify the project compiles**
337
+
338
+ ```bash
339
+ cd native/macos/src-tauri && cargo check
340
+ ```
341
+
342
+ Expected: Successful compilation check (no errors).
343
+
344
+ **Step 11: Commit**
345
+
346
+ ```bash
347
+ git add native/macos/
348
+ git commit -m "feat(macos): scaffold Tauri v2 project structure"
349
+ ```
350
+
351
+ ---
352
+
353
+ ## Phase 2: Port Discovery & Server Detection
354
+
355
+ ### Task 2: Implement port discovery in Rust
356
+
357
+ The app must find the running a2a server. Strategy:
358
+ 1. Read `~/.config/openclaw/a2a-config.json` for `onboarding.server_port`
359
+ 2. Fall back to probing default ports: `[3001, 80, 8080, 8443, 9001]`
360
+ 3. Probe via `GET http://127.0.0.1:{port}/api/a2a/ping` (200 = found)
361
+
362
+ **Files:**
363
+ - Create: `native/macos/src-tauri/src/discovery.rs`
364
+ - Modify: `native/macos/src-tauri/src/lib.rs`
365
+
366
+ **Step 1: Write `discovery.rs` with port scanning logic**
367
+
368
+ ```rust
369
+ use serde::{Deserialize, Serialize};
370
+ use std::path::PathBuf;
371
+ use std::time::Duration;
372
+
373
+ const DEFAULT_PORTS: &[u16] = &[3001, 80, 8080, 8443, 9001];
374
+ const PROBE_TIMEOUT: Duration = Duration::from_millis(800);
375
+
376
+ #[derive(Debug, Serialize, Deserialize)]
377
+ pub struct DiscoveryResult {
378
+ pub port: Option<u16>,
379
+ pub source: String, // "config" | "scan" | "none"
380
+ }
381
+
382
+ #[derive(Debug, Deserialize)]
383
+ struct A2AConfig {
384
+ onboarding: Option<OnboardingConfig>,
385
+ }
386
+
387
+ #[derive(Debug, Deserialize)]
388
+ struct OnboardingConfig {
389
+ server_port: Option<u16>,
390
+ }
391
+
392
+ /// Read port from ~/.config/openclaw/a2a-config.json
393
+ fn read_config_port() -> Option<u16> {
394
+ let config_dir = std::env::var("A2A_CONFIG_DIR")
395
+ .or_else(|_| std::env::var("OPENCLAW_CONFIG_DIR"))
396
+ .map(PathBuf::from)
397
+ .unwrap_or_else(|_| {
398
+ dirs::home_dir()
399
+ .unwrap_or_else(|| PathBuf::from("/tmp"))
400
+ .join(".config")
401
+ .join("openclaw")
402
+ });
403
+
404
+ let config_path = config_dir.join("a2a-config.json");
405
+ let content = std::fs::read_to_string(config_path).ok()?;
406
+ let config: A2AConfig = serde_json::from_str(&content).ok()?;
407
+ config.onboarding?.server_port
408
+ }
409
+
410
+ /// Probe a single port — returns true if a2a server responds
411
+ async fn probe_port(port: u16) -> bool {
412
+ let url = format!("http://127.0.0.1:{}/api/a2a/ping", port);
413
+ let client = reqwest::Client::builder()
414
+ .timeout(PROBE_TIMEOUT)
415
+ .build();
416
+
417
+ let client = match client {
418
+ Ok(c) => c,
419
+ Err(_) => return false,
420
+ };
421
+
422
+ match client.get(&url).send().await {
423
+ Ok(resp) => resp.status().is_success(),
424
+ Err(_) => false,
425
+ }
426
+ }
427
+
428
+ /// Discover the running a2a server
429
+ pub async fn discover_server() -> DiscoveryResult {
430
+ // 1. Try config port first
431
+ if let Some(port) = read_config_port() {
432
+ if probe_port(port).await {
433
+ return DiscoveryResult {
434
+ port: Some(port),
435
+ source: "config".to_string(),
436
+ };
437
+ }
438
+ }
439
+
440
+ // 2. Scan default ports
441
+ for &port in DEFAULT_PORTS {
442
+ if probe_port(port).await {
443
+ return DiscoveryResult {
444
+ port: Some(port),
445
+ source: "scan".to_string(),
446
+ };
447
+ }
448
+ }
449
+
450
+ DiscoveryResult {
451
+ port: None,
452
+ source: "none".to_string(),
453
+ }
454
+ }
455
+ ```
456
+
457
+ **Step 2: Register the Tauri command in `lib.rs`**
458
+
459
+ ```rust
460
+ use tauri::Manager;
461
+
462
+ mod discovery;
463
+
464
+ #[tauri::command]
465
+ async fn discover_server() -> Result<discovery::DiscoveryResult, String> {
466
+ Ok(discovery::discover_server().await)
467
+ }
468
+
469
+ #[cfg_attr(mobile, tauri::mobile_entry_point)]
470
+ pub fn run() {
471
+ tauri::Builder::default()
472
+ .plugin(tauri_plugin_shell::init())
473
+ .plugin(tauri_plugin_notification::init())
474
+ .plugin(tauri_plugin_deep_link::init())
475
+ .plugin(tauri_plugin_window_state::Builder::new().build())
476
+ .invoke_handler(tauri::generate_handler![discover_server])
477
+ .run(tauri::generate_context!())
478
+ .expect("error while running A2A Callbook");
479
+ }
480
+ ```
481
+
482
+ **Step 3: Verify compilation**
483
+
484
+ ```bash
485
+ cd native/macos/src-tauri && cargo check
486
+ ```
487
+
488
+ Expected: Compiles successfully.
489
+
490
+ **Step 4: Commit**
491
+
492
+ ```bash
493
+ git add native/macos/src-tauri/src/discovery.rs native/macos/src-tauri/src/lib.rs
494
+ git commit -m "feat(macos): add port discovery — config read + port scanning"
495
+ ```
496
+
497
+ ---
498
+
499
+ ## Phase 3: Server Lifecycle Management
500
+
501
+ ### Task 3: Implement server start/stop commands
502
+
503
+ When the server isn't running, the app offers a "Start Server" button that spawns `a2a server --port 3001` as a detached child process.
504
+
505
+ **Files:**
506
+ - Create: `native/macos/src-tauri/src/server.rs`
507
+ - Modify: `native/macos/src-tauri/src/lib.rs`
508
+
509
+ **Step 1: Write `server.rs`**
510
+
511
+ ```rust
512
+ use serde::Serialize;
513
+ use std::process::Command;
514
+
515
+ #[derive(Debug, Serialize)]
516
+ pub struct StartResult {
517
+ pub success: bool,
518
+ pub message: String,
519
+ }
520
+
521
+ /// Find the `a2a` CLI binary
522
+ fn find_a2a_binary() -> Option<String> {
523
+ // Check common locations
524
+ let candidates = [
525
+ "a2a", // In PATH
526
+ ];
527
+
528
+ for candidate in &candidates {
529
+ let result = Command::new("which")
530
+ .arg(candidate)
531
+ .output();
532
+
533
+ if let Ok(output) = result {
534
+ if output.status.success() {
535
+ let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
536
+ if !path.is_empty() {
537
+ return Some(path);
538
+ }
539
+ }
540
+ }
541
+ }
542
+
543
+ None
544
+ }
545
+
546
+ /// Start the a2a server as a detached process
547
+ pub fn start_server() -> StartResult {
548
+ let binary = match find_a2a_binary() {
549
+ Some(b) => b,
550
+ None => {
551
+ return StartResult {
552
+ success: false,
553
+ message: "Could not find 'a2a' CLI. Is a2acalling installed? Run: npm install -g a2acalling".to_string(),
554
+ };
555
+ }
556
+ };
557
+
558
+ let result = Command::new(&binary)
559
+ .args(["server", "--port", "3001"])
560
+ .stdout(std::process::Stdio::null())
561
+ .stderr(std::process::Stdio::null())
562
+ .stdin(std::process::Stdio::null())
563
+ .spawn();
564
+
565
+ match result {
566
+ Ok(_child) => StartResult {
567
+ success: true,
568
+ message: "Server starting on port 3001...".to_string(),
569
+ },
570
+ Err(err) => StartResult {
571
+ success: false,
572
+ message: format!("Failed to start server: {}", err),
573
+ },
574
+ }
575
+ }
576
+ ```
577
+
578
+ **Step 2: Register `start_server` command in `lib.rs`**
579
+
580
+ Add to `lib.rs`:
581
+
582
+ ```rust
583
+ mod server;
584
+
585
+ #[tauri::command]
586
+ fn start_server() -> Result<server::StartResult, String> {
587
+ Ok(server::start_server())
588
+ }
589
+ ```
590
+
591
+ Add `start_server` to the invoke handler:
592
+
593
+ ```rust
594
+ .invoke_handler(tauri::generate_handler![discover_server, start_server])
595
+ ```
596
+
597
+ **Step 3: Verify compilation**
598
+
599
+ ```bash
600
+ cd native/macos/src-tauri && cargo check
601
+ ```
602
+
603
+ **Step 4: Commit**
604
+
605
+ ```bash
606
+ git add native/macos/src-tauri/src/server.rs native/macos/src-tauri/src/lib.rs
607
+ git commit -m "feat(macos): add server lifecycle — start a2a server from app"
608
+ ```
609
+
610
+ ---
611
+
612
+ ## Phase 4: macOS App Lifecycle & Window Behavior
613
+
614
+ ### Task 4: Implement Cmd+W hide, keyboard shortcuts, window state
615
+
616
+ **Files:**
617
+ - Modify: `native/macos/src-tauri/src/lib.rs`
618
+ - Modify: `native/macos/src-tauri/tauri.conf.json`
619
+
620
+ **Step 1: Add macOS-specific window behavior to `lib.rs`**
621
+
622
+ Cmd+W should hide the window (not quit). Cmd+Q quits. Cmd+1–5 switch tabs. Cmd+R refreshes. Cmd+, opens Settings.
623
+
624
+ ```rust
625
+ use tauri::{Manager, RunEvent, WindowEvent};
626
+ use tauri::menu::{Menu, MenuItem, Submenu, PredefinedMenuItem, AboutMetadata};
627
+
628
+ mod discovery;
629
+ mod server;
630
+
631
+ #[tauri::command]
632
+ async fn discover_server() -> Result<discovery::DiscoveryResult, String> {
633
+ Ok(discovery::discover_server().await)
634
+ }
635
+
636
+ #[tauri::command]
637
+ fn start_server() -> Result<server::StartResult, String> {
638
+ Ok(server::start_server())
639
+ }
640
+
641
+ fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
642
+ let about = PredefinedMenuItem::about(app, Some("About A2A Callbook"), Some(AboutMetadata {
643
+ name: Some("A2A Callbook".into()),
644
+ version: Some(env!("CARGO_PKG_VERSION").into()),
645
+ ..Default::default()
646
+ }))?;
647
+ let quit = PredefinedMenuItem::quit(app, Some("Quit A2A Callbook"))?;
648
+ let hide = PredefinedMenuItem::hide(app, Some("Hide A2A Callbook"))?;
649
+ let separator = PredefinedMenuItem::separator(app)?;
650
+
651
+ let app_menu = Submenu::with_items(app, "A2A Callbook", true, &[
652
+ &about, &separator, &hide, &separator, &quit,
653
+ ])?;
654
+
655
+ // View menu with tab shortcuts
656
+ let contacts = MenuItem::with_id(app, "tab-contacts", "Contacts", true, Some("CmdOrCtrl+1"))?;
657
+ let calls = MenuItem::with_id(app, "tab-calls", "Calls", true, Some("CmdOrCtrl+2"))?;
658
+ let logs = MenuItem::with_id(app, "tab-logs", "Logs", true, Some("CmdOrCtrl+3"))?;
659
+ let settings = MenuItem::with_id(app, "tab-settings", "Settings", true, Some("CmdOrCtrl+4"))?;
660
+ let invites = MenuItem::with_id(app, "tab-invites", "Invites", true, Some("CmdOrCtrl+5"))?;
661
+ let sep2 = PredefinedMenuItem::separator(app)?;
662
+ let refresh = MenuItem::with_id(app, "refresh", "Refresh", true, Some("CmdOrCtrl+R"))?;
663
+
664
+ let view_menu = Submenu::with_items(app, "View", true, &[
665
+ &contacts, &calls, &logs, &settings, &invites, &sep2, &refresh,
666
+ ])?;
667
+
668
+ // Edit menu (standard macOS)
669
+ let copy = PredefinedMenuItem::copy(app, None)?;
670
+ let paste = PredefinedMenuItem::paste(app, None)?;
671
+ let cut = PredefinedMenuItem::cut(app, None)?;
672
+ let select_all = PredefinedMenuItem::select_all(app, None)?;
673
+ let edit_menu = Submenu::with_items(app, "Edit", true, &[
674
+ &cut, &copy, &paste, &select_all,
675
+ ])?;
676
+
677
+ let window_menu = Submenu::with_items(app, "Window", true, &[
678
+ &PredefinedMenuItem::minimize(app, None)?,
679
+ &PredefinedMenuItem::close_window(app, Some("Hide Window"))?,
680
+ ])?;
681
+
682
+ Menu::with_items(app, &[&app_menu, &edit_menu, &view_menu, &window_menu])
683
+ }
684
+
685
+ #[cfg_attr(mobile, tauri::mobile_entry_point)]
686
+ pub fn run() {
687
+ let app = tauri::Builder::default()
688
+ .plugin(tauri_plugin_shell::init())
689
+ .plugin(tauri_plugin_notification::init())
690
+ .plugin(tauri_plugin_deep_link::init())
691
+ .plugin(tauri_plugin_window_state::Builder::new().build())
692
+ .invoke_handler(tauri::generate_handler![discover_server, start_server])
693
+ .setup(|app| {
694
+ let menu = build_menu(app.handle())?;
695
+ app.set_menu(menu)?;
696
+
697
+ // Handle menu events
698
+ let app_handle = app.handle().clone();
699
+ app.on_menu_event(move |_app, event| {
700
+ let id = event.id().0.as_str();
701
+ let tab = match id {
702
+ "tab-contacts" => Some("contacts"),
703
+ "tab-calls" => Some("calls"),
704
+ "tab-logs" => Some("logs"),
705
+ "tab-settings" => Some("settings"),
706
+ "tab-invites" => Some("invites"),
707
+ "refresh" => {
708
+ if let Some(window) = app_handle.get_webview_window("main") {
709
+ let _ = window.eval("window.location.reload()");
710
+ }
711
+ None
712
+ }
713
+ _ => None,
714
+ };
715
+
716
+ if let Some(tab_name) = tab {
717
+ if let Some(window) = app_handle.get_webview_window("main") {
718
+ let js = format!("window.location.hash = '{}'", tab_name);
719
+ let _ = window.eval(&js);
720
+ }
721
+ }
722
+ });
723
+
724
+ Ok(())
725
+ })
726
+ .build(tauri::generate_context!())
727
+ .expect("error building A2A Callbook");
728
+
729
+ // Cmd+W hides window instead of quitting
730
+ app.run(|app_handle, event| {
731
+ if let RunEvent::WindowEvent { label, event: WindowEvent::CloseRequested { api, .. }, .. } = &event {
732
+ api.prevent_close();
733
+ if let Some(window) = app_handle.get_webview_window(label) {
734
+ let _ = window.hide();
735
+ }
736
+ }
737
+ });
738
+ }
739
+ ```
740
+
741
+ **Step 2: Update `tauri.conf.json` to enable Cmd+, shortcut for Settings**
742
+
743
+ Add to the window config:
744
+
745
+ ```json
746
+ {
747
+ "app": {
748
+ "windows": [
749
+ {
750
+ "title": "A2A Callbook",
751
+ "width": 1024,
752
+ "height": 720,
753
+ "minWidth": 480,
754
+ "minHeight": 600,
755
+ "resizable": true
756
+ }
757
+ ]
758
+ }
759
+ }
760
+ ```
761
+
762
+ (The Cmd+, mapping is handled by menu item "tab-settings" with shortcut "CmdOrCtrl+," — add this to the app menu in the Rust code above as a "Preferences" item.)
763
+
764
+ **Step 3: Verify compilation**
765
+
766
+ ```bash
767
+ cd native/macos/src-tauri && cargo check
768
+ ```
769
+
770
+ **Step 4: Commit**
771
+
772
+ ```bash
773
+ git add native/macos/src-tauri/
774
+ git commit -m "feat(macos): add app lifecycle — Cmd+W hide, tab shortcuts, native menus"
775
+ ```
776
+
777
+ ---
778
+
779
+ ## Phase 5: Reconnection Overlay
780
+
781
+ ### Task 5: Add server disconnection detection and auto-reconnect
782
+
783
+ When the server stops mid-session, the SPA will get fetch errors. We need a reconnection overlay and health-check polling.
784
+
785
+ **Files:**
786
+ - Create: `native/macos/src-tauri/src/health.rs`
787
+ - Modify: `native/macos/src-tauri/src/lib.rs`
788
+ - Modify: `native/macos/index.html` (add reconnection overlay JS)
789
+
790
+ **Step 1: Write `health.rs` — periodic health check**
791
+
792
+ ```rust
793
+ use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
794
+ use std::sync::Arc;
795
+ use std::time::Duration;
796
+ use tauri::Emitter;
797
+
798
+ static CONNECTED: AtomicBool = AtomicBool::new(false);
799
+ static CURRENT_PORT: AtomicU16 = AtomicU16::new(0);
800
+
801
+ pub fn is_connected() -> bool {
802
+ CONNECTED.load(Ordering::Relaxed)
803
+ }
804
+
805
+ pub fn current_port() -> u16 {
806
+ CURRENT_PORT.load(Ordering::Relaxed)
807
+ }
808
+
809
+ pub fn set_connected(port: u16) {
810
+ CURRENT_PORT.store(port, Ordering::Relaxed);
811
+ CONNECTED.store(true, Ordering::Relaxed);
812
+ }
813
+
814
+ /// Start background health check loop — emits "server-status" events
815
+ pub fn start_health_monitor(app: tauri::AppHandle) {
816
+ let handle = Arc::new(app);
817
+ tokio::spawn(async move {
818
+ loop {
819
+ tokio::time::sleep(Duration::from_secs(3)).await;
820
+
821
+ let port = CURRENT_PORT.load(Ordering::Relaxed);
822
+ if port == 0 {
823
+ continue;
824
+ }
825
+
826
+ let url = format!("http://127.0.0.1:{}/api/a2a/ping", port);
827
+ let client = reqwest::Client::builder()
828
+ .timeout(Duration::from_millis(1500))
829
+ .build()
830
+ .unwrap();
831
+
832
+ let ok = match client.get(&url).send().await {
833
+ Ok(resp) => resp.status().is_success(),
834
+ Err(_) => false,
835
+ };
836
+
837
+ let was_connected = CONNECTED.swap(ok, Ordering::Relaxed);
838
+
839
+ // Only emit on state change
840
+ if ok != was_connected {
841
+ let _ = handle.emit("server-status", serde_json::json!({
842
+ "connected": ok,
843
+ "port": port
844
+ }));
845
+ }
846
+ }
847
+ });
848
+ }
849
+ ```
850
+
851
+ **Step 2: Add reconnection overlay to `index.html`**
852
+
853
+ Append to the `<script>` block in `index.html`:
854
+
855
+ ```javascript
856
+ // Listen for server disconnect/reconnect from Tauri backend
857
+ const { listen } = window.__TAURI__.event;
858
+
859
+ listen('server-status', (event) => {
860
+ const { connected, port } = event.payload;
861
+ if (!connected) {
862
+ showReconnectionOverlay();
863
+ } else {
864
+ hideReconnectionOverlay();
865
+ }
866
+ });
867
+
868
+ function showReconnectionOverlay() {
869
+ if (document.getElementById('reconnect-overlay')) return;
870
+ const overlay = document.createElement('div');
871
+ overlay.id = 'reconnect-overlay';
872
+ overlay.innerHTML = `
873
+ <div style="position:fixed;top:0;left:0;right:0;z-index:9999;
874
+ background:#fef3c7;border-bottom:2px solid #f59e0b;padding:12px 24px;
875
+ text-align:center;font-family:-apple-system,sans-serif;font-size:14px;color:#92400e;">
876
+ <span style="display:inline-block;width:8px;height:8px;border-radius:50%;
877
+ background:#f59e0b;margin-right:8px;animation:pulse 1.5s infinite;vertical-align:middle;"></span>
878
+ Server disconnected — Reconnecting...
879
+ </div>`;
880
+ document.body.appendChild(overlay);
881
+ }
882
+
883
+ function hideReconnectionOverlay() {
884
+ const overlay = document.getElementById('reconnect-overlay');
885
+ if (overlay) overlay.remove();
886
+ }
887
+ ```
888
+
889
+ **Step 3: Wire health monitor into `lib.rs` setup**
890
+
891
+ In the `setup` closure, after menu setup:
892
+
893
+ ```rust
894
+ let handle = app.handle().clone();
895
+ tokio::spawn(async move {
896
+ health::start_health_monitor(handle).await;
897
+ });
898
+ ```
899
+
900
+ Actually — since `start_health_monitor` spawns its own tokio task, just call it directly:
901
+
902
+ ```rust
903
+ health::start_health_monitor(app.handle().clone());
904
+ ```
905
+
906
+ **Step 4: Verify compilation**
907
+
908
+ ```bash
909
+ cd native/macos/src-tauri && cargo check
910
+ ```
911
+
912
+ **Step 5: Commit**
913
+
914
+ ```bash
915
+ git add native/macos/
916
+ git commit -m "feat(macos): add server health monitor with reconnection overlay"
917
+ ```
918
+
919
+ ---
920
+
921
+ ## Phase 6: Native Notifications
922
+
923
+ ### Task 6: Implement macOS notification bridge
924
+
925
+ Poll the calls endpoint for new inbound calls and fire macOS notifications.
926
+
927
+ **Files:**
928
+ - Create: `native/macos/src-tauri/src/notifications.rs`
929
+ - Modify: `native/macos/src-tauri/src/lib.rs`
930
+
931
+ **Step 1: Write `notifications.rs`**
932
+
933
+ ```rust
934
+ use std::collections::HashSet;
935
+ use std::sync::Mutex;
936
+ use std::time::Duration;
937
+ use tauri::Manager;
938
+ use tauri_plugin_notification::NotificationExt;
939
+
940
+ static SEEN_CALLS: Mutex<Option<HashSet<String>>> = Mutex::new(None);
941
+
942
+ #[derive(Debug, serde::Deserialize)]
943
+ struct CallsResponse {
944
+ success: bool,
945
+ conversations: Option<Vec<Conversation>>,
946
+ }
947
+
948
+ #[derive(Debug, serde::Deserialize)]
949
+ struct Conversation {
950
+ id: String,
951
+ caller_name: Option<String>,
952
+ summary: Option<String>,
953
+ status: Option<String>,
954
+ created_at: Option<String>,
955
+ }
956
+
957
+ /// Poll for new inbound calls and fire native notifications
958
+ pub fn start_notification_poller(app: tauri::AppHandle) {
959
+ // Initialize seen set
960
+ {
961
+ let mut seen = SEEN_CALLS.lock().unwrap();
962
+ *seen = Some(HashSet::new());
963
+ }
964
+
965
+ tokio::spawn(async move {
966
+ // Wait for initial server discovery
967
+ tokio::time::sleep(Duration::from_secs(10)).await;
968
+
969
+ loop {
970
+ tokio::time::sleep(Duration::from_secs(15)).await;
971
+
972
+ let port = crate::health::current_port();
973
+ if port == 0 || !crate::health::is_connected() {
974
+ continue;
975
+ }
976
+
977
+ let url = format!(
978
+ "http://127.0.0.1:{}/api/a2a/dashboard/calls?status=active",
979
+ port
980
+ );
981
+
982
+ let client = reqwest::Client::builder()
983
+ .timeout(Duration::from_secs(5))
984
+ .build();
985
+
986
+ let client = match client {
987
+ Ok(c) => c,
988
+ Err(_) => continue,
989
+ };
990
+
991
+ let resp = match client.get(&url).send().await {
992
+ Ok(r) => r,
993
+ Err(_) => continue,
994
+ };
995
+
996
+ let data: CallsResponse = match resp.json().await {
997
+ Ok(d) => d,
998
+ Err(_) => continue,
999
+ };
1000
+
1001
+ if !data.success {
1002
+ continue;
1003
+ }
1004
+
1005
+ let conversations = data.conversations.unwrap_or_default();
1006
+ let mut seen = SEEN_CALLS.lock().unwrap();
1007
+ let seen_set = seen.as_mut().unwrap();
1008
+
1009
+ for conv in &conversations {
1010
+ if seen_set.contains(&conv.id) {
1011
+ continue;
1012
+ }
1013
+ seen_set.insert(conv.id.clone());
1014
+
1015
+ let caller = conv.caller_name.as_deref().unwrap_or("Unknown agent");
1016
+ let summary = conv.summary.as_deref().unwrap_or("New inbound call");
1017
+
1018
+ let _ = app.notification()
1019
+ .builder()
1020
+ .title(&format!("Inbound call from {}", caller))
1021
+ .body(summary)
1022
+ .show();
1023
+ }
1024
+ }
1025
+ });
1026
+ }
1027
+ ```
1028
+
1029
+ **Step 2: Wire into `lib.rs` setup**
1030
+
1031
+ In the setup closure:
1032
+
1033
+ ```rust
1034
+ notifications::start_notification_poller(app.handle().clone());
1035
+ ```
1036
+
1037
+ **Step 3: Verify compilation**
1038
+
1039
+ ```bash
1040
+ cd native/macos/src-tauri && cargo check
1041
+ ```
1042
+
1043
+ **Step 4: Commit**
1044
+
1045
+ ```bash
1046
+ git add native/macos/src-tauri/src/notifications.rs native/macos/src-tauri/src/lib.rs
1047
+ git commit -m "feat(macos): add native notification bridge — polls for inbound calls"
1048
+ ```
1049
+
1050
+ ---
1051
+
1052
+ ## Phase 7: Deep Link Support (a2a:// URL Scheme)
1053
+
1054
+ ### Task 7: Register `a2a://` URL scheme handler
1055
+
1056
+ When a user clicks an `a2a://` invite link, the app should open and handle it.
1057
+
1058
+ **Files:**
1059
+ - Modify: `native/macos/src-tauri/src/lib.rs`
1060
+ - Modify: `native/macos/src-tauri/tauri.conf.json`
1061
+
1062
+ **Step 1: Add deep link config to `tauri.conf.json`**
1063
+
1064
+ Add to the bundle → macOS section:
1065
+
1066
+ ```json
1067
+ {
1068
+ "bundle": {
1069
+ "macOS": {
1070
+ "minimumSystemVersion": "12.0"
1071
+ }
1072
+ },
1073
+ "plugins": {
1074
+ "deep-link": {
1075
+ "desktop": {
1076
+ "schemes": ["a2a"]
1077
+ }
1078
+ }
1079
+ }
1080
+ }
1081
+ ```
1082
+
1083
+ **Step 2: Handle deep link events in `lib.rs`**
1084
+
1085
+ In the setup closure, add deep link listener:
1086
+
1087
+ ```rust
1088
+ use tauri_plugin_deep_link::DeepLinkExt;
1089
+
1090
+ // In setup:
1091
+ let handle = app.handle().clone();
1092
+ app.deep_link().on_open_url(move |event| {
1093
+ let urls = event.urls();
1094
+ for url in urls {
1095
+ let url_str = url.to_string();
1096
+ // a2a://host/callbook/CODE or a2a://host/fed_TOKEN
1097
+ if let Some(window) = handle.get_webview_window("main") {
1098
+ let _ = window.show();
1099
+ let _ = window.set_focus();
1100
+ // Pass URL to the SPA via JS
1101
+ let js = format!(
1102
+ "window.__A2A_DEEP_LINK = '{}'; \
1103
+ window.dispatchEvent(new CustomEvent('a2a-deep-link', {{ detail: '{}' }}))",
1104
+ url_str.replace('\'', "\\'"),
1105
+ url_str.replace('\'', "\\'")
1106
+ );
1107
+ let _ = window.eval(&js);
1108
+ }
1109
+ }
1110
+ });
1111
+ ```
1112
+
1113
+ **Step 3: Verify compilation**
1114
+
1115
+ ```bash
1116
+ cd native/macos/src-tauri && cargo check
1117
+ ```
1118
+
1119
+ **Step 4: Commit**
1120
+
1121
+ ```bash
1122
+ git add native/macos/src-tauri/
1123
+ git commit -m "feat(macos): register a2a:// URL scheme via deep-link plugin"
1124
+ ```
1125
+
1126
+ ---
1127
+
1128
+ ## Phase 8: Menu Bar Status Item
1129
+
1130
+ ### Task 8: Add optional menu bar tray icon showing server status
1131
+
1132
+ **Files:**
1133
+ - Create: `native/macos/src-tauri/icons/tray-connected.png` (16x16, green dot)
1134
+ - Create: `native/macos/src-tauri/icons/tray-disconnected.png` (16x16, red dot)
1135
+ - Modify: `native/macos/src-tauri/src/lib.rs`
1136
+
1137
+ **Step 1: Create placeholder tray icons**
1138
+
1139
+ These will be simple 16x16 PNGs. For initial development, generate them programmatically or use placeholder files. The final icons should be designed later.
1140
+
1141
+ ```bash
1142
+ # Create placeholder icon files (will be replaced with real assets)
1143
+ # For now, create empty files as placeholders
1144
+ touch native/macos/src-tauri/icons/tray-connected.png
1145
+ touch native/macos/src-tauri/icons/tray-disconnected.png
1146
+ ```
1147
+
1148
+ > **Note:** Real tray icons must be created by a designer or generated. For development, use any 16x16 PNG with green/red dots.
1149
+
1150
+ **Step 2: Add tray setup to `lib.rs`**
1151
+
1152
+ ```rust
1153
+ use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
1154
+ use tauri::menu::{Menu as TrayMenu, MenuItem as TrayMenuItem};
1155
+
1156
+ // In setup:
1157
+ let show = TrayMenuItem::with_id(app, "show", "Show A2A Callbook", true, None::<&str>)?;
1158
+ let quit = TrayMenuItem::with_id(app, "tray-quit", "Quit", true, None::<&str>)?;
1159
+ let tray_menu = TrayMenu::with_items(app, &[&show, &quit])?;
1160
+
1161
+ let _tray = TrayIconBuilder::new()
1162
+ .tooltip("A2A Callbook")
1163
+ .menu(&tray_menu)
1164
+ .on_menu_event(|app, event| {
1165
+ match event.id().0.as_str() {
1166
+ "show" => {
1167
+ if let Some(window) = app.get_webview_window("main") {
1168
+ let _ = window.show();
1169
+ let _ = window.set_focus();
1170
+ }
1171
+ }
1172
+ "tray-quit" => {
1173
+ app.exit(0);
1174
+ }
1175
+ _ => {}
1176
+ }
1177
+ })
1178
+ .on_tray_icon_event(|tray, event| {
1179
+ if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event {
1180
+ let app = tray.app_handle();
1181
+ if let Some(window) = app.get_webview_window("main") {
1182
+ let _ = window.show();
1183
+ let _ = window.set_focus();
1184
+ }
1185
+ }
1186
+ })
1187
+ .build(app)?;
1188
+ ```
1189
+
1190
+ **Step 3: Verify compilation**
1191
+
1192
+ ```bash
1193
+ cd native/macos/src-tauri && cargo check
1194
+ ```
1195
+
1196
+ **Step 4: Commit**
1197
+
1198
+ ```bash
1199
+ git add native/macos/src-tauri/
1200
+ git commit -m "feat(macos): add menu bar tray icon with server status"
1201
+ ```
1202
+
1203
+ ---
1204
+
1205
+ ## Phase 9: CLI Integration
1206
+
1207
+ ### Task 9: Update `a2a gui` to prefer native app when available
1208
+
1209
+ When A2A.app is installed, `a2a gui` should launch it instead of opening a browser.
1210
+
1211
+ **Files:**
1212
+ - Modify: `bin/cli.js` (the `gui` command, around line 1460)
1213
+
1214
+ **Step 1: Write a failing test**
1215
+
1216
+ Create a test that verifies the GUI command checks for the native app.
1217
+
1218
+ **File:** `test/unit/cli-gui-native.test.js`
1219
+
1220
+ ```javascript
1221
+ module.exports = function (test, assert, helpers, ctx) {
1222
+ const fs = require('fs');
1223
+ const path = require('path');
1224
+ const os = require('os');
1225
+
1226
+ test('findNativeApp returns app path when A2A.app exists', () => {
1227
+ // The function should check ~/Applications/A2A Callbook.app
1228
+ // and /Applications/A2A Callbook.app
1229
+ const { findNativeApp } = require('../../bin/cli');
1230
+
1231
+ // Since the app won't be installed in test env, should return null
1232
+ const result = findNativeApp();
1233
+ assert.equal(result, null);
1234
+ });
1235
+ };
1236
+ ```
1237
+
1238
+ **Step 2: Run test to verify it fails**
1239
+
1240
+ ```bash
1241
+ npm test -- --filter cli-gui-native
1242
+ ```
1243
+
1244
+ Expected: FAIL (findNativeApp not exported)
1245
+
1246
+ **Step 3: Add `findNativeApp` function to `bin/cli.js`**
1247
+
1248
+ Add near the top of cli.js (after the existing helper functions):
1249
+
1250
+ ```javascript
1251
+ function findNativeApp() {
1252
+ const fs = require('fs');
1253
+ const path = require('path');
1254
+ const os = require('os');
1255
+
1256
+ if (os.platform() !== 'darwin') return null;
1257
+
1258
+ const candidates = [
1259
+ path.join(os.homedir(), 'Applications', 'A2A Callbook.app'),
1260
+ '/Applications/A2A Callbook.app',
1261
+ ];
1262
+
1263
+ for (const appPath of candidates) {
1264
+ try {
1265
+ if (fs.existsSync(appPath)) return appPath;
1266
+ } catch (_) {}
1267
+ }
1268
+
1269
+ return null;
1270
+ }
1271
+ ```
1272
+
1273
+ Export it for testing (add to existing module.exports if present, or add at bottom):
1274
+
1275
+ ```javascript
1276
+ // At bottom of cli.js, if there's a test export pattern
1277
+ if (typeof module !== 'undefined') {
1278
+ module.exports = { findNativeApp };
1279
+ }
1280
+ ```
1281
+
1282
+ **Step 4: Update the `gui` command to prefer native app**
1283
+
1284
+ Modify the `gui` command (line ~1460):
1285
+
1286
+ ```javascript
1287
+ gui: async (args) => {
1288
+ const tab = (args.flags.tab || args.flags.t || '').trim().toLowerCase();
1289
+ const allowedTabs = new Set(['contacts', 'calls', 'logs', 'settings', 'invites']);
1290
+ const hash = allowedTabs.has(tab) ? `#${tab}` : '';
1291
+
1292
+ const urlFlag = args.flags.url;
1293
+ if (urlFlag) {
1294
+ const url = String(urlFlag);
1295
+ console.log(`Dashboard URL: ${url}`);
1296
+ openInBrowser(url);
1297
+ return;
1298
+ }
1299
+
1300
+ // Prefer native app on macOS
1301
+ if (!args.flags.browser) {
1302
+ const nativeApp = findNativeApp();
1303
+ if (nativeApp) {
1304
+ console.log('Opening A2A Callbook native app...');
1305
+ const tabArg = hash ? ['--args', `--tab=${tab}`] : [];
1306
+ const result = openInBrowser(nativeApp);
1307
+ if (result.attempted) {
1308
+ return;
1309
+ }
1310
+ }
1311
+ }
1312
+
1313
+ // Fall back to browser
1314
+ const preferred = [];
1315
+ if (args.flags.port || args.flags.p) preferred.push(args.flags.port || args.flags.p);
1316
+ if (process.env.A2A_PORT) preferred.push(process.env.A2A_PORT);
1317
+ if (process.env.PORT) preferred.push(process.env.PORT);
1318
+
1319
+ const port = await findLocalServerPort(preferred);
1320
+ if (!port) {
1321
+ console.log('Dashboard is not reachable on common ports.');
1322
+ console.log('Start the server (example):');
1323
+ console.log(' A2A_HOSTNAME="localhost:3001" a2a server --port 3001');
1324
+ console.log('Then open:');
1325
+ console.log(' http://127.0.0.1:3001/dashboard/');
1326
+ return;
1327
+ }
1328
+
1329
+ const url = `http://127.0.0.1:${port}/dashboard/${hash}`;
1330
+ console.log(`Dashboard URL: ${url}`);
1331
+ const opened = openInBrowser(url);
1332
+ if (opened.attempted) {
1333
+ console.log(`Opening browser via: ${opened.command}`);
1334
+ } else {
1335
+ console.log('Could not auto-open browser; open the URL above manually.');
1336
+ }
1337
+ },
1338
+ ```
1339
+
1340
+ **Step 5: Run tests**
1341
+
1342
+ ```bash
1343
+ npm test
1344
+ ```
1345
+
1346
+ Expected: All tests pass.
1347
+
1348
+ **Step 6: Commit**
1349
+
1350
+ ```bash
1351
+ git add bin/cli.js test/unit/cli-gui-native.test.js
1352
+ git commit -m "feat: a2a gui prefers native macOS app when installed"
1353
+ ```
1354
+
1355
+ ---
1356
+
1357
+ ## Phase 10: Installation Flow
1358
+
1359
+ ### Task 10: Add npm postinstall hook for macOS app download
1360
+
1361
+ On macOS, `npm install -g a2acalling` should download A2A.app from GitHub Releases and install it to `~/Applications/`.
1362
+
1363
+ **Files:**
1364
+ - Modify: `scripts/postinstall.js`
1365
+
1366
+ **Step 1: Add macOS app download to postinstall**
1367
+
1368
+ Add to `scripts/postinstall.js` — appended after existing logic:
1369
+
1370
+ ```javascript
1371
+ async function installMacOSApp() {
1372
+ const os = require('os');
1373
+ const fs = require('fs');
1374
+ const path = require('path');
1375
+ const { execSync } = require('child_process');
1376
+
1377
+ if (os.platform() !== 'darwin') return;
1378
+
1379
+ const version = require('../package.json').version;
1380
+ const appDir = path.join(os.homedir(), 'Applications');
1381
+ const appPath = path.join(appDir, 'A2A Callbook.app');
1382
+
1383
+ // Skip if already installed at same version
1384
+ const plistPath = path.join(appPath, 'Contents', 'Info.plist');
1385
+ if (fs.existsSync(plistPath)) {
1386
+ try {
1387
+ const plist = fs.readFileSync(plistPath, 'utf8');
1388
+ if (plist.includes(version)) {
1389
+ return; // Same version already installed
1390
+ }
1391
+ } catch (_) {}
1392
+ }
1393
+
1394
+ const tarUrl = `https://github.com/onthegonow/a2a_calling/releases/download/v${version}/A2A-Callbook-${version}.app.tar.gz`;
1395
+ const tmpFile = path.join(os.tmpdir(), `a2a-callbook-${version}.tar.gz`);
1396
+
1397
+ try {
1398
+ // Download
1399
+ execSync(`curl -sL -o "${tmpFile}" "${tarUrl}"`, { timeout: 30000 });
1400
+
1401
+ if (!fs.existsSync(tmpFile) || fs.statSync(tmpFile).size < 1000) {
1402
+ return; // Download failed or too small — skip silently
1403
+ }
1404
+
1405
+ // Ensure ~/Applications exists
1406
+ fs.mkdirSync(appDir, { recursive: true });
1407
+
1408
+ // Extract
1409
+ execSync(`tar -xzf "${tmpFile}" -C "${appDir}"`, { timeout: 15000 });
1410
+
1411
+ // Cleanup
1412
+ try { fs.unlinkSync(tmpFile); } catch (_) {}
1413
+ } catch (_) {
1414
+ // Silently fail — native app is optional
1415
+ try { fs.unlinkSync(tmpFile); } catch (_) {}
1416
+ }
1417
+ }
1418
+
1419
+ // Call at end of postinstall
1420
+ installMacOSApp().catch(() => {});
1421
+ ```
1422
+
1423
+ **Step 2: Run existing tests to verify no regression**
1424
+
1425
+ ```bash
1426
+ npm test
1427
+ ```
1428
+
1429
+ Expected: All tests pass.
1430
+
1431
+ **Step 3: Commit**
1432
+
1433
+ ```bash
1434
+ git add scripts/postinstall.js
1435
+ git commit -m "feat: postinstall downloads A2A Callbook.app on macOS"
1436
+ ```
1437
+
1438
+ ---
1439
+
1440
+ ## Phase 11: GitHub Actions CI for Tauri Builds
1441
+
1442
+ ### Task 11: Add CI workflow for building and releasing the macOS app
1443
+
1444
+ **Files:**
1445
+ - Create: `.github/workflows/tauri-build.yml`
1446
+
1447
+ **Step 1: Create the workflow file**
1448
+
1449
+ ```yaml
1450
+ name: Build macOS App
1451
+
1452
+ on:
1453
+ push:
1454
+ tags:
1455
+ - 'v*'
1456
+ workflow_dispatch:
1457
+
1458
+ jobs:
1459
+ build-macos:
1460
+ runs-on: macos-latest
1461
+ steps:
1462
+ - uses: actions/checkout@v4
1463
+
1464
+ - name: Install Rust
1465
+ uses: dtolnay/rust-toolchain@stable
1466
+ with:
1467
+ targets: aarch64-apple-darwin,x86_64-apple-darwin
1468
+
1469
+ - name: Install Tauri CLI
1470
+ run: cargo install tauri-cli --version "^2"
1471
+
1472
+ - name: Build universal binary
1473
+ working-directory: native/macos/src-tauri
1474
+ run: |
1475
+ cargo tauri build --target universal-apple-darwin
1476
+ env:
1477
+ # For signed builds, add these secrets:
1478
+ # APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
1479
+ # APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
1480
+ # APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
1481
+ # APPLE_ID: ${{ secrets.APPLE_ID }}
1482
+ # APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
1483
+ # APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
1484
+ TAURI_SIGNING_PRIVATE_KEY: ""
1485
+
1486
+ - name: Package artifacts
1487
+ run: |
1488
+ VERSION=${GITHUB_REF_NAME#v}
1489
+ cd native/macos/src-tauri/target/universal-apple-darwin/release/bundle
1490
+
1491
+ # Create tar.gz of .app for postinstall download
1492
+ if [ -d "macos/A2A Callbook.app" ]; then
1493
+ cd macos
1494
+ tar -czf "../../../A2A-Callbook-${VERSION}.app.tar.gz" "A2A Callbook.app"
1495
+ cd ..
1496
+ fi
1497
+
1498
+ - name: Upload artifacts
1499
+ uses: actions/upload-artifact@v4
1500
+ with:
1501
+ name: macos-app
1502
+ path: |
1503
+ native/macos/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
1504
+ native/macos/src-tauri/target/universal-apple-darwin/release/bundle/A2A-Callbook-*.app.tar.gz
1505
+
1506
+ - name: Attach to GitHub Release
1507
+ if: startsWith(github.ref, 'refs/tags/v')
1508
+ uses: softprops/action-gh-release@v2
1509
+ with:
1510
+ files: |
1511
+ native/macos/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
1512
+ native/macos/src-tauri/target/universal-apple-darwin/release/bundle/A2A-Callbook-*.app.tar.gz
1513
+ env:
1514
+ GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
1515
+ ```
1516
+
1517
+ **Step 2: Commit**
1518
+
1519
+ ```bash
1520
+ git add .github/workflows/tauri-build.yml
1521
+ git commit -m "ci: add GitHub Actions workflow for macOS Tauri builds"
1522
+ ```
1523
+
1524
+ ---
1525
+
1526
+ ## Phase 12: Uninstall Support
1527
+
1528
+ ### Task 12: Update `a2a uninstall` to remove the native app
1529
+
1530
+ **Files:**
1531
+ - Modify: `bin/cli.js` (uninstall command)
1532
+
1533
+ **Step 1: Find the uninstall command and add app removal**
1534
+
1535
+ Locate the `uninstall` command in `bin/cli.js` and add cleanup for the macOS app:
1536
+
1537
+ ```javascript
1538
+ // Add to the uninstall flow, after existing cleanup:
1539
+ if (os.platform() === 'darwin') {
1540
+ const appCandidates = [
1541
+ path.join(os.homedir(), 'Applications', 'A2A Callbook.app'),
1542
+ '/Applications/A2A Callbook.app',
1543
+ ];
1544
+ for (const appPath of appCandidates) {
1545
+ if (fs.existsSync(appPath)) {
1546
+ try {
1547
+ fs.rmSync(appPath, { recursive: true, force: true });
1548
+ console.log(`Removed ${appPath}`);
1549
+ } catch (err) {
1550
+ console.log(`Could not remove ${appPath}: ${err.message}`);
1551
+ }
1552
+ }
1553
+ }
1554
+ }
1555
+ ```
1556
+
1557
+ **Step 2: Run tests**
1558
+
1559
+ ```bash
1560
+ npm test
1561
+ ```
1562
+
1563
+ Expected: All tests pass.
1564
+
1565
+ **Step 3: Commit**
1566
+
1567
+ ```bash
1568
+ git add bin/cli.js
1569
+ git commit -m "feat: a2a uninstall removes macOS native app"
1570
+ ```
1571
+
1572
+ ---
1573
+
1574
+ ## Phase 13: Documentation
1575
+
1576
+ ### Task 13: Update README and add native app docs
1577
+
1578
+ **Files:**
1579
+ - Modify: `README.md` (add native app section)
1580
+ - Modify: `CLAUDE.md` (add native app dev notes)
1581
+
1582
+ **Step 1: Add native app section to README**
1583
+
1584
+ Add a "Native macOS App" section describing:
1585
+ - What it does (wraps the dashboard in a native window)
1586
+ - Installation (automatic via `npm install -g a2acalling` on macOS)
1587
+ - Manual install (download .dmg from Releases)
1588
+ - Features (native notifications, Cmd+1–5 tab switching, menu bar, deep links)
1589
+ - Building from source (`cd native/macos/src-tauri && cargo tauri build`)
1590
+
1591
+ **Step 2: Add dev notes to CLAUDE.md**
1592
+
1593
+ ```markdown
1594
+ ## Native macOS App (Tauri)
1595
+
1596
+ Located in `native/macos/`. Tauri v2 app wrapping the dashboard SPA.
1597
+
1598
+ ### Dev setup
1599
+ \`\`\`bash
1600
+ # Install Rust: https://rustup.rs
1601
+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
1602
+
1603
+ # Install Tauri CLI
1604
+ cargo install tauri-cli --version "^2"
1605
+
1606
+ # Dev mode (live reload)
1607
+ cd native/macos/src-tauri
1608
+ cargo tauri dev
1609
+
1610
+ # Production build
1611
+ cargo tauri build
1612
+ \`\`\`
1613
+
1614
+ ### Key files
1615
+ - `native/macos/src-tauri/src/lib.rs` - App entry, menus, event handling
1616
+ - `native/macos/src-tauri/src/discovery.rs` - Port scanning / server detection
1617
+ - `native/macos/src-tauri/src/health.rs` - Background health monitor
1618
+ - `native/macos/src-tauri/src/notifications.rs` - macOS notification bridge
1619
+ - `native/macos/src-tauri/src/server.rs` - Server lifecycle (start/stop)
1620
+ - `native/macos/index.html` - Loading page (shown before server found)
1621
+ ```
1622
+
1623
+ **Step 3: Commit**
1624
+
1625
+ ```bash
1626
+ git add README.md CLAUDE.md
1627
+ git commit -m "docs: add native macOS app documentation"
1628
+ ```
1629
+
1630
+ ---
1631
+
1632
+ ## Summary
1633
+
1634
+ | Phase | Task | Description | Key Files |
1635
+ |-------|------|-------------|-----------|
1636
+ | 1 | 1 | Scaffold Tauri v2 project | `native/macos/` (all) |
1637
+ | 2 | 2 | Port discovery (config + scan) | `discovery.rs` |
1638
+ | 3 | 3 | Server start/stop | `server.rs` |
1639
+ | 4 | 4 | macOS lifecycle (Cmd+W, menus, shortcuts) | `lib.rs` |
1640
+ | 5 | 5 | Reconnection overlay + health monitor | `health.rs`, `index.html` |
1641
+ | 6 | 6 | Native notifications (poll + notify) | `notifications.rs` |
1642
+ | 7 | 7 | Deep links (`a2a://` URL scheme) | `lib.rs`, `tauri.conf.json` |
1643
+ | 8 | 8 | Menu bar tray icon | `lib.rs` |
1644
+ | 9 | 9 | CLI integration (`a2a gui` prefers app) | `bin/cli.js` |
1645
+ | 10 | 10 | npm postinstall app download | `scripts/postinstall.js` |
1646
+ | 11 | 11 | GitHub Actions CI for builds | `.github/workflows/tauri-build.yml` |
1647
+ | 12 | 12 | Uninstall cleanup | `bin/cli.js` |
1648
+ | 13 | 13 | Documentation | `README.md`, `CLAUDE.md` |
1649
+
1650
+ ### Out of Scope (per ticket)
1651
+
1652
+ - Windows/Linux builds
1653
+ - Embedded server (beyond spawning `a2a server`)
1654
+ - Offline mode / bundled SPA
1655
+ - Custom native UI (SwiftUI)
1656
+ - Real-time push (WebSocket/SSE)
1657
+ - Auto-update mechanism
1658
+ - App Store distribution
1659
+ - Multiple server connections
1660
+ - iOS/iPadOS companion