Skip to content
Draft
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
36 changes: 34 additions & 2 deletions apps/zgx/src/cloud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@
//! balance, available }
//! cloud.repos the repositories Cloud can open, newest push
//! first: { { name, private, branch, clone_url,
//! pushed } }; `github_not_connected` without
//! a GitHub connection for Cloud
//! pushed, account?, accounts?, installation? } }
//! (`account`: the GitHub login a new chat would
//! use; `installation`: the GitHub App's, which
//! gives the access; all absent from an older
//! vault); `github_not_connected` without a
//! GitHub connection for Cloud
//! cloud.branches({ repo, default? })
//! the branches a Cloud chat of `repo` can start
//! from, the default first, as `chat.refs`
Expand Down Expand Up @@ -203,13 +207,24 @@ fn repos_value(listed: &Json) -> Value {
("clone_url", str_or_nil(&r["cloneUrl"])),
// (Seconds, as the scripts' times are.)
("pushed", Value::from(r["pushedAt"].as_f64().unwrap_or(0.0) / 1000.0)),
("account", str_or_nil(&r["account"])),
("accounts", accounts_value(&r["accounts"])),
("installation", num(&r["installationId"])),
])
})
.collect::<Vec<_>>()
.into(),
)
}

/// The logins that reach a repository; nil from an older vault.
fn accounts_value(accounts: &Json) -> Value {
let Some(list) = accounts.as_array() else {
return Value::Nil;
};
Value::List(list.iter().filter_map(|a| a.as_str().map(Value::from)).collect::<Vec<_>>().into())
}

/// A Cloud repository's branches: the default first (as `current`, the
/// one a chat starts from unless another is chosen), then as GitHub lists
/// them.
Expand Down Expand Up @@ -324,4 +339,21 @@ mod tests {
assert_eq!(out[0]["branch"], "main");
assert_eq!(out[0]["pushed"], 5.0);
}

#[test]
fn repos_name_who_reaches_them_when_the_vault_says() {
let listed = json!({ "repos": [
{ "fullName": "a/both", "pushedAt": 2000, "account": "work-me",
"accounts": ["work-me", "home-me"], "installationId": 100 },
// As a vault from before several logins answers.
{ "fullName": "a/old", "pushedAt": 1000 },
] });
let out: Json = serde_json::from_str(&repos_value(&listed).to_json()).unwrap();
assert_eq!(out[0]["account"], "work-me");
assert_eq!(out[0]["accounts"], json!(["work-me", "home-me"]));
assert_eq!(out[0]["installation"], 100.0);
assert!(out[1]["account"].is_null());
assert!(out[1]["accounts"].is_null());
assert!(out[1]["installation"].is_null());
}
}
178 changes: 166 additions & 12 deletions apps/zgx/src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@
//! What scripts see, `github`:
//! { phase = "checking" | "unavailable" | "idle" | "starting" | "waiting"
//! | "failed" | "connected",
//! code?, account?, repos = { "owner/name" }, failure? }
//! code?, account?, repos = { "owner/name" }, failure?,
//! accounts = { { slot, login, avatar?, status = "connected"
//! | "needs_reconnect" } },
//! already? }
//! (`failure` is what went wrong, said for the screen: a code that expired,
//! turned down, or GitHub out of reach.)
//! turned down, or GitHub out of reach. `accounts`: every login connected for
//! Cloud, empty from an engine or vault from before several logins, which
//! leaves `account` to name the one there is. `already`: the login just
//! approved was connected before, so nothing was added.)
//! and call:
//! github.load (an action) where the account stands
//! github.connect (an action) asks GitHub for a code, then waits for it
Expand All @@ -22,6 +28,7 @@
//! github.choose (an action) GitHub's page for the repositories Cloud
//! can open
//! github.disconnect (an action) GitHub no longer used by Cloud
//! github.forget ({ slot }) one login disconnected, the others kept

use std::{
cell::{Cell, RefCell},
Expand All @@ -46,12 +53,60 @@ enum Phase {
Connected,
}

/// A GitHub login connected for Cloud.
#[derive(Clone, Debug, PartialEq)]
struct Account {
slot: String,
login: String,
avatar: Option<String>,
connected: bool,
devices: Vec<String>,
}

/// The logins from `GithubAccounts`.
fn accounts_from(answer: &Json) -> Vec<Account> {
answer["accounts"]
.as_array()
.into_iter()
.flatten()
.filter_map(|a| {
Some(Account {
slot: a["slot"].as_str()?.into(),
login: a["account"].as_str().unwrap_or_default().into(),
avatar: a["avatarUrl"].as_str().filter(|u| u.starts_with("https://")).map(Into::into),
connected: a["status"].as_str() != Some("needsReconnect"),
devices: a["authorizedDevices"]
.as_array()
.map(|d| d.iter().filter_map(|d| d.as_str().map(Into::into)).collect())
.unwrap_or_default(),
})
})
.collect()
}

/// Whether GitHub works for Cloud: some login does. Without a list (an engine
/// or vault from before several logins), the one connection the status names.
fn usable(accounts: &[Account], connection: Option<&Json>, cloud: &str) -> bool {
if accounts.is_empty() {
return connection.is_some_and(|c| {
c["status"].as_str() != Some("needsReconnect")
&& c["authorizedDevices"]
.as_array()
.is_some_and(|d| d.iter().any(|d| d.as_str() == Some(cloud)))
});
}
accounts.iter().any(|a| a.connected && a.devices.iter().any(|d| d == cloud))
}

struct GitHub {
shared: RefCell<Vec<Shared>>,
phase: Cell<Phase>,
code: RefCell<Option<String>>,
verification: RefCell<Option<String>>,
account: RefCell<Option<String>>,
accounts: RefCell<Vec<Account>>,
/// The login just approved, when it was connected already.
already: RefCell<Option<String>>,
repos: RefCell<Vec<String>>,
failure: RefCell<Option<String>>,
/// The logical Cloud device, once Cloud is on.
Expand Down Expand Up @@ -84,7 +139,8 @@ pub fn install(_cx: &mut WindowContext, host: &ScriptHost) {
host.action("github.cancel", move |_| {
if g.phase.get() == Phase::Waiting || g.phase.get() == Phase::Starting {
g.next_generation();
g.set(Phase::Idle);
let usable = usable(&g.accounts.borrow(), None, &g.cloud.borrow().clone().unwrap_or_default());
g.set(if usable { Phase::Connected } else { Phase::Idle });
}
});
let g = github.clone();
Expand All @@ -100,10 +156,26 @@ pub fn install(_cx: &mut WindowContext, host: &ScriptHost) {
open_page(url);
}
});
let g = github;
let g = github.clone();
host.action("github.disconnect", move |_| {
start_disconnect(g.clone());
});
let g = github;
host.action("github.forget", move |args| {
if let Some(slot) = args.field("slot").map(crate::auth::text).filter(|s| !s.is_empty()) {
start_forget(g.clone(), slot);
}
});
}

/// One login disconnected, unless an unkept preview asks, or a connection is
/// under way (it holds the state of this): whether it started.
fn start_forget(g: Rc<GitHub>, slot: String) -> bool {
if matches!(g.phase.get(), Phase::Starting | Phase::Waiting) || crate::plugins::preview::refuse_aloud("github.forget") {
return false;
}
engine::get().spawn(async move { g.forget(slot).await });
true
}

/// GitHub disconnected, unless an unkept preview asks: whether it started.
Expand Down Expand Up @@ -139,6 +211,8 @@ impl GitHub {
code: RefCell::default(),
verification: RefCell::default(),
account: RefCell::default(),
accounts: RefCell::default(),
already: RefCell::default(),
repos: RefCell::default(),
failure: RefCell::default(),
cloud: RefCell::default(),
Expand All @@ -159,10 +233,25 @@ impl GitHub {
};
let text = |v: &RefCell<Option<String>>| v.borrow().as_deref().map_or(Value::Nil, Value::from);
let repos: Vec<Value> = self.repos.borrow().iter().map(|r| Value::from(r.as_str())).collect();
let accounts: Vec<Value> = self
.accounts
.borrow()
.iter()
.map(|a| {
Value::record([
("slot", Value::from(a.slot.as_str())),
("login", Value::from(a.login.as_str())),
("avatar", a.avatar.as_deref().map_or(Value::Nil, Value::from)),
("status", Value::from(if a.connected { "connected" } else { "needs_reconnect" })),
])
})
.collect();
Value::record([
("phase", Value::from(phase)),
("code", text(&self.code)),
("account", text(&self.account)),
("accounts", Value::List(accounts.into())),
("already", text(&self.already)),
("repos", Value::List(repos.into())),
("failure", text(&self.failure)),
])
Expand Down Expand Up @@ -241,8 +330,8 @@ impl GitHub {
self.set(Phase::Unavailable);
}

/// The vault's GitHub: connected for Cloud (its account and
/// repositories), or not.
/// The vault's GitHub: connected for Cloud (its logins and repositories),
/// or not.
async fn refresh_vault(&self, generation: u64) {
let vault = match engine::get().call("VaultStatus", json!({})).await {
Ok(vault) => vault,
Expand All @@ -263,12 +352,20 @@ impl GitHub {
.and_then(|c| c.iter().find(|c| c["provider"].as_str() == Some("github")))
.cloned();
*self.vault.borrow_mut() = vault;
let connected = connection.as_ref().is_some_and(|c| {
c["status"].as_str() != Some("needsReconnect")
&& c["authorizedDevices"]
.as_array()
.is_some_and(|d| d.iter().any(|d| d.as_str() == Some(cloud.as_str())))
});
// (An engine or vault from before several logins doesn't answer: the
// connection in the status stands for its one.)
let accounts = match engine::get().call("GithubAccounts", json!({})).await {
Ok(answer) => accounts_from(&answer),
Err(error) => {
eprintln!("zeron-zgx: github: no list of logins: {error}");
Vec::new()
}
};
if self.generation.get() != generation {
return;
}
let connected = usable(&accounts, connection.as_ref(), &cloud);
*self.accounts.borrow_mut() = accounts;
if !connected {
*self.account.borrow_mut() = None;
self.repos.borrow_mut().clear();
Expand Down Expand Up @@ -306,6 +403,7 @@ impl GitHub {
};
*self.failure.borrow_mut() = None;
*self.code.borrow_mut() = None;
*self.already.borrow_mut() = None;
self.set(Phase::Starting);
// Every device already allowed keeps its access; Cloud is added.
let mut devices: Vec<String> = self.vault.borrow()["connections"]
Expand Down Expand Up @@ -364,6 +462,9 @@ impl GitHub {
*self.code.borrow_mut() = None;
if let Some(account) = progress["account"].as_str() {
*self.account.borrow_mut() = Some(account.into());
if progress["alreadyConnected"].as_bool() == Some(true) {
*self.already.borrow_mut() = Some(account.into());
}
}
self.set(Phase::Connected);
self.refresh_vault(generation).await;
Expand Down Expand Up @@ -396,6 +497,22 @@ impl GitHub {
}
}

/// One login disconnected; the others carry on. Its chats stay on it and
/// say so until it's connected again.
async fn forget(self: Rc<Self>, slot: String) {
let generation = self.next_generation();
*self.already.borrow_mut() = None;
match engine::get().call("GithubAccountForget", json!({ "slot": slot })).await {
Ok(_) => self.refresh_vault(generation).await,
Err(error) => {
eprintln!("zeron-zgx: github: didn't disconnect a login: {error}");
if self.generation.get() == generation {
self.publish();
}
}
}
}

/// GitHub no longer used by Cloud: forgotten, unless other devices still
/// use it, which keep it.
async fn disconnect(self: Rc<Self>) {
Expand Down Expand Up @@ -444,4 +561,41 @@ mod disconnect_tests {
preview::set(None);
assert!(start_disconnect(g));
}

#[test]
fn logins_are_read_from_the_engine() {
let accounts = accounts_from(&json!({ "accounts": [
{ "slot": "a1", "account": "work-me", "status": "connected", "authorizedDevices": ["cloud-1"],
"avatarUrl": "https://github.com/work-me.png?size=64" },
{ "slot": "a2", "account": "home-me", "status": "needsReconnect", "avatarUrl": "http://not-this" },
{ "account": "no-slot" },
] }));
assert_eq!(accounts.len(), 2);
assert_eq!(accounts[0].login, "work-me");
assert_eq!(accounts[0].avatar.as_deref(), Some("https://github.com/work-me.png?size=64"));
assert!(accounts[0].connected);
assert_eq!(accounts[1].avatar, None);
assert!(!accounts[1].connected);
}

#[test]
fn github_works_while_any_login_does() {
let account = |connected: bool, device: &str| Account {
slot: "s".into(),
login: "l".into(),
avatar: None,
connected,
devices: vec![device.into()],
};
assert!(usable(&[account(false, "cloud"), account(true, "cloud")], None, "cloud"));
assert!(!usable(&[account(false, "cloud")], None, "cloud"));
assert!(!usable(&[account(true, "laptop")], None, "cloud"));
// Without a list, the one connection the status names.
let one = json!({ "status": "connected", "authorizedDevices": ["cloud"] });
assert!(usable(&[], Some(&one), "cloud"));
assert!(!usable(&[], Some(&one), "other"));
let dead = json!({ "status": "needsReconnect", "authorizedDevices": ["cloud"] });
assert!(!usable(&[], Some(&dead), "cloud"));
assert!(!usable(&[], None, "cloud"));
}
}
16 changes: 14 additions & 2 deletions apps/zgx/ui/account/cloud.luau
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
-- cloud.branches(repo, default?) a promise of a repository's branches
--
-- A status: { state, available, device?, credits?, error?, failed_action?,
-- awake?, max_awake? }. A repository: { name, private, branch?, pushed }.
-- awake?, max_awake? }. A repository: { name, private, branch?, pushed,
-- account?, accounts?, installation? }: `account`, the GitHub login a new chat
-- on it would use; `accounts`, every one that reaches it; `installation`, the
-- GitHub App's installation that gives the access. (None from an older vault.)
-- Every function here is a field, so a fixture answers in the host's place.

local auth = require("@account/auth")
Expand All @@ -35,7 +38,16 @@ export type Status = {
awake: number?,
max_awake: number?,
}
export type Repo = { name: string, private: boolean, branch: string?, clone_url: string?, pushed: number }
export type Repo = {
name: string,
private: boolean,
branch: string?,
clone_url: string?,
pushed: number,
account: string?,
accounts: { string }?,
installation: number?,
}
export type Now = {
phase: string,
status: Status?,
Expand Down
Loading
Loading