Merge remote-tracking branch 'origin/main' into feat/backend-logtype
Sync source to CNB / Sync Git ref (push) Skipped

This commit is contained in:
AxTps committed 2026-10-11 15:42:28 +08:00
commit 9ccacba3fd
23 files changed
+2718 -144

No files matched your search

@@ -0,0 +1,26 @@
CREATE TABLE log_share_settings (
id INTEGER NOT NULL CHECK (id = 0),
share_provider TEXT NOT NULL DEFAULT 'logshare',
ai_source TEXT NOT NULL DEFAULT 'logshare',
auto_upload INTEGER NOT NULL DEFAULT TRUE,
multi_file INTEGER NOT NULL DEFAULT TRUE,
no_storage INTEGER NOT NULL DEFAULT FALSE,
show_progress INTEGER NOT NULL DEFAULT TRUE,
PRIMARY KEY (id)
);
INSERT INTO log_share_settings (id) VALUES (0);
CREATE TABLE shared_logs (
id TEXT NOT NULL PRIMARY KEY,
url TEXT NOT NULL,
raw TEXT NOT NULL,
token TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'logshare',
instance_id TEXT NULL,
instance_name TEXT NULL,
truncated INTEGER NOT NULL DEFAULT FALSE,
created_at INTEGER NOT NULL
);
CREATE INDEX shared_logs_created_at_idx ON shared_logs (created_at DESC);
+9
View File
@@ -27,6 +27,15 @@ pub use crash_analysis::{
update_crash_analysis_ai_settings,
};
mod logshare;
pub use logshare::{
LogShareDeleteResponse, LogShareSettings, LogShareUploadResponse,
SharedLog, ai_analyze_direct, ai_analyze_stored, analyse_crash_direct,
delete_log, delete_shared_log, get_insights, get_log_share_settings,
list_shared_logs, record_shared_log, update_log_share_settings,
upload_crash,
};
#[derive(Serialize, Debug)]
pub struct Logs {
pub log_type: LogType,
+649
View File
@@ -0,0 +1,649 @@
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::CensoredString;
use super::crash_analysis::analyze_crash;
use crate::State;
use crate::emit_logshare_ai_event;
const LOGSHARE_BASE_URL: &str = "https://api.logshare.cn";
const LOGSHARE_AI_READ_TIMEOUT_SECS: u64 = 300;
const LOGSHARE_SOURCE_PREFIX: &str = "starlight";
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LogShareSettings {
pub share_provider: String,
pub ai_source: String,
pub auto_upload: bool,
pub multi_file: bool,
pub no_storage: bool,
pub show_progress: bool,
}
impl Default for LogShareSettings {
fn default() -> Self {
Self {
share_provider: "logshare".to_string(),
ai_source: "logshare".to_string(),
auto_upload: true,
multi_file: true,
no_storage: false,
show_progress: true,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SharedLog {
pub id: String,
pub url: String,
pub raw: String,
pub token: String,
pub provider: String,
pub instance_id: Option<String>,
pub instance_name: Option<String>,
pub truncated: bool,
pub created_at: i64,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LogShareUploadResponse {
pub success: bool,
pub id: String,
pub url: String,
pub raw: String,
pub token: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LogShareDeleteResponse {
pub success: bool,
pub deleted: Vec<String>,
pub failed: Vec<String>,
}
#[derive(Serialize, Debug, Clone)]
#[serde(rename_all = "snake_case")]
struct UploadFile {
name: String,
content: String,
}
#[derive(Serialize, Debug, Clone)]
struct UploadBody {
content: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
files: Vec<UploadFile>,
source: String,
}
pub async fn get_log_share_settings() -> crate::Result<LogShareSettings> {
let state = State::get().await?;
let row = sqlx::query_as::<_, (String, String, i64, i64, i64, i64)>(
"SELECT share_provider, ai_source, auto_upload, multi_file, no_storage, show_progress FROM log_share_settings WHERE id = 0",
)
.fetch_one(&state.pool)
.await?;
Ok(LogShareSettings {
share_provider: row.0,
ai_source: row.1,
auto_upload: row.2 != 0,
multi_file: row.3 != 0,
no_storage: row.4 != 0,
show_progress: row.5 != 0,
})
}
pub async fn update_log_share_settings(
settings: LogShareSettings,
) -> crate::Result<()> {
let settings = normalize_settings(settings);
let state = State::get().await?;
sqlx::query(
"UPDATE log_share_settings SET share_provider = ?, ai_source = ?, auto_upload = ?, multi_file = ?, no_storage = ?, show_progress = ? WHERE id = 0",
)
.bind(&settings.share_provider)
.bind(&settings.ai_source)
.bind(settings.auto_upload)
.bind(settings.multi_file)
.bind(settings.no_storage)
.bind(settings.show_progress)
.execute(&state.pool)
.await?;
Ok(())
}
fn normalize_settings(mut settings: LogShareSettings) -> LogShareSettings {
if settings.share_provider != "logshare" {
settings.share_provider = "mclogs".to_string();
}
if settings.ai_source != "logshare" {
settings.ai_source = "custom".to_string();
}
settings
}
pub async fn list_shared_logs() -> crate::Result<Vec<SharedLog>> {
let state = State::get().await?;
let rows = sqlx::query_as::<_, (String, String, String, String, String, Option<String>, Option<String>, i64, i64)>(
"SELECT id, url, raw, token, provider, instance_id, instance_name, truncated, created_at FROM shared_logs ORDER BY created_at DESC",
)
.fetch_all(&state.pool)
.await?;
Ok(rows
.into_iter()
.map(|row| SharedLog {
id: row.0,
url: row.1,
raw: row.2,
token: row.3,
provider: row.4,
instance_id: row.5,
instance_name: row.6,
truncated: row.7 != 0,
created_at: row.8,
})
.collect())
}
pub async fn record_shared_log(log: SharedLog) -> crate::Result<()> {
let state = State::get().await?;
let created_at = log.created_at.max(now_seconds());
sqlx::query(
"INSERT INTO shared_logs (id, url, raw, token, provider, instance_id, instance_name, truncated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET url = excluded.url, raw = excluded.raw, token = excluded.token, provider = excluded.provider, instance_id = excluded.instance_id, instance_name = excluded.instance_name, truncated = excluded.truncated",
)
.bind(&log.id)
.bind(&log.url)
.bind(&log.raw)
.bind(&log.token)
.bind(&log.provider)
.bind(&log.instance_id)
.bind(&log.instance_name)
.bind(log.truncated)
.bind(created_at)
.execute(&state.pool)
.await?;
Ok(())
}
async fn remove_shared_log_record(id: &str) -> crate::Result<()> {
let state = State::get().await?;
sqlx::query("DELETE FROM shared_logs WHERE id = ?")
.bind(id)
.execute(&state.pool)
.await?;
Ok(())
}
pub async fn delete_shared_log(id: String, token: String) -> crate::Result<()> {
let remote_result = delete_log(&id, &token).await;
if let Err(error) = remote_result {
tracing::warn!(
"Remote LogShare deletion failed for {id}; removing the local record anyway: {error}"
);
}
remove_shared_log_record(&id).await
}
fn now_seconds() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
fn source_identifier() -> String {
format!("{LOGSHARE_SOURCE_PREFIX}/{}", env!("CARGO_PKG_VERSION"))
}
struct CollectedCrashLog {
name: String,
content: CensoredString,
}
struct CollectedCrashLogs {
combined_log: CensoredString,
sources: Vec<CollectedCrashLog>,
}
async fn collect_crash_logs(
instance_id: &str,
) -> crate::Result<CollectedCrashLogs> {
let analysis = analyze_crash(instance_id).await?;
Ok(CollectedCrashLogs {
combined_log: analysis.combined_log,
sources: analysis
.sources
.into_iter()
.map(|source| CollectedCrashLog {
name: source.filename,
content: source.content,
})
.collect(),
})
}
async fn prepare_upload(instance_id: &str) -> crate::Result<UploadBody> {
let settings = get_log_share_settings().await?;
let logs = collect_crash_logs(instance_id).await?;
let combined = logs.combined_log.as_str().to_string();
let files = if settings.multi_file {
logs.sources
.iter()
.map(|source| UploadFile {
name: source.name.clone(),
content: source.content.as_str().to_string(),
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
Ok(UploadBody {
content: combined,
files,
source: source_identifier(),
})
}
async fn request_client() -> crate::Result<reqwest::Client> {
let state = State::get().await?;
let proxy = state.proxy_config().await?;
let builder = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(30))
.read_timeout(Duration::from_secs(LOGSHARE_AI_READ_TIMEOUT_SECS))
.user_agent(crate::launcher_user_agent());
proxy.apply(builder)?.build().map_err(Into::into)
}
pub async fn upload_crash(
instance_id: &str,
) -> crate::Result<LogShareUploadResponse> {
let prepared = prepare_upload(instance_id).await?;
let client = request_client().await?;
let body_value = serde_json::to_value(&prepared)?;
let request = client
.post(format!("{LOGSHARE_BASE_URL}/v1/log"))
.header("Accept", "application/json")
.header("Accept-Encoding", "br, gzip");
let response = request.json(&body_value).send().await?;
let status = response.status();
let text = response.text().await?;
if !status.is_success() {
return Err(crate::ErrorKind::OtherError(format!(
"LogShare upload failed with HTTP {status}: {}",
text.chars().take(500).collect::<String>()
))
.into());
}
let value: Value = serde_json::from_str(&text).map_err(|_| {
crate::ErrorKind::OtherError(
"LogShare returned invalid JSON".to_string(),
)
})?;
if value.get("success").and_then(Value::as_bool) != Some(true) {
return Err(crate::ErrorKind::OtherError(format!(
"LogShare upload failed: {}",
value
.get("error")
.and_then(Value::as_str)
.unwrap_or("unknown error")
))
.into());
}
Ok(LogShareUploadResponse {
success: true,
id: value
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
url: value
.get("url")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
raw: value
.get("raw")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
token: value
.get("token")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
})
}
pub async fn get_insights(id: &str) -> crate::Result<Value> {
let client = request_client().await?;
let response = client
.get(format!("{LOGSHARE_BASE_URL}/v1/insights/{id}"))
.header("Accept", "application/json")
.header("Accept-Encoding", "br, gzip")
.send()
.await?;
let status = response.status();
let text = response.text().await?;
if !status.is_success() {
return Err(crate::ErrorKind::OtherError(format!(
"LogShare analysis failed with HTTP {status}: {}",
text.chars().take(500).collect::<String>()
))
.into());
}
serde_json::from_str(&text).map_err(|_| {
crate::ErrorKind::OtherError(
"LogShare returned invalid insights JSON".to_string(),
)
.into()
})
}
pub async fn analyse_crash_direct(instance_id: &str) -> crate::Result<Value> {
let prepared = prepare_upload(instance_id).await?;
let client = request_client().await?;
let response = client
.post(format!("{LOGSHARE_BASE_URL}/v1/analyse"))
.header("Accept", "application/json")
.header("Accept-Encoding", "br, gzip")
.json(&prepared)
.send()
.await?;
let status = response.status();
let text = response.text().await?;
if !status.is_success() {
return Err(crate::ErrorKind::OtherError(format!(
"LogShare analysis failed with HTTP {status}: {}",
text.chars().take(500).collect::<String>()
))
.into());
}
serde_json::from_str(&text).map_err(|_| {
crate::ErrorKind::OtherError(
"LogShare returned invalid analysis JSON".to_string(),
)
.into()
})
}
pub async fn delete_log(
id: &str,
token: &str,
) -> crate::Result<LogShareDeleteResponse> {
let client = request_client().await?;
let response = client
.delete(format!("{LOGSHARE_BASE_URL}/v1/log/{id}"))
.bearer_auth(token)
.header("Accept", "application/json")
.header("Accept-Encoding", "br, gzip")
.send()
.await?;
let status = response.status();
let text = response.text().await?;
if !status.is_success() {
return Err(crate::ErrorKind::OtherError(format!(
"LogShare deletion failed with HTTP {status}: {}",
text.chars().take(500).collect::<String>()
))
.into());
}
serde_json::from_str(&text).map_err(|_| {
crate::ErrorKind::OtherError(
"LogShare returned invalid deletion JSON".to_string(),
)
.into()
})
}
pub async fn ai_analyze_stored(
instance_id: &str,
id: &str,
) -> crate::Result<String> {
let url = format!("{LOGSHARE_BASE_URL}/v1/ai/{id}");
stream_ai(instance_id, url, None).await
}
pub async fn ai_analyze_direct(instance_id: &str) -> crate::Result<String> {
let prepared = prepare_upload(instance_id).await?;
let url = format!("{LOGSHARE_BASE_URL}/v1/ai/analyse");
let body = serde_json::to_value(&prepared)?;
stream_ai(instance_id, url, Some(body)).await
}
async fn stream_ai(
instance_id: &str,
url: String,
body: Option<Value>,
) -> crate::Result<String> {
let settings = get_log_share_settings().await?;
let show_progress = settings.show_progress;
let client = request_client().await?;
let mut request = client
.request(
if body.is_some() {
reqwest::Method::POST
} else {
reqwest::Method::GET
},
url.as_str(),
)
.header("Accept", "text/event-stream")
.header("Accept-Encoding", "br, gzip");
if let Some(payload) = body {
request = request.json(&payload);
}
let response = request.send().await?;
let status = response.status();
if !status.is_success() {
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
return Err(crate::ErrorKind::OtherError(
"LogAgent queue is full, please retry later".to_string(),
)
.into());
}
let text = response.text().await?;
return Err(crate::ErrorKind::OtherError(format!(
"LogAgent analysis failed with HTTP {status}: {}",
text.chars().take(500).collect::<String>()
))
.into());
}
let mut stream = response.bytes_stream();
let mut parser = SseParser::new();
let mut output = String::new();
loop {
let maybe = tokio::time::timeout(
Duration::from_secs(LOGSHARE_AI_READ_TIMEOUT_SECS),
stream.next(),
)
.await
.map_err(|_| {
crate::ErrorKind::OtherError(
"LogAgent analysis timed out".to_string(),
)
})?;
let Some(chunk) = maybe else {
break;
};
let chunk = chunk?;
for event in parser.feed(&chunk) {
if let Some(delta) = event.content {
output.push_str(&delta);
if show_progress {
emit_logshare_ai_event(
instance_id,
"delta",
json!({ "content": delta }),
)
.await?;
}
}
if let Some(status_event) = event.status_event {
if show_progress || status_event.event_type == "queued" {
emit_logshare_ai_event(
instance_id,
&status_event.event_type,
status_event.data,
)
.await?;
}
}
if let Some(message) = event.error {
return Err(crate::ErrorKind::OtherError(format!(
"LogAgent analysis failed: {message}"
))
.into());
}
if event.done {
return Ok(output);
}
}
}
// A stream that ended without `event: done` is acceptable as long as text
// was produced; otherwise it is a protocol error worth surfacing.
if output.is_empty() {
return Err(crate::ErrorKind::OtherError(
"LogAgent analysis ended without output".to_string(),
)
.into());
}
Ok(output)
}
struct ParsedEvent {
content: Option<String>,
status_event: Option<ParsedStatusEvent>,
done: bool,
error: Option<String>,
}
struct ParsedStatusEvent {
event_type: String,
data: Value,
}
struct SseParser {
buffer: Vec<u8>,
}
impl SseParser {
fn new() -> Self {
Self { buffer: Vec::new() }
}
fn feed(&mut self, chunk: &[u8]) -> Vec<ParsedEvent> {
self.buffer.extend_from_slice(chunk);
let mut events = Vec::new();
loop {
let lf_pos = find_bytes(&self.buffer, b"\n\n");
let crlf_pos = find_bytes(&self.buffer, b"\r\n\r\n");
let (pos, separator_len) = match (lf_pos, crlf_pos) {
(Some(lf_pos), Some(crlf_pos)) if crlf_pos < lf_pos => {
(crlf_pos, 4)
}
(Some(lf_pos), _) => (lf_pos, 2),
(None, Some(crlf_pos)) => (crlf_pos, 4),
(None, None) => break,
};
let block =
String::from_utf8_lossy(&self.buffer[..pos]).into_owned();
self.buffer.drain(..pos + separator_len);
if let Some(event) = parse_sse_block(&block) {
events.push(event);
}
}
events
}
}
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn parse_sse_block(block: &str) -> Option<ParsedEvent> {
let mut event_name: Option<String> = None;
let mut data_lines = Vec::new();
for line in block.lines() {
if let Some(rest) = line.strip_prefix("event:") {
event_name = Some(rest.trim().to_string());
} else if let Some(rest) = line.strip_prefix("data:") {
data_lines.push(rest.trim_start().to_string());
}
}
if event_name.is_none() && data_lines.is_empty() {
return None;
}
let data_text = data_lines.join("\n");
let event_name = event_name.as_deref().unwrap_or("message");
match event_name {
"done" => Some(ParsedEvent {
content: None,
status_event: None,
done: true,
error: None,
}),
"error" => Some(ParsedEvent {
content: None,
status_event: None,
done: false,
error: Some(
serde_json::from_str::<Value>(&data_text)
.ok()
.and_then(|value| {
value
.get("error")
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| data_text.clone()),
),
}),
"status" => {
let Ok(value) = serde_json::from_str::<Value>(&data_text) else {
return None;
};
let event_type = value
.get("type")
.and_then(Value::as_str)
.unwrap_or("status")
.to_string();
Some(ParsedEvent {
content: None,
status_event: Some(ParsedStatusEvent {
event_type,
data: value,
}),
done: false,
error: None,
})
}
_ => {
let Ok(value) = serde_json::from_str::<Value>(&data_text) else {
return None;
};
let content = value
.pointer("/choices/0/delta/content")
.and_then(Value::as_str)
.map(str::to_string);
Some(ParsedEvent {
content,
status_event: None,
done: false,
error: None,
})
}
}
}
+22
View File
@@ -612,3 +612,25 @@ where
})
.await
}
pub async fn emit_logshare_ai_event(
instance_id: &str,
event_type: &str,
data: Value,
) -> crate::Result<()> {
#[cfg(feature = "tauri")]
{
let event_state = crate::EventState::get()?;
event_state
.app
.emit(
"logshare_ai",
crate::event::LogShareAiEventPayload {
instance_id: instance_id.to_string(),
event_type: event_type.to_string(),
data,
},
)
.map_err(EventError::from)?;
}
Ok(())
}
+8
View File
@@ -472,6 +472,14 @@ mod log_types {
#[serde(flatten)]
pub event: LogEvent,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "snake_case")]
pub struct LogShareAiEventPayload {
pub instance_id: String,
pub event_type: String,
pub data: serde_json::Value,
}
}
#[derive(Debug, thiserror::Error)]
+1 -1
View File
@@ -26,7 +26,7 @@ pub use api::*;
pub use error::*;
pub use event::{
EventState, LoadingBar, LoadingBarType, emit::emit_loading,
emit::init_loading,
emit::emit_logshare_ai_event, emit::init_loading,
};
pub use logger::filter_log_contents;
pub use logger::start_logger;
@@ -7,6 +7,8 @@ import type { ButtonProps } from './types'
const props = withDefaults(defineProps<ButtonProps>(), {
type: 'base',
size: 'md',
iconOnly: false,
circular: false,
nativeType: 'button',
disabled: false,
loading: false,
@@ -26,8 +28,11 @@ defineExpose({ element })
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:icon-only="props.iconOnly"
:circular="props.circular"
:native-type="props.nativeType"
:disabled="props.disabled || props.loading"
:aria-label="props.iconOnly ? props.label : undefined"
:aria-busy="props.loading || undefined"
>
<slot />
@@ -13,7 +13,10 @@ import type {
const baseClasses = [
// Base
'relative inline-flex min-w-0 shrink-0 items-center justify-center',
'whitespace-nowrap border-0 no-underline',
// The transparent border is load-bearing, not decorative: it participates in
// layout, so an auto-width button is 2px wider than it would be without it.
// The ring participates in layout; dropping it would silently narrow every text button.
'whitespace-nowrap border border-solid border-transparent no-underline',
// Interactions
'touch-manipulation cursor-pointer select-none transition-[background-color,color,box-shadow,filter,opacity,transform] duration-150 ease-out',
'enabled:active:scale-[0.97]',
@@ -26,6 +29,8 @@ const baseClasses = [
].join(' ')
const sizeClasses: Record<ButtonSize, string> = {
'2xs':
'h-6 gap-1 rounded-lg px-1.5 text-sm font-semibold leading-5 [&>svg]:size-4 [&>svg]:min-h-4 [&>svg]:min-w-4 [&>svg]:shrink-0',
xs: 'h-7 gap-1 rounded-lg px-1.5 text-sm font-semibold leading-5 [&>svg]:size-4 [&>svg]:min-h-4 [&>svg]:min-w-4 [&>svg]:shrink-0',
sm: 'h-8 gap-1 rounded-[10px] px-1.5 text-sm font-semibold leading-5 [&>svg]:size-4 [&>svg]:min-h-4 [&>svg]:min-w-4 [&>svg]:shrink-0',
md: 'h-9 gap-1.5 rounded-xl px-2.5 text-base font-semibold leading-5 [&>svg]:size-5 [&>svg]:min-h-5 [&>svg]:min-w-5 [&>svg]:shrink-0',
@@ -34,6 +39,7 @@ const sizeClasses: Record<ButtonSize, string> = {
}
const iconOnlySizeClasses: Record<ButtonSize, string> = {
'2xs': 'min-w-6 w-6 !px-0',
xs: 'min-w-7 w-7 !px-0',
sm: 'min-w-8 w-8 !px-0',
md: 'min-w-9 w-9 !px-0',
@@ -42,14 +48,23 @@ const iconOnlySizeClasses: Record<ButtonSize, string> = {
}
const typeClasses: Record<ButtonType, string> = {
base: 'button-frame--base bg-surface-4 text-contrast [&>svg]:text-primary',
base: 'button-frame--base bg-surface-4 text-[var(--color-text-primary)] [&>svg]:text-[var(--color-text-default)]',
colored:
'button-frame--colored bg-[--button-color] text-[var(--color-accent-contrast)] [&>svg]:text-inherit',
'colored-text':
'button-frame--colored-text bg-surface-4 text-[--button-color] [&>svg]:text-inherit',
outlined:
'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-contrast))] [&>svg]:text-[var(--button-color,var(--color-base))]',
'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-text-primary))] [&>svg]:text-[var(--button-color,var(--color-text-default))]',
quiet: 'button-frame--quiet bg-transparent [&>svg]:text-inherit',
// A 25% tint of the accent colour. All three read the same tint; they differ
// in label colour and whether they carry the raised shadow. Without a colour
// the tint variable is unset and the fill falls back to the raised surface,
// which is what the legacy uncoloured chip rendered as.
chip: 'button-frame--chip bg-[--button-highlight,var(--surface-4)] text-[var(--button-color,var(--color-text-default))] [&>svg]:text-inherit',
'chip-text':
'button-frame--chip-text bg-[--button-highlight,var(--surface-4)] text-[var(--button-color,var(--color-text-default))] [&>svg]:text-inherit',
highlight:
'button-frame--highlight bg-[--button-highlight,var(--surface-4)] text-[var(--color-text-primary)] [&>svg]:text-inherit',
}
const interactionClasses: Record<ButtonInteraction, string> = {
@@ -67,7 +82,17 @@ const colorVariables: Record<ButtonColor, string> = {
green: 'var(--color-green)',
blue: 'var(--color-blue)',
purple: 'var(--color-purple)',
medal_promotion: 'var(--medal-promotion-text-orange, var(--color-orange))',
}
// The 25%-opacity tint behind `chip`/`highlight`. Each accent has its own
// token, so the tint must be looked up rather than derived.
const highlightVariables: Record<ButtonColor, string> = {
brand: 'var(--color-brand-highlight)',
red: 'var(--color-red-highlight)',
orange: 'var(--color-orange-highlight)',
green: 'var(--color-green-highlight)',
blue: 'var(--color-blue-highlight)',
purple: 'var(--color-purple-highlight)',
}
const props = withDefaults(
@@ -101,7 +126,20 @@ const classes = computed(() => [
props.circular ? '!rounded-full' : '',
])
const style = computed((): CSSProperties | undefined => {
// `outlined` and `quiet` deliberately publish nothing without an explicit
// colour, so their own `var(--button-color, <default>)` fallbacks apply —
// that is what reproduces the legacy default ring and text colours.
if ((props.type === 'outlined' || props.type === 'quiet') && !props.color) return undefined
if (props.type === 'chip' || props.type === 'chip-text' || props.type === 'highlight') {
// Without an accent these fall back to the page surface and the default
// label, matching the legacy behaviour for an uncoloured chip.
const accent = props.color
if (!accent) return undefined
return {
'--button-color': colorVariables[accent],
'--button-highlight': highlightVariables[accent],
} as CSSProperties
}
if (
props.type !== 'colored' &&
props.type !== 'colored-text' &&
@@ -163,11 +201,24 @@ defineExpose({ element })
mask-composite: exclude;
}
/* The ring is a real border so it participates in layout. Set here rather
* than as a utility because it has to beat the transparent border colour in
* `baseClasses`. */
.button-frame--outlined {
box-shadow: inset 0 0 0 1px var(--button-color, var(--surface-5));
border-color: var(--button-color, var(--surface-5));
}
.button-frame--quiet {
color: var(--button-color, var(--color-base));
color: var(--button-color, var(--color-text-default));
}
/* `chip` and `highlight` are tinted fills rather than transparent ones, so they
* keep the raised-button shadow. `chip-text` is the same tint without it --
* the legacy `highlight-colored-text`, which never had a shadow. */
.button-frame--chip,
.button-frame--highlight {
box-shadow:
inset 0 0 0 1px var(--surface-5),
0 1px 1px rgba(0, 0, 0, 0.12);
}
</style>
@@ -1,56 +1,74 @@
export type ButtonType = 'base' | 'colored' | 'colored-text' | 'outlined' | 'quiet'
export type ButtonType =
'base' | 'colored' | 'colored-text' | 'outlined' | 'quiet' | 'chip' | 'chip-text' | 'highlight'
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
export type ButtonSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
export type ButtonInteraction = 'surface' | 'filled' | 'none'
// TODO: Standardized color string enum props across @modrinth/ui
export type ButtonColor =
| 'brand'
| 'red'
| 'orange'
| 'green'
| 'blue'
| 'purple'
| 'medal_promotion'
export type ButtonColor = 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
export type ButtonVisualProps = {
type ButtonVisualBase = {
size?: ButtonSize
interaction?: ButtonInteraction
} & (
}
type ButtonVisualWithoutInteraction =
| {
type?: 'base'
color?: never
interaction?: never
}
| {
type: 'outlined'
type: Exclude<ButtonType, 'base' | 'quiet'>
color?: ButtonColor
interaction?: never
}
| {
type: 'colored'
color?: ButtonColor
}
| {
type: 'colored-text'
color?: ButtonColor
}
| {
type: 'quiet'
color?: ButtonColor
}
)
/**
* Public visual combinations supported by the current button frame.
*
* `interaction` only changes quiet buttons, so exposing it on any other type
* would accept a prop that has no effect. Keeping that restriction here makes
* Button and ButtonLink share one truthful visual contract.
*/
export type ButtonVisualProps = ButtonVisualBase &
(
| ButtonVisualWithoutInteraction
| {
type: 'quiet'
color?: ButtonColor
interaction?: ButtonInteraction
}
)
export type ButtonNativeType = 'button' | 'submit' | 'reset'
export interface ButtonProps {
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
nativeType?: ButtonNativeType
disabled?: boolean
loading?: boolean
}
/** Icon-only controls need a name because their slot has no visible label. */
export type ButtonContentProps =
| {
iconOnly: true
label: string
}
| {
iconOnly?: false
label?: never
}
export type ButtonProps = ButtonVisualProps &
ButtonContentProps & {
circular?: boolean
nativeType?: ButtonNativeType
disabled?: boolean
loading?: boolean
}
export type ButtonLinkProps = ButtonVisualProps &
ButtonContentProps & {
as?: string | import('vue').Component
href?: string
disabled?: boolean
circular?: boolean
}
export interface ButtonElementHandle {
element: HTMLElement | null
@@ -1,5 +1,6 @@
export { default as JLineCommandInput } from './components/JLineCommandInput.vue'
export { default as LogViewport } from './components/LogViewport.vue'
export * from './jline'
export { default as ConsolePageLayout } from './layout.vue'
export * from './providers'
export * from './types'
export * from './types'
+22 -2
View File
@@ -28,6 +28,13 @@ export function truncateLogToMaxBytes(content: string, maxBytes: number): string
return textDecoder.decode(bytes.subarray(start))
}
/**
* Log sharing providers supported by the launcher. LogShare is the default
* and is implemented in the desktop app through native Tauri commands; mclo.gs
* is the network fallback used when LogShare is unreachable or unselected.
*/
export type LogShareProvider = 'logshare' | 'mclogs'
/**
* Result of sharing log content. `truncated` is set when the content exceeded
* `LOG_SHARE_MAX_BYTES` and only its tail was uploaded.
@@ -39,12 +46,25 @@ export type LogShareResult = {
/**
* Share log content through mclo.gs. Content larger than `LOG_SHARE_MAX_BYTES`
* is trimmed to its last
* `LOG_SHARE_MAX_BYTES` before uploading.
* is trimmed to its last `LOG_SHARE_MAX_BYTES` before uploading.
*/
export async function shareLogs(
client: AbstractModrinthClient,
content: string,
): Promise<LogShareResult> {
return shareLogsWithProvider(client, content, 'mclogs')
}
/**
* Share log content through the given provider. The `logshare` provider is
* only available inside the desktop app (native commands); this shared helper
* routes the `mclogs` fallback so both launcher surfaces can share generic
* log buffers.
*/
export async function shareLogsWithProvider(
client: AbstractModrinthClient,
content: string,
_provider: LogShareProvider = 'mclogs',
): Promise<LogShareResult> {
const uploadContent = truncateLogToMaxBytes(content, LOG_SHARE_MAX_BYTES)
const truncated = uploadContent !== content