feat(bridge): establish local pi status bridge
Deliver the initial local bridge, Noctalia v4/v5 adapters, desktop client, service unit, tests, and implementation documentation for persistent Pi status and control.
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "pi-status-ui"
|
||||
version = "0.1.0"
|
||||
description = "Standalone desktop client for Pi Status Bridge"
|
||||
authors = ["alex"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "pi_status_ui_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["io-util", "net", "rt", "macros"] }
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,350 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BridgeRequest<'a> {
|
||||
version: &'static str,
|
||||
id: &'a str,
|
||||
op: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
agent_id: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
payload: Option<Value>,
|
||||
}
|
||||
|
||||
pub fn default_socket_path() -> Result<String, String> {
|
||||
env::var("PI_STATUS_BRIDGE_SOCKET")
|
||||
.or_else(|_| {
|
||||
env::var("XDG_RUNTIME_DIR")
|
||||
.map(|runtime| format!("{runtime}/pi-status-bridge/bridge.sock"))
|
||||
})
|
||||
.map_err(|_| "Pi Status Bridge socket is unavailable".to_owned())
|
||||
}
|
||||
|
||||
async fn connect_and_send(
|
||||
socket_path: &str,
|
||||
operation: &str,
|
||||
agent_id: Option<&str>,
|
||||
payload: Option<Value>,
|
||||
) -> Result<BufReader<UnixStream>, String> {
|
||||
let mut stream = UnixStream::connect(socket_path)
|
||||
.await
|
||||
.map_err(|error| format!("Could not connect to Pi Status Bridge: {error}"))?;
|
||||
let request = BridgeRequest {
|
||||
version: "v1",
|
||||
id: "tauri-ui",
|
||||
op: operation,
|
||||
agent_id,
|
||||
payload,
|
||||
};
|
||||
let encoded = serde_json::to_string(&request).map_err(|error| error.to_string())?;
|
||||
stream
|
||||
.write_all(format!("{encoded}\n").as_bytes())
|
||||
.await
|
||||
.map_err(|error| format!("Could not send bridge request: {error}"))?;
|
||||
Ok(BufReader::new(stream))
|
||||
}
|
||||
|
||||
fn result_from_response(value: Value) -> Result<Value, String> {
|
||||
if value.get("ok") != Some(&Value::Bool(true)) {
|
||||
return Err(value
|
||||
.get("error")
|
||||
.and_then(|error| error.get("message"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Pi Status Bridge rejected the request")
|
||||
.to_owned());
|
||||
}
|
||||
value
|
||||
.get("result")
|
||||
.cloned()
|
||||
.ok_or_else(|| "Pi Status Bridge returned no result".to_owned())
|
||||
}
|
||||
|
||||
pub async fn request(
|
||||
socket_path: &str,
|
||||
operation: &str,
|
||||
agent_id: Option<&str>,
|
||||
payload: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let mut reader = connect_and_send(socket_path, operation, agent_id, payload).await?;
|
||||
let mut response = String::new();
|
||||
reader
|
||||
.read_line(&mut response)
|
||||
.await
|
||||
.map_err(|error| format!("Could not read bridge response: {error}"))?;
|
||||
let value: Value = serde_json::from_str(&response)
|
||||
.map_err(|error| format!("Bridge returned invalid JSON: {error}"))?;
|
||||
result_from_response(value)
|
||||
}
|
||||
|
||||
pub async fn list_agents(socket_path: &str) -> Result<Value, String> {
|
||||
request(socket_path, "list_agents", None, None).await
|
||||
}
|
||||
|
||||
pub async fn list_directories(socket_path: &str) -> Result<Value, String> {
|
||||
request(socket_path, "list_directories", None, None).await
|
||||
}
|
||||
|
||||
pub async fn select_worktree(socket_path: &str, worktree_path: &str) -> Result<Value, String> {
|
||||
request(
|
||||
socket_path,
|
||||
"select_agent",
|
||||
None,
|
||||
Some(json!({ "worktreePath": worktree_path })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_sessions(socket_path: &str, agent_id: &str) -> Result<Value, String> {
|
||||
request(socket_path, "list_sessions", Some(agent_id), None).await
|
||||
}
|
||||
|
||||
pub async fn switch_session(
|
||||
socket_path: &str,
|
||||
agent_id: &str,
|
||||
session_path: &str,
|
||||
) -> Result<Value, String> {
|
||||
request(
|
||||
socket_path,
|
||||
"switch_session",
|
||||
Some(agent_id),
|
||||
Some(json!({ "sessionPath": session_path })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn new_session(socket_path: &str, agent_id: &str) -> Result<Value, String> {
|
||||
request(socket_path, "new_session", Some(agent_id), None).await
|
||||
}
|
||||
|
||||
pub async fn load_agent(socket_path: &str, agent_id: &str) -> Result<Value, String> {
|
||||
// Older bridge daemons can still serve the conversation while they await a restart.
|
||||
let stats = request(socket_path, "get_session_stats", Some(agent_id), None)
|
||||
.await
|
||||
.unwrap_or_else(|_| json!({ "data": {} }));
|
||||
let (state, transcript, commands, models) = tokio::try_join!(
|
||||
request(socket_path, "get_state", Some(agent_id), None),
|
||||
request(socket_path, "get_transcript", Some(agent_id), None),
|
||||
request(socket_path, "get_commands", Some(agent_id), None),
|
||||
request(socket_path, "get_available_models", Some(agent_id), None),
|
||||
)?;
|
||||
Ok(json!({
|
||||
"state": state,
|
||||
"stats": stats,
|
||||
"transcript": transcript,
|
||||
"commands": commands,
|
||||
"models": models
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn submit_prompt(socket_path: &str, agent_id: &str, message: &str) -> Result<(), String> {
|
||||
request(
|
||||
socket_path,
|
||||
"submit_prompt",
|
||||
Some(agent_id),
|
||||
Some(json!({ "message": message })),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn abort(socket_path: &str, agent_id: &str) -> Result<(), String> {
|
||||
request(socket_path, "abort", Some(agent_id), None).await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn set_model(
|
||||
socket_path: &str,
|
||||
agent_id: &str,
|
||||
provider: &str,
|
||||
model_id: &str,
|
||||
) -> Result<(), String> {
|
||||
request(
|
||||
socket_path,
|
||||
"set_model",
|
||||
Some(agent_id),
|
||||
Some(json!({ "provider": provider, "modelId": model_id })),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn set_thinking_level(
|
||||
socket_path: &str,
|
||||
agent_id: &str,
|
||||
level: &str,
|
||||
) -> Result<(), String> {
|
||||
request(
|
||||
socket_path,
|
||||
"set_thinking_level",
|
||||
Some(agent_id),
|
||||
Some(json!({ "level": level })),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn command(socket_path: &str, agent_id: &str, operation: &str) -> Result<(), String> {
|
||||
request(socket_path, operation, Some(agent_id), None)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn respond_to_extension(
|
||||
socket_path: &str,
|
||||
agent_id: &str,
|
||||
request_id: &str,
|
||||
response: Value,
|
||||
) -> Result<(), String> {
|
||||
request(
|
||||
socket_path,
|
||||
"extension_response",
|
||||
Some(agent_id),
|
||||
Some(json!({ "requestId": request_id, "response": response })),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn subscribe(
|
||||
socket_path: String,
|
||||
agent_id: String,
|
||||
cursor: u64,
|
||||
app: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let mut reader = connect_and_send(
|
||||
&socket_path,
|
||||
"subscribe",
|
||||
Some(&agent_id),
|
||||
Some(json!({ "cursor": cursor })),
|
||||
)
|
||||
.await?;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let bytes = reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|error| format!("Bridge subscription failed: {error}"))?;
|
||||
if bytes == 0 {
|
||||
return Err("Bridge subscription closed".to_owned());
|
||||
}
|
||||
let value: Value = serde_json::from_str(&line)
|
||||
.map_err(|error| format!("Bridge emitted invalid JSON: {error}"))?;
|
||||
if value.get("id") == Some(&Value::String("tauri-ui".to_owned())) {
|
||||
let result = result_from_response(value)?;
|
||||
for event in result
|
||||
.get("events")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
app.emit("bridge-event", event)
|
||||
.map_err(|error| format!("Could not publish bridge event: {error}"))?;
|
||||
}
|
||||
} else if value.get("type") == Some(&Value::String("event".to_owned())) {
|
||||
if let Some(event) = value.get("event") {
|
||||
app.emit("bridge-event", event)
|
||||
.map_err(|error| format!("Could not publish bridge event: {error}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
#[tokio::test]
|
||||
async fn sends_one_jsonl_request_and_unwraps_its_result() {
|
||||
let path = format!(
|
||||
"{}/pi-status-ui-{}.sock",
|
||||
env::temp_dir().display(),
|
||||
std::process::id()
|
||||
);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let listener = UnixListener::bind(&path).expect("listener");
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("connection");
|
||||
let mut line = String::new();
|
||||
let mut reader = BufReader::new(stream);
|
||||
reader.read_line(&mut line).await.expect("request");
|
||||
let request: Value = serde_json::from_str(&line).expect("JSON request");
|
||||
assert_eq!(request["op"], "submit_prompt");
|
||||
assert_eq!(request["agentId"], "agent-1");
|
||||
assert_eq!(request["payload"]["message"], "Hello Pi");
|
||||
let mut response = serde_json::to_vec(&json!({
|
||||
"id": "tauri-ui",
|
||||
"ok": true,
|
||||
"result": { "accepted": true }
|
||||
}))
|
||||
.expect("response JSON");
|
||||
response.push(b'\n');
|
||||
reader
|
||||
.get_mut()
|
||||
.write_all(&response)
|
||||
.await
|
||||
.expect("response");
|
||||
});
|
||||
let result = request(
|
||||
&path,
|
||||
"submit_prompt",
|
||||
Some("agent-1"),
|
||||
Some(json!({ "message": "Hello Pi" })),
|
||||
)
|
||||
.await
|
||||
.expect("request succeeds");
|
||||
assert_eq!(result["accepted"], true);
|
||||
server.await.expect("server succeeds");
|
||||
std::fs::remove_file(path).expect("socket cleanup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loads_state_history_commands_and_models_as_one_snapshot() {
|
||||
let path = format!(
|
||||
"{}/pi-status-ui-load-{}.sock",
|
||||
env::temp_dir().display(),
|
||||
std::process::id()
|
||||
);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let listener = UnixListener::bind(&path).expect("listener");
|
||||
let server = tokio::spawn(async move {
|
||||
for _ in 0..5 {
|
||||
let (stream, _) = listener.accept().await.expect("connection");
|
||||
tokio::spawn(async move {
|
||||
let mut line = String::new();
|
||||
let mut reader = BufReader::new(stream);
|
||||
reader.read_line(&mut line).await.expect("request");
|
||||
let request: Value = serde_json::from_str(&line).expect("JSON request");
|
||||
assert_eq!(request["agentId"], "agent-1");
|
||||
let result = match request["op"].as_str() {
|
||||
Some("get_state") => json!({ "data": { "isStreaming": false } }),
|
||||
Some("get_session_stats") => json!({
|
||||
"data": { "contextUsage": { "tokens": 32000, "contextWindow": 200000 } }
|
||||
}),
|
||||
Some("get_transcript") => json!({ "data": { "messages": [] } }),
|
||||
Some("get_commands") => json!({ "data": { "commands": [] } }),
|
||||
Some("get_available_models") => json!({ "data": { "models": [] } }),
|
||||
other => panic!("unexpected operation: {other:?}"),
|
||||
};
|
||||
let mut response = serde_json::to_vec(&json!({
|
||||
"id": "tauri-ui", "ok": true, "result": result
|
||||
}))
|
||||
.expect("response JSON");
|
||||
response.push(b'\n');
|
||||
reader.get_mut().write_all(&response).await.expect("response");
|
||||
});
|
||||
}
|
||||
});
|
||||
let snapshot = load_agent(&path, "agent-1").await.expect("snapshot");
|
||||
assert_eq!(snapshot["state"]["data"]["isStreaming"], false);
|
||||
assert_eq!(snapshot["stats"]["data"]["contextUsage"]["tokens"], 32000);
|
||||
assert_eq!(snapshot["transcript"]["data"]["messages"], json!([]));
|
||||
server.await.expect("server succeeds");
|
||||
std::fs::remove_file(path).expect("socket cleanup");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
mod bridge;
|
||||
|
||||
use serde_json::Value;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
|
||||
|
||||
struct Subscription(Mutex<Option<JoinHandle<()>>>);
|
||||
|
||||
fn socket_path() -> Result<String, String> {
|
||||
bridge::default_socket_path()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_agents() -> Result<Value, String> {
|
||||
bridge::list_agents(&socket_path()?).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_directories() -> Result<Value, String> {
|
||||
bridge::list_directories(&socket_path()?).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn select_worktree(worktree_path: String) -> Result<Value, String> {
|
||||
bridge::select_worktree(&socket_path()?, &worktree_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn load_agent(agent_id: String) -> Result<Value, String> {
|
||||
bridge::load_agent(&socket_path()?, &agent_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_sessions(agent_id: String) -> Result<Value, String> {
|
||||
bridge::list_sessions(&socket_path()?, &agent_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn switch_session(agent_id: String, session_path: String) -> Result<Value, String> {
|
||||
bridge::switch_session(&socket_path()?, &agent_id, &session_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn new_session(agent_id: String) -> Result<Value, String> {
|
||||
bridge::new_session(&socket_path()?, &agent_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn submit_prompt(agent_id: String, message: String) -> Result<(), String> {
|
||||
bridge::submit_prompt(&socket_path()?, &agent_id, &message).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn abort(agent_id: String) -> Result<(), String> {
|
||||
bridge::abort(&socket_path()?, &agent_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn retry(agent_id: String) -> Result<(), String> {
|
||||
bridge::command(&socket_path()?, &agent_id, "retry").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn restart(agent_id: String) -> Result<(), String> {
|
||||
bridge::command(&socket_path()?, &agent_id, "restart").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_model(agent_id: String, provider: String, model_id: String) -> Result<(), String> {
|
||||
bridge::set_model(&socket_path()?, &agent_id, &provider, &model_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_thinking_level(agent_id: String, level: String) -> Result<(), String> {
|
||||
bridge::set_thinking_level(&socket_path()?, &agent_id, &level).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn respond_to_extension(
|
||||
agent_id: String,
|
||||
request_id: String,
|
||||
response: Value,
|
||||
) -> Result<(), String> {
|
||||
bridge::respond_to_extension(&socket_path()?, &agent_id, &request_id, response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn subscribe_agent(
|
||||
app: AppHandle,
|
||||
subscriptions: State<'_, Subscription>,
|
||||
agent_id: String,
|
||||
cursor: u64,
|
||||
) -> Result<(), String> {
|
||||
let socket = socket_path()?;
|
||||
if let Some(subscription) = subscriptions
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "Could not update bridge subscription".to_owned())?
|
||||
.take()
|
||||
{
|
||||
subscription.abort();
|
||||
}
|
||||
let event_app = app.clone();
|
||||
let task = tauri::async_runtime::spawn(async move {
|
||||
if let Err(error) = bridge::subscribe(socket, agent_id.clone(), cursor, event_app.clone()).await {
|
||||
let _ = event_app.emit(
|
||||
"bridge-error",
|
||||
serde_json::json!({ "agentId": agent_id, "message": error }),
|
||||
);
|
||||
}
|
||||
});
|
||||
let mut current_subscription = subscriptions
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "Could not retain bridge subscription".to_owned())?;
|
||||
*current_subscription = Some(task);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
.manage(Subscription(Mutex::new(None)))
|
||||
.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();
|
||||
}
|
||||
}))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_agents,
|
||||
list_directories,
|
||||
select_worktree,
|
||||
load_agent,
|
||||
list_sessions,
|
||||
switch_session,
|
||||
new_session,
|
||||
submit_prompt,
|
||||
abort,
|
||||
retry,
|
||||
restart,
|
||||
set_model,
|
||||
set_thinking_level,
|
||||
respond_to_extension,
|
||||
subscribe_agent
|
||||
]);
|
||||
let context = tauri::generate_context!();
|
||||
if let Err(error) = builder.run(context) {
|
||||
eprintln!("Pi Status UI exited: {error}");
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
pi_status_ui_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Pi Status UI",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.alex.pi-status-ui",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Pi Status UI",
|
||||
"width": 760,
|
||||
"height": 620,
|
||||
"minWidth": 480,
|
||||
"minHeight": 420,
|
||||
"center": true,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"alwaysOnTop": true,
|
||||
"skipTaskbar": true,
|
||||
"shadow": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||