Bug fix and UX improve #3
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -6089,6 +6089,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -7,7 +7,7 @@ use axum::Router;
|
||||
use sprimo_protocol::v1::{
|
||||
CommandEnvelope, ErrorResponse, FrontendStateSnapshot, HealthResponse,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -19,7 +19,7 @@ use uuid::Uuid;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiConfig {
|
||||
pub bind_addr: SocketAddr,
|
||||
pub auth_token: String,
|
||||
pub auth_tokens: Vec<String>,
|
||||
pub app_version: String,
|
||||
pub app_build: String,
|
||||
pub dedupe_capacity: usize,
|
||||
@@ -29,9 +29,14 @@ pub struct ApiConfig {
|
||||
impl ApiConfig {
|
||||
#[must_use]
|
||||
pub fn default_with_token(auth_token: String) -> Self {
|
||||
Self::default_with_tokens(vec![auth_token])
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn default_with_tokens(auth_tokens: Vec<String>) -> Self {
|
||||
Self {
|
||||
bind_addr: SocketAddr::from(([127, 0, 0, 1], 32_145)),
|
||||
auth_token,
|
||||
auth_tokens,
|
||||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
app_build: "dev".to_string(),
|
||||
dedupe_capacity: 5_000,
|
||||
@@ -43,7 +48,7 @@ impl ApiConfig {
|
||||
#[derive(Debug)]
|
||||
pub struct ApiState {
|
||||
start_at: Instant,
|
||||
auth_token: String,
|
||||
auth_tokens: Arc<RwLock<HashSet<String>>>,
|
||||
app_version: String,
|
||||
app_build: String,
|
||||
dedupe_capacity: usize,
|
||||
@@ -59,10 +64,14 @@ impl ApiState {
|
||||
config: ApiConfig,
|
||||
snapshot: Arc<RwLock<FrontendStateSnapshot>>,
|
||||
command_tx: mpsc::Sender<CommandEnvelope>,
|
||||
auth_tokens: Arc<RwLock<HashSet<String>>>,
|
||||
) -> Self {
|
||||
if let Ok(mut guard) = auth_tokens.write() {
|
||||
*guard = config.auth_tokens.into_iter().collect();
|
||||
}
|
||||
Self {
|
||||
start_at: Instant::now(),
|
||||
auth_token: config.auth_token,
|
||||
auth_tokens,
|
||||
app_version: config.app_version,
|
||||
app_build: config.app_build,
|
||||
dedupe_capacity: config.dedupe_capacity,
|
||||
@@ -188,12 +197,19 @@ fn require_auth(headers: &HeaderMap, state: &ApiState) -> Result<(), ApiError> {
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
let expected = format!("Bearer {}", state.auth_token);
|
||||
if raw == expected {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(token) = raw.strip_prefix("Bearer ") else {
|
||||
return Err(ApiError::Unauthorized);
|
||||
};
|
||||
let guard = state
|
||||
.auth_tokens
|
||||
.read()
|
||||
.map_err(|_| ApiError::Internal("auth token lock poisoned".to_string()))?;
|
||||
if guard.contains(token) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
enum ApiError {
|
||||
Unauthorized,
|
||||
@@ -240,6 +256,7 @@ mod tests {
|
||||
use sprimo_protocol::v1::{
|
||||
CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tokio::sync::mpsc;
|
||||
use tower::ServiceExt;
|
||||
@@ -255,6 +272,7 @@ mod tests {
|
||||
ApiConfig::default_with_token("token".to_string()),
|
||||
snapshot,
|
||||
tx,
|
||||
Arc::new(RwLock::new(HashSet::new())),
|
||||
)),
|
||||
rx,
|
||||
)
|
||||
@@ -335,6 +353,48 @@ mod tests {
|
||||
assert_eq!(received.id, command.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_accepts_with_any_configured_token() {
|
||||
let snapshot =
|
||||
FrontendStateSnapshot::idle(CapabilityFlags::default());
|
||||
let snapshot = Arc::new(RwLock::new(snapshot));
|
||||
let (tx, mut rx) = mpsc::channel(8);
|
||||
let state = Arc::new(ApiState::new(
|
||||
ApiConfig::default_with_tokens(vec![
|
||||
"token-a".to_string(),
|
||||
"token-b".to_string(),
|
||||
]),
|
||||
snapshot,
|
||||
tx,
|
||||
Arc::new(RwLock::new(HashSet::new())),
|
||||
));
|
||||
let app = app_router(state);
|
||||
let command = CommandEnvelope {
|
||||
id: Uuid::new_v4(),
|
||||
ts_ms: 1,
|
||||
command: FrontendCommand::Toast {
|
||||
text: "hi".to_string(),
|
||||
ttl_ms: None,
|
||||
},
|
||||
};
|
||||
let body = serde_json::to_vec(&command).expect("json");
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/command")
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer token-b")
|
||||
.body(Body::from(body))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||
let received = rx.recv().await.expect("forwarded command");
|
||||
assert_eq!(received.id, command.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_json_returns_bad_request() {
|
||||
let (state, _) = build_state();
|
||||
|
||||
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -106,13 +107,41 @@ impl Default for SpriteConfig {
|
||||
pub struct ApiConfig {
|
||||
pub port: u16,
|
||||
pub auth_token: String,
|
||||
pub auth_tokens: Vec<ApiTokenEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct ApiTokenEntry {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub token: String,
|
||||
pub created_at_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for ApiTokenEntry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
label: "default".to_string(),
|
||||
token: Uuid::new_v4().to_string(),
|
||||
created_at_ms: now_unix_ms(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ApiConfig {
|
||||
fn default() -> Self {
|
||||
let token = Uuid::new_v4().to_string();
|
||||
Self {
|
||||
port: 32_145,
|
||||
auth_token: Uuid::new_v4().to_string(),
|
||||
auth_token: token.clone(),
|
||||
auth_tokens: vec![ApiTokenEntry {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
label: "default".to_string(),
|
||||
token,
|
||||
created_at_ms: now_unix_ms(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,6 +235,13 @@ pub fn save(path: &Path, config: &AppConfig) -> Result<(), ConfigError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|v| v.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{load_or_create_at, save, AppConfig};
|
||||
|
||||
@@ -14,6 +14,7 @@ sprimo-protocol = { path = "../sprimo-protocol" }
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.12.0"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use sprimo_api::{ApiConfig, ApiServerError, ApiState};
|
||||
use sprimo_config::{save, AppConfig, ConfigError};
|
||||
use sprimo_config::{save, ApiTokenEntry, AppConfig, ConfigError};
|
||||
use sprimo_protocol::v1::{CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use thiserror::Error;
|
||||
use tokio::runtime::Runtime;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
@@ -19,6 +21,10 @@ pub enum RuntimeCoreError {
|
||||
SnapshotPoisoned,
|
||||
#[error("config lock poisoned")]
|
||||
ConfigPoisoned,
|
||||
#[error("api token not found: {0}")]
|
||||
ApiTokenNotFound(String),
|
||||
#[error("cannot revoke the last API token")]
|
||||
LastApiToken,
|
||||
}
|
||||
|
||||
pub struct RuntimeCore {
|
||||
@@ -26,6 +32,7 @@ pub struct RuntimeCore {
|
||||
config: Arc<RwLock<AppConfig>>,
|
||||
snapshot: Arc<RwLock<FrontendStateSnapshot>>,
|
||||
api_config: ApiConfig,
|
||||
auth_store: Arc<RwLock<HashSet<String>>>,
|
||||
command_tx: mpsc::Sender<CommandEnvelope>,
|
||||
command_rx: Arc<Mutex<mpsc::Receiver<CommandEnvelope>>>,
|
||||
}
|
||||
@@ -43,6 +50,7 @@ impl RuntimeCore {
|
||||
) -> Result<Self, RuntimeCoreError> {
|
||||
let click_through_was_enabled = config_value.window.click_through;
|
||||
config_value.window.click_through = false;
|
||||
let tokens_changed = normalize_api_tokens(&mut config_value);
|
||||
let mut snapshot = FrontendStateSnapshot::idle(capabilities);
|
||||
snapshot.x = config_value.window.x;
|
||||
snapshot.y = config_value.window.y;
|
||||
@@ -52,18 +60,34 @@ impl RuntimeCore {
|
||||
snapshot.flags.visible = config_value.window.visible;
|
||||
snapshot.active_sprite_pack = config_value.sprite.selected_pack.clone();
|
||||
|
||||
let api_config = ApiConfig::default_with_token(config_value.api.auth_token.clone());
|
||||
let api_config = ApiConfig::default_with_tokens(
|
||||
config_value
|
||||
.api
|
||||
.auth_tokens
|
||||
.iter()
|
||||
.map(|entry| entry.token.clone())
|
||||
.collect(),
|
||||
);
|
||||
let (command_tx, command_rx) = mpsc::channel(1_024);
|
||||
let auth_store = Arc::new(RwLock::new(
|
||||
config_value
|
||||
.api
|
||||
.auth_tokens
|
||||
.iter()
|
||||
.map(|entry| entry.token.clone())
|
||||
.collect(),
|
||||
));
|
||||
|
||||
let core = Self {
|
||||
config_path,
|
||||
config: Arc::new(RwLock::new(config_value)),
|
||||
snapshot: Arc::new(RwLock::new(snapshot)),
|
||||
api_config,
|
||||
auth_store,
|
||||
command_tx,
|
||||
command_rx: Arc::new(Mutex::new(command_rx)),
|
||||
};
|
||||
if click_through_was_enabled {
|
||||
if click_through_was_enabled || tokens_changed {
|
||||
core.persist_config()?;
|
||||
}
|
||||
Ok(core)
|
||||
@@ -137,15 +161,108 @@ impl RuntimeCore {
|
||||
self.api_config.clone()
|
||||
}
|
||||
|
||||
pub fn list_api_tokens(&self) -> Result<Vec<ApiTokenEntry>, RuntimeCoreError> {
|
||||
let guard = self
|
||||
.config
|
||||
.read()
|
||||
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
||||
Ok(guard.api.auth_tokens.clone())
|
||||
}
|
||||
|
||||
pub fn create_api_token(
|
||||
&self,
|
||||
label: Option<String>,
|
||||
) -> Result<ApiTokenEntry, RuntimeCoreError> {
|
||||
let entry = ApiTokenEntry {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
label: normalize_token_label(label.as_deref().unwrap_or("")),
|
||||
token: uuid::Uuid::new_v4().to_string(),
|
||||
created_at_ms: now_unix_ms(),
|
||||
};
|
||||
{
|
||||
let mut guard = self
|
||||
.config
|
||||
.write()
|
||||
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
||||
guard.api.auth_tokens.push(entry.clone());
|
||||
guard.api.auth_token = guard
|
||||
.api
|
||||
.auth_tokens
|
||||
.first()
|
||||
.map(|token| token.token.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
self.persist_config()?;
|
||||
self.refresh_auth_store_from_config()?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
pub fn rename_api_token(&self, id: &str, label: &str) -> Result<(), RuntimeCoreError> {
|
||||
let mut found = false;
|
||||
{
|
||||
let mut guard = self
|
||||
.config
|
||||
.write()
|
||||
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
||||
for entry in &mut guard.api.auth_tokens {
|
||||
if entry.id == id {
|
||||
entry.label = normalize_token_label(label);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Err(RuntimeCoreError::ApiTokenNotFound(id.to_string()));
|
||||
}
|
||||
self.persist_config()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn revoke_api_token(&self, id: &str) -> Result<(), RuntimeCoreError> {
|
||||
let removed = {
|
||||
let mut guard = self
|
||||
.config
|
||||
.write()
|
||||
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
||||
if guard.api.auth_tokens.len() <= 1 {
|
||||
return Err(RuntimeCoreError::LastApiToken);
|
||||
}
|
||||
let before = guard.api.auth_tokens.len();
|
||||
guard.api.auth_tokens.retain(|entry| entry.id != id);
|
||||
let after = guard.api.auth_tokens.len();
|
||||
guard.api.auth_token = guard
|
||||
.api
|
||||
.auth_tokens
|
||||
.first()
|
||||
.map(|token| token.token.clone())
|
||||
.unwrap_or_default();
|
||||
before != after
|
||||
};
|
||||
if !removed {
|
||||
return Err(RuntimeCoreError::ApiTokenNotFound(id.to_string()));
|
||||
}
|
||||
self.persist_config()?;
|
||||
self.refresh_auth_store_from_config()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn spawn_api(&self, runtime: &Runtime) {
|
||||
let mut cfg = self.api_config.clone();
|
||||
if let Ok(guard) = self.config.read() {
|
||||
cfg.bind_addr = ([127, 0, 0, 1], guard.api.port).into();
|
||||
cfg.auth_tokens = guard
|
||||
.api
|
||||
.auth_tokens
|
||||
.iter()
|
||||
.map(|entry| entry.token.clone())
|
||||
.collect();
|
||||
}
|
||||
let state = Arc::new(ApiState::new(
|
||||
cfg.clone(),
|
||||
Arc::clone(&self.snapshot),
|
||||
self.command_tx.clone(),
|
||||
Arc::clone(&self.auth_store),
|
||||
));
|
||||
runtime.spawn(async move {
|
||||
if let Err(err) = sprimo_api::run_server(cfg, state).await {
|
||||
@@ -273,6 +390,27 @@ impl RuntimeCore {
|
||||
save(&self.config_path, &guard)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh_auth_store_from_config(&self) -> Result<(), RuntimeCoreError> {
|
||||
let tokens = {
|
||||
let guard = self
|
||||
.config
|
||||
.read()
|
||||
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
||||
guard
|
||||
.api
|
||||
.auth_tokens
|
||||
.iter()
|
||||
.map(|entry| entry.token.clone())
|
||||
.collect::<HashSet<_>>()
|
||||
};
|
||||
let mut auth = self
|
||||
.auth_store
|
||||
.write()
|
||||
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
||||
*auth = tokens;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_animation_for_state(state: sprimo_protocol::v1::FrontendState) -> &'static str {
|
||||
@@ -297,6 +435,98 @@ fn log_api_error(err: ApiServerError) {
|
||||
warn!(%err, "runtime core api server exited");
|
||||
}
|
||||
|
||||
fn normalize_api_tokens(config: &mut AppConfig) -> bool {
|
||||
let mut changed = false;
|
||||
let mut seen = HashSet::new();
|
||||
let mut normalized = Vec::new();
|
||||
|
||||
let legacy = config.api.auth_token.trim().to_string();
|
||||
if legacy != config.api.auth_token {
|
||||
config.api.auth_token = legacy.clone();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for mut entry in config.api.auth_tokens.clone() {
|
||||
let original_id = entry.id.clone();
|
||||
let original_label = entry.label.clone();
|
||||
let original_token = entry.token.clone();
|
||||
let original_created = entry.created_at_ms;
|
||||
|
||||
entry.id = entry.id.trim().to_string();
|
||||
if entry.id.is_empty() {
|
||||
entry.id = uuid::Uuid::new_v4().to_string();
|
||||
}
|
||||
entry.label = normalize_token_label(&entry.label);
|
||||
entry.token = entry.token.trim().to_string();
|
||||
if entry.created_at_ms == 0 {
|
||||
entry.created_at_ms = now_unix_ms();
|
||||
}
|
||||
|
||||
let field_changed = entry.id != original_id
|
||||
|| entry.label != original_label
|
||||
|| entry.token != original_token
|
||||
|| entry.created_at_ms != original_created;
|
||||
if field_changed {
|
||||
changed = true;
|
||||
}
|
||||
if entry.token.is_empty() {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if !seen.insert(entry.token.clone()) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
normalized.push(entry);
|
||||
}
|
||||
|
||||
if normalized.is_empty() {
|
||||
let token = if legacy.is_empty() {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
} else {
|
||||
legacy
|
||||
};
|
||||
normalized.push(ApiTokenEntry {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
label: "default".to_string(),
|
||||
token,
|
||||
created_at_ms: now_unix_ms(),
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
|
||||
let mirror = normalized
|
||||
.first()
|
||||
.map(|entry| entry.token.clone())
|
||||
.unwrap_or_default();
|
||||
if config.api.auth_token != mirror {
|
||||
config.api.auth_token = mirror;
|
||||
changed = true;
|
||||
}
|
||||
if config.api.auth_tokens != normalized {
|
||||
config.api.auth_tokens = normalized;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
fn normalize_token_label(value: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
"token".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|v| v.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RuntimeCore;
|
||||
@@ -399,4 +629,48 @@ mod tests {
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_token_create_rename_revoke_roundtrip() {
|
||||
let temp = TempDir::new().expect("tempdir");
|
||||
let path = temp.path().join("config.toml");
|
||||
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
||||
.expect("core init");
|
||||
|
||||
let initial_len = core.list_api_tokens().expect("tokens").len();
|
||||
let created = core
|
||||
.create_api_token(Some("backend-ci".to_string()))
|
||||
.expect("create token");
|
||||
let after_create = core.list_api_tokens().expect("tokens");
|
||||
assert_eq!(after_create.len(), initial_len + 1);
|
||||
assert!(after_create.iter().any(|entry| entry.id == created.id));
|
||||
|
||||
core.rename_api_token(&created.id, "automation")
|
||||
.expect("rename token");
|
||||
let after_rename = core.list_api_tokens().expect("tokens");
|
||||
assert!(
|
||||
after_rename
|
||||
.iter()
|
||||
.any(|entry| entry.id == created.id && entry.label == "automation")
|
||||
);
|
||||
|
||||
core.revoke_api_token(&created.id).expect("revoke token");
|
||||
let after_revoke = core.list_api_tokens().expect("tokens");
|
||||
assert_eq!(after_revoke.len(), initial_len);
|
||||
assert!(!after_revoke.iter().any(|entry| entry.id == created.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cannot_revoke_last_api_token() {
|
||||
let temp = TempDir::new().expect("tempdir");
|
||||
let path = temp.path().join("config.toml");
|
||||
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
||||
.expect("core init");
|
||||
let only = core.list_api_tokens().expect("tokens");
|
||||
assert_eq!(only.len(), 1);
|
||||
let err = core
|
||||
.revoke_api_token(&only[0].id)
|
||||
.expect_err("last token revoke must fail");
|
||||
assert!(format!("{err}").contains("last API token"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,14 @@ struct UiSpritePackOption {
|
||||
pack_id_or_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct UiApiToken {
|
||||
id: String,
|
||||
label: String,
|
||||
token: String,
|
||||
created_at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum AppError {
|
||||
#[error("{0}")]
|
||||
@@ -243,6 +251,60 @@ fn list_sprite_packs(state: tauri::State<'_, AppState>) -> Result<Vec<UiSpritePa
|
||||
Ok(packs)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_api_tokens(state: tauri::State<'_, AppState>) -> Result<Vec<UiApiToken>, String> {
|
||||
let entries = state
|
||||
.runtime_core
|
||||
.list_api_tokens()
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(entries.into_iter().map(to_ui_api_token).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_api_token(
|
||||
state: tauri::State<'_, AppState>,
|
||||
label: Option<String>,
|
||||
) -> Result<UiApiToken, String> {
|
||||
let entry = state
|
||||
.runtime_core
|
||||
.create_api_token(label)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(to_ui_api_token(entry))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn rename_api_token(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
label: String,
|
||||
) -> Result<Vec<UiApiToken>, String> {
|
||||
state
|
||||
.runtime_core
|
||||
.rename_api_token(&id, &label)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let entries = state
|
||||
.runtime_core
|
||||
.list_api_tokens()
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(entries.into_iter().map(to_ui_api_token).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn revoke_api_token(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<Vec<UiApiToken>, String> {
|
||||
state
|
||||
.runtime_core
|
||||
.revoke_api_token(&id)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let entries = state
|
||||
.runtime_core
|
||||
.list_api_tokens()
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(entries.into_iter().map(to_ui_api_token).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_sprite_pack(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -382,6 +444,10 @@ fn main() -> Result<(), AppError> {
|
||||
tauri_animation_slowdown_factor,
|
||||
set_tauri_animation_slowdown_factor,
|
||||
list_sprite_packs,
|
||||
list_api_tokens,
|
||||
create_api_token,
|
||||
rename_api_token,
|
||||
revoke_api_token,
|
||||
set_sprite_pack,
|
||||
set_scale,
|
||||
set_visibility,
|
||||
@@ -502,6 +568,15 @@ fn to_ui_snapshot(snapshot: &FrontendStateSnapshot) -> UiSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
fn to_ui_api_token(entry: sprimo_config::ApiTokenEntry) -> UiApiToken {
|
||||
UiApiToken {
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
token: entry.token,
|
||||
created_at_ms: entry.created_at_ms,
|
||||
}
|
||||
}
|
||||
|
||||
fn sprite_pack_root(runtime_core: &RuntimeCore) -> Result<std::path::PathBuf, String> {
|
||||
let sprite_packs_dir = runtime_core
|
||||
.config()
|
||||
|
||||
@@ -25,6 +25,13 @@ type UiSpritePackOption = {
|
||||
pack_id_or_path: string;
|
||||
};
|
||||
|
||||
type UiApiToken = {
|
||||
id: string;
|
||||
label: string;
|
||||
token: string;
|
||||
created_at_ms: number;
|
||||
};
|
||||
|
||||
const WINDOW_PADDING = 16;
|
||||
const WINDOW_WORKAREA_MARGIN = 80;
|
||||
const MIN_WINDOW_SIZE = 64;
|
||||
@@ -62,6 +69,22 @@ async function invokeSetAnimationSlowdownFactor(factor: number): Promise<number>
|
||||
return invoke<number>("set_tauri_animation_slowdown_factor", { factor });
|
||||
}
|
||||
|
||||
async function invokeListApiTokens(): Promise<UiApiToken[]> {
|
||||
return invoke<UiApiToken[]>("list_api_tokens");
|
||||
}
|
||||
|
||||
async function invokeCreateApiToken(label?: string): Promise<UiApiToken> {
|
||||
return invoke<UiApiToken>("create_api_token", { label });
|
||||
}
|
||||
|
||||
async function invokeRenameApiToken(id: string, label: string): Promise<UiApiToken[]> {
|
||||
return invoke<UiApiToken[]>("rename_api_token", { id, label });
|
||||
}
|
||||
|
||||
async function invokeRevokeApiToken(id: string): Promise<UiApiToken[]> {
|
||||
return invoke<UiApiToken[]>("revoke_api_token", { id });
|
||||
}
|
||||
|
||||
function fittedWindowSize(
|
||||
scale: number
|
||||
): { width: number; height: number } {
|
||||
@@ -396,6 +419,9 @@ function MainOverlayWindow(): JSX.Element {
|
||||
function SettingsWindow(): JSX.Element {
|
||||
const [settings, setSettings] = React.useState<UiSettingsSnapshot | null>(null);
|
||||
const [packs, setPacks] = React.useState<UiSpritePackOption[]>([]);
|
||||
const [tokens, setTokens] = React.useState<UiApiToken[]>([]);
|
||||
const [tokenDrafts, setTokenDrafts] = React.useState<Record<string, string>>({});
|
||||
const [newTokenLabel, setNewTokenLabel] = React.useState("");
|
||||
const [activePack, setActivePack] = React.useState<UiSpritePack | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [pending, setPending] = React.useState(false);
|
||||
@@ -406,15 +432,23 @@ function SettingsWindow(): JSX.Element {
|
||||
Promise.all([
|
||||
invoke<UiSettingsSnapshot>("settings_snapshot"),
|
||||
invoke<UiSpritePackOption[]>("list_sprite_packs"),
|
||||
invoke<UiSpritePack>("load_active_sprite_pack")
|
||||
invoke<UiSpritePack>("load_active_sprite_pack"),
|
||||
invokeListApiTokens()
|
||||
])
|
||||
.then(async ([snapshot, options, pack]) => {
|
||||
.then(async ([snapshot, options, pack, authTokens]) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setSettings(snapshot);
|
||||
setPacks(options);
|
||||
setActivePack(pack);
|
||||
setTokens(authTokens);
|
||||
setTokenDrafts(
|
||||
authTokens.reduce<Record<string, string>>((acc, token) => {
|
||||
acc[token.id] = token.label;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
@@ -574,6 +608,85 @@ function SettingsWindow(): JSX.Element {
|
||||
[withPending]
|
||||
);
|
||||
|
||||
const onNewTokenLabelChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewTokenLabel(event.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onCreateToken = React.useCallback(async () => {
|
||||
const created = await withPending(() => invokeCreateApiToken(newTokenLabel || undefined));
|
||||
if (created === null) {
|
||||
return;
|
||||
}
|
||||
const refreshed = await withPending(() => invokeListApiTokens());
|
||||
if (refreshed === null) {
|
||||
return;
|
||||
}
|
||||
setTokens(refreshed);
|
||||
setTokenDrafts(
|
||||
refreshed.reduce<Record<string, string>>((acc, token) => {
|
||||
acc[token.id] = token.label;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
setNewTokenLabel("");
|
||||
}, [newTokenLabel, withPending]);
|
||||
|
||||
const onTokenDraftChange = React.useCallback((id: string, value: string) => {
|
||||
setTokenDrafts((prev) => ({
|
||||
...prev,
|
||||
[id]: value
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const onRenameToken = React.useCallback(
|
||||
async (id: string) => {
|
||||
const nextLabel = tokenDrafts[id] ?? "";
|
||||
const refreshed = await withPending(() => invokeRenameApiToken(id, nextLabel));
|
||||
if (refreshed === null) {
|
||||
return;
|
||||
}
|
||||
setTokens(refreshed);
|
||||
setTokenDrafts(
|
||||
refreshed.reduce<Record<string, string>>((acc, token) => {
|
||||
acc[token.id] = token.label;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
},
|
||||
[tokenDrafts, withPending]
|
||||
);
|
||||
|
||||
const onCopyToken = React.useCallback(async (token: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onRevokeToken = React.useCallback(
|
||||
async (id: string) => {
|
||||
if (!window.confirm("Revoke this API token?")) {
|
||||
return;
|
||||
}
|
||||
const refreshed = await withPending(() => invokeRevokeApiToken(id));
|
||||
if (refreshed === null) {
|
||||
return;
|
||||
}
|
||||
setTokens(refreshed);
|
||||
setTokenDrafts(
|
||||
refreshed.reduce<Record<string, string>>((acc, token) => {
|
||||
acc[token.id] = token.label;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
},
|
||||
[withPending]
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="settings-root">
|
||||
<section className="settings-card">
|
||||
@@ -642,6 +755,68 @@ function SettingsWindow(): JSX.Element {
|
||||
/>
|
||||
<span>Always on top</span>
|
||||
</label>
|
||||
<section className="token-section">
|
||||
<h2>API Tokens</h2>
|
||||
<p className="token-help">
|
||||
Use any listed token as `Authorization: Bearer <token>`.
|
||||
</p>
|
||||
<div className="token-create">
|
||||
<input
|
||||
type="text"
|
||||
value={newTokenLabel}
|
||||
placeholder="New token label"
|
||||
disabled={pending}
|
||||
onChange={onNewTokenLabelChange}
|
||||
/>
|
||||
<button type="button" disabled={pending} onClick={onCreateToken}>
|
||||
Create token
|
||||
</button>
|
||||
</div>
|
||||
<div className="token-list">
|
||||
{tokens.map((entry) => (
|
||||
<article className="token-item" key={entry.id}>
|
||||
<label className="field">
|
||||
<span>Label</span>
|
||||
<input
|
||||
type="text"
|
||||
value={tokenDrafts[entry.id] ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
onTokenDraftChange(entry.id, event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Token</span>
|
||||
<input type="text" value={entry.token} readOnly />
|
||||
</label>
|
||||
<div className="token-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => onCopyToken(entry.token)}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => onRenameToken(entry.id)}
|
||||
>
|
||||
Save label
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending || tokens.length <= 1}
|
||||
onClick={() => onRevokeToken(entry.id)}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -85,6 +85,7 @@ dd {
|
||||
}
|
||||
|
||||
.settings-root {
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
@@ -92,6 +93,8 @@ dd {
|
||||
background: linear-gradient(180deg, #f1f5f9 0%, #dbe4ee 100%);
|
||||
color: #0f172a;
|
||||
user-select: none;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
@@ -102,6 +105,7 @@ dd {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.settings-card h1 {
|
||||
@@ -137,7 +141,8 @@ dd {
|
||||
}
|
||||
|
||||
.field select,
|
||||
.field input[type="range"] {
|
||||
.field input[type="range"],
|
||||
.field input[type="text"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -149,6 +154,15 @@ dd {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.field input[type="text"] {
|
||||
border: 1px solid #94a3b8;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -156,3 +170,64 @@ dd {
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.token-section {
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid #cbd5e1;
|
||||
padding-top: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.token-section h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.token-help {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.token-create {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.token-create button,
|
||||
.token-actions button {
|
||||
border: 1px solid #94a3b8;
|
||||
border-radius: 8px;
|
||||
padding: 7px 10px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.token-create button:disabled,
|
||||
.token-actions button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.token-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.token-item {
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.token-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user