mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge #781 feat(desktop): blink the lid Apple logo with what the agents are doing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
a7343fe087
9 files changed
+2040
-19
No files matched your search
Generated
+1
@@ -164,6 +164,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"block2",
|
||||
"dirs 6.0.0",
|
||||
"libc",
|
||||
"muda",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
|
||||
@@ -30,6 +30,9 @@ ureq = { version = "3", features = ["platform-verifier"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = "3"
|
||||
# Ignoring signals in the logo blinker (src/logo.rs). Already in the tree
|
||||
# through wry and tao, so no new crate is added.
|
||||
libc = "0.2"
|
||||
# JavaScript dialogs, HTTP Basic challenges, clearing cookies and the window's
|
||||
# content size (src/macos.rs). The versions wry already pulls in, so no new
|
||||
# crate is added.
|
||||
@@ -39,7 +42,9 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"objc2-core-foundation",
|
||||
"block2",
|
||||
"NSBundle",
|
||||
"NSDate",
|
||||
"NSDictionary",
|
||||
"NSGeometry",
|
||||
"NSObject",
|
||||
"NSOperation",
|
||||
@@ -53,6 +58,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
||||
"NSURLRequest",
|
||||
"NSURLResponse",
|
||||
"NSURLSession",
|
||||
"NSValue",
|
||||
] }
|
||||
objc2-app-kit = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
|
||||
@@ -13,8 +13,8 @@ Windows and Linux later.
|
||||
- **Stack:** Rust with [tao](https://crates.io/crates/tao) (window and event loop),
|
||||
[wry](https://crates.io/crates/wry) (the system web view: WKWebView on macOS) and
|
||||
[muda](https://crates.io/crates/muda) (native menus), plus [objc2](https://crates.io/crates/objc2)
|
||||
for the two things wry leaves out on macOS (JavaScript dialogs, HTTP Basic login).
|
||||
No bundled browser and no Node in the app.
|
||||
for what wry leaves out on macOS (JavaScript dialogs, HTTP Basic login) and for
|
||||
the lid logo. No bundled browser and no Node in the app.
|
||||
- **Server:** unchanged. The app is a client of Codeman's web UI, like a browser tab.
|
||||
|
||||
## Why it is cheap
|
||||
@@ -40,6 +40,7 @@ cannot do.
|
||||
| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (see Known limits). |
|
||||
| **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. |
|
||||
| **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. |
|
||||
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing, with a blink style of your own for each state: **View > Blink Apple Logo > While Working**, **Waiting for You (Yellow)** and **Needs Permission (Red)**. Off, Calm, Slow, Wink, Heartbeat, Fast, or Alarm, three sharp flashes for a state that needs you. All three are off until you choose. When several states hold, the most urgent one with a style shows: red, then yellow, then work. A style chosen in an earlier version stays as the one for work, and yellow and red start as Wink and Alarm. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. |
|
||||
| **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. |
|
||||
|
||||
## Build (on a Mac)
|
||||
@@ -187,8 +188,68 @@ instead of being answered. A refusal that lands while you are in another app
|
||||
does not bring Codeman forward: the connect screen loads where the window is,
|
||||
and the Dock icon bounces.
|
||||
|
||||
**The logo blinks from a child process, through CoreBrightness.** Apple documents
|
||||
one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
|
||||
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. Doing that
|
||||
for every blink would need a root helper and an administrator password. The same
|
||||
setting is also a property of `BrightnessSystemClient` in the private
|
||||
CoreBrightness framework, and a process of the logged-in user can set it there,
|
||||
which is what `src/logo.rs` does. Measured on a Mac17,13 with macOS 27.2 from an
|
||||
ad-hoc signed binary: `corebrightnessd` logs the logo dark about a millisecond
|
||||
after the write, with no fade, and the NVRAM variable `bklb-backlight-enabled`
|
||||
keeps its value while the logo is dark.
|
||||
|
||||
The setting is system-wide and survives a reboot, so the app must not end with the
|
||||
logo dark, and the release profile has `panic = "abort"`, so no cleanup code of the
|
||||
app can be relied on. So all of it runs as this same binary with
|
||||
`--logo-blink`, for as long as any style is chosen and a server is open, tied to the app
|
||||
by nothing but a pipe: when the pipe closes, however the app ended, it puts the logo
|
||||
back and exits. Switching it off closes the same pipe, so the path a crash
|
||||
takes is the one used every day. A marker file
|
||||
(`logo-blink`, next to `desktop.json`) covers what the child cannot survive itself:
|
||||
at the next launch the app puts back a logo a blink left dark. The framework is
|
||||
loaded at run time and never linked, and `LEBacklightMode` reading nil (a Mac mini
|
||||
answers nil) is what hides the option, so a macOS that drops it loses this feature
|
||||
and nothing else.
|
||||
|
||||
**Red and yellow come from the server's approvals list.** `GET /api/approvals` is what the
|
||||
page reads to rebuild its red and yellow tabs after a reload, and it follows the same rules:
|
||||
`permission` and `question` items are red, an `idle` item is yellow until someone has looked
|
||||
at it (`acknowledgedAt`). So the logo stops when the tab's colour goes. The request is skipped
|
||||
when neither alert has a style, and a server from before the list reads as no alerts. Each
|
||||
poll with a dialog on screen costs that server one tmux capture, because the list re-checks
|
||||
the dialog, which also clears red soon after a dialog is answered in the terminal.
|
||||
|
||||
**The agent count is asked for natively, because the page cannot be trusted with it.**
|
||||
The first version read the page's own event stream, which needs no second connection
|
||||
and no login. On a real Mac it failed in both directions as soon as the window was
|
||||
covered, because WebKit stalls the stream of a hidden page: a turn that started while
|
||||
it was stalled was never seen and the logo stayed still, and in another run the logo
|
||||
kept blinking 39 seconds after the agent finished, until the page's watchdog reopened
|
||||
the stream. A hidden window is the normal case for a signal meant to be read from
|
||||
across the room, so `src/agents.rs` asks `api/sessions` itself, from the child process,
|
||||
every two seconds. That needs care with the login. A request with a good password and
|
||||
no session cookie makes Codeman create a login session, it keeps 100 of them, and the
|
||||
oldest goes first, so polling with the password alone would log every browser out
|
||||
within minutes. The poller therefore sends the cookie of the first answer with every
|
||||
request, which Codeman reads first, so one session is created per server start. A
|
||||
refused login ends the polling for good, because Codeman counts each refusal against
|
||||
the login rate limit. The app and the child share nothing but the pipe the login
|
||||
crosses, so the login is never on a command line.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **The logo blink rests on a private macOS interface.** Apple documents the
|
||||
setting, not the way the app reaches it, so a macOS update can take it away;
|
||||
the menu then disappears. Every change of the logo writes `LEBacklightMode`,
|
||||
from about once every two seconds (*Slow*, *Wink*) to about three times a
|
||||
second (*Fast*), which `corebrightnessd` keeps in root's preferences. The
|
||||
setting is on or off, so these styles are rhythms of hard changes. After a blink
|
||||
the key stays there rather than being absent as on a fresh Mac. That reads the same to macOS; `sudo defaults delete
|
||||
com.apple.CoreBrightness LEBacklightMode` removes it. After a power cut in a
|
||||
dark phase the logo stays dark until the app next starts. `api/sessions` does
|
||||
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
|
||||
keep-alive turn blinks too.
|
||||
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
|
||||
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
|
||||
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
//! What the agents on a server are doing, asked from outside the web view: at
|
||||
//! work, finished with nobody having looked yet (a yellow tab), or stopped on a
|
||||
//! permission or a question (a red tab).
|
||||
//!
|
||||
//! The page knows this too, but it cannot be relied on for it: WebKit stalls the
|
||||
//! event stream of a hidden page. Measured on macOS 27.2 with the page hidden: a
|
||||
//! `session:idle` the server sent never reached it, its own watchdog reopened the
|
||||
//! stream 39 seconds later, and in another run a whole turn went unseen. A hidden
|
||||
//! window is the normal case for this question, so it is asked natively, of
|
||||
//! `api/sessions`, a stable route that works with any Codeman, and of
|
||||
//! `api/approvals`, the list the page itself builds its red and yellow tabs from
|
||||
//! when it loads. A server from before that list has no such route, which reads
|
||||
//! as no alerts.
|
||||
//!
|
||||
//! A login has a price here that a single request does not show. A request with
|
||||
//! a good password and no session cookie makes Codeman create a login session,
|
||||
//! it keeps 100 of them, and the oldest goes first, so asking every two seconds
|
||||
//! with the password alone would log every browser out within minutes. So the
|
||||
//! cookie of the first answer goes along from then on, and Codeman reads the
|
||||
//! cookie first: the password is only used when the cookie has lapsed (a server
|
||||
//! restart), and then it creates the one new session that replaces it. Sending
|
||||
//! both also means a stale cookie costs no failed login. A refused login ends
|
||||
//! the polling for good, since Codeman counts each one against the login rate
|
||||
//! limit (see `probe`).
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
/// How long the logo can lag behind the server. The list is cached for a
|
||||
/// second on the server's side and is a few kilobytes.
|
||||
pub const POLL_EVERY: Duration = Duration::from_secs(2);
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SESSIONS_PATH: &str = "api/sessions";
|
||||
const APPROVALS_PATH: &str = "api/approvals";
|
||||
const COOKIE_NAME: &str = "codeman_session";
|
||||
|
||||
/// What the agents on a server are doing. Any number of these can be true.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct Activity {
|
||||
/// At least one agent is at work.
|
||||
pub working: bool,
|
||||
/// An agent finished and nobody has looked at it yet: a yellow tab.
|
||||
pub waiting: bool,
|
||||
/// An agent stopped on a permission or a question: a red tab.
|
||||
pub needs_you: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Poll {
|
||||
Seen(Activity),
|
||||
/// No answer this time. Worth asking again.
|
||||
Failed,
|
||||
/// The server turned the login down. Never ask again.
|
||||
Refused,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Session {
|
||||
#[serde(default)]
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
mode: String,
|
||||
}
|
||||
|
||||
/// Codeman wraps the list in its API envelope, and older servers sent it bare.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Sessions {
|
||||
Envelope { data: Vec<Session> },
|
||||
Bare(Vec<Session>),
|
||||
}
|
||||
|
||||
/// Agents only: a shell tab with a `tail -f` is a run of output, which the
|
||||
/// server may call busy, and that is nobody's agent.
|
||||
fn count_working(body: &str) -> Option<u32> {
|
||||
let (Sessions::Envelope { data: sessions } | Sessions::Bare(sessions)) = serde_json::from_str(body).ok()?;
|
||||
let working = sessions.iter().filter(|s| s.status == "busy" && s.mode != "shell");
|
||||
u32::try_from(working.count()).ok()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Approval {
|
||||
#[serde(default)]
|
||||
kind: String,
|
||||
#[serde(default, rename = "acknowledgedAt")]
|
||||
acknowledged_at: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Approvals {
|
||||
approvals: Vec<Approval>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ApprovalsAnswer {
|
||||
data: Approvals,
|
||||
}
|
||||
|
||||
/// The two alerts, by the page's own rules: `permission_prompt` and a question
|
||||
/// make a tab red, an idle prompt makes it yellow until someone has looked at it,
|
||||
/// and an acknowledged item stays listed but alerts nobody.
|
||||
fn read_alerts(body: &str) -> Option<(bool, bool)> {
|
||||
let answer: ApprovalsAnswer = serde_json::from_str(body).ok()?;
|
||||
let items = &answer.data.approvals;
|
||||
let waiting = items.iter().any(|i| i.kind == "idle" && i.acknowledged_at.is_none());
|
||||
let needs_you = items.iter().any(|i| i.kind == "permission" || i.kind == "question");
|
||||
Some((waiting, needs_you))
|
||||
}
|
||||
|
||||
/// `name=value` out of a `Set-Cookie` header, for Codeman's session cookie.
|
||||
fn session_cookie(header: &str) -> Option<&str> {
|
||||
let pair = header.split(';').next()?.trim();
|
||||
let (name, value) = pair.split_once('=')?;
|
||||
(name == COOKIE_NAME && !value.is_empty()).then_some(pair)
|
||||
}
|
||||
|
||||
/// What a status code says about asking again.
|
||||
fn answer(status: u16, body: impl FnOnce() -> Option<String>) -> Poll {
|
||||
match status {
|
||||
200 => body()
|
||||
.as_deref()
|
||||
.and_then(count_working)
|
||||
.map_or(Poll::Failed, |working| {
|
||||
Poll::Seen(Activity {
|
||||
working: working > 0,
|
||||
..Activity::default()
|
||||
})
|
||||
}),
|
||||
401 | 403 | 429 => Poll::Refused,
|
||||
_ => Poll::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Poller {
|
||||
agent: ureq::Agent,
|
||||
sessions_url: String,
|
||||
/// None when no alert has a style: that request is then never made.
|
||||
approvals_url: Option<String>,
|
||||
authorization: Option<String>,
|
||||
cookie: Option<String>,
|
||||
/// The last alerts seen, kept through a request that failed.
|
||||
alerts: (bool, bool),
|
||||
}
|
||||
|
||||
impl Poller {
|
||||
pub fn new(server: &Url, authorization: Option<String>, alerts: bool) -> Option<Self> {
|
||||
let approvals_url = match alerts {
|
||||
true => Some(server.join(APPROVALS_PATH).ok()?.to_string()),
|
||||
false => None,
|
||||
};
|
||||
Some(Poller {
|
||||
agent: crate::probe::agent(TIMEOUT),
|
||||
sessions_url: server.join(SESSIONS_PATH).ok()?.to_string(),
|
||||
approvals_url,
|
||||
authorization,
|
||||
cookie: None,
|
||||
alerts: (false, false),
|
||||
})
|
||||
}
|
||||
|
||||
/// One GET with the login, and the status with the body of a 200.
|
||||
fn ask(&mut self, url: &str) -> Option<(u16, Option<String>)> {
|
||||
let mut request = self.agent.get(url).header("Accept", "application/json");
|
||||
if let Some(authorization) = &self.authorization {
|
||||
request = request.header("Authorization", authorization);
|
||||
}
|
||||
if let Some(cookie) = &self.cookie {
|
||||
request = request.header("Cookie", cookie);
|
||||
}
|
||||
let mut response = request.call().ok()?;
|
||||
let fresh = response
|
||||
.headers()
|
||||
.get_all("set-cookie")
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.find_map(session_cookie)
|
||||
.map(str::to_owned);
|
||||
if fresh.is_some() {
|
||||
self.cookie = fresh;
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
let body = (status == 200).then(|| {
|
||||
let body = response.body_mut().with_config();
|
||||
body.limit(crate::probe::MAX_BODY).read_to_string().ok()
|
||||
});
|
||||
Some((status, body.flatten()))
|
||||
}
|
||||
|
||||
pub fn poll(&mut self) -> Poll {
|
||||
let url = self.sessions_url.clone();
|
||||
let Some((status, body)) = self.ask(&url) else {
|
||||
return Poll::Failed;
|
||||
};
|
||||
let mut activity = match answer(status, || body) {
|
||||
Poll::Seen(activity) => activity,
|
||||
other => return other,
|
||||
};
|
||||
if let Some(url) = self.approvals_url.clone() {
|
||||
match self.ask(&url) {
|
||||
Some((200, body)) => {
|
||||
if let Some(alerts) = body.as_deref().and_then(read_alerts) {
|
||||
self.alerts = alerts;
|
||||
}
|
||||
}
|
||||
Some((401 | 403 | 429, _)) => return Poll::Refused,
|
||||
// A server from before the list has no alerts to report.
|
||||
Some((404, _)) => self.alerts = (false, false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(activity.waiting, activity.needs_you) = self.alerts;
|
||||
Poll::Seen(activity)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// The start of a real answer of `GET /api/sessions`, captured from a
|
||||
/// server: other fields around the two that matter, in Codeman's envelope.
|
||||
const REAL_ANSWER: &str = r#"{"success":true,"data":[{"id":"0f8d0215-661e-4a1c-bfad-d7f6fd2ed4b5",
|
||||
"pid":61017,"status":"busy","workingDir":"/Users/lee/codeman-cases/testcase",
|
||||
"paneCwd":"/Users/lee/codeman-cases/testcase","currentTaskId":null,"mode":"opencode"}]}"#;
|
||||
|
||||
#[test]
|
||||
fn reads_the_answer_a_real_server_gives() {
|
||||
assert_eq!(count_working(REAL_ANSWER), Some(1));
|
||||
assert_eq!(count_working(r#"{"success":true,"data":[]}"#), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_agents_at_work_and_nothing_else() {
|
||||
let body = r#"{"success":true,"data":[
|
||||
{"id":"a","status":"busy","mode":"claude"},
|
||||
{"id":"b","status":"busy","mode":"codex"},
|
||||
{"id":"c","status":"idle","mode":"claude"},
|
||||
{"id":"d","status":"busy","mode":"shell"},
|
||||
{"id":"e","status":"stopped","mode":"claude"},
|
||||
{"id":"f","status":"error","mode":"opencode"}
|
||||
]}"#;
|
||||
assert_eq!(count_working(body), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_bare_list_of_an_older_server() {
|
||||
assert_eq!(count_working(r#"[{"status":"busy","mode":"claude"}]"#), Some(1));
|
||||
assert_eq!(count_working("[]"), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_without_a_mode_counts_and_one_without_a_status_does_not() {
|
||||
assert_eq!(
|
||||
count_working(r#"{"data":[{"status":"busy"},{"mode":"claude"}]}"#),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_that_is_not_the_list_is_no_answer() {
|
||||
assert_eq!(count_working("<html>"), None);
|
||||
assert_eq!(count_working(r#"{"success":false,"error":"nope"}"#), None);
|
||||
assert_eq!(answer(200, || Some("<html>".into())), Poll::Failed);
|
||||
assert_eq!(answer(200, || None), Poll::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_login_refusal_ends_the_polling() {
|
||||
for status in [401, 403, 429] {
|
||||
assert_eq!(answer(status, || None), Poll::Refused, "{status}");
|
||||
}
|
||||
for status in [301, 404, 500, 502, 503] {
|
||||
assert_eq!(answer(status, || None), Poll::Failed, "{status}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_codemans_cookie_in_a_set_cookie_header() {
|
||||
assert_eq!(
|
||||
session_cookie("codeman_session=abc123; Max-Age=86400; Path=/; HttpOnly; SameSite=Lax"),
|
||||
Some("codeman_session=abc123")
|
||||
);
|
||||
assert_eq!(session_cookie("codeman_session=abc123"), Some("codeman_session=abc123"));
|
||||
assert_eq!(session_cookie("other=1; Path=/"), None);
|
||||
assert_eq!(session_cookie("codeman_session=; Max-Age=0"), None);
|
||||
assert_eq!(session_cookie(""), None);
|
||||
}
|
||||
|
||||
/// A server that answers each connection with the next canned response
|
||||
/// and records the headers it was sent.
|
||||
fn serve(responses: Vec<String>) -> (u16, Arc<Mutex<Vec<Vec<String>>>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let log = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
for response in responses {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut headers = Vec::new();
|
||||
let mut line = String::new();
|
||||
while reader.read_line(&mut line).is_ok() && line.trim() != "" {
|
||||
headers.push(line.trim().to_ascii_lowercase());
|
||||
line.clear();
|
||||
}
|
||||
log.lock().unwrap().push(headers);
|
||||
stream.write_all(response.as_bytes()).unwrap();
|
||||
}
|
||||
});
|
||||
(port, seen)
|
||||
}
|
||||
|
||||
const EMPTY: &str = r#"{"success":true,"data":[]}"#;
|
||||
|
||||
fn plain(working: bool) -> Poll {
|
||||
Poll::Seen(Activity {
|
||||
working,
|
||||
..Activity::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// The shape of a real `GET /api/approvals`, with the frame text cut.
|
||||
fn approvals(items: &[&str]) -> String {
|
||||
format!(r#"{{"success":true,"data":{{"approvals":[{}]}}}}"#, items.join(","))
|
||||
}
|
||||
const IDLE: &str = r#"{"id":"s:1","sessionId":"s","sessionName":"w4","kind":"idle","createdAt":1,"context":"x"}"#;
|
||||
const LOOKED_AT: &str = r#"{"id":"s:2","sessionId":"s","kind":"idle","createdAt":1,"acknowledgedAt":2}"#;
|
||||
const PERMISSION: &str = r#"{"id":"s:3","sessionId":"s","kind":"permission","createdAt":1,"toolName":"Bash"}"#;
|
||||
const QUESTION: &str = r#"{"id":"s:4","sessionId":"s","kind":"question","createdAt":1}"#;
|
||||
|
||||
#[test]
|
||||
fn the_alerts_follow_the_pages_rules() {
|
||||
assert_eq!(read_alerts(&approvals(&[])), Some((false, false)));
|
||||
assert_eq!(read_alerts(&approvals(&[IDLE])), Some((true, false)));
|
||||
assert_eq!(read_alerts(&approvals(&[LOOKED_AT])), Some((false, false)), "seen");
|
||||
assert_eq!(read_alerts(&approvals(&[PERMISSION])), Some((false, true)));
|
||||
assert_eq!(read_alerts(&approvals(&[QUESTION])), Some((false, true)));
|
||||
assert_eq!(read_alerts(&approvals(&[IDLE, PERMISSION])), Some((true, true)));
|
||||
assert_eq!(read_alerts("not json"), None);
|
||||
assert_eq!(read_alerts(EMPTY), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_all_three_states_from_one_poll() {
|
||||
let busy = r#"{"success":true,"data":[{"status":"busy","mode":"claude"}]}"#;
|
||||
let (port, seen_requests) = serve(vec![ok("", busy), ok("", &approvals(&[IDLE, PERMISSION]))]);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
|
||||
let mut poller = Poller::new(&server, None, true).unwrap();
|
||||
let everything = Activity {
|
||||
working: true,
|
||||
waiting: true,
|
||||
needs_you: true,
|
||||
};
|
||||
assert_eq!(poller.poll(), Poll::Seen(everything));
|
||||
assert!(seen_requests.lock().unwrap()[1][0].starts_with("get /api/approvals "));
|
||||
}
|
||||
|
||||
fn status(code: u16) -> String {
|
||||
format!("HTTP/1.1 {code} X\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_without_the_list_still_shows_work() {
|
||||
let busy = r#"{"data":[{"status":"busy","mode":"claude"}]}"#;
|
||||
let (port, _) = serve(vec![ok("", busy), status(404)]);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
|
||||
assert_eq!(Poller::new(&server, None, true).unwrap().poll(), plain(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_list_ends_the_polling_and_a_failed_one_keeps_the_last_answer() {
|
||||
let (port, _) = serve(vec![ok("", EMPTY), status(401)]);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
|
||||
assert_eq!(Poller::new(&server, None, true).unwrap().poll(), Poll::Refused);
|
||||
|
||||
let (port, _) = serve(vec![
|
||||
ok("", EMPTY),
|
||||
ok("", &approvals(&[PERMISSION])),
|
||||
ok("", EMPTY),
|
||||
status(500),
|
||||
]);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
|
||||
let mut poller = Poller::new(&server, None, true).unwrap();
|
||||
let red = Poll::Seen(Activity {
|
||||
needs_you: true,
|
||||
..Activity::default()
|
||||
});
|
||||
assert_eq!(poller.poll(), red);
|
||||
assert_eq!(
|
||||
poller.poll(),
|
||||
Poll::Seen(Activity {
|
||||
needs_you: true,
|
||||
..Activity::default()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_alert_with_a_style_means_no_second_request() {
|
||||
let (port, seen_requests) = serve(vec![ok("", EMPTY)]);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
|
||||
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), plain(false));
|
||||
assert_eq!(seen_requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
fn ok(extra_headers: &str, body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\n{extra_headers}\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_one_login_session_across_polls() {
|
||||
let body = r#"{"success":true,"data":[{"status":"busy","mode":"claude"}]}"#;
|
||||
let (port, seen) = serve(vec![
|
||||
ok("Set-Cookie: codeman_session=first; Max-Age=86400; Path=/\r\n", body),
|
||||
ok("Set-Cookie: codeman_session=first; Max-Age=86400; Path=/\r\n", body),
|
||||
ok("Set-Cookie: codeman_session=second; Max-Age=86400; Path=/\r\n", EMPTY),
|
||||
ok("", EMPTY),
|
||||
]);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
|
||||
let mut poller = Poller::new(&server, Some("Basic abc".into()), false).unwrap();
|
||||
assert_eq!(poller.poll(), plain(true));
|
||||
assert_eq!(poller.poll(), plain(true));
|
||||
assert_eq!(poller.poll(), plain(false));
|
||||
assert_eq!(poller.poll(), plain(false));
|
||||
|
||||
let seen = seen.lock().unwrap();
|
||||
let cookie = |n: usize| seen[n].iter().find(|h| h.starts_with("cookie:")).cloned();
|
||||
assert_eq!(cookie(0), None);
|
||||
assert_eq!(cookie(1).as_deref(), Some("cookie: codeman_session=first"));
|
||||
assert_eq!(cookie(2).as_deref(), Some("cookie: codeman_session=first"));
|
||||
// A server restart hands out a new one; a response without one keeps it.
|
||||
assert_eq!(cookie(3).as_deref(), Some("cookie: codeman_session=second"));
|
||||
assert!(seen.iter().all(|h| h.contains(&"authorization: basic abc".to_string())));
|
||||
assert!(seen[0][0].starts_with("get /api/sessions "), "{:?}", seen[0][0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asks_under_the_servers_base_path() {
|
||||
let (port, seen) = serve(vec![ok("", EMPTY)]);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/codeman/")).unwrap();
|
||||
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), plain(false));
|
||||
let seen = seen.lock().unwrap();
|
||||
assert!(seen[0][0].starts_with("get /codeman/api/sessions "), "{:?}", seen[0][0]);
|
||||
assert!(!seen[0].iter().any(|h| h.starts_with("authorization:")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_answers_nothing_is_only_a_failed_poll() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
|
||||
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), Poll::Failed);
|
||||
}
|
||||
}
|
||||
@@ -184,17 +184,21 @@ pub const HOST_SHIM: &str = r#"(function () {
|
||||
}
|
||||
})();"#;
|
||||
|
||||
/// A toast inside Codeman when a download finished (Codeman's own
|
||||
/// `showToast`; nothing happens on a page without it).
|
||||
/// A toast inside Codeman (its own `showToast`; nothing happens on a page
|
||||
/// without it). `kind` is one of Codeman's fixed toast kinds.
|
||||
pub fn toast_script(text: &str, kind: &str) -> String {
|
||||
let text = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into());
|
||||
format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, \"{kind}\"); }} catch (e) {{}}")
|
||||
}
|
||||
|
||||
/// A toast inside Codeman when a download finished.
|
||||
pub fn download_toast_script(file_name: &str, ok: bool) -> String {
|
||||
let text = if ok {
|
||||
format!("Downloaded {file_name} to Downloads")
|
||||
} else {
|
||||
format!("Download of {file_name} failed")
|
||||
};
|
||||
let text = serde_json::to_string(&text).unwrap_or_else(|_| "\"Download finished\"".into());
|
||||
let kind = if ok { "\"success\"" } else { "\"error\"" };
|
||||
format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, {kind}); }} catch (e) {{}}")
|
||||
toast_script(&text, if ok { "success" } else { "error" })
|
||||
}
|
||||
|
||||
/// Runs in the window that asked for a window the native side did not open
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords
|
||||
//! never go here; they live in the Keychain (see `credentials`).
|
||||
|
||||
use crate::logo::BlinkStyles;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
@@ -43,6 +44,11 @@ pub struct Config {
|
||||
pub auto_connect: bool,
|
||||
/// Always open in full screen (otherwise the last state is restored).
|
||||
pub start_fullscreen: bool,
|
||||
/// What the lit Apple logo does for each state of the agents (logo.rs). The
|
||||
/// menu only shows on a Mac that has one. A value this version does not know
|
||||
/// reads as off: a bad style must not make the whole file unreadable.
|
||||
#[serde(deserialize_with = "lenient_blink")]
|
||||
pub logo_blink: BlinkStyles,
|
||||
/// Anything that is not a whole frame reads as None: a bad frame must not
|
||||
/// make the whole file unreadable, which would cost the server list.
|
||||
#[serde(deserialize_with = "lenient_window")]
|
||||
@@ -54,12 +60,17 @@ fn lenient_window<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Windo
|
||||
Ok(serde_json::from_value(value).ok())
|
||||
}
|
||||
|
||||
fn lenient_blink<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BlinkStyles, D::Error> {
|
||||
Ok(BlinkStyles::read(&serde_json::Value::deserialize(d)?))
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
servers: Vec::new(),
|
||||
auto_connect: true,
|
||||
start_fullscreen: false,
|
||||
logo_blink: BlinkStyles::default(),
|
||||
window: None,
|
||||
}
|
||||
}
|
||||
@@ -399,6 +410,32 @@ mod tests {
|
||||
assert!(can_save);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logo_blink_styles_round_trip_and_a_bad_one_reads_as_off() {
|
||||
use crate::logo::BlinkStyle;
|
||||
let mut c = Config::default();
|
||||
assert_eq!(c.logo_blink, BlinkStyles::default());
|
||||
c.logo_blink.needs_you = BlinkStyle::Alarm;
|
||||
let text = serde_json::to_string(&c).unwrap();
|
||||
assert!(text.contains(r#""needsYou":"alarm""#), "{text}");
|
||||
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().logo_blink, c.logo_blink);
|
||||
for bad in [
|
||||
r#"{"logoBlink":3}"#,
|
||||
r#"{"logoBlink":null}"#,
|
||||
r#"{"logoBlink":"strobe"}"#,
|
||||
] {
|
||||
let c: Config = serde_json::from_str(bad).unwrap();
|
||||
assert_eq!(c.logo_blink, BlinkStyles::default(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_style_a_mac_saved_before_there_were_states_is_kept() {
|
||||
use crate::logo::BlinkStyle;
|
||||
let c: Config = serde_json::from_str(r#"{"logoBlink":"heartbeat"}"#).unwrap();
|
||||
assert_eq!(c.logo_blink.working, BlinkStyle::Heartbeat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_and_atomic_save() {
|
||||
let dir = tmp_dir("roundtrip");
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,8 +6,9 @@
|
||||
//! installs Codeman on this machine (`setup`, `local_server`) and installs it
|
||||
//! on another one over SSH, real full screen, native menus, pop-outs as app
|
||||
//! windows (Codeman's `window.CodemanHost` bridge), downloads to ~/Downloads,
|
||||
//! passwords in the Keychain, and on macOS the JavaScript dialogs and HTTP
|
||||
//! Basic challenges wry leaves out (`macos`).
|
||||
//! passwords in the Keychain, on macOS the JavaScript dialogs and HTTP Basic
|
||||
//! challenges wry leaves out (`macos`), and on a MacBook Pro with a lit Apple
|
||||
//! logo a blink that says what the agents are doing (`logo`).
|
||||
//!
|
||||
//! Every URL and message decision is in `policy` (pure, unit-tested); this file
|
||||
//! only wires those decisions to windows. The main window moves through
|
||||
@@ -19,10 +20,12 @@
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod agents;
|
||||
mod assets;
|
||||
mod config;
|
||||
mod credentials;
|
||||
mod local_server;
|
||||
mod logo;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
mod policy;
|
||||
@@ -51,6 +54,7 @@ use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, WebView, WebView
|
||||
|
||||
use config::{Config, Rect, WindowFrame};
|
||||
use credentials::Keychain;
|
||||
use logo::{BlinkStyle, Signal};
|
||||
use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Recovery, Via};
|
||||
use probe::ProbeOutcome;
|
||||
|
||||
@@ -96,6 +100,7 @@ const AUTH_RATE_LIMITED: &str = "Too many failed logins from this address. The s
|
||||
enter it again (a single-user Codeman accepts the right password at once), or wait up to 15 minutes.";
|
||||
const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again.";
|
||||
const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it was closed. Connect again.";
|
||||
const LOGO_SWITCHED_OFF: &str = "The Apple logo is switched off on this Mac, so it cannot blink.";
|
||||
const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address.";
|
||||
|
||||
mod menu_id {
|
||||
@@ -234,6 +239,9 @@ struct AppMenu {
|
||||
window_menu: Submenu,
|
||||
help_menu: Submenu,
|
||||
start_fullscreen: CheckMenuItem,
|
||||
/// One item per state and style. Empty on a Mac without a lit logo, which has no
|
||||
/// such menu.
|
||||
logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>,
|
||||
}
|
||||
|
||||
enum Load<'a> {
|
||||
@@ -289,9 +297,16 @@ struct App {
|
||||
/// While the main window may still have to be put into the full screen it
|
||||
/// asked for at launch: when to look next, and how many retries were made.
|
||||
fullscreen_retry: Option<(Instant, u8)>,
|
||||
/// The child that watches the connected server and blinks the logo.
|
||||
logo_watch: logo::Watcher,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// The blinker is this binary without a window (logo.rs).
|
||||
if std::env::args().nth(1).as_deref() == Some(logo::BLINK_ARG) {
|
||||
logo::blink_main();
|
||||
}
|
||||
|
||||
let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
|
||||
let proxy = event_loop.create_proxy();
|
||||
|
||||
@@ -320,6 +335,7 @@ fn main() {
|
||||
};
|
||||
let config_path = config_path.filter(|_| can_save);
|
||||
let notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string());
|
||||
logo::recover_at_launch();
|
||||
let menu = build_menu(&config).unwrap_or_else(|e| fatal(&format!("Could not build the menu: {e}")));
|
||||
let phase = Rc::new(RefCell::new(Phase::Connect));
|
||||
// Started once: the probe workers and the event loop share this queue,
|
||||
@@ -371,6 +387,7 @@ fn main() {
|
||||
menu,
|
||||
keychain,
|
||||
fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)),
|
||||
logo_watch: logo::Watcher::default(),
|
||||
};
|
||||
|
||||
event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow));
|
||||
@@ -459,6 +476,26 @@ fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
),
|
||||
],
|
||||
)?;
|
||||
// Only a Mac with a lit Apple logo has this menu at all.
|
||||
let mut logo_styles = Vec::new();
|
||||
if logo::available() {
|
||||
let submenu = Submenu::new("Blink Apple Logo", true);
|
||||
for signal in Signal::ALL {
|
||||
let group = Submenu::new(signal.label(), true);
|
||||
for style in BlinkStyle::ALL {
|
||||
let chosen = style == config.logo_blink.get(signal);
|
||||
let item = CheckMenuItem::with_id(style.menu_id(signal), style.label(), true, chosen, None);
|
||||
group.append(&item)?;
|
||||
if style == BlinkStyle::Off {
|
||||
group.append(&PredefinedMenuItem::separator())?;
|
||||
}
|
||||
logo_styles.push((signal, style, item));
|
||||
}
|
||||
submenu.append(&group)?;
|
||||
}
|
||||
// Right under "Always Open in Full Screen", before the separator.
|
||||
view.insert(&submenu, 4)?;
|
||||
}
|
||||
let window_menu = Submenu::with_items(
|
||||
"Window",
|
||||
true,
|
||||
@@ -485,6 +522,7 @@ fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
window_menu,
|
||||
help_menu,
|
||||
start_fullscreen,
|
||||
logo_styles,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -925,6 +963,7 @@ impl App {
|
||||
if let Some(next) = next {
|
||||
*self.phase.borrow_mut() = next;
|
||||
}
|
||||
self.sync_logo_watch();
|
||||
}
|
||||
if let Some(w) = self.windows.get_mut(&window) {
|
||||
w.committed = Url::parse(&url).ok();
|
||||
@@ -1102,7 +1141,42 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// The logo is watched while any style is chosen and a server is open.
|
||||
fn sync_logo_watch(&mut self) {
|
||||
let styles = self.config.logo_blink;
|
||||
let phase = self.phase.borrow();
|
||||
let server = phase.loaded().filter(|_| styles.any() && logo::available());
|
||||
self.logo_watch
|
||||
.sync(server.map(|url| (url.as_str(), self.auth.as_deref(), styles)));
|
||||
}
|
||||
|
||||
fn choose_logo_blink(&mut self, signal: Signal, style: BlinkStyle) {
|
||||
// A click flips the item it landed on, the chosen one included, so all
|
||||
// of that state's are set.
|
||||
for (item_signal, item_style, item) in &self.menu.logo_styles {
|
||||
if *item_signal == signal {
|
||||
item.set_checked(*item_style == style);
|
||||
}
|
||||
}
|
||||
let was_off = !self.config.logo_blink.any();
|
||||
self.config.logo_blink.set(signal, style);
|
||||
self.config_dirty = true;
|
||||
self.sync_logo_watch();
|
||||
// Only from off: with a style running, a dark logo may be our own.
|
||||
if was_off && style != BlinkStyle::Off && logo::switched_off() {
|
||||
if let Some(main) = self.windows.get(&self.main_id) {
|
||||
let _ = main
|
||||
.webview
|
||||
.evaluate_script(&assets::toast_script(LOGO_SWITCHED_OFF, "warning"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_menu(&mut self, id: &str) {
|
||||
if let Some((signal, style)) = BlinkStyle::from_menu_id(id) {
|
||||
self.choose_logo_blink(signal, style);
|
||||
return;
|
||||
}
|
||||
match id {
|
||||
menu_id::SWITCH => self.show_connect_page(None, true),
|
||||
menu_id::RELOAD => self.reload_focused(),
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MAX_BODY: u64 = 8 * 1024 * 1024;
|
||||
pub const MAX_BODY: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// The path the probe asks for, relative to the server's base URL.
|
||||
const STATUS_PATH: &str = "api/status";
|
||||
@@ -231,16 +231,13 @@ pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome {
|
||||
probe_within(server, authorization, TIMEOUT)
|
||||
}
|
||||
|
||||
/// `probe` with the time the whole exchange may take.
|
||||
fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome {
|
||||
let status_url = match server.join(STATUS_PATH) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
|
||||
};
|
||||
/// The app's one way of asking a server something: the OS's certificate trust,
|
||||
/// a bound on the whole exchange, and status codes as answers.
|
||||
pub fn agent(timeout: Duration) -> ureq::Agent {
|
||||
let tls = ureq::tls::TlsConfig::builder()
|
||||
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
|
||||
.build();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
ureq::Agent::config_builder()
|
||||
.timeout_global(Some(timeout))
|
||||
.http_status_as_error(false)
|
||||
// Never follow: a 3xx comes back as an answer and is explained.
|
||||
@@ -248,8 +245,18 @@ fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) ->
|
||||
.tls_config(tls)
|
||||
.user_agent(concat!("CodemanDesktop/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.into();
|
||||
let mut req = agent.get(status_url.as_str()).header("Accept", "application/json");
|
||||
.into()
|
||||
}
|
||||
|
||||
/// `probe` with the time the whole exchange may take.
|
||||
fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome {
|
||||
let status_url = match server.join(STATUS_PATH) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
|
||||
};
|
||||
let mut req = agent(timeout)
|
||||
.get(status_url.as_str())
|
||||
.header("Accept", "application/json");
|
||||
if let Some(auth) = authorization {
|
||||
req = req.header("Authorization", auth);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user