Ai-launcher V1

This commit is contained in:
Name
2026-05-15 14:36:06 +10:00
commit 904214dcd4
4 changed files with 382 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
# AI Launcher — Setup Guide
A floating GTK4 AI assistant for Linux. Press a keybind anywhere on the desktop to show/hide it. Type a question, get a Claude response. Escape to dismiss.
---
## How it works
```
Keybind pressed
ai-launcher binary (GTK4/Rust daemon, starts hidden)
│ HTTP POST → localhost:3456/v1/chat/completions
claude-max-api-proxy (Node.js, OpenAI-compatible API server)
│ calls Claude Code CLI internally
claude CLI (uses your existing Anthropic login)
Anthropic API → response flows back up
```
The keybind re-runs the binary. GTK's single-instance D-Bus detects the running daemon and sends it an activate signal, toggling show/hide — no cold start.
---
## Prerequisites
### 1. Rust
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env
```
### 2. GTK4 + build dependencies
```bash
sudo apt install libgtk-4-dev build-essential pkg-config libssl-dev
```
### 3. Node.js + npm
```bash
sudo apt install nodejs npm
```
### 4. Claude Code CLI
```bash
npm install -g @anthropic-ai/claude-code
claude # follow the login/auth flow
```
### 5. claude-max-api-proxy
```bash
npm install -g claude-max-api-proxy
```
After install, find where npm placed the binary:
```bash
which claude-max-api
```
Note this path — you'll need it for the service file.
---
## Build & install the launcher
```bash
cd ~/ai-launcher
cargo install --path .
```
This places the binary at `~/.cargo/bin/ai-launcher`.
---
## Systemd user services
Create the service directory:
```bash
mkdir -p ~/.config/systemd/user
```
### claude-max-api.service
Create `~/.config/systemd/user/claude-max-api.service`:
```ini
[Unit]
Description=Claude Max API Proxy
After=network.target
[Service]
ExecStart=/bin/bash -lc 'REPLACE_WITH_OUTPUT_OF_which_claude-max-api'
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
```
Replace `REPLACE_WITH_OUTPUT_OF_which_claude-max-api` with the actual path (e.g. `/usr/bin/claude-max-api` or `/home/USER/.npm-global/bin/claude-max-api`).
The `/bin/bash -lc` wrapper is required so npm's PATH is available to systemd.
### ai-launcher.service
Create `~/.config/systemd/user/ai-launcher.service`:
```ini
[Unit]
Description=AI Launcher
After=graphical-session.target claude-max-api.service
PartOf=graphical-session.target
[Service]
ExecStart=/home/YOUR_USERNAME/.cargo/bin/ai-launcher
Restart=on-failure
RestartSec=3
[Install]
WantedBy=graphical-session.target
```
Replace `YOUR_USERNAME` with your Linux username.
### Enable and start
```bash
systemctl --user daemon-reload
systemctl --user enable --now claude-max-api
systemctl --user enable --now ai-launcher
```
### Verify both are running
```bash
systemctl --user status claude-max-api
systemctl --user status ai-launcher
```
---
## Keybind (GNOME)
Settings → Keyboard → Custom Shortcuts → Add:
- **Name:** AI Launcher
- **Command:** `/home/YOUR_USERNAME/.cargo/bin/ai-launcher`
- **Shortcut:** your preferred key combo (e.g. Super+Space)
---
## Troubleshooting
| Problem | Fix |
|---|---|
| `status=203/EXEC` | Binary not found — run `cargo install --path .` first, check username in service file |
| `status=127` (claude-max-api) | Wrong binary path — run `which claude-max-api` and update `ExecStart` |
| `status=1` with "Claude CLI not found" | Missing `-lc` in ExecStart, or `claude` not authenticated — run `claude` to log in |
| `Could not find openssl` during cargo build | Run `sudo apt install pkg-config libssl-dev` |
| Window doesn't appear on keybind | Run `systemctl --user status ai-launcher` — service may have crashed |
---
## Files in this guide
```
guides/ai-launcher/
├── README.md — this file
├── Cargo.toml — Rust dependencies
├── src/
│ └── main.rs — application source
└── services/
├── claude-max-api.service — systemd service template
└── ai-launcher.service — systemd service template
```
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=AI Launcher
After=graphical-session.target claude-max-api.service
PartOf=graphical-session.target
[Service]
ExecStart=/home/YOUR_USERNAME/.cargo/bin/ai-launcher
Restart=on-failure
RestartSec=3
[Install]
WantedBy=graphical-session.target
@@ -0,0 +1,11 @@
[Unit]
Description=Claude Max API Proxy
After=network.target
[Service]
ExecStart=/bin/bash -lc 'BINARY_PATH_HERE'
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
+187
View File
@@ -0,0 +1,187 @@
use gtk4::prelude::*;
use gtk4::{
Application, ApplicationWindow, Box, CssProvider, Entry,
Orientation, ScrolledWindow, TextView, WrapMode,
};
use std::sync::{Arc, Mutex};
const APP_ID: &str = "com.ailauncher.app";
const CSS: &str = "
window {
background: rgba(30, 30, 30, 0.95);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.launcher-input {
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 10px 14px;
font-size: 16px;
caret-color: #7aa2f7;
}
.launcher-input:focus {
border-color: #7aa2f7;
}
.launcher-response {
background: transparent;
color: #c0caf5;
font-size: 14px;
padding: 8px;
}
";
#[derive(Clone, serde::Serialize)]
struct Message {
role: String,
content: String,
}
#[derive(Clone, serde::Serialize)]
struct ChatRequest {
model: String,
messages: Vec<Message>,
stream: bool,
}
fn build_ui(app: &Application) {
let window = ApplicationWindow::builder()
.application(app)
.title("AI Launcher")
.default_width(620)
.default_height(400)
.decorated(false)
.resizable(false)
.build();
let provider = CssProvider::new();
provider.load_from_data(CSS);
gtk4::style_context_add_provider_for_display(
&gtk4::gdk::Display::default().unwrap(),
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
let vbox = Box::new(Orientation::Vertical, 10);
vbox.set_margin_top(20);
vbox.set_margin_bottom(20);
vbox.set_margin_start(20);
vbox.set_margin_end(20);
let entry = Entry::new();
entry.set_placeholder_text(Some("Ask me anything..."));
entry.add_css_class("launcher-input");
let response_view = TextView::new();
response_view.set_editable(false);
response_view.set_wrap_mode(WrapMode::Word);
response_view.add_css_class("launcher-response");
let scrolled = ScrolledWindow::builder()
.child(&response_view)
.vexpand(true)
.build();
vbox.append(&entry);
vbox.append(&scrolled);
window.set_child(Some(&vbox));
let key_controller = gtk4::EventControllerKey::new();
let win_clone = window.clone();
key_controller.connect_key_pressed(move |_, key, _, _| {
if key == gtk4::gdk::Key::Escape {
win_clone.hide();
}
glib::Propagation::Proceed
});
window.add_controller(key_controller);
let history: Arc<Mutex<Vec<Message>>> = Arc::new(Mutex::new(vec![]));
let response_view_clone = response_view.clone();
let history_clone = history.clone();
entry.connect_activate(move |e| {
let text = e.text().to_string();
if text.is_empty() { return; }
e.set_text("");
let buffer = response_view_clone.buffer();
buffer.set_text("Thinking...");
history_clone.lock().unwrap().push(Message {
role: "user".to_string(),
content: text,
});
let messages = history_clone.lock().unwrap().clone();
let history_ref = history_clone.clone();
let buffer_clone = buffer.clone();
let (sender, receiver) = async_channel::bounded::<String>(1);
tokio::spawn(async move {
let client = reqwest::Client::new();
let request = ChatRequest {
model: "claude-sonnet-4".to_string(),
messages,
stream: false,
};
match client
.post("http://localhost:3456/v1/chat/completions")
.json(&request)
.send()
.await
{
Ok(resp) => {
match resp.json::<serde_json::Value>().await {
Ok(json) => {
let content = json["choices"][0]["message"]["content"]
.as_str().unwrap_or("No response")
.to_string();
sender.send(content).await.unwrap();
}
Err(e) => { sender.send(format!("Parse error: {}", e)).await.unwrap(); }
}
}
Err(e) => { sender.send(format!("Request error: {}", e)).await.unwrap(); }
}
});
glib::MainContext::default().spawn_local(async move {
if let Ok(response) = receiver.recv().await {
buffer_clone.set_text(&response);
history_ref.lock().unwrap().push(Message {
role: "assistant".to_string(),
content: response,
});
}
});
});
window.hide();
}
#[tokio::main]
async fn main() {
let app = Application::builder()
.application_id(APP_ID)
.build();
app.connect_activate(|app| {
if let Some(win) = app.windows().first() {
if win.is_visible() {
win.hide();
} else {
win.show();
win.present();
}
} else {
build_ui(app);
}
});
app.run();
}