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.
- package/README.md +26 -0
- package/bin/cli.js +216 -19
- package/docs/plans/2026-02-16-a2a-callbook-macos-app.md +1660 -0
- package/docs/plans/2026-02-16-bugfixes-22-24.md +246 -0
- package/native/macos/index.html +172 -0
- package/native/macos/package.json +8 -0
- package/native/macos/src-tauri/Cargo.toml +23 -0
- package/native/macos/src-tauri/build.rs +3 -0
- package/native/macos/src-tauri/capabilities/default.json +16 -0
- package/native/macos/src-tauri/icons/128x128.png +0 -0
- package/native/macos/src-tauri/icons/128x128@2x.png +0 -0
- package/native/macos/src-tauri/icons/32x32.png +0 -0
- package/native/macos/src-tauri/icons/icon.icns +0 -0
- package/native/macos/src-tauri/icons/tray-connected.png +0 -0
- package/native/macos/src-tauri/icons/tray-disconnected.png +0 -0
- package/native/macos/src-tauri/src/discovery.rs +86 -0
- package/native/macos/src-tauri/src/health.rs +64 -0
- package/native/macos/src-tauri/src/lib.rs +185 -0
- package/native/macos/src-tauri/src/main.rs +6 -0
- package/native/macos/src-tauri/src/notifications.rs +101 -0
- package/native/macos/src-tauri/src/server.rs +67 -0
- package/native/macos/src-tauri/tauri.conf.json +48 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +49 -0
- package/src/lib/disclosure.js +2 -0
|
@@ -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, ©, &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
|