Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/Localization/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,6 @@
"Couldn't sign in: %@" = "登录失败:%@";
"Couldn't sign out: %@" = "退出登录失败:%@";
"Couldn't delete the account: %@" = "删除账户失败:%@";
"Voice today: %lld of %lld" = "今天的语音:已用 %lld / %lld 条";
"Today's voice is used up. It comes back at midnight UTC." = "今天的语音已用完,UTC 零点后恢复。";
"Voice is paused for everyone today. It comes back at midnight UTC." = "今天的语音已全局暂停,UTC 零点后恢复。";
4 changes: 4 additions & 0 deletions app/Sources/SelfTest/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ do {
{"available":true,"signed_in":true,"email":null,"github_id":583231,"github_login":"octocat"}
""".utf8))
check(account == AccountStatus(available: true, signedIn: true, githubID: 583231, githubLogin: "octocat"), "account decodes")
let used = try StatusCoding.decoder().decode(AccountStatus.self, from: Data("""
{"available":true,"signed_in":true,"github_id":1,"today":{"day":"2026-10-24","commands":7,"commands_limit":100},"refused":"allowance"}
""".utf8))
check(used.today == AccountUsage(day: "2026-10-24", commands: 7, commandsLimit: 100) && used.refused == "allowance", "today's voice use decodes")
} catch {
check(false, "status decoding threw: \(error)")
}
Expand Down
3 changes: 3 additions & 0 deletions app/Sources/VibeBuddy/DaemonClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ struct DaemonClient {

func signOut() async throws { try await accountCall("/v1/account/sign-out", method: "POST") }

/// Has the daemon read today's voice use from the service; it comes back in the status.
func refreshAccount() async throws { try await accountCall("/v1/account/refresh", method: "POST") }

/// Deletes the account on the service; when the service doesn't, the daemon stays signed in.
func deleteAccount() async throws { try await accountCall("/v1/account", method: "DELETE") }

Expand Down
16 changes: 16 additions & 0 deletions app/Sources/VibeBuddy/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,11 @@ struct AccountView: View {
} else if let account, account.available {
if account.signedIn {
Text(signedInText)
if let refusal {
Text(refusal).foregroundStyle(.orange)
} else if let today = account.today {
Text("Voice today: \(today.commands) of \(today.commandsLimit)").foregroundStyle(.secondary)
}
HStack {
Button("Sign out") { signOut() }
Button("Delete account…") { confirmDelete() }
Expand All @@ -839,6 +844,17 @@ struct AccountView: View {
}
.formStyle(.grouped)
.onChange(of: account?.signedIn) { waiting = false }
// Reading the day's use costs a request to the service, so only when the tab is shown.
.task { if account?.signedIn == true { try? await model.client.refreshAccount() } }
}

/// Why voice is refused today, if it is.
private var refusal: String? {
switch account?.refused {
case nil: nil
case "global_cap": String(localized: "Voice is paused for everyone today. It comes back at midnight UTC.")
default: String(localized: "Today's voice is used up. It comes back at midnight UTC.")
}
}

private var signedInText: String {
Expand Down
19 changes: 17 additions & 2 deletions app/Sources/VibeBuddyCore/Status.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,24 @@ public struct AccountStatus: Codable, Equatable {
public var githubID: Int?
/// Looked up on GitHub when signing in; nil if that lookup failed.
public var githubLogin: String?
enum CodingKeys: String, CodingKey { case available, signedIn = "signed_in", email, githubID = "github_id", githubLogin = "github_login" }
public init(available: Bool, signedIn: Bool, email: String? = nil, githubID: Int? = nil, githubLogin: String? = nil) {
/// Voice used in the current UTC day, when the daemon knows; absent from daemons before it.
public var today: AccountUsage?
/// Why the service refused voice today: `allowance` or `global_cap`.
public var refused: String?
enum CodingKeys: String, CodingKey { case available, signedIn = "signed_in", email, githubID = "github_id", githubLogin = "github_login", today, refused }
public init(available: Bool, signedIn: Bool, email: String? = nil, githubID: Int? = nil, githubLogin: String? = nil, today: AccountUsage? = nil, refused: String? = nil) {
self.available = available; self.signedIn = signedIn; self.email = email; self.githubID = githubID; self.githubLogin = githubLogin
self.today = today; self.refused = refused
}
}

public struct AccountUsage: Codable, Equatable {
public var day: String
public var commands: Int
public var commandsLimit: Int
enum CodingKeys: String, CodingKey { case day, commands, commandsLimit = "commands_limit" }
public init(day: String, commands: Int, commandsLimit: Int) {
self.day = day; self.commands = commands; self.commandsLimit = commandsLimit
}
}

Expand Down
114 changes: 113 additions & 1 deletion daemon/src/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,26 @@ pub struct AccountStatus {
pub github_id: Option<u64>,
/// Looked up on GitHub when signing in, for the App to show; the service keeps only the id.
pub github_login: Option<String>,
/// Voice used in the current UTC day, when known: read from the service at sign-in and when the App asks, and
/// counted here as commands are recognized.
pub today: Option<Usage>,
/// Why the service last refused voice today (`allowance` or `global_cap`), until the UTC day ends.
pub refused: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Usage {
/// The UTC day, `2026-10-24`.
pub day: String,
pub commands: u64,
pub commands_limit: u64,
}

/// What the daemon knows of today's voice; cleared at the UTC day's end by [`Account::status`].
#[derive(Default)]
struct Today {
usage: Option<Usage>,
refused: Option<(String, String)>,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
Expand Down Expand Up @@ -78,6 +98,9 @@ pub struct Account {
github_api: String,
saved: Mutex<Option<Saved>>,
pending: Mutex<Option<Pending>>,
today: Mutex<Today>,
/// Bumped whenever [`AccountStatus::today`] or [`AccountStatus::refused`] changes, for the status stream.
usage_changed: watch::Sender<u64>,
/// Whether signed in, for whoever must follow it: the status stream and the box's offer of voice.
signed_in: watch::Sender<bool>,
}
Expand All @@ -88,17 +111,25 @@ impl Account {
let file = config_dir.map(|dir| dir.join(ACCOUNT_FILE));
let saved = file.as_deref().and_then(load);
let (signed_in, _) = watch::channel(saved.is_some());
let (usage_changed, _) = watch::channel(0);
Self {
base,
file,
redirect_uri: format!("http://127.0.0.1:{port}/v1/account/callback"),
github_api: "https://api.github.com".to_owned(),
saved: Mutex::new(saved),
pending: Mutex::new(None),
today: Mutex::default(),
usage_changed,
signed_in,
}
}

/// Changes whenever today's usage or a refusal changes.
pub fn watch_usage(&self) -> watch::Receiver<u64> {
self.usage_changed.subscribe()
}

/// Changes each time the account is signed in or out, from here or by the service refusing the session.
pub fn watch(&self) -> watch::Receiver<bool> {
self.signed_in.subscribe()
Expand All @@ -112,15 +143,68 @@ impl Account {

pub fn status(&self) -> AccountStatus {
let saved = self.saved.lock().unwrap().clone();
let day = utc_day();
let today = self.today.lock().unwrap();
let signed_in = saved.is_some();
AccountStatus {
available: self.base.is_some() && self.file.is_some(),
signed_in: saved.is_some(),
signed_in,
email: saved.as_ref().and_then(|s| s.email.clone()),
github_id: saved.as_ref().and_then(|s| s.github_id),
github_login: saved.and_then(|s| s.github_login),
// A new UTC day starts over: nothing used, nothing refused.
today: today.usage.clone().filter(|_| signed_in).map(|usage| if usage.day == day { usage } else { Usage { day: day.clone(), commands: 0, ..usage } }),
refused: today.refused.as_ref().filter(|(on, _)| signed_in && *on == day).map(|(_, reason)| reason.clone()),
}
}

/// A command was recognized by the voice service, which counted it.
pub fn note_command(&self) {
let day = utc_day();
let mut today = self.today.lock().unwrap();
if let Some(usage) = &mut today.usage {
if usage.day != day {
*usage = Usage { day, commands: 0, ..usage.clone() };
}
usage.commands += 1;
}
drop(today);
self.usage_changed.send_modify(|n| *n += 1);
}

/// The service refused voice for `reason` (`allowance` or `global_cap`).
pub fn note_refusal(&self, reason: &str) {
self.today.lock().unwrap().refused = Some((utc_day(), reason.to_owned()));
self.usage_changed.send_modify(|n| *n += 1);
}

/// Reads today's usage from the service. Blocking.
pub fn refresh_usage(&self) -> Result<(), AccountError> {
let token = self.token().ok_or(AccountError::NotSignedIn)?;
let base = self.base.clone().ok_or(AccountError::Unavailable)?;
let (status, text) = read(crate::speech::agent(TIMEOUT).get(&format!("{base}/v1/account")).header("Authorization", &format!("Bearer {token}")).call())?;
if status == 401 {
self.forget();
return Err(AccountError::NotSignedIn);
}
if status != 200 {
return Err(AccountError::Service(format!("the account couldn't be read: HTTP {status}")));
}
let account: serde_json::Value = serde_json::from_str(&text).map_err(|error| AccountError::Service(error.to_string()))?;
self.take_usage(&account);
Ok(())
}

/// Today's usage from a `GET /v1/account` answer (docs/api.md).
fn take_usage(&self, account: &serde_json::Value) {
let usage = match (account["today"]["day"].as_str(), account["today"]["commands"].as_u64(), account["limits"]["commands"].as_u64()) {
(Some(day), Some(commands), Some(limit)) => Some(Usage { day: day.to_owned(), commands, commands_limit: limit }),
_ => None,
};
self.today.lock().unwrap().usage = usage;
self.usage_changed.send_modify(|n| *n += 1);
}

/// The session for calls to the service, when signed in.
pub fn token(&self) -> Option<String> {
self.saved.lock().unwrap().as_ref().map(|saved| saved.token.clone())
Expand Down Expand Up @@ -176,6 +260,7 @@ impl Account {
return Err(AccountError::Service(format!("the new session didn't work: HTTP {status}")));
}
let account: serde_json::Value = serde_json::from_str(&text).map_err(|error| AccountError::Service(error.to_string()))?;
self.take_usage(&account);
let github_id = account["github_id"].as_u64();
let saved = Saved {
token,
Expand Down Expand Up @@ -258,6 +343,10 @@ pub fn signed_in_for_tests(dir: &Path, token: &str) -> Account {
Account::new(Some("http://127.0.0.1:9".to_owned()), Some(dir), 7331)
}

fn utc_day() -> String {
chrono::Utc::now().format("%Y-%m-%d").to_string()
}

fn load(path: &Path) -> Option<Saved> {
let text = std::fs::read_to_string(path).ok()?;
match serde_json::from_str(&text) {
Expand Down Expand Up @@ -457,6 +546,29 @@ mod tests {
assert!(seen.lock().unwrap()[1].starts_with("DELETE /v1/account"));
}

#[test]
fn keeps_todays_usage_counts_commands_and_remembers_a_refusal() {
let dir = scratch();
let day = utc_day();
let account_answer = format!(r#"{{"id":"a","github_id":1,"today":{{"day":"{day}","commands":7,"replies":9}},"limits":{{"commands":100,"replies":300}}}}"#);
let (service, _) = serve(vec![(200, Box::leak(account_answer.into_boxed_str()))]);
let account = signed_in(&service, dir.as_path());
let changes = account.watch_usage();
assert_eq!(account.status().today, None);
account.refresh_usage().unwrap();
assert_eq!(account.status().today, Some(Usage { day: day.clone(), commands: 7, commands_limit: 100 }));
account.note_command();
assert_eq!(account.status().today.unwrap().commands, 8);
account.note_refusal("allowance");
assert_eq!(account.status().refused.as_deref(), Some("allowance"));
assert!(changes.has_changed().unwrap());
// A refusal from another day is forgotten; signed out, nothing is shown.
account.today.lock().unwrap().refused = Some(("2001-01-01".to_owned(), "global_cap".to_owned()));
assert_eq!(account.status().refused, None);
account.forget();
assert_eq!((account.status().today, account.status().refused), (None, None));
}

#[test]
fn is_unavailable_without_a_service() {
let dir = scratch();
Expand Down
18 changes: 18 additions & 0 deletions daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,14 @@ fn follow_account(state: &AppState) {
offer_voice_again(&follower).await;
}
});
// Today's usage and refusals are only shown.
let mut usage = state.account.watch_usage();
let follower = state.clone();
tokio::spawn(async move {
while usage.changed().await.is_ok() {
follower.notify_status();
}
});
}

/// Checks the resident session's readiness every [`resident::READINESS_TTL`] and tells the box each time it flips.
Expand Down Expand Up @@ -663,6 +671,7 @@ fn app(state: AppState) -> Router {
.route("/v1/account/sign-in", post(post_sign_in))
.route("/v1/account/callback", get(get_account_callback))
.route("/v1/account/sign-out", post(post_sign_out))
.route("/v1/account/refresh", post(post_account_refresh))
.with_state(state)
}

Expand Down Expand Up @@ -1712,6 +1721,15 @@ async fn post_sign_out(State(state): State<AppState>) -> (StatusCode, Json<ApiRe
}
}

/// Reads today's voice use from the service, for the App's Account tab; the status stream carries it.
async fn post_account_refresh(State(state): State<AppState>) -> (StatusCode, Json<ApiResponse>) {
let account = state.account.clone();
match tokio::task::spawn_blocking(move || account.refresh_usage()).await.unwrap_or_else(|error| Err(account::AccountError::Service(error.to_string()))) {
Ok(()) => (StatusCode::OK, Json(ApiResponse { accepted: true, message: "refreshed".to_owned() })),
Err(error) => account_refusal(&error),
}
}

async fn delete_account(State(state): State<AppState>) -> (StatusCode, Json<ApiResponse>) {
let account = state.account.clone();
let result = tokio::task::spawn_blocking(move || account.delete()).await.unwrap_or_else(|error| Err(account::AccountError::Service(error.to_string())));
Expand Down
20 changes: 15 additions & 5 deletions daemon/src/speech.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,11 @@ impl Recognizer for VoiceService {
if let Some(refusal) = refusal(status, &body, self.session.as_deref()) {
return Err(refusal);
}
Ok(Self::parse(status, &body)?)
let transcript = Self::parse(status, &body)?;
if let Some(session) = &self.session {
session.note_command();
}
Ok(transcript)
}

fn ready(&self) -> bool {
Expand All @@ -197,10 +201,16 @@ pub fn refusal(status: u16, body: &str, session: Option<&Account>) -> Option<Rec
}
Some(signed_out())
}
429 => Some(match serde_json::from_str::<Value>(body).ok().and_then(|value| value.get("reason")?.as_str().map(str::to_owned)).as_deref() {
Some("global_cap") => RecognitionError { reason: "global_cap", message: "Voice is paused for today".to_owned() },
_ => RecognitionError { reason: "allowance", message: "Today's voice is used up".to_owned() },
}),
429 => {
let refusal = match serde_json::from_str::<Value>(body).ok().and_then(|value| value.get("reason")?.as_str().map(str::to_owned)).as_deref() {
Some("global_cap") => RecognitionError { reason: "global_cap", message: "Voice is paused for today".to_owned() },
_ => RecognitionError { reason: "allowance", message: "Today's voice is used up".to_owned() },
};
if let Some(session) = session {
session.note_refusal(refusal.reason);
}
Some(refusal)
}
_ => None,
}
}
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,11 @@ pub async fn sign_in() -> Result<String, String> {
}

/// Ends this computer's session only.
/// Has the daemon read today's voice use from the service; it comes back in the status.
pub async fn refresh_account() -> Result<(), String> {
post("/v1/account/refresh", None).await
}

pub async fn sign_out() -> Result<(), String> {
post("/v1/account/sign-out", None).await
}
Expand Down
12 changes: 11 additions & 1 deletion desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,10 @@ impl App {
self.conversation_seen = None;
return Task::done(Message::PollConversation);
}
// Reading the day's use costs a request to the service, so only when the tab opens; the status brings it.
if tab == Tab::Account && self.status.as_ref().is_some_and(|status| status.account.signed_in) {
return Task::perform(client::refresh_account(), |_| Message::AccountDone(Ok(())));
}
}
Message::Resident(Ok(enabled)) => {
self.resident = enabled;
Expand Down Expand Up @@ -1942,7 +1946,13 @@ impl App {
.spacing(8)
.into()
};
column![text(who), actions].spacing(12).into()
let today = match (account.refused.as_deref(), &account.today) {
(Some("global_cap"), _) => Some(text(tr("Voice is paused for everyone today. It comes back at midnight UTC.", &[])).size(13).style(text::warning)),
(Some(_), _) => Some(text(tr("Today's voice is used up. It comes back at midnight UTC.", &[])).size(13).style(text::warning)),
(None, Some(usage)) => Some(text(tr("Voice today: %lld of %lld", &[&usage.commands, &usage.commands_limit])).size(13).style(text::secondary)),
(None, None) => None,
};
column![text(who)].push(today).push(actions).spacing(12).into()
}

fn advanced(&self) -> Element<'_, Message> {
Expand Down
Loading
Loading