moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
# Intentionally NO `linker` override here.
#
# A previous version pinned the MSVC linker to an absolute, version-stamped path
# (…/MSVC/14.44.35207/…/link.exe) so Git Bash's GNU coreutils `link` couldn't
# shadow MSVC's linker on one local machine. That pin rotted: windows-latest CI
# ships a different MSVC toolset, and so does any dev box without that exact
# version, so cargo failed with:
# error: linker `…14.44.35207…\link.exe` not found
# note: The system cannot find the path specified. (os error 3)
#
# rustc/cc auto-detect the MSVC linker via vswhere on a properly installed
# toolchain — windows-latest works out of the box, no override needed.
#
# If Git's `link` shadows MSVC's linker locally, fix it in the ENVIRONMENT, not
# here: build from a "Developer Command Prompt for VS 2022" (or run
# vcvarsall.bat) so MSVC's bin precedes Git on PATH. Do NOT re-pin an absolute,
# version-stamped path — it breaks CI and every other machine.

6185
app/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

29
app/src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,29 @@
[package]
name = "waggle"
version = "0.2.0"
description = "Waggle - Your personal AI agent swarm"
authors = ["Marko Markovic"]
edition = "2021"
[lib]
name = "waggle_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
tauri-plugin-autostart = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-notification = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
urlencoding = "2"
uuid = { version = "1", features = ["v4"] }

3
app/src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,12 @@
{
"identifier": "default",
"description": "Default capabilities",
"windows": ["main"],
"permissions": [
"core:default",
"shell:default",
"notification:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capabilities","local":true,"windows":["main"],"permissions":["core:default","shell:default","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,69 @@
; ─── Waggle NSIS Installer Template ──────────────────────────────────────────
;
; Custom hooks for the Tauri NSIS installer:
; 1. Welcome message with Waggle branding
; 2. Desktop shortcut creation
; 3. Start Menu entry
; 4. "Launch Waggle" on finish
; 5. Uninstaller with optional ~/.waggle/ data removal
;
; Tauri injects NSIS defines: PRODUCT_NAME, PRODUCT_VERSION, MAINBINARYNAME,
; DEFAULT_INSTALL_DIR. Autostart is handled by tauri-plugin-autostart at
; runtime, not by the installer.
;
; Reference: https://tauri.app/distribute/windows-installer/#nsis
; ─────────────────────────────────────────────────────────────────────────────
InstallDir "${DEFAULT_INSTALL_DIR}"
!macro NSIS_HOOK_PREINSTALL
DetailPrint "Installing ${PRODUCT_NAME} v${PRODUCT_VERSION}..."
DetailPrint "Your personal AI agent workspace — powered by Waggle."
!macroend
!macro NSIS_HOOK_POSTINSTALL
; ── Desktop shortcut ──────────────────────────────────────────────────────
CreateShortcut "$DESKTOP\${PRODUCT_NAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" \
"" "$INSTDIR\${MAINBINARYNAME}.exe" 0
DetailPrint "Desktop shortcut created."
; ── Start Menu entry ──────────────────────────────────────────────────────
CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"
CreateShortcut "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk" \
"$INSTDIR\${MAINBINARYNAME}.exe" "" "$INSTDIR\${MAINBINARYNAME}.exe" 0
CreateShortcut "$SMPROGRAMS\${PRODUCT_NAME}\Uninstall ${PRODUCT_NAME}.lnk" \
"$INSTDIR\uninstall.exe" "" "$INSTDIR\uninstall.exe" 0
DetailPrint "Start Menu entry created."
; ── Launch after install ──────────────────────────────────────────────────
Exec '"$INSTDIR\${MAINBINARYNAME}.exe"'
DetailPrint "Launching ${PRODUCT_NAME}..."
!macroend
!macro NSIS_HOOK_POSTUNINSTALL
; ── Remove desktop shortcut ─────────────────────────────────────────────
Delete "$DESKTOP\${PRODUCT_NAME}.lnk"
; ── Remove Start Menu entries ───────────────────────────────────────────
Delete "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk"
Delete "$SMPROGRAMS\${PRODUCT_NAME}\Uninstall ${PRODUCT_NAME}.lnk"
RMDir "$SMPROGRAMS\${PRODUCT_NAME}"
; ── Ask about user data removal ─────────────────────────────────────────
MessageBox MB_YESNO|MB_ICONQUESTION \
"Waggle stores your data (agents, memories, configuration) in:$\r$\n$\r$\n\
$PROFILE\.waggle$\r$\n$\r$\n\
Do you want to remove this data as well?$\r$\n$\r$\n\
Choose $\"Yes$\" to delete all data, or $\"No$\" to keep it for future use." \
IDYES removeData IDNO skipData
removeData:
RMDir /r "$PROFILE\.waggle"
DetailPrint "User data removed: $PROFILE\.waggle"
Goto doneData
skipData:
DetailPrint "User data preserved: $PROFILE\.waggle"
doneData:
!macroend

View File

View File

@@ -0,0 +1,268 @@
// CC Sesija A §2.1 Task A3 — agent loop streaming Tauri command.
//
// Brief: briefs/2026-04-30-cc-sesija-A-waggle-apps-web-integration.md §2.1 Task A3
//
// Pattern: command returns a request_id immediately; tokio task POSTs to the
// sidecar's `/api/chat` SSE endpoint, parses each event block, and emits
// `agent-stream-{request_id}` to the webview per chunk. End-of-stream emits
// `agent-stream-{request_id}-end` with either `{ ok: true }` or `{ error, ... }`.
//
// The webview's tauri-bindings.runAgentQuery() returns the request_id + an
// unlisten handle so React components can subscribe per-conversation without
// global state.
//
// A3.1 follow-up tracking:
// - sidecar `/api/chat` calls `runAgentLoop`, not `runRetrievalAgentLoop`
// as the brief requested. Faza 1's runRetrievalAgentLoop with shape
// selection is not yet wired into the chat path. The `shape` param is
// accepted here and passed through the body so a future sidecar patch
// can read it without changing this command's surface. Document in
// A3.1 follow-up.
use serde_json::{json, Value};
use std::time::Duration;
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use crate::service::ServiceState;
const STREAM_TIMEOUT_SECS: u64 = 300;
/// Start an agent query. Returns a request_id; chunks arrive via the
/// `agent-stream-{request_id}` Tauri event, end via `agent-stream-{request_id}-end`.
#[tauri::command]
pub async fn run_agent_query(
app: AppHandle,
state: State<'_, ServiceState>,
query: String,
shape: Option<String>,
workspace_id: Option<String>,
persona: Option<String>,
model: Option<String>,
session: Option<String>,
) -> Result<String, String> {
let request_id = format!("agent-{}", Uuid::new_v4());
let port = state.port;
let app_clone = app.clone();
let req_id_clone = request_id.clone();
tokio::spawn(async move {
if let Err(e) = stream_chat(
app_clone,
port,
req_id_clone,
query,
shape,
workspace_id,
persona,
model,
session,
)
.await
{
// The end event is already emitted from inside stream_chat on error
// paths; this stderr is a developer-facing breadcrumb only.
eprintln!("[agent] run_agent_query stream task error: {}", e);
}
});
Ok(request_id)
}
#[allow(clippy::too_many_arguments)]
async fn stream_chat(
app: AppHandle,
port: u16,
request_id: String,
query: String,
shape: Option<String>,
workspace_id: Option<String>,
persona: Option<String>,
model: Option<String>,
session: Option<String>,
) -> Result<(), String> {
// A3.1 (2026-04-30): re-pointed from /api/chat to /api/agent/run.
// /api/agent/run is the dedicated shape-aware structured-retrieval
// endpoint (runRetrievalAgentLoop) — distinct from /api/chat which is
// for conversational multi-turn dialogue (runAgentLoop). Shape now flows
// end-to-end: Tauri body field "shape" → sidecar promptShapeOverride
// → runRetrievalAgentLoop pickShape().
//
// The TS binding's `session` arg is intentionally ignored here:
// /api/agent/run is one-shot (no persistent session), so passing
// session through would just be dead weight. Binding signature stays
// stable so callers don't break.
let url = format!("http://127.0.0.1:{}/api/agent/run", port);
let mut body = json!({ "question": query });
if let Some(ws) = workspace_id {
body["workspace"] = json!(ws);
}
if let Some(p) = persona {
body["persona"] = json!(p);
}
if let Some(m) = model {
body["model"] = json!(m);
}
if let Some(sh) = shape {
body["shape"] = json!(sh);
}
// `session` arg accepted by the Tauri command for binding-stability but
// not threaded into /api/agent/run (one-shot). Suppress unused-warn.
let _ = session;
let event_name = format!("agent-stream-{}", request_id);
let end_event = format!("agent-stream-{}-end", request_id);
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(STREAM_TIMEOUT_SECS))
.build()
{
Ok(c) => c,
Err(e) => {
let _ = app.emit(
&end_event,
json!({ "error": format!("client build failed: {}", e) }),
);
return Err(e.to_string());
}
};
let mut resp = match client.post(&url).json(&body).send().await {
Ok(r) => r,
Err(e) => {
let _ = app.emit(
&end_event,
json!({ "error": format!("HTTP POST failed: {}", e) }),
);
return Err(e.to_string());
}
};
if !resp.status().is_success() {
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
let _ = app.emit(
&end_event,
json!({
"error": format!("sidecar returned {}", status),
"body": body_text,
}),
);
return Err(format!("sidecar returned {}", status));
}
// Read SSE stream chunk by chunk. SSE events are delimited by a blank
// line (\n\n). Each block has zero or one `event:` line and one or more
// `data:` lines. We accumulate bytes into a String buffer, then drain
// complete blocks one at a time.
let mut buffer = String::new();
loop {
match resp.chunk().await {
Ok(Some(bytes)) => {
if let Ok(s) = std::str::from_utf8(&bytes) {
buffer.push_str(s);
}
while let Some(idx) = buffer.find("\n\n") {
let block: String = buffer.drain(..idx + 2).collect();
if let Some(parsed) = parse_sse_event(&block) {
let _ = app.emit(&event_name, parsed);
}
}
}
Ok(None) => break,
Err(e) => {
let _ = app.emit(
&end_event,
json!({ "error": format!("stream read error: {}", e) }),
);
return Err(e.to_string());
}
}
}
// Drain any tail block that didn't end with a blank line (server may close
// the connection without a final separator).
let tail = buffer.trim();
if !tail.is_empty() {
if let Some(parsed) = parse_sse_event(tail) {
let _ = app.emit(&event_name, parsed);
}
}
let _ = app.emit(&end_event, json!({ "ok": true }));
Ok(())
}
/// Parse a single SSE event block. Returns `{ event, data }` where `data` is the
/// parsed JSON value when possible, otherwise the raw concatenated data text.
/// SSE allows multiple `data:` lines within a single event — they are joined
/// with `\n` per the spec.
fn parse_sse_event(block: &str) -> Option<Value> {
let mut event_name = String::from("message");
let mut data_lines: Vec<String> = Vec::new();
for line in block.lines() {
if let Some(rest) = line.strip_prefix("event:") {
event_name = rest.trim().to_string();
} else if let Some(rest) = line.strip_prefix("data:") {
// Per SSE spec, a single leading space after `data:` is stripped.
let value = rest.strip_prefix(' ').unwrap_or(rest);
data_lines.push(value.to_string());
}
// `id:` and `retry:` and comment (`:`) lines are intentionally ignored.
}
if data_lines.is_empty() {
return None;
}
let data_str = data_lines.join("\n");
let data_value: Value =
serde_json::from_str(&data_str).unwrap_or_else(|_| Value::String(data_str));
Some(json!({
"event": event_name,
"data": data_value,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_sse_simple_data_only() {
let block = "data: hello\n\n";
let parsed = parse_sse_event(block).expect("should parse");
assert_eq!(parsed["event"], "message");
assert_eq!(parsed["data"], "hello");
}
#[test]
fn parse_sse_event_and_json_data() {
let block = "event: step\ndata: {\"chunk\":\"hi\"}\n\n";
let parsed = parse_sse_event(block).expect("should parse");
assert_eq!(parsed["event"], "step");
assert_eq!(parsed["data"]["chunk"], "hi");
}
#[test]
fn parse_sse_multiline_data_joined_with_newline() {
let block = "event: step\ndata: line1\ndata: line2\n\n";
let parsed = parse_sse_event(block).expect("should parse");
assert_eq!(parsed["data"], "line1\nline2");
}
#[test]
fn parse_sse_empty_data_returns_none() {
let block = "event: ping\n\n";
assert!(parse_sse_event(block).is_none());
}
#[test]
fn parse_sse_strips_single_leading_space_after_colon() {
let block = "data: foo\n\n";
let parsed = parse_sse_event(block).expect("should parse");
assert_eq!(parsed["data"], "foo");
}
}

View File

@@ -0,0 +1,43 @@
// CC Sesija A §2.1 Task A4 — shared HTTP helpers for sidecar-proxy commands.
//
// Extracted from commands/memory.rs at A4 because wiki.rs becomes the second
// consumer (rule of two). Future command modules (onboarding A10+, agent.rs
// where applicable) should also import from here rather than reimplementing.
use serde_json::Value;
const SIDECAR_HOST: &str = "127.0.0.1";
pub fn sidecar_url(port: u16, path: &str) -> String {
format!("http://{}:{}{}", SIDECAR_HOST, port, path)
}
pub async fn http_get(url: &str) -> Result<reqwest::Response, String> {
reqwest::get(url)
.await
.map_err(|e| format!("HTTP GET {} failed: {}", url, e))
}
pub async fn http_post(url: &str, body: &Value) -> Result<reqwest::Response, String> {
let client = reqwest::Client::new();
client
.post(url)
.json(body)
.send()
.await
.map_err(|e| format!("HTTP POST {} failed: {}", url, e))
}
pub async fn parse_json(resp: reqwest::Response) -> Result<Value, String> {
let status = resp.status();
if !status.is_success() {
let body_text = resp
.text()
.await
.unwrap_or_else(|_| "<unreadable body>".to_string());
return Err(format!("sidecar returned {}: {}", status, body_text));
}
resp.json::<Value>()
.await
.map_err(|e| format!("sidecar returned non-JSON body: {}", e))
}

View File

@@ -0,0 +1,137 @@
// CC Sesija A §2.1 Tasks A1 + A4 — memory + KG + identity Tauri commands.
//
// Architecture: thin HTTP proxies to local sidecar (Fastify, port from ServiceState).
// Tauri Rust shell intentionally has no direct sqlite/sqlite-vec dependencies — all
// substrate access goes through the sidecar so that the storage layer (FrameStore,
// HybridSearch, KnowledgeGraph, wiki-compiler) lives in one process.
//
// Command surface (4 commands here; wiki commands moved to commands/wiki.rs at A4):
// recall_memory → GET /api/memory/search
// save_memory → POST /api/memory/frames
// search_entities → GET /api/memory/graph
// get_identity → GET /api/identity (route not yet in sidecar — graceful 404
// → placeholder; A1.1 follow-up adds route)
//
// Shared HTTP helpers extracted to commands/http.rs at A4 (rule of two — wiki.rs is
// the second consumer).
use serde_json::{json, Value};
use tauri::State;
use crate::commands::http::{http_get, http_post, parse_json, sidecar_url};
use crate::service::ServiceState;
/// Recall memory frames matching `query`. Optional `scope` (all|personal|workspace),
/// `limit` (default sidecar-side ~20), `workspace_id` (workspace mind to search).
#[tauri::command]
pub async fn recall_memory(
state: State<'_, ServiceState>,
query: String,
scope: Option<String>,
limit: Option<u32>,
workspace_id: Option<String>,
) -> Result<Value, String> {
let mut url = format!(
"{}?q={}",
sidecar_url(state.port, "/api/memory/search"),
urlencoding::encode(&query)
);
if let Some(s) = scope {
url.push_str(&format!("&scope={}", urlencoding::encode(&s)));
}
if let Some(l) = limit {
url.push_str(&format!("&limit={}", l));
}
if let Some(ws) = workspace_id {
url.push_str(&format!("&workspace={}", urlencoding::encode(&ws)));
}
let resp = http_get(&url).await?;
parse_json(resp).await
}
/// Persist a new memory frame. `content` is required. Optional `workspace_id`,
/// `importance` (low|normal|high|critical per @waggle/core Importance enum), `source`
/// (one of: user_stated, tool_verified, agent_inferred, import, system).
#[tauri::command]
pub async fn save_memory(
state: State<'_, ServiceState>,
content: String,
workspace_id: Option<String>,
importance: Option<String>,
source: Option<String>,
) -> Result<Value, String> {
let mut body = json!({ "content": content });
if let Some(ws) = workspace_id {
body["workspace"] = json!(ws);
}
if let Some(imp) = importance {
body["importance"] = json!(imp);
}
if let Some(src) = source {
body["source"] = json!(src);
}
let url = sidecar_url(state.port, "/api/memory/frames");
let resp = http_post(&url, &body).await?;
parse_json(resp).await
}
/// Search the knowledge graph for entities/relations. Returns `{nodes, edges}` shape
/// from sidecar's KnowledgeGraph layer. `scope` defaults to "all" if not provided.
#[tauri::command]
pub async fn search_entities(
state: State<'_, ServiceState>,
workspace_id: Option<String>,
scope: Option<String>,
) -> Result<Value, String> {
let mut url = sidecar_url(state.port, "/api/memory/graph").to_string();
let mut params: Vec<String> = Vec::new();
if let Some(ws) = workspace_id {
params.push(format!("workspace={}", urlencoding::encode(&ws)));
}
if let Some(s) = scope {
params.push(format!("scope={}", urlencoding::encode(&s)));
}
if !params.is_empty() {
url.push('?');
url.push_str(&params.join("&"));
}
let resp = http_get(&url).await?;
parse_json(resp).await
}
/// Get the user identity record from sidecar's IdentityLayer.
///
/// Backed by /api/identity sidecar route (A1.1 shipped 2026-04-30). The route
/// always returns a 200 with either configured: true + IdentityLayer fields,
/// or configured: false + null fields + optional _note. The 404 + transport-
/// error fallbacks here remain as defense-in-depth — they were the original
/// pre-A1.1 placeholders and now only fire on hard sidecar outages.
#[tauri::command]
pub async fn get_identity(state: State<'_, ServiceState>) -> Result<Value, String> {
let url = sidecar_url(state.port, "/api/identity");
match http_get(&url).await {
Ok(resp) if resp.status().as_u16() == 404 => Ok(identity_placeholder(
"sidecar route 404 (unexpected post-A1.1)",
)),
Ok(resp) => parse_json(resp).await,
Err(_) => Ok(identity_placeholder("sidecar unreachable")),
}
}
fn identity_placeholder(note: &str) -> Value {
json!({
"configured": false,
"name": null,
"role": null,
"department": null,
"personality": null,
"capabilities": null,
"system_prompt": null,
"created_at": null,
"updated_at": null,
"_note": note
})
}

View File

@@ -0,0 +1,8 @@
// CC Sesija A §2.1 + §2.3: command modules grouped under commands/.
// Each submodule exposes #[tauri::command] async fns wired in lib.rs invoke_handler.
pub mod agent;
pub mod http;
pub mod memory;
pub mod onboarding;
pub mod wiki;

View File

@@ -0,0 +1,120 @@
// CC Sesija A §2.3 Task A10 — first-launch detection via filesystem flag.
//
// Brief: briefs/2026-04-30-cc-sesija-A-waggle-apps-web-integration.md §2.3 Task A10
//
// Persists a flag file at `~/.waggle/first-launch.flag` so the onboarding wizard
// state survives across app reinstalls (browser localStorage doesn't, since
// Tauri builds may use a fresh WebView profile per install). The web `npm run
// dev` path continues to use localStorage via useOnboarding — these commands
// are the durable Tauri-mode addition, not a replacement.
//
// Cross-platform user-home resolution uses std::env (USERPROFILE on Windows,
// HOME on Unix) to avoid pulling in a new dirs/home crate dep.
use std::path::PathBuf;
const FLAG_DIR: &str = ".waggle";
const FLAG_FILE: &str = "first-launch.flag";
fn home_dir() -> Option<PathBuf> {
std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(PathBuf::from)
}
/// Resolve the flag location: `WAGGLE_DATA_DIR` (when set and non-empty) else
/// `~/.waggle` — the SAME resolution order the sidecar's resolveDataDir uses
/// (UX-Refactor P4/D11). Without this, a custom-data-dir install reads/writes
/// the flag in `~/.waggle` while the server's completion stamp lives in the
/// data dir — and a stale `~/.waggle` flag from a prior default install would
/// auto-skip onboarding against a brand-new data dir (P4 review, 5 findings).
fn flag_path() -> Result<PathBuf, String> {
if let Some(dir) = std::env::var_os("WAGGLE_DATA_DIR") {
if !dir.is_empty() {
return Ok(PathBuf::from(dir).join(FLAG_FILE));
}
}
let home = home_dir().ok_or_else(|| {
"could not resolve user home directory (USERPROFILE/HOME unset)".to_string()
})?;
Ok(home.join(FLAG_DIR).join(FLAG_FILE))
}
/// Returns true if the user has not yet completed onboarding.
/// Implementation: returns `!flag_file_exists`. On any IO error (e.g. home dir
/// unresolvable in a sandboxed environment) returns `true` so the wizard runs
/// — better to show the wizard once too often than to silently skip it.
#[tauri::command]
pub async fn is_first_launch() -> Result<bool, String> {
let path = match flag_path() {
Ok(p) => p,
Err(_) => return Ok(true),
};
Ok(!path.exists())
}
/// Marks onboarding as complete by creating the flag file. Idempotent.
/// Creates the parent `~/.waggle/` directory if needed.
#[tauri::command]
pub async fn mark_first_launch_complete() -> Result<(), String> {
let path = flag_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create_dir_all {} failed: {}", parent.display(), e))?;
}
std::fs::write(&path, b"completed\n")
.map_err(|e| format!("write {} failed: {}", path.display(), e))?;
Ok(())
}
/// Resets the first-launch flag (deletes the file). For dev / QA flows that
/// need to re-trigger onboarding without a full reinstall.
#[tauri::command]
pub async fn reset_first_launch() -> Result<(), String> {
let path = flag_path()?;
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| format!("remove {} failed: {}", path.display(), e))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flag_path_resolution_order() {
// ONE sequential test — env vars are process-global and cargo runs
// tests in parallel threads; split tests would race on WAGGLE_DATA_DIR.
let tmp = std::env::temp_dir().join(format!("waggle-test-home-{}", std::process::id()));
std::env::set_var("USERPROFILE", &tmp);
std::env::set_var("HOME", &tmp);
// Default: ~/.waggle.
std::env::remove_var("WAGGLE_DATA_DIR");
let path = flag_path().expect("flag_path resolves with USERPROFILE/HOME set");
assert!(
path.ends_with(".waggle/first-launch.flag")
|| path.ends_with(".waggle\\first-launch.flag")
);
// P4/D11: a custom data dir keeps the flag NEXT TO the server's stamp.
let data_dir =
std::env::temp_dir().join(format!("waggle-test-datadir-{}", std::process::id()));
std::env::set_var("WAGGLE_DATA_DIR", &data_dir);
let custom = flag_path().expect("flag_path resolves with WAGGLE_DATA_DIR set");
assert!(custom.starts_with(&data_dir));
assert!(custom.ends_with("first-launch.flag"));
// Empty env value falls through to the home default (matches resolveDataDir).
std::env::set_var("WAGGLE_DATA_DIR", "");
let fallback = flag_path().expect("flag_path resolves with empty WAGGLE_DATA_DIR");
assert!(
fallback.ends_with(".waggle/first-launch.flag")
|| fallback.ends_with(".waggle\\first-launch.flag")
);
std::env::remove_var("WAGGLE_DATA_DIR");
}
}

View File

@@ -0,0 +1,75 @@
// CC Sesija A §2.1 Task A4 — wiki Tauri commands.
//
// Brief: briefs/2026-04-30-cc-sesija-A-waggle-apps-web-integration.md §2.1 Task A4
//
// Read-side wiki commands proxying to the sidecar's /api/wiki/* routes which read
// from the per-workspace MindDB (FrameStore + KnowledgeGraph). The compile route
// triggers a fresh wiki-compiler pass for the active workspace.
//
// Brief §2.1 Task A4 says "reads packages/wiki-compiler output direktno iz hive-mind
// frame store" — direktno here means "without intermediate cache" not "direct
// sqlite from Rust" (Tauri shell intentionally has no sqlite deps; substrate stays
// in the sidecar process — see commands/memory.rs header for full rationale).
//
// Note: compile_wiki_section was previously colocated in commands/memory.rs (A1).
// A4 moves it here for cohesion. Tauri command surface is unchanged; lib.rs
// invoke_handler entry just rewires from `memory::compile_wiki_section` to
// `wiki::compile_wiki_section`.
use serde_json::{json, Value};
use tauri::State;
use crate::commands::http::{http_get, http_post, parse_json, sidecar_url};
use crate::service::ServiceState;
/// List all compiled wiki pages for the active workspace. Returns the page
/// index (slugs + titles + metadata); call get_wiki_page_content for the body.
#[tauri::command]
pub async fn get_wiki_pages(state: State<'_, ServiceState>) -> Result<Value, String> {
let url = sidecar_url(state.port, "/api/wiki/pages");
let resp = http_get(&url).await?;
parse_json(resp).await
}
/// Fetch a single wiki page's metadata (title, type, source frame ids, etc.)
/// without the full markdown body. Use get_wiki_page_content for the body.
#[tauri::command]
pub async fn get_wiki_page(state: State<'_, ServiceState>, slug: String) -> Result<Value, String> {
let url = sidecar_url(
state.port,
&format!("/api/wiki/pages/{}", urlencoding::encode(&slug)),
);
let resp = http_get(&url).await?;
parse_json(resp).await
}
/// Fetch the full markdown content for a wiki page by slug. Returns whatever
/// the sidecar emits (typically `{ slug, content, ... }`).
#[tauri::command]
pub async fn get_wiki_page_content(
state: State<'_, ServiceState>,
slug: String,
) -> Result<Value, String> {
let url = sidecar_url(
state.port,
&format!("/api/wiki/pages/{}/content", urlencoding::encode(&slug)),
);
let resp = http_get(&url).await?;
parse_json(resp).await
}
/// Trigger wiki compilation for the active or specified workspace. Returns the
/// compilation summary (page count, entities, gaps) from packages/wiki-compiler.
#[tauri::command]
pub async fn compile_wiki_section(
state: State<'_, ServiceState>,
workspace_id: Option<String>,
) -> Result<Value, String> {
let mut body = json!({});
if let Some(ws) = workspace_id {
body["workspace"] = json!(ws);
}
let url = sidecar_url(state.port, "/api/wiki/compile");
let resp = http_post(&url, &body).await?;
parse_json(resp).await
}

130
app/src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,130 @@
// Tauri 2 requires a lib.rs for the cdylib/staticlib crate types.
// The actual app entry point is main.rs.
mod commands;
mod service;
mod tray;
use service::ServiceState;
use tauri::Manager;
#[tauri::command]
async fn show_notification(
app: tauri::AppHandle,
title: String,
body: String,
) -> Result<(), String> {
use tauri_plugin_notification::NotificationExt;
app.notification()
.builder()
.title(&title)
.body(&body)
.show()
.map_err(|e| e.to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec!["--minimized"]),
))
.manage(ServiceState::new(3333))
.invoke_handler(tauri::generate_handler![
service::ensure_service,
service::stop_service,
service::get_service_port,
show_notification,
commands::memory::recall_memory,
commands::memory::save_memory,
commands::memory::search_entities,
commands::memory::get_identity,
commands::wiki::get_wiki_pages,
commands::wiki::get_wiki_page,
commands::wiki::get_wiki_page_content,
commands::wiki::compile_wiki_section,
commands::agent::run_agent_query,
commands::onboarding::is_first_launch,
commands::onboarding::mark_first_launch_complete,
commands::onboarding::reset_first_launch,
])
.setup(|app| {
tray::setup_tray(app.handle())?;
// Register global hotkey: Ctrl+Shift+W to toggle window visibility
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut};
let app_handle = app.handle().clone();
app.handle().plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |_app, _shortcut, event| {
if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed {
if let Some(window) = app_handle.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
}
}
})
.build(),
)?;
let shortcut = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyW);
// R7-005: a hotkey collision must not crash setup — log and continue.
if let Err(e) = app.global_shortcut().register(shortcut) {
eprintln!(
"[waggle] Failed to register Ctrl+Shift+W global shortcut: {}",
e
);
}
// Auto-start the sidecar service before the webview loads so the
// React app finds it already healthy on localhost:3333.
let service_state = app.state::<ServiceState>();
let port = service_state.port;
match service::spawn_service_sync(port, &service_state.process) {
Ok(()) => eprintln!("[waggle] Sidecar spawn initiated on port {}", port),
Err(e) => eprintln!("[waggle] Failed to auto-start sidecar: {}", e),
}
// Start service watchdog
let app_handle_watchdog = app.handle().clone();
service::start_watchdog(app_handle_watchdog, port);
Ok(())
})
// Window management: close minimizes to tray instead of quitting
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
// R7-002: kill the sidecar on app exit so it doesn't orphan and hold port 3333.
if let tauri::RunEvent::Exit = event {
if let Some(state) = app_handle.try_state::<ServiceState>() {
if let Ok(mut proc) = state.process.lock() {
if let Some(mut child) = proc.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
}
});
}

View File

@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
waggle_lib::run();
}

View File

@@ -0,0 +1,361 @@
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager, State};
pub struct ServiceState {
pub process: Mutex<Option<Child>>,
pub port: u16,
}
impl ServiceState {
pub fn new(port: u16) -> Self {
Self {
process: Mutex::new(None),
port,
}
}
}
/// Resolve the Node.js binary path.
/// Priority: WAGGLE_NODE_PATH env → bundled resources/node[.exe] → system PATH "node"
fn resolve_node_path() -> String {
// 1. Explicit env override (development/advanced users)
if let Ok(custom) = std::env::var("WAGGLE_NODE_PATH") {
return custom;
}
// 2. Bundled Node.js in resources/ directory (next to exe)
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
if let Some(ref dir) = exe_dir {
let bundled = if cfg!(windows) {
dir.join("resources").join("node.exe")
} else {
dir.join("resources").join("node")
};
if bundled.exists() {
return bundled.to_string_lossy().to_string();
}
}
// 3. Development fallback: relative to src-tauri working dir
if cfg!(debug_assertions) {
let dev_resources = if cfg!(windows) {
"resources/node.exe"
} else {
"resources/node"
};
if std::path::Path::new(dev_resources).exists() {
return dev_resources.to_string();
}
}
// 4. System PATH fallback
"node".to_string()
}
#[derive(Debug, PartialEq, Eq)]
enum ServiceScriptKind {
Bundled,
DevSource,
}
#[derive(Debug, PartialEq, Eq)]
struct ServiceScript {
path: PathBuf,
kind: ServiceScriptKind,
}
fn find_dev_service_script(current_dir: &Path) -> Option<PathBuf> {
for dir in current_dir.ancestors() {
let candidate = dir
.join("packages")
.join("server")
.join("src")
.join("local")
.join("service.ts");
if candidate.exists() {
return Some(candidate);
}
}
None
}
fn resolve_service_script(
exe_dir: Option<&Path>,
current_dir: &Path,
) -> Result<ServiceScript, String> {
if let Some(dir) = exe_dir {
let bundled = dir.join("resources").join("service.js");
if bundled.exists() {
return Ok(ServiceScript {
path: bundled,
kind: ServiceScriptKind::Bundled,
});
}
}
if cfg!(debug_assertions) {
if let Some(script) = find_dev_service_script(current_dir) {
return Ok(ServiceScript {
path: script,
kind: ServiceScriptKind::DevSource,
});
}
}
Err("Unable to locate sidecar service.js resource".to_string())
}
/// Build the Command used to spawn the sidecar process.
/// Shared by both the sync auto-start path and the async `ensure_service` tauri command.
fn build_service_command(port: u16) -> Result<Command, String> {
let node_path = resolve_node_path();
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
let current_dir = std::env::current_dir().map_err(|e| e.to_string())?;
let service_script = resolve_service_script(exe_dir.as_deref(), &current_dir)?;
let mut cmd = if service_script.kind == ServiceScriptKind::DevSource {
let mut c = Command::new(&node_path);
c.arg("--import").arg("tsx").arg(&service_script.path);
c
} else {
let mut c = Command::new(&node_path);
c.arg(&service_script.path);
c
};
cmd.env("WAGGLE_PORT", port.to_string());
if service_script.kind == ServiceScriptKind::Bundled {
cmd.env("WAGGLE_SKIP_LITELLM", "1");
if let Some(ref dir) = exe_dir {
let resources_dir = dir.join("resources");
let native_dir = resources_dir.join("native");
let node_modules_dir = resources_dir.join("node_modules");
// NODE_PATH must include the staged production deps
// (resources/node_modules — better-sqlite3, @fastify/static,
// drizzle-orm, @huggingface/transformers, …) so the sidecar's bare
// require()/import() calls resolve, plus resources/native for any
// abs-path native consumers. Node accepts multiple entries,
// ';'-separated on Windows and ':' elsewhere.
let sep = if cfg!(windows) { ";" } else { ":" };
let node_path = format!(
"{}{}{}",
node_modules_dir.to_string_lossy(),
sep,
native_dir.to_string_lossy(),
);
cmd.env("NODE_PATH", node_path);
let vec_ext = if cfg!(windows) {
native_dir.join("vec0.dll")
} else if cfg!(target_os = "macos") {
native_dir.join("vec0.dylib")
} else {
native_dir.join("vec0.so")
};
if vec_ext.exists() {
cmd.env("WAGGLE_SQLITE_VEC_PATH", vec_ext.to_string_lossy().as_ref());
}
let ort_dir = native_dir.join("onnxruntime");
if ort_dir.exists() {
cmd.env(
"ONNXRUNTIME_NODE_BINDING_PATH",
ort_dir.to_string_lossy().as_ref(),
);
}
}
}
Ok(cmd)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn service_script_prefers_bundled_resource_when_present() {
let root =
std::env::temp_dir().join(format!("waggle-service-script-{}", std::process::id()));
let resources = root.join("resources");
std::fs::create_dir_all(&resources).expect("creates temp resources");
std::fs::write(resources.join("service.js"), "console.log('ok')").expect("writes service");
let script = resolve_service_script(Some(&root), Path::new("D:/Projects/waggle-os"))
.expect("bundled script resolves");
assert_eq!(script.kind, ServiceScriptKind::Bundled);
assert_eq!(script.path, resources.join("service.js"));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn dev_service_script_searches_current_dir_ancestors() {
let root =
std::env::temp_dir().join(format!("waggle-dev-service-script-{}", std::process::id()));
let script = root
.join("packages")
.join("server")
.join("src")
.join("local")
.join("service.ts");
std::fs::create_dir_all(script.parent().expect("script parent")).expect("creates dirs");
std::fs::write(&script, "export {};").expect("writes service");
let nested = root.join("app").join("src-tauri");
std::fs::create_dir_all(&nested).expect("creates nested cwd");
assert_eq!(find_dev_service_script(&nested), Some(script));
let _ = std::fs::remove_dir_all(root);
}
}
/// Synchronously spawn the sidecar process if not already running. Does not wait
/// for the health check. Safe to call from Tauri's synchronous `.setup()` callback.
pub fn spawn_service_sync(port: u16, process: &Mutex<Option<Child>>) -> Result<(), String> {
{
let proc = process.lock().map_err(|e| e.to_string())?;
if proc.is_some() {
return Ok(());
}
}
let mut cmd = build_service_command(port)?;
let child = cmd
.spawn()
.map_err(|e| format!("Failed to start service: {}", e))?;
let mut proc = process.lock().map_err(|e| e.to_string())?;
*proc = Some(child);
Ok(())
}
#[tauri::command]
pub async fn ensure_service(state: State<'_, ServiceState>) -> Result<String, String> {
let port = state.port;
let health_url = format!("http://127.0.0.1:{}/health", port);
match reqwest::get(&health_url).await {
Ok(resp) if resp.status().is_success() => {
return Ok("Service already running".to_string());
}
_ => {}
}
spawn_service_sync(port, &state.process)?;
for _ in 0..30 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
match reqwest::get(&health_url).await {
Ok(resp) if resp.status().is_success() => {
return Ok("Service started".to_string());
}
_ => continue,
}
}
Err("Service failed to start within 30 seconds".to_string())
}
#[tauri::command]
pub async fn stop_service(state: State<'_, ServiceState>) -> Result<String, String> {
let mut proc = state.process.lock().map_err(|e| e.to_string())?;
if let Some(mut child) = proc.take() {
let _ = child.kill();
let _ = child.wait();
}
Ok("Service stopped".to_string())
}
#[tauri::command]
pub async fn get_service_port(state: State<'_, ServiceState>) -> Result<u16, String> {
Ok(state.port)
}
pub fn start_watchdog(app: AppHandle, port: u16) {
tauri::async_runtime::spawn(async move {
let health_url = format!("http://127.0.0.1:{}/health", port);
let mut consecutive_failures: u32 = 0;
let mut restart_count: u32 = 0;
let mut restart_window_start = Instant::now();
const MAX_RESTARTS: u32 = 5;
const RESTART_WINDOW: Duration = Duration::from_secs(600);
// Wait for initial startup
tokio::time::sleep(Duration::from_secs(15)).await;
loop {
tokio::time::sleep(Duration::from_secs(10)).await;
match reqwest::get(&health_url).await {
Ok(resp) if resp.status().is_success() => {
consecutive_failures = 0;
}
_ => {
consecutive_failures += 1;
if consecutive_failures >= 3 {
if restart_window_start.elapsed() > RESTART_WINDOW {
restart_count = 0;
restart_window_start = Instant::now();
}
if restart_count >= MAX_RESTARTS {
let _ = app.emit(
"waggle://service-status",
serde_json::json!({ "status": "failed" }),
);
eprintln!("[waggle] Watchdog: max restarts exceeded, giving up");
break;
}
let _ = app.emit(
"waggle://service-status",
serde_json::json!({ "status": "restarting" }),
);
eprintln!(
"[waggle] Watchdog: server unresponsive, respawning (attempt {})",
restart_count + 1
);
// R7-003: self-heal — reap the dead child (so spawn_service_sync's
// is_some() early-return clears) then respawn the sidecar in place.
if let Some(state) = app.try_state::<ServiceState>() {
{
if let Ok(mut proc) = state.process.lock() {
if let Some(mut child) = proc.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
match spawn_service_sync(port, &state.process) {
Ok(()) => eprintln!("[waggle] Watchdog: sidecar respawned"),
Err(e) => eprintln!("[waggle] Watchdog: respawn failed: {}", e),
}
}
let _ = app.emit("waggle://service-restart-needed", ());
restart_count += 1;
consecutive_failures = 0;
tokio::time::sleep(Duration::from_secs(10)).await;
}
}
}
}
});
}

76
app/src-tauri/src/tray.rs Normal file
View File

@@ -0,0 +1,76 @@
use tauri::{
image::Image,
menu::{MenuBuilder, MenuItemBuilder},
tray::TrayIconBuilder,
AppHandle, Emitter, Manager,
};
fn generate_tray_icon() -> (Vec<u8>, u32, u32) {
let size: u32 = 32;
let mut rgba = vec![0u8; (size * size * 4) as usize];
// Orange filled square with rounded-ish corners
for y in 0..size {
for x in 0..size {
let idx = ((y * size + x) * 4) as usize;
// Simple circle mask for rounded look
let cx = (x as f32) - 15.5;
let cy = (y as f32) - 15.5;
let dist = (cx * cx + cy * cy).sqrt();
if dist < 14.0 {
// Orange: #E8922A
rgba[idx] = 0xE8; // R
rgba[idx + 1] = 0x92; // G
rgba[idx + 2] = 0x2A; // B
rgba[idx + 3] = 0xFF; // A
}
}
}
(rgba, size, size)
}
fn show_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
pub fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
let show = MenuItemBuilder::with_id("show", "Open Waggle").build(app)?;
let settings = MenuItemBuilder::with_id("settings", "Settings").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit Waggle").build(app)?;
let menu = MenuBuilder::new(app)
.items(&[&show, &settings, &quit])
.build()?;
let (rgba, w, h) = generate_tray_icon();
let icon = Image::new_owned(rgba, w, h);
TrayIconBuilder::new()
.icon(icon)
.menu(&menu)
.tooltip("Waggle Agent Service")
.on_menu_event(|app, event| match event.id().as_ref() {
"show" => {
show_main_window(app);
}
"settings" => {
show_main_window(app);
let _ = app.emit("waggle://navigate", "/settings");
}
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let tauri::tray::TrayIconEvent::Click { .. } = event {
show_main_window(tray.app_handle());
}
})
.build(app)?;
eprintln!("[waggle] System tray icon created successfully");
Ok(())
}

View File

@@ -0,0 +1,10 @@
{
"build": {
"beforeBuildCommand": ""
},
"bundle": {
"macOS": {
"signingIdentity": "-"
}
}
}

View File

@@ -0,0 +1,53 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Waggle",
"version": "0.2.0",
"identifier": "com.egzakta.waggle",
"build": {
"frontendDist": "../../apps/web/dist",
"devUrl": "http://localhost:8080",
"beforeDevCommand": "cd ../apps/web && npx vite",
"beforeBuildCommand": "node ../scripts/build-sidecar.mjs && node ../scripts/check-sidecar-resources.mjs && cd ../apps/web && npx vite build"
},
"bundle": {
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico", "icons/icon.png"],
"active": true,
"targets": "all",
"resources": ["resources/*", "resources/native/*", "resources/native/onnxruntime/*", "resources/node_modules/**/*"],
"windows": {
"nsis": {
"installerHooks": "nsis/installer.nsi",
"installerIcon": "icons/icon.ico",
"installMode": "currentUser",
"languages": ["English"],
"displayLanguageSelector": false
}
}
},
"app": {
"windows": [
{
"title": "Waggle",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"decorations": true
}
],
"security": {
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https://us.i.posthog.com; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:"
},
"trayIcon": {
"iconPath": "icons/icon.png",
"tooltip": "Waggle - AI Agent Swarm"
}
},
"plugins": {
"shell": {
"open": true
}
}
}

View File

@@ -0,0 +1,10 @@
{
"build": {
"beforeDevCommand": ""
},
"app": {
"security": {
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https://us.i.posthog.com; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data: https: blob:"
}
}
}