mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 15:01:40 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b7fd3098a |
No files matched your search
Generated
+1
-1
@@ -425,7 +425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.8.2-preview.1"
|
||||
version = "0.8.2-preview.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.8.2-preview.1"
|
||||
version = "0.8.2-preview.2"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
+167
-7
@@ -45,6 +45,20 @@ pub(crate) fn interruptible<T: Send + 'static>(
|
||||
cancel: &crate::jobs::Cancellation,
|
||||
timeout: Duration,
|
||||
action: impl FnOnce(crate::jobs::Cancellation) -> Result<T> + Send + 'static,
|
||||
) -> Result<T> {
|
||||
interruptible_inner(cancel, Some(timeout), action)
|
||||
}
|
||||
/// Downloads have an idle socket timeout, not a wall-clock deadline.
|
||||
pub(crate) fn interruptible_download<T: Send + 'static>(
|
||||
cancel: &crate::jobs::Cancellation,
|
||||
action: impl FnOnce(crate::jobs::Cancellation) -> Result<T> + Send + 'static,
|
||||
) -> Result<T> {
|
||||
interruptible_inner(cancel, None, action)
|
||||
}
|
||||
fn interruptible_inner<T: Send + 'static>(
|
||||
cancel: &crate::jobs::Cancellation,
|
||||
timeout: Option<Duration>,
|
||||
action: impl FnOnce(crate::jobs::Cancellation) -> Result<T> + Send + 'static,
|
||||
) -> Result<T> {
|
||||
use std::sync::{atomic::Ordering, mpsc};
|
||||
cancel.check()?;
|
||||
@@ -78,12 +92,17 @@ pub(crate) fn interruptible<T: Send + 'static>(
|
||||
let _permit = permit;
|
||||
let _ = tx.send(action(worker_token));
|
||||
})?;
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
let deadline = timeout.map(|timeout| std::time::Instant::now() + timeout);
|
||||
loop {
|
||||
cancel.check()?;
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
ensure!(!remaining.is_zero(), "网络请求超时");
|
||||
match rx.recv_timeout(remaining.min(Duration::from_millis(50))) {
|
||||
let wait = if let Some(deadline) = deadline {
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
ensure!(!remaining.is_zero(), "网络请求超时");
|
||||
remaining.min(Duration::from_millis(50))
|
||||
} else {
|
||||
Duration::from_millis(50)
|
||||
};
|
||||
match rx.recv_timeout(wait) {
|
||||
Ok(result) => return result,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(_) => bail!("网络请求任务异常"),
|
||||
@@ -109,7 +128,39 @@ pub fn get_with_proxy_cancel(
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
get_blocking(&initial, allow_http, timeout, &refs, &proxy, &token)
|
||||
get_blocking(&initial, allow_http, timeout, &refs, &proxy, &token, false)
|
||||
})
|
||||
}
|
||||
/// Bound connection/individual socket waits while allowing a progressing body
|
||||
/// to take arbitrarily long. Metadata requests retain their total deadlines.
|
||||
pub fn get_download_with_proxy(
|
||||
initial: &str,
|
||||
allow_http: bool,
|
||||
idle_timeout: Duration,
|
||||
headers: &[(&str, &str)],
|
||||
proxy: &crate::model::ProxySettings,
|
||||
) -> Result<ureq::Response> {
|
||||
let initial = initial.to_owned();
|
||||
let proxy = proxy.clone();
|
||||
let headers: Vec<_> = headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
// Only obtaining headers has a total deadline; the returned body does not.
|
||||
interruptible(&Default::default(), idle_timeout, move |token| {
|
||||
let refs: Vec<_> = headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
get_blocking(
|
||||
&initial,
|
||||
allow_http,
|
||||
idle_timeout,
|
||||
&refs,
|
||||
&proxy,
|
||||
&token,
|
||||
true,
|
||||
)
|
||||
})
|
||||
}
|
||||
fn get_blocking(
|
||||
@@ -119,14 +170,22 @@ fn get_blocking(
|
||||
headers: &[(&str, &str)],
|
||||
proxy: &crate::model::ProxySettings,
|
||||
cancel: &crate::jobs::Cancellation,
|
||||
download: bool,
|
||||
) -> Result<ureq::Response> {
|
||||
cancel.check()?;
|
||||
proxy.validate()?;
|
||||
crate::model::validate_url(initial, allow_http)?;
|
||||
let mut builder = ureq::AgentBuilder::new()
|
||||
.timeout(timeout)
|
||||
let builder = ureq::AgentBuilder::new()
|
||||
.redirects(0)
|
||||
.try_proxy_from_env(false);
|
||||
let mut builder = if download {
|
||||
builder
|
||||
.timeout_connect(timeout)
|
||||
.timeout_read(timeout)
|
||||
.timeout_write(timeout)
|
||||
} else {
|
||||
builder.timeout(timeout)
|
||||
};
|
||||
if proxy.http_enabled {
|
||||
builder = builder.proxy(ureq::Proxy::new(&proxy.http)?);
|
||||
}
|
||||
@@ -211,6 +270,107 @@ fn routed_url(url: &url::Url, proxy: &crate::model::ProxySettings) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn download_progress_outlives_idle_timeout() {
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
};
|
||||
for chunked in [false, true] {
|
||||
let server = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}/slow", server.local_addr().unwrap());
|
||||
let server_thread = std::thread::spawn(move || {
|
||||
let (mut stream, _) = server.accept().unwrap();
|
||||
let headers = if chunked {
|
||||
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
|
||||
} else {
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 12\r\nConnection: close\r\n\r\n"
|
||||
};
|
||||
stream.write_all(headers.as_bytes()).unwrap();
|
||||
for _ in 0..12 {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
stream
|
||||
.write_all(if chunked { b"1\r\nx\r\n" } else { b"x" })
|
||||
.unwrap();
|
||||
stream.flush().unwrap();
|
||||
}
|
||||
if chunked {
|
||||
stream.write_all(b"0\r\n\r\n").unwrap();
|
||||
}
|
||||
});
|
||||
let start = std::time::Instant::now();
|
||||
let result = interruptible_download(&Default::default(), move |_| {
|
||||
let response = get_download_with_proxy(
|
||||
&url,
|
||||
true,
|
||||
Duration::from_millis(500),
|
||||
&[],
|
||||
&Default::default(),
|
||||
)?;
|
||||
let mut body = Vec::new();
|
||||
response.into_reader().read_to_end(&mut body)?;
|
||||
Ok(body)
|
||||
});
|
||||
server_thread.join().unwrap();
|
||||
assert_eq!(result.unwrap(), b"xxxxxxxxxxxx");
|
||||
assert!(start.elapsed() > Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn download_stalled_body_still_times_out() {
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
sync::mpsc,
|
||||
};
|
||||
let server = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}/stalled", server.local_addr().unwrap());
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
let server_thread = std::thread::spawn(move || {
|
||||
let (mut stream, _) = server.accept().unwrap();
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nh")
|
||||
.unwrap();
|
||||
release_rx.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
});
|
||||
let response = get_download_with_proxy(
|
||||
&url,
|
||||
true,
|
||||
Duration::from_millis(250),
|
||||
&[],
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
let result = response.into_reader().read_to_end(&mut Vec::new());
|
||||
release_tx.send(()).unwrap();
|
||||
server_thread.join().unwrap();
|
||||
assert!(result.is_err());
|
||||
assert!(start.elapsed() < Duration::from_secs(2));
|
||||
}
|
||||
#[test]
|
||||
fn download_without_total_deadline_remains_cancellable() {
|
||||
use std::sync::mpsc;
|
||||
let token = crate::jobs::Cancellation::default();
|
||||
let worker_token = token.clone();
|
||||
let (seen_tx, seen_rx) = mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
interruptible_download(&worker_token, move |cancel| {
|
||||
seen_tx.send(()).unwrap();
|
||||
loop {
|
||||
cancel.check()?;
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
seen_rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
token.stop();
|
||||
assert!(worker.join().unwrap().is_err());
|
||||
assert!(start.elapsed() < Duration::from_millis(500));
|
||||
}
|
||||
#[test]
|
||||
fn redirect_download_retains_integrity_and_bounds() {
|
||||
use tiny_http::{Header, Response, Server};
|
||||
let server = Server::http("127.0.0.1:0").unwrap();
|
||||
|
||||
+1
-2
@@ -275,9 +275,8 @@ impl Jobs {
|
||||
let download_request = request.clone();
|
||||
let download_jobs = jobs.clone();
|
||||
let download_key = key.clone();
|
||||
let bytes = crate::http::interruptible(
|
||||
let bytes = crate::http::interruptible_download(
|
||||
&cancel,
|
||||
std::time::Duration::from_secs(180),
|
||||
move |token| {
|
||||
package::stage_request_proxy(
|
||||
&download_request,
|
||||
|
||||
+2
-2
@@ -223,7 +223,7 @@ pub fn stage_request_proxy(
|
||||
progress(0, None)?;
|
||||
let url = v["url"].as_str().context("Missing package/URL")?;
|
||||
validate_url(url, v["allowHttp"].as_bool().unwrap_or(false))?;
|
||||
let response = crate::http::get_with_proxy(
|
||||
let response = crate::http::get_download_with_proxy(
|
||||
url,
|
||||
v["allowHttp"].as_bool().unwrap_or(false),
|
||||
std::time::Duration::from_secs(60),
|
||||
@@ -421,7 +421,7 @@ pub fn from_request_proxy(
|
||||
progress(0, None)?;
|
||||
let url = v["url"].as_str().context("Missing package/URL")?;
|
||||
validate_url(url, v["allowHttp"].as_bool().unwrap_or(false))?;
|
||||
let response = crate::http::get_with_proxy(
|
||||
let response = crate::http::get_download_with_proxy(
|
||||
url,
|
||||
v["allowHttp"].as_bool().unwrap_or(false),
|
||||
std::time::Duration::from_secs(60),
|
||||
|
||||
+1
-1
@@ -350,7 +350,7 @@ impl Updater {
|
||||
};
|
||||
let result = (|| -> Result<Value> {
|
||||
report(json!({"phase":"downloading","received":0,"total":release.size,"version":release.version}))?;
|
||||
let response = crate::http::get_with_proxy(&release.url, false, Duration::from_secs(120), &[], &proxy)?;
|
||||
let response = crate::http::get_download_with_proxy(&release.url, false, Duration::from_secs(120), &[], &proxy)?;
|
||||
if let Some(length) = response.header("Content-Length") {
|
||||
ensure!(length.parse::<u64>()? == release.size, "下载长度与更新清单不一致");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import {build} from 'esbuild';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
Object.defineProperty(globalThis,'navigator',{value:{languages:['zh-CN']},configurable:true});
|
||||
globalThis.document={documentElement:{lang:''},addEventListener(){}};
|
||||
globalThis.window={addEventListener(){}};
|
||||
const bundle=await build({stdin:{contents:`import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';import {Transfer} from './ui/src/store';export const render=(job)=>renderToStaticMarkup(<Transfer job={job} close={()=>{}}/>);`,resolveDir:process.cwd(),loader:'tsx'},bundle:true,platform:'node',format:'cjs',write:false});
|
||||
const module={exports:{}};
|
||||
new Function('module','exports','require',bundle.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
|
||||
const render=(received,total,phase='downloading')=>module.exports.render({id:'test',kind:'inspect',phase,received,total});
|
||||
for(const received of [995,999,1000,1001]){
|
||||
const html=render(received,1000);
|
||||
assert.ok(html.includes('99%'));
|
||||
assert.ok(!html.includes('100%'));
|
||||
assert.ok(html.includes('value="99"'));
|
||||
}
|
||||
assert.ok(render(500,1000).includes('50%'));
|
||||
assert.ok(!render(500,undefined).includes('NaN'));
|
||||
assert.ok(!render(1000,1000,'verifying').includes('<progress'));
|
||||
console.log('PASS: incomplete downloads never display 100%, including rounded and final-byte progress');
|
||||
+1
-1
@@ -58,7 +58,7 @@ export function Store({plugins,sources,onInspect,onSources,busy,target}:{target?
|
||||
if(selected){const p=selected.entry,s=selected.source,installed=plugins[p.id],state=stateFor(p,installed,s);const latest=all.find(x=>x.entry.id===p.id&&x.source.id===s.id)?.entry??p;const versions=[latest,...(history.key===historyKey?history.entries:[]).filter(v=>v.version!==latest.version)];return <><button className="back-link" onClick={()=>setSelected(null)}><Icon name="arrow"/>{t("返回插件库")}</button><section className="store-detail"><div className="detail-heading"><PluginImage src={p.icon} name={p.name} size={80}/><div><h1>{p.name}</h1><p><PluginAuthor plugin={p}/> · {p.version}</p></div><button className="primary" disabled={busy||['incompatible'].includes(state.kind)} onClick={()=>void onInspect(request(p,s))}>{state.kind==='update'?t("更新"):state.kind==='new'?t("安装"):state.kind==='other'||state.kind==='different'?t("检查此版本"):state.kind==='installed'?t("重新安装"):state.kind==='older'?t("安装所选版本"):state.label}</button></div><RunUserNotice user={p.runAs}/><section className="settings-card">{history.key===historyKey&&history.loading&&<p className="sub" role="status">{t("正在加载历史版本…")}</p>}{history.key===historyKey&&history.error&&<p className="error" role="alert">{t("历史版本暂时不可用,仍可安装最新版。")}<button onClick={()=>setHistoryRetry(v=>v+1)}>{t("重试")}</button></p>}<Select label={t("可选安装版本")} value={p.version} onChange={version=>{const entry=versions.find(v=>v.version===version);if(entry)setSelected({entry,source:s});}} options={versions.map(v=>({value:v.version,label:v.version+(v.version===installed?.manifest.version?t(" · 已安装"):v.compatible===false?t(" · 不兼容"):v===versions[0]?t(" · 推荐版本"):"")}))}/></section><div className="badges"><span>{state.label}</span><span>{s.name}</span>{(p.tags??[]).map(t=><span key={t}>{t}</span>)}</div>{state.kind==='other'&&<p className="banner">{t("当前安装来自其他源,确认安装此版本后会更换来源。")}</p>}<p className="detail-summary">{p.description}</p><PluginLinks plugin={p}/>{p.engines?.framely&&<p className={p.compatible===false?'banner':'sub'}>{t('要求 Framely:{0}',{0:p.engines.framely})}</p>}{!!p.screenshots?.length&&<div className="screenshots">{p.screenshots.map((url,i)=><figure key={url}><img src={url} alt={t("{0}截图 {1}", {"0": p.name, "1": i+1})}/></figure>)}</div>}<h2>{t("关于插件")}</h2><p className="prose">{p.details||p.description||t("开发者尚未提供详细介绍。")}</p><h2>{t("依赖与冲突")}</h2>{Object.entries(p.dependencies??{}).map(([id,d])=><p key={id}>{t("必需:")}{id} · {typeof d==='string'?d:d.version}{typeof d==='object'&&<small className="source-url">{d.source}</small>}</p>)}{Object.entries(p.optionalDependencies??{}).map(([id,d])=><p key={id}>{t("可选:")}{id} · {typeof d==='string'?d:d.version}</p>)}{Object.entries(p.conflicts??{}).map(([id,range])=><p key={id}>{t("冲突:")}{id} · {range}</p>)}{!!p.exclusiveResources?.length&&<p>{t("独占资源:")}{p.exclusiveResources.join(', ')}</p>}<h2>{t("更新记录")}</h2><p className="prose">{p.changelog||t("开发者尚未提供更新记录。")}</p><section className="settings-card"><h2>{t("版本与来源")}</h2><p>{installed?t("本机版本:{0}", {"0": installed.manifest.version}):t("尚未安装")}{t("· 可用版本:")}{p.version}</p><p className="sub">{t("安装前会展示运行用户。插件库资料由插件源提供,以包内的信息为准。")}</p></section></section></>;}
|
||||
return <><div className="list-heading"><div><h1>{t("插件库")}</h1><p>{t("浏览已添加插件源中的插件。")}</p></div><div className="row"><button onClick={onSources}><Icon name="link"/>{t("管理插件源")}</button><button disabled={loading} onClick={()=>void load()}><Icon name="refresh"/>{loading?t("刷新中…"):t("刷新")}</button></div></div>{loading&&<div className="banner" role="status"><span>{t("插件源加载进度:{0} / {1}",{0:loadJob?.completedSources??0,1:loadJob?.totalSources??sources.filter(s=>s.enabled).length})}</span><button disabled={!loadJob} onClick={()=>void cancelLoad()}>{t("取消加载")}</button></div>}<div className="store-filters"><div className="search-field"><Icon name="search"/><input aria-label={t("搜索插件库")} placeholder={t("搜索名称、作者或标签")} value={query} onChange={e=>setQuery(e.target.value)}/></div><Select multiple label={t("标签")} value={selectedTags} onChange={setSelectedTags} options={tags.map(value=>({value,label:value}))}/><Select label={t("插件源")} value={source} onChange={setSource} options={[{value:'all',label:t("所有来源")},...sources.filter(s=>s.enabled).map(s=>({value:s.id,label:s.name}))]}/><Select label={t("安装状态")} value={filter} onChange={setFilter} options={[{value:"all",label:t("全部状态")},{value:"installed",label:t("已安装")},{value:"update",label:t("可更新")}]}/></div>{error&&<p className="error">{error}<button onClick={()=>void load()}>{t("重试")}</button></p>}{data.filter(c=>c.error).map(c=><div className="banner" key={c.source?.id}><b>{c.source?.name??t("插件源")}{t("暂时不可用")}</b><p>{c.error}{c.cached?t(",正在显示上次成功读取的目录。"):''}</p><button disabled={loading} onClick={()=>void load()}>{t("重新连接")}</button></div>)}{loading&&!all.length?<div className="empty-state"><h2>{t("正在加载插件目录…")}</h2><p>{t("已安装插件仍可正常使用。")}</p></div>:!sources.some(s=>s.enabled)?<div className="empty-state"><Icon name="link" size={48}/><h2>{t("尚未添加插件源")}</h2><p>{t("配置你信任的目录地址,即可浏览和安装插件。")}</p><button className="primary" onClick={onSources}>{t("管理插件源")}</button></div>:!shown.length?<div className="empty-state"><h2>{all.length?t("没有符合条件的插件"):t("目录暂时没有插件")}</h2><p>{all.length?t("试试其他关键词或清除筛选。"):t("可以检查插件源连接,或导入本地插件包。")}</p>{!!all.length&&<button onClick={()=>{setQuery('');setSelectedTags([]);setSource('all');setFilter('all');}}>{t("清除筛选")}</button>}</div>:<><p className="sub">{shown.length}{t("个结果")}</p><div className="store-grid">{shown.map(({entry:p,source:s,state})=><article className="store-card" key={`${s.id}:${p.id}`} onClick={()=>setSelected({entry:p,source:s})}><PluginImage src={p.icon} name={p.name} size={80}/><div className="store-copy"><h2 className="store-title-row"><button className="store-title" onClick={()=>setSelected({entry:p,source:s})}>{p.name}</button><RunUserBadge user={p.runAs}/></h2><p>{p.description||t("查看插件详情")}</p><small><PluginAuthor plugin={p}/> · {s.name}</small></div><span className="store-version">{p.version}</span><span className={`badge ${state.kind==='update'?'accent':''}`}>{state.label}</span><Icon name="arrow"/></article>)}</div></>}</>;
|
||||
}
|
||||
export function Transfer({job,retry,cancel,close}:{job:Job;retry?:()=>void;cancel?:()=>void;close:()=>void}){const labels:Record<string,string>={queued:t("等待开始"),downloading:t("正在下载"),verifying:t("正在校验内容"),resolving:t("正在解析插件依赖"),installing:t("正在安装"),loading:t("正在连接"),done:t("已完成"),failed:t("操作失败"),cancelled:t("已取消")};const percent=job.total?Math.min(100,Math.round(job.received/job.total*100)):undefined;return <div className="modal transfer"><h2>{labels[job.phase]??job.phase}</h2>{job.phase==='downloading'&&<><progress max={100} value={percent}/><p>{(job.received/1024/1024).toFixed(1)} MiB{job.total?` / ${(job.total/1024/1024).toFixed(1)} MiB · ${percent}%`:''}</p></>}{job.error&&<p className="error">{job.error}</p>}{job.phase==='failed'&&retry&&<button className="primary" onClick={retry}>{t("重试")}</button>}{!['done','failed','cancelled','installing'].includes(job.phase)&&cancel&&<button onClick={cancel}>{t("取消")}</button>}{['done','failed','cancelled'].includes(job.phase)&&<button onClick={close}>{t("关闭")}</button>}{job.phase==='installing'&&<p className="sub">{t("请稍候,正在保存插件和配置。")}</p>}</div>}
|
||||
export function Transfer({job,retry,cancel,close}:{job:Job;retry?:()=>void;cancel?:()=>void;close:()=>void}){const labels:Record<string,string>={queued:t("等待开始"),downloading:t("正在下载"),verifying:t("正在校验内容"),resolving:t("正在解析插件依赖"),installing:t("正在安装"),loading:t("正在连接"),done:t("已完成"),failed:t("操作失败"),cancelled:t("已取消")};const percent=job.total?Math.min(job.phase==='downloading'?99:100,Math.floor(job.received/job.total*100)):undefined;return <div className="modal transfer"><h2>{labels[job.phase]??job.phase}</h2>{job.phase==='downloading'&&<><progress max={100} value={percent}/><p>{(job.received/1024/1024).toFixed(1)} MiB{job.total?` / ${(job.total/1024/1024).toFixed(1)} MiB · ${percent}%`:''}</p></>}{job.error&&<p className="error">{job.error}</p>}{job.phase==='failed'&&retry&&<button className="primary" onClick={retry}>{t("重试")}</button>}{!['done','failed','cancelled','installing'].includes(job.phase)&&cancel&&<button onClick={cancel}>{t("取消")}</button>}{['done','failed','cancelled'].includes(job.phase)&&<button onClick={close}>{t("关闭")}</button>}{job.phase==='installing'&&<p className="sub">{t("请稍候,正在保存插件和配置。")}</p>}</div>}
|
||||
export function Sources({sources,save,origins}:{origins?:Record<string,{manual:boolean;subscriptions:Record<string,string>}>;sources:Source[];save:(s:Source[])=>Promise<unknown>}){const[name,setName]=useState(''),[url,setUrl]=useState(''),[http,setHttp]=useState(false),[edit,setEdit]=useState<string|null>(null),[error,setError]=useState(''),[probe,setProbe]=useState(''),[testing,setTesting]=useState(false),[busy,setBusy]=useState(false),[deleting,setDeleting]=useState<Source|null>(null),[formOpen,setFormOpen]=useState(false);
|
||||
const form=()=>({id:edit??`source-${Date.now()}`,name:name.trim(),url:url.trim(),allowHttp:http,enabled:true});
|
||||
async function change(next:Source[]){setBusy(true);setError('');try{await save(next);return true;}catch(e){setError(String(e));return false;}finally{setBusy(false);}}
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
|
||||
</div>
|
||||
{checked&&!release&&<p className="banner" role="status">{t('所选渠道暂无可用发行版本。')}</p>}
|
||||
{job&&<p className="banner" role="status">{job.kind==='system.check'?t('正在读取更新清单…'):job.phase==='verifying'?t('下载完成,正在校验发行包…'):t('正在下载发行包…')}</p>}
|
||||
{progress?.phase==='downloading'&&<div className="download-progress"><progress aria-label={t('下载进度')} max={progress.total??1} value={progress.received??0}/><p>{((progress.received??0)/1024/1024).toFixed(1)} / {((progress.total??0)/1024/1024).toFixed(1)} MiB</p></div>}
|
||||
{progress?.phase==='downloading'&&<div className="download-progress"><progress aria-label={t('下载进度')} max={progress.total??1} value={Math.min(progress.received??0,(progress.total??1)*.99)}/><p>{((progress.received??0)/1024/1024).toFixed(1)} / {((progress.total??0)/1024/1024).toFixed(1)} MiB</p></div>}
|
||||
{progress?.phase==='verifying'&&<div className="download-progress"><progress aria-label={t('校验进度')} max={progress.total??1} value={progress.verified??0}/><p>{t('已校验 {0} / {1} MiB',{'0':((progress.verified??0)/1024/1024).toFixed(1),'1':((progress.total??0)/1024/1024).toFixed(1)})}</p></div>}
|
||||
{progress?.phase==='failed'&&<p className="error">{t('上次更新失败:')}{progress.error}<small>{t('诊断日志:/var/lib/framely/logs/update.log')}</small></p>}
|
||||
{progress?.phase==='done'&&<p className="banner">{t('上次更新操作已完成。')}</p>}
|
||||
|
||||
Reference in new issue
Block a user