mirror of https://github.com/synctv-org/synctv
feat(web): add client-aware playback and reproducible UI (#433)
## Summary - add a versioned playback client profile for browser/runtime protocol, container, codec, header, proxy, insecure-media, and P2P-loader capabilities - generate compatible direct and proxy resources inside each provider and return a structured incompatibility error when route policy leaves no viable result - force provider proxy delivery for browser-forbidden headers, including affected Bilibili variants, while preserving explicit direct-only failures - keep legacy clients compatible and isolate capability-aware playback cache entries - serve `/oauth2/callback` through the same Flutter SPA entry point and keep public discovery anonymous ## Reproducible Web UI - move Flutter acquisition/build, source configuration, asset manifests, Brotli/gzip compression, and compile-time embedding into the independent `synctv-web-ui` crate - support prebuilt distributions, local projects, and Git sources pinned to an immutable full commit, with an ignored local override - fingerprint source, Flutter version, build arguments, dart-defines, builder generation, output, and compression settings - validate cached Git repository/revision/commit identity, support offline cache reuse, and rebuild only when relevant inputs change - pin and checksum the Flutter SDK in the Docker Web asset stage; backend-only builds require no Flutter, Git fetch, or network - serve embedded assets with ETags, compression negotiation, CSP, cache policy, Origin/CORS handling, and SPA fallback outside API/media routes ## Verification - `cargo fmt --all -- --check` - workspace `cargo check`, test/doc-test, and all-targets Clippy with warnings denied - provider tests: 200 passed, 1 ignored - OAuth core/API tests: 72 + 16 passed - `synctv-web-ui`: 10 passed - `synctv-api-http --features web-ui`: 12 passed - default Git source cold build from the pinned App commit, followed by offline hot-cache reuse in about 1.1 seconds - `cargo check -p synctv --features web-ui` and `docker buildx build --check .` - real Chrome playback, P2P, OAuth/Casdoor, multi-user sync, chat, media, playlist, upload, settings, and playback-history flows Companion frontend PR: https://github.com/synctv-org/synctv-app/pull/52 Release pin validation: https://github.com/synctv-org/synctv-release/pull/9pull/436/head
parent
9936e555d7
commit
63c370876d
@ -1,4 +1,4 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "nightly"
|
channel = "nightly-2026-08-21"
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
components = ["clippy", "rustfmt"]
|
components = ["clippy", "rustfmt"]
|
||||||
|
|||||||
@ -0,0 +1,593 @@
|
|||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{header, HeaderMap, HeaderValue, StatusCode, Uri};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
||||||
|
use synctv_web_ui::{Asset, ASSETS, WEB_UI_AVAILABLE};
|
||||||
|
|
||||||
|
const PROVIDER_VERIFICATION_PAGE: &str = "provider_verification.html";
|
||||||
|
const PROVIDER_VERIFICATION_CSP: &str = "default-src 'none'; \
|
||||||
|
script-src 'self' https://static.geetest.com https://*.geetest.com https://dn-staticdown.qbox.me; \
|
||||||
|
connect-src https://geetest.com https://*.geetest.com https://monitor.geetest.com https://dn-staticdown.qbox.me; \
|
||||||
|
img-src data: blob: https://geetest.com https://*.geetest.com https://dn-staticdown.qbox.me; \
|
||||||
|
style-src 'self' 'unsafe-inline' https://*.geetest.com; \
|
||||||
|
font-src data: https://*.geetest.com; \
|
||||||
|
frame-src https://*.geetest.com; \
|
||||||
|
frame-ancestors 'self'; \
|
||||||
|
base-uri 'none'; \
|
||||||
|
form-action 'none'";
|
||||||
|
|
||||||
|
pub async fn index(headers: HeaderMap) -> Response {
|
||||||
|
serve_path("index.html", true, &headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fallback(uri: Uri, headers: HeaderMap) -> Response {
|
||||||
|
let path = uri.path().trim_start_matches('/');
|
||||||
|
if path.is_empty() {
|
||||||
|
return serve_path("index.html", true, &headers);
|
||||||
|
}
|
||||||
|
if path.starts_with("api/")
|
||||||
|
|| path == "api"
|
||||||
|
|| path.starts_with("ws/")
|
||||||
|
|| path == "ws"
|
||||||
|
|| path.starts_with("grpc/")
|
||||||
|
|| path == "grpc"
|
||||||
|
{
|
||||||
|
return not_found();
|
||||||
|
}
|
||||||
|
if path.contains("..") || path.contains('\\') {
|
||||||
|
return not_found();
|
||||||
|
}
|
||||||
|
if let Some(response) = find_asset(path).map(|asset| asset_response(asset, false, &headers)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
let accepts_html = headers
|
||||||
|
.get(header::ACCEPT)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|value| {
|
||||||
|
value.split(',').any(|part| {
|
||||||
|
part.split(';').next().is_some_and(|media_type| {
|
||||||
|
matches!(media_type.trim(), "text/html" | "application/xhtml+xml")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if !path.contains('.') && (accepts_html || headers.get(header::ACCEPT).is_none()) {
|
||||||
|
return serve_path("index.html", true, &headers);
|
||||||
|
}
|
||||||
|
not_found()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve_path(path: &str, html_navigation: bool, headers: &HeaderMap) -> Response {
|
||||||
|
if !WEB_UI_AVAILABLE {
|
||||||
|
return (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
|
||||||
|
"The embedded SyncTV Web client is not available in this build.",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
find_asset(path)
|
||||||
|
.map(|asset| asset_response(asset, html_navigation, headers))
|
||||||
|
.unwrap_or_else(not_found)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_asset(path: &str) -> Option<&'static Asset> {
|
||||||
|
ASSETS.iter().find(|asset| asset.path == path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn asset_response(asset: &'static Asset, html_navigation: bool, headers: &HeaderMap) -> Response {
|
||||||
|
let cache_control = if asset.path == PROVIDER_VERIFICATION_PAGE {
|
||||||
|
"no-store"
|
||||||
|
} else if versioned_playback_asset(asset.path) {
|
||||||
|
"public, max-age=31536000, immutable"
|
||||||
|
} else if html_navigation || update_metadata_asset(asset.path) {
|
||||||
|
"no-cache"
|
||||||
|
} else {
|
||||||
|
"public, max-age=0, must-revalidate"
|
||||||
|
};
|
||||||
|
let representation = match select_representation(asset, headers) {
|
||||||
|
Some(representation) => representation,
|
||||||
|
None => return StatusCode::NOT_ACCEPTABLE.into_response(),
|
||||||
|
};
|
||||||
|
if if_none_match_matches(headers, representation.etag) {
|
||||||
|
let mut response = StatusCode::NOT_MODIFIED.into_response();
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::ETAG, HeaderValue::from_static(representation.etag));
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static(cache_control),
|
||||||
|
);
|
||||||
|
apply_representation_headers(response.headers_mut(), representation);
|
||||||
|
apply_asset_security_headers(asset.path, response.headers_mut());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
let mut response = Response::new(Body::from(representation.bytes));
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static(asset.content_type),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static(cache_control),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::ETAG, HeaderValue::from_static(representation.etag));
|
||||||
|
apply_representation_headers(response.headers_mut(), representation);
|
||||||
|
apply_asset_security_headers(asset.path, response.headers_mut());
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_asset_security_headers(path: &str, headers: &mut HeaderMap) {
|
||||||
|
if path != PROVIDER_VERIFICATION_PAGE {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_SECURITY_POLICY,
|
||||||
|
HeaderValue::from_static(PROVIDER_VERIFICATION_CSP),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::HeaderName::from_static("x-frame-options"),
|
||||||
|
HeaderValue::from_static("SAMEORIGIN"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::REFERRER_POLICY,
|
||||||
|
HeaderValue::from_static("no-referrer"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::HeaderName::from_static("permissions-policy"),
|
||||||
|
HeaderValue::from_static(
|
||||||
|
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), \
|
||||||
|
magnetometer=(), microphone=(), payment=(), picture-in-picture=(), usb=()",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::HeaderName::from_static("cross-origin-resource-policy"),
|
||||||
|
HeaderValue::from_static("same-origin"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Representation {
|
||||||
|
bytes: &'static [u8],
|
||||||
|
etag: &'static str,
|
||||||
|
encoding: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_representation(asset: &'static Asset, headers: &HeaderMap) -> Option<Representation> {
|
||||||
|
let qualities = accepted_encoding_qualities(headers);
|
||||||
|
let brotli_quality = asset.brotli.map_or(0, |_| qualities.brotli);
|
||||||
|
let gzip_quality = asset.gzip.map_or(0, |_| qualities.gzip);
|
||||||
|
if brotli_quality > 0 && brotli_quality >= gzip_quality && brotli_quality >= qualities.identity
|
||||||
|
{
|
||||||
|
let encoded = asset.brotli?;
|
||||||
|
return Some(Representation {
|
||||||
|
bytes: encoded.bytes,
|
||||||
|
etag: encoded.etag,
|
||||||
|
encoding: Some("br"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if gzip_quality > 0 && gzip_quality >= qualities.identity {
|
||||||
|
let encoded = asset.gzip?;
|
||||||
|
return Some(Representation {
|
||||||
|
bytes: encoded.bytes,
|
||||||
|
etag: encoded.etag,
|
||||||
|
encoding: Some("gzip"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(qualities.identity > 0).then_some(Representation {
|
||||||
|
bytes: asset.bytes,
|
||||||
|
etag: asset.etag,
|
||||||
|
encoding: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_representation_headers(headers: &mut HeaderMap, representation: Representation) {
|
||||||
|
headers.append(header::VARY, HeaderValue::from_static("Accept-Encoding"));
|
||||||
|
if let Some(encoding) = representation.encoding {
|
||||||
|
headers.insert(header::CONTENT_ENCODING, HeaderValue::from_static(encoding));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
struct EncodingQualities {
|
||||||
|
brotli: u16,
|
||||||
|
gzip: u16,
|
||||||
|
identity: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accepted_encoding_qualities(headers: &HeaderMap) -> EncodingQualities {
|
||||||
|
if !headers.contains_key(header::ACCEPT_ENCODING) {
|
||||||
|
return EncodingQualities {
|
||||||
|
brotli: 0,
|
||||||
|
gzip: 0,
|
||||||
|
identity: 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut brotli = None;
|
||||||
|
let mut gzip = None;
|
||||||
|
let mut identity = None;
|
||||||
|
let mut wildcard = None;
|
||||||
|
for value in headers.get_all(header::ACCEPT_ENCODING) {
|
||||||
|
let Ok(value) = value.to_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for item in value.split(',') {
|
||||||
|
let mut parts = item.trim().split(';');
|
||||||
|
let name = parts.next().unwrap_or_default().trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut quality = 1000;
|
||||||
|
for parameter in parts {
|
||||||
|
let Some((key, value)) = parameter.trim().split_once('=') else {
|
||||||
|
quality = 0;
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if key.trim().eq_ignore_ascii_case("q") {
|
||||||
|
quality = parse_quality(value.trim()).unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match name.to_ascii_lowercase().as_str() {
|
||||||
|
"br" => brotli = Some(quality),
|
||||||
|
"gzip" | "x-gzip" => gzip = Some(quality),
|
||||||
|
"identity" => identity = Some(quality),
|
||||||
|
"*" => wildcard = Some(quality),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EncodingQualities {
|
||||||
|
brotli: brotli.or(wildcard).unwrap_or(0),
|
||||||
|
gzip: gzip.or(wildcard).unwrap_or(0),
|
||||||
|
identity: identity.unwrap_or_else(|| if wildcard == Some(0) { 0 } else { 1000 }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_quality(value: &str) -> Option<u16> {
|
||||||
|
if value == "0" {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
if value == "1" {
|
||||||
|
return Some(1000);
|
||||||
|
}
|
||||||
|
let (whole, fraction) = value.split_once('.')?;
|
||||||
|
if !matches!(whole, "0" | "1") || fraction.len() > 3 || fraction.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut fraction_value = fraction.parse::<u16>().ok()?;
|
||||||
|
if whole == "1" && fraction_value != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for _ in fraction.len()..3 {
|
||||||
|
fraction_value *= 10;
|
||||||
|
}
|
||||||
|
Some(if whole == "1" { 1000 } else { fraction_value })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn versioned_playback_asset(path: &str) -> bool {
|
||||||
|
path.starts_with("playback/") && path.as_bytes().iter().any(u8::is_ascii_digit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_metadata_asset(path: &str) -> bool {
|
||||||
|
path.ends_with(".html")
|
||||||
|
|| matches!(
|
||||||
|
path,
|
||||||
|
"manifest.json" | "version.json" | "flutter_service_worker.js"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn if_none_match_matches(headers: &HeaderMap, current_etag: &str) -> bool {
|
||||||
|
headers
|
||||||
|
.get_all(header::IF_NONE_MATCH)
|
||||||
|
.iter()
|
||||||
|
.any(|value| etag_list_matches(value.as_bytes(), current_etag.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn etag_list_matches(value: &[u8], current_etag: &[u8]) -> bool {
|
||||||
|
let mut index = 0;
|
||||||
|
let mut matched = false;
|
||||||
|
|
||||||
|
while index < value.len() {
|
||||||
|
skip_optional_whitespace(value, &mut index);
|
||||||
|
if value.get(index) == Some(&b'*') {
|
||||||
|
index += 1;
|
||||||
|
skip_optional_whitespace(value, &mut index);
|
||||||
|
return index == value.len();
|
||||||
|
}
|
||||||
|
if value.get(index..index + 2) == Some(b"W/") {
|
||||||
|
index += 2;
|
||||||
|
}
|
||||||
|
if value.get(index) != Some(&b'"') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tag_start = index;
|
||||||
|
index += 1;
|
||||||
|
while let Some(byte) = value.get(index) {
|
||||||
|
if *byte == b'"' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !matches!(*byte, 0x21 | 0x23..=0x7e | 0x80..=0xff) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
if value.get(index) != Some(&b'"') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
matched |= &value[tag_start..index] == current_etag;
|
||||||
|
|
||||||
|
skip_optional_whitespace(value, &mut index);
|
||||||
|
if index == value.len() {
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
if value.get(index) != Some(&b',') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
if index == value.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_optional_whitespace(value: &[u8], index: &mut usize) {
|
||||||
|
while value
|
||||||
|
.get(*index)
|
||||||
|
.is_some_and(|byte| matches!(byte, b' ' | b'\t'))
|
||||||
|
{
|
||||||
|
*index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_found() -> Response {
|
||||||
|
StatusCode::NOT_FOUND.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const ETAG: &str = "\"0123456789abcdef-42\"";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entity_tag_lists_use_weak_comparison_and_support_wildcards() {
|
||||||
|
assert!(etag_list_matches(ETAG.as_bytes(), ETAG.as_bytes()));
|
||||||
|
assert!(etag_list_matches(
|
||||||
|
b"W/\"0123456789abcdef-42\"",
|
||||||
|
ETAG.as_bytes()
|
||||||
|
));
|
||||||
|
assert!(etag_list_matches(
|
||||||
|
b"\"different\", W/\"0123456789abcdef-42\"",
|
||||||
|
ETAG.as_bytes()
|
||||||
|
));
|
||||||
|
assert!(etag_list_matches(b" * \t", ETAG.as_bytes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_or_partial_entity_tags_do_not_match() {
|
||||||
|
assert!(!etag_list_matches(
|
||||||
|
b"\"0123456789abcdef-42",
|
||||||
|
ETAG.as_bytes()
|
||||||
|
));
|
||||||
|
assert!(!etag_list_matches(
|
||||||
|
b"\"0123456789abcdef-42\" trailing",
|
||||||
|
ETAG.as_bytes()
|
||||||
|
));
|
||||||
|
assert!(!etag_list_matches(
|
||||||
|
b"\"different, \"0123456789abcdef-42\"",
|
||||||
|
ETAG.as_bytes()
|
||||||
|
));
|
||||||
|
assert!(!etag_list_matches(b"*, \"other\"", ETAG.as_bytes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_versioned_playback_assets_are_immutable() {
|
||||||
|
assert!(versioned_playback_asset("playback/hls-1.7.1.min.js"));
|
||||||
|
assert!(!versioned_playback_asset("main.dart.js"));
|
||||||
|
assert!(!versioned_playback_asset("playback/engine.min.js"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browser_update_metadata_always_revalidates() {
|
||||||
|
assert!(update_metadata_asset("index.html"));
|
||||||
|
assert!(update_metadata_asset("manifest.json"));
|
||||||
|
assert!(update_metadata_asset("version.json"));
|
||||||
|
assert!(update_metadata_asset("flutter_service_worker.js"));
|
||||||
|
assert!(!update_metadata_asset("main.dart.js"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn oauth_callback_uses_the_spa_entrypoint() {
|
||||||
|
let response = fallback(
|
||||||
|
"/oauth2/callback"
|
||||||
|
.parse::<Uri>()
|
||||||
|
.expect("valid callback URI"),
|
||||||
|
HeaderMap::new(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if !WEB_UI_AVAILABLE {
|
||||||
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers().get(header::CACHE_CONTROL),
|
||||||
|
Some(&HeaderValue::from_static("no-cache"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_verification_page_has_dedicated_security_policy() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
apply_asset_security_headers(PROVIDER_VERIFICATION_PAGE, &mut headers);
|
||||||
|
|
||||||
|
let csp = headers
|
||||||
|
.get(header::CONTENT_SECURITY_POLICY)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.expect("verification CSP should be valid");
|
||||||
|
assert!(csp.contains("default-src 'none'"));
|
||||||
|
assert!(csp.contains("script-src 'self' https://static.geetest.com"));
|
||||||
|
assert!(csp.contains("https://monitor.geetest.com"));
|
||||||
|
assert!(csp.contains("https://dn-staticdown.qbox.me"));
|
||||||
|
assert!(csp.contains("frame-ancestors 'self'"));
|
||||||
|
assert!(!csp.contains("unsafe-eval"));
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("x-frame-options"),
|
||||||
|
Some(&HeaderValue::from_static("SAMEORIGIN"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers.get(header::REFERRER_POLICY),
|
||||||
|
Some(&HeaderValue::from_static("no-referrer"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("cross-origin-resource-policy"),
|
||||||
|
Some(&HeaderValue::from_static("same-origin"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_assets_keep_the_global_security_policy() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
apply_asset_security_headers("index.html", &mut headers);
|
||||||
|
assert!(!headers.contains_key(header::CONTENT_SECURITY_POLICY));
|
||||||
|
assert!(!headers.contains_key("x-frame-options"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_encoding_quality_prefers_brotli_then_gzip() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::ACCEPT_ENCODING,
|
||||||
|
HeaderValue::from_static("gzip, deflate, br"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
accepted_encoding_qualities(&headers),
|
||||||
|
EncodingQualities {
|
||||||
|
brotli: 1000,
|
||||||
|
gzip: 1000,
|
||||||
|
identity: 1000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
headers.insert(
|
||||||
|
header::ACCEPT_ENCODING,
|
||||||
|
HeaderValue::from_static("br;q=0.4, gzip;q=0.8, identity;q=0.1"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
accepted_encoding_qualities(&headers),
|
||||||
|
EncodingQualities {
|
||||||
|
brotli: 400,
|
||||||
|
gzip: 800,
|
||||||
|
identity: 100,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_encoding_quality_honors_identity_and_wildcard_exclusions() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::ACCEPT_ENCODING,
|
||||||
|
HeaderValue::from_static("br;q=0.5"),
|
||||||
|
);
|
||||||
|
assert_eq!(accepted_encoding_qualities(&headers).identity, 1000);
|
||||||
|
|
||||||
|
headers.insert(
|
||||||
|
header::ACCEPT_ENCODING,
|
||||||
|
HeaderValue::from_static("*;q=0, gzip;q=0.7"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
accepted_encoding_qualities(&headers),
|
||||||
|
EncodingQualities {
|
||||||
|
brotli: 0,
|
||||||
|
gzip: 700,
|
||||||
|
identity: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quality_parser_rejects_values_outside_http_range() {
|
||||||
|
assert_eq!(parse_quality("0.5"), Some(500));
|
||||||
|
assert_eq!(parse_quality("1.000"), Some(1000));
|
||||||
|
assert_eq!(parse_quality("1.1"), None);
|
||||||
|
assert_eq!(parse_quality("0.0000"), None);
|
||||||
|
assert_eq!(parse_quality("invalid"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fallback_negotiates_encoded_assets_and_revalidates_each_representation() {
|
||||||
|
if !WEB_UI_AVAILABLE {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let asset = ASSETS
|
||||||
|
.iter()
|
||||||
|
.find(|asset| asset.brotli.is_some() && asset.gzip.is_some())
|
||||||
|
.expect("the Web UI build should contain a compressible asset");
|
||||||
|
let uri = format!("/{}", asset.path).parse::<Uri>().unwrap();
|
||||||
|
|
||||||
|
let mut brotli_headers = HeaderMap::new();
|
||||||
|
brotli_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br"));
|
||||||
|
let brotli_response = fallback(uri.clone(), brotli_headers).await;
|
||||||
|
assert_eq!(brotli_response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
brotli_response.headers().get(header::CONTENT_ENCODING),
|
||||||
|
Some(&HeaderValue::from_static("br"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
brotli_response.headers().get(header::VARY),
|
||||||
|
Some(&HeaderValue::from_static("Accept-Encoding"))
|
||||||
|
);
|
||||||
|
let brotli_etag = brotli_response
|
||||||
|
.headers()
|
||||||
|
.get(header::ETAG)
|
||||||
|
.cloned()
|
||||||
|
.expect("Brotli response should include an ETag");
|
||||||
|
|
||||||
|
let mut gzip_headers = HeaderMap::new();
|
||||||
|
gzip_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip"));
|
||||||
|
let gzip_response = fallback(uri.clone(), gzip_headers).await;
|
||||||
|
assert_eq!(gzip_response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
gzip_response.headers().get(header::CONTENT_ENCODING),
|
||||||
|
Some(&HeaderValue::from_static("gzip"))
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
gzip_response.headers().get(header::ETAG),
|
||||||
|
Some(&brotli_etag)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut revalidation_headers = HeaderMap::new();
|
||||||
|
revalidation_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br"));
|
||||||
|
revalidation_headers.insert(header::IF_NONE_MATCH, brotli_etag.clone());
|
||||||
|
let revalidation_response = fallback(uri.clone(), revalidation_headers).await;
|
||||||
|
assert_eq!(revalidation_response.status(), StatusCode::NOT_MODIFIED);
|
||||||
|
assert_eq!(
|
||||||
|
revalidation_response
|
||||||
|
.headers()
|
||||||
|
.get(header::CONTENT_ENCODING),
|
||||||
|
Some(&HeaderValue::from_static("br"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
revalidation_response.headers().get(header::ETAG),
|
||||||
|
Some(&brotli_etag)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut rejected_headers = HeaderMap::new();
|
||||||
|
rejected_headers.insert(
|
||||||
|
header::ACCEPT_ENCODING,
|
||||||
|
HeaderValue::from_static("br;q=0, gzip;q=0, identity;q=0"),
|
||||||
|
);
|
||||||
|
let rejected_response = fallback(uri, rejected_headers).await;
|
||||||
|
assert_eq!(rejected_response.status(), StatusCode::NOT_ACCEPTABLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "synctv-web-ui"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
embed = []
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
brotli.workspace = true
|
||||||
|
flate2.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
hex.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
tempfile.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
# SyncTV Web UI assets
|
||||||
|
|
||||||
|
This crate owns the acquisition, optional Flutter build, compression, manifest,
|
||||||
|
and compile-time embedding of the SyncTV browser client. `synctv-api-http`
|
||||||
|
only serves the generated asset table.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
`web-ui.toml` reads prebuilt files from `dist/`. The directory is empty in Git
|
||||||
|
apart from `.gitkeep`; place a Web distribution there before enabling the
|
||||||
|
server's `web-ui` feature.
|
||||||
|
|
||||||
|
`web-ui.production.toml` is the versioned production source used by CI. Its Git
|
||||||
|
source pins both the requested revision and its expected full lowercase commit
|
||||||
|
SHA. The build fails when the revision resolves to another commit.
|
||||||
|
|
||||||
|
For local development, create the ignored `web-ui.local.toml` beside the
|
||||||
|
default file. It takes precedence unless `SYNCTV_WEB_CONFIG` names another
|
||||||
|
configuration. Relative paths resolve from the selected configuration file.
|
||||||
|
|
||||||
|
Prebuilt distribution:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
schema-version = 1
|
||||||
|
|
||||||
|
[source]
|
||||||
|
kind = "dist"
|
||||||
|
path = "../synctv-app/build/web"
|
||||||
|
```
|
||||||
|
|
||||||
|
Local Flutter project:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
schema-version = 1
|
||||||
|
|
||||||
|
[source]
|
||||||
|
kind = "local-project"
|
||||||
|
path = "../../flutter/synctv-app"
|
||||||
|
allow-dirty = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Immutable Git checkout:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
schema-version = 1
|
||||||
|
|
||||||
|
[source]
|
||||||
|
kind = "git"
|
||||||
|
repository = "https://github.com/synctv-org/synctv-app.git"
|
||||||
|
revision = "refs/tags/v1.2.3"
|
||||||
|
commit = "0123456789abcdef0123456789abcdef01234567"
|
||||||
|
```
|
||||||
|
|
||||||
|
The optional `[build]` table accepts `flutter`, `arguments`, and a
|
||||||
|
`dart-defines` mapping. Arguments and defines participate in the build
|
||||||
|
fingerprint.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Build and export the Web distribution:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SYNCTV_WEB_CONFIG=synctv-web-ui/web-ui.production.toml \
|
||||||
|
make web-ui-build WEB_UI_EXPORT_DIR=synctv-web-ui/dist
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the release server with the assets embedded:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make web-release-build
|
||||||
|
```
|
||||||
|
|
||||||
|
The Web-only command exports to `target/web-ui-dist` by default. CI uploads the
|
||||||
|
exported distribution once, then passes its authenticated artifact URL and
|
||||||
|
SHA-256 digest to the existing multi-platform Docker build. Docker verifies and
|
||||||
|
embeds the archive; it never installs Flutter or builds the frontend.
|
||||||
|
|
||||||
|
## Build controls
|
||||||
|
|
||||||
|
| Variable | Behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| `SYNCTV_WEB_CONFIG` | Select a configuration file explicitly. |
|
||||||
|
| `SYNCTV_WEB_DIST` | Use a prebuilt directory. This compatibility override takes precedence over configured sources. |
|
||||||
|
| `SYNCTV_WEB_CACHE_DIR` | Select the Git, Flutter output, and compression cache root. |
|
||||||
|
| `SYNCTV_WEB_EXPORT_DIR` | Copy the final uncompressed distribution to a disjoint directory. |
|
||||||
|
| `SYNCTV_WEB_OFFLINE` | Disable Git fetches and use `flutter pub get --offline`. Missing cache entries fail. |
|
||||||
|
| `SYNCTV_WEB_FORCE_REBUILD` | Fetch the pinned revision again and rebuild Flutter output. |
|
||||||
|
|
||||||
|
Relative paths in these controls resolve from the workspace root containing the
|
||||||
|
`synctv-web-ui` crate. Paths inside a selected configuration resolve from that
|
||||||
|
configuration file.
|
||||||
|
|
||||||
|
The fingerprint includes the source file hash or pinned commit, Flutter version,
|
||||||
|
build arguments, dart-defines, builder version, and final distribution hash.
|
||||||
|
Git checkout, Flutter output, and compression data use separate cache layers.
|
||||||
|
Ordinary workspace builds do not enable the `embed` feature and require no
|
||||||
|
Flutter installation, Git access, or network access.
|
||||||
@ -0,0 +1,560 @@
|
|||||||
|
#[path = "src/build_support.rs"]
|
||||||
|
mod build_support;
|
||||||
|
|
||||||
|
use build_support::{
|
||||||
|
build_fingerprint, collect_files, ensure_disjoint_directories, hash_files, load_config,
|
||||||
|
prepare_git_source, project_watch_directories, resolve_path, FlutterBuild, WebUiSource,
|
||||||
|
};
|
||||||
|
use flate2::write::GzEncoder;
|
||||||
|
use flate2::Compression;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::env;
|
||||||
|
use std::ffi::{OsStr, OsString};
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Output};
|
||||||
|
|
||||||
|
const CONFIG_ENV: &str = "SYNCTV_WEB_CONFIG";
|
||||||
|
const LEGACY_DIST_ENV: &str = "SYNCTV_WEB_DIST";
|
||||||
|
const CACHE_ENV: &str = "SYNCTV_WEB_CACHE_DIR";
|
||||||
|
const OFFLINE_ENV: &str = "SYNCTV_WEB_OFFLINE";
|
||||||
|
const FORCE_ENV: &str = "SYNCTV_WEB_FORCE_REBUILD";
|
||||||
|
const EXPORT_ENV: &str = "SYNCTV_WEB_EXPORT_DIR";
|
||||||
|
const COMPRESSION_CACHE_VERSION: &str = "br-q9-w22-gzip-best-v1";
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
if let Err(error) = build() {
|
||||||
|
panic!("Web UI build failed: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build() -> Result<(), String> {
|
||||||
|
let out_dir = required_path("OUT_DIR")?;
|
||||||
|
if env::var_os("CARGO_FEATURE_EMBED").is_none() {
|
||||||
|
fs::write(
|
||||||
|
out_dir.join("web_assets.rs"),
|
||||||
|
"pub const WEB_UI_AVAILABLE: bool = false;\n\
|
||||||
|
pub const BUILD_FINGERPRINT: &str = \"disabled\";\n\
|
||||||
|
pub static ASSETS: &[Asset] = &[];\n",
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("failed to write disabled Web UI manifest: {error}"))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for name in [
|
||||||
|
CONFIG_ENV,
|
||||||
|
LEGACY_DIST_ENV,
|
||||||
|
CACHE_ENV,
|
||||||
|
OFFLINE_ENV,
|
||||||
|
FORCE_ENV,
|
||||||
|
EXPORT_ENV,
|
||||||
|
] {
|
||||||
|
println!("cargo:rerun-if-env-changed={name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest_dir = required_path("CARGO_MANIFEST_DIR")?;
|
||||||
|
let control_base = manifest_dir.parent().unwrap_or(&manifest_dir);
|
||||||
|
for config_name in [build_support::DEFAULT_CONFIG, build_support::LOCAL_CONFIG] {
|
||||||
|
println!(
|
||||||
|
"cargo:rerun-if-changed={}",
|
||||||
|
manifest_dir.join(config_name).display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let explicit_config = env::var_os(CONFIG_ENV)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.map(|path| absolute_control_path(control_base, path));
|
||||||
|
let legacy_dist = env::var_os(LEGACY_DIST_ENV)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.map(|path| absolute_control_path(control_base, path));
|
||||||
|
let loaded = load_config(
|
||||||
|
&manifest_dir,
|
||||||
|
explicit_config.as_deref(),
|
||||||
|
legacy_dist.as_deref(),
|
||||||
|
)?;
|
||||||
|
println!("cargo:rerun-if-changed={}", loaded.path.display());
|
||||||
|
|
||||||
|
let cache_dir = env::var_os(CACHE_ENV).map_or_else(
|
||||||
|
|| {
|
||||||
|
manifest_dir
|
||||||
|
.parent()
|
||||||
|
.unwrap_or(&manifest_dir)
|
||||||
|
.join("target/web-ui-cache")
|
||||||
|
},
|
||||||
|
|value| absolute_control_path(control_base, PathBuf::from(value)),
|
||||||
|
);
|
||||||
|
fs::create_dir_all(&cache_dir).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to create Web UI cache {}: {error}",
|
||||||
|
cache_dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let offline = env_flag(OFFLINE_ENV)?;
|
||||||
|
let force = env_flag(FORCE_ENV)?;
|
||||||
|
|
||||||
|
let (dist, source_identity, flutter_version) = match &loaded.config.source {
|
||||||
|
WebUiSource::Dist { path } => {
|
||||||
|
let dist = resolve_path(&loaded.path, path);
|
||||||
|
watch_tree(&dist, false)?;
|
||||||
|
let files = collect_files(&dist, false)?;
|
||||||
|
let identity = format!("dist:{}", hash_files(&dist, &files)?);
|
||||||
|
(dist, identity, "prebuilt".to_owned())
|
||||||
|
}
|
||||||
|
WebUiSource::LocalProject { path, allow_dirty } => {
|
||||||
|
let project = resolve_path(&loaded.path, path);
|
||||||
|
if !allow_dirty {
|
||||||
|
ensure_clean_checkout(&project)?;
|
||||||
|
}
|
||||||
|
let files = watch_tree(&project, true)?;
|
||||||
|
let identity = format!("local:{}", hash_files(&project, &files)?);
|
||||||
|
build_flutter_project(
|
||||||
|
&project,
|
||||||
|
&identity,
|
||||||
|
&loaded.config.build,
|
||||||
|
&cache_dir,
|
||||||
|
offline,
|
||||||
|
force,
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
WebUiSource::Git {
|
||||||
|
repository,
|
||||||
|
revision,
|
||||||
|
commit,
|
||||||
|
} => {
|
||||||
|
let project =
|
||||||
|
prepare_git_source(repository, revision, commit, &cache_dir, offline, force)?;
|
||||||
|
let identity = format!("git:{repository}@{commit}");
|
||||||
|
build_flutter_project(
|
||||||
|
&project,
|
||||||
|
&identity,
|
||||||
|
&loaded.config.build,
|
||||||
|
&cache_dir,
|
||||||
|
offline,
|
||||||
|
force,
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !dist.join("index.html").is_file() {
|
||||||
|
return Err(format!(
|
||||||
|
"Web UI output {} does not contain index.html",
|
||||||
|
dist.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(export_dir) = env::var_os(EXPORT_ENV)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.map(|path| absolute_control_path(control_base, path))
|
||||||
|
{
|
||||||
|
ensure_disjoint_directories(&dist, &export_dir)?;
|
||||||
|
replace_directory(&dist, &export_dir)?;
|
||||||
|
}
|
||||||
|
let files = collect_files(&dist, false)?;
|
||||||
|
let fingerprint = build_fingerprint(
|
||||||
|
&format!("{source_identity}:{}", hash_files(&dist, &files)?),
|
||||||
|
&flutter_version,
|
||||||
|
&loaded.config.build,
|
||||||
|
);
|
||||||
|
generate_assets(&dist, &files, &out_dir, &cache_dir, &fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_directory(source: &Path, destination: &Path) -> Result<(), String> {
|
||||||
|
if destination.exists() {
|
||||||
|
fs::remove_dir_all(destination).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to clear Web UI export {}: {error}",
|
||||||
|
destination.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
copy_directory(source, destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_directory(source: &Path, destination: &Path) -> Result<(), String> {
|
||||||
|
fs::create_dir_all(destination).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to create Web UI export {}: {error}",
|
||||||
|
destination.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
for entry in fs::read_dir(source)
|
||||||
|
.map_err(|error| format!("failed to read {}: {error}", source.display()))?
|
||||||
|
{
|
||||||
|
let entry =
|
||||||
|
entry.map_err(|error| format!("failed to inspect {}: {error}", source.display()))?;
|
||||||
|
let destination_path = destination.join(entry.file_name());
|
||||||
|
if entry.path().is_dir() {
|
||||||
|
copy_directory(&entry.path(), &destination_path)?;
|
||||||
|
} else if entry.path().is_file() {
|
||||||
|
fs::copy(entry.path(), &destination_path).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to export Web asset {}: {error}",
|
||||||
|
entry.path().display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_path(name: &str) -> Result<PathBuf, String> {
|
||||||
|
env::var_os(name)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.ok_or_else(|| format!("{name} is not set"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn absolute_control_path(base: &Path, path: PathBuf) -> PathBuf {
|
||||||
|
if path.is_absolute() {
|
||||||
|
path
|
||||||
|
} else {
|
||||||
|
base.join(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_flag(name: &str) -> Result<bool, String> {
|
||||||
|
let Some(value) = env::var_os(name) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
match value.to_string_lossy().trim().to_ascii_lowercase().as_str() {
|
||||||
|
"" | "0" | "false" | "no" | "off" => Ok(false),
|
||||||
|
"1" | "true" | "yes" | "on" => Ok(true),
|
||||||
|
_ => Err(format!("{name} must be a boolean value")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn watch_tree(root: &Path, project: bool) -> Result<Vec<PathBuf>, String> {
|
||||||
|
if !root.is_dir() {
|
||||||
|
return Err(format!(
|
||||||
|
"Web UI source directory {} is missing",
|
||||||
|
root.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !project {
|
||||||
|
println!("cargo:rerun-if-changed={}", root.display());
|
||||||
|
}
|
||||||
|
let files = collect_files(root, project)?;
|
||||||
|
if project {
|
||||||
|
for directory in project_watch_directories(root)? {
|
||||||
|
println!("cargo:rerun-if-changed={}", directory.display());
|
||||||
|
}
|
||||||
|
let git_index = root.join(".git/index");
|
||||||
|
if git_index.is_file() {
|
||||||
|
println!("cargo:rerun-if-changed={}", git_index.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path in &files {
|
||||||
|
println!("cargo:rerun-if-changed={}", path.display());
|
||||||
|
}
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_clean_checkout(project: &Path) -> Result<(), String> {
|
||||||
|
let output = run_output(
|
||||||
|
Command::new("git").arg("-C").arg(project).args([
|
||||||
|
"status",
|
||||||
|
"--porcelain",
|
||||||
|
"--untracked-files=normal",
|
||||||
|
]),
|
||||||
|
"inspect local Web UI checkout",
|
||||||
|
)?;
|
||||||
|
if output.stdout.is_empty() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"local Web UI checkout {} has uncommitted changes; set source.allow-dirty=true for development",
|
||||||
|
project.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_flutter_project(
|
||||||
|
project: &Path,
|
||||||
|
source_identity: &str,
|
||||||
|
build: &FlutterBuild,
|
||||||
|
cache_dir: &Path,
|
||||||
|
offline: bool,
|
||||||
|
force: bool,
|
||||||
|
) -> Result<(PathBuf, String, String), String> {
|
||||||
|
if !project.join("pubspec.yaml").is_file() {
|
||||||
|
return Err(format!(
|
||||||
|
"Flutter Web project {} does not contain pubspec.yaml",
|
||||||
|
project.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let flutter_version = command_stdout(
|
||||||
|
Command::new(&build.flutter).args(["--version", "--machine"]),
|
||||||
|
"read Flutter version",
|
||||||
|
)?;
|
||||||
|
let fingerprint = build_fingerprint(source_identity, &flutter_version, build);
|
||||||
|
let output = cache_dir.join("builds").join(&fingerprint).join("web");
|
||||||
|
if output.join("index.html").is_file() && !force {
|
||||||
|
return Ok((output, source_identity.to_owned(), flutter_version));
|
||||||
|
}
|
||||||
|
|
||||||
|
let temporary = cache_dir.join("builds").join(".tmp").join(format!(
|
||||||
|
"{}-{}",
|
||||||
|
&fingerprint[..20],
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
if temporary.exists() {
|
||||||
|
fs::remove_dir_all(&temporary).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to clear temporary Web UI output {}: {error}",
|
||||||
|
temporary.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
fs::create_dir_all(&temporary).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to create temporary Web UI output {}: {error}",
|
||||||
|
temporary.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut pub_get = Command::new(&build.flutter);
|
||||||
|
pub_get.current_dir(project).args(["pub", "get"]);
|
||||||
|
if offline {
|
||||||
|
pub_get.arg("--offline");
|
||||||
|
}
|
||||||
|
run_visible(&mut pub_get, "resolve Flutter Web dependencies")?;
|
||||||
|
|
||||||
|
let temporary_dist = temporary.join("web");
|
||||||
|
let mut flutter_build = Command::new(&build.flutter);
|
||||||
|
flutter_build
|
||||||
|
.current_dir(project)
|
||||||
|
.args(["build", "web", "--no-pub", "--output"])
|
||||||
|
.arg(&temporary_dist)
|
||||||
|
.args(&build.arguments);
|
||||||
|
for (key, value) in &build.dart_defines {
|
||||||
|
flutter_build
|
||||||
|
.arg("--dart-define")
|
||||||
|
.arg(format!("{key}={value}"));
|
||||||
|
}
|
||||||
|
run_visible(&mut flutter_build, "build Flutter Web client")?;
|
||||||
|
if !temporary_dist.join("index.html").is_file() {
|
||||||
|
return Err("Flutter Web build completed without index.html".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent = output
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "invalid Web UI build cache path".to_owned())?;
|
||||||
|
fs::create_dir_all(parent).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to create Web UI build cache {}: {error}",
|
||||||
|
parent.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if output.exists() {
|
||||||
|
fs::remove_dir_all(&output).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to replace Web UI cache {}: {error}",
|
||||||
|
output.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
fs::rename(&temporary_dist, &output).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to publish Web UI cache {}: {error}",
|
||||||
|
output.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let _ = fs::remove_dir_all(&temporary);
|
||||||
|
Ok((output, source_identity.to_owned(), flutter_version))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_assets(
|
||||||
|
dist: &Path,
|
||||||
|
files: &[PathBuf],
|
||||||
|
out_dir: &Path,
|
||||||
|
cache_dir: &Path,
|
||||||
|
fingerprint: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let compression_cache = cache_dir.join("compressed");
|
||||||
|
fs::create_dir_all(&compression_cache).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to create compression cache {}: {error}",
|
||||||
|
compression_cache.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut source = format!(
|
||||||
|
"pub const WEB_UI_AVAILABLE: bool = {};\npub const BUILD_FINGERPRINT: &str = {fingerprint:?};\npub static ASSETS: &[Asset] = &[\n",
|
||||||
|
!files.is_empty()
|
||||||
|
);
|
||||||
|
for path in files {
|
||||||
|
let relative = path
|
||||||
|
.strip_prefix(dist)
|
||||||
|
.map_err(|error| format!("failed to relativize {}: {error}", path.display()))?;
|
||||||
|
let route = relative
|
||||||
|
.components()
|
||||||
|
.map(|component| component.as_os_str().to_string_lossy())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/");
|
||||||
|
if route.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bytes = fs::read(path)
|
||||||
|
.map_err(|error| format!("failed to read Web asset {}: {error}", path.display()))?;
|
||||||
|
let content_type = content_type(&route);
|
||||||
|
let etag = bytes_etag(&bytes);
|
||||||
|
let path_literal = rust_string_literal(&path.to_string_lossy());
|
||||||
|
let (brotli, gzip) = if compressible_content_type(content_type) {
|
||||||
|
let digest = hex::encode(Sha256::digest(&bytes));
|
||||||
|
let brotli_path =
|
||||||
|
compression_cache.join(format!("{COMPRESSION_CACHE_VERSION}-{digest}.br"));
|
||||||
|
let gzip_path =
|
||||||
|
compression_cache.join(format!("{COMPRESSION_CACHE_VERSION}-{digest}.gz"));
|
||||||
|
ensure_compressed(&bytes, &brotli_path, &gzip_path)?;
|
||||||
|
(
|
||||||
|
encoded_asset_source(&brotli_path)?,
|
||||||
|
encoded_asset_source(&gzip_path)?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
("None".to_owned(), "None".to_owned())
|
||||||
|
};
|
||||||
|
writeln!(
|
||||||
|
source,
|
||||||
|
" Asset {{ path: {route:?}, content_type: {content_type:?}, etag: {etag:?}, bytes: include_bytes!({path_literal}), brotli: {brotli}, gzip: {gzip} }},"
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("failed to render Web asset table: {error}"))?;
|
||||||
|
}
|
||||||
|
source.push_str("];\n");
|
||||||
|
fs::write(out_dir.join("web_assets.rs"), source)
|
||||||
|
.map_err(|error| format!("failed to write generated Web asset table: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_compressed(bytes: &[u8], brotli_path: &Path, gzip_path: &Path) -> Result<(), String> {
|
||||||
|
if !brotli_path.is_file() {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
{
|
||||||
|
let mut encoder = brotli::CompressorWriter::new(&mut encoded, 64 * 1024, 9, 22);
|
||||||
|
encoder
|
||||||
|
.write_all(bytes)
|
||||||
|
.map_err(|error| format!("failed to Brotli-compress Web asset: {error}"))?;
|
||||||
|
}
|
||||||
|
fs::write(brotli_path, encoded).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to write Brotli cache {}: {error}",
|
||||||
|
brotli_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
if !gzip_path.is_file() {
|
||||||
|
let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
|
||||||
|
encoder
|
||||||
|
.write_all(bytes)
|
||||||
|
.map_err(|error| format!("failed to gzip Web asset: {error}"))?;
|
||||||
|
let encoded = encoder
|
||||||
|
.finish()
|
||||||
|
.map_err(|error| format!("failed to finish gzip Web asset: {error}"))?;
|
||||||
|
fs::write(gzip_path, encoded).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to write gzip cache {}: {error}",
|
||||||
|
gzip_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encoded_asset_source(path: &Path) -> Result<String, String> {
|
||||||
|
let bytes = fs::read(path)
|
||||||
|
.map_err(|error| format!("failed to read encoded asset {}: {error}", path.display()))?;
|
||||||
|
let path_literal = rust_string_literal(&path.to_string_lossy());
|
||||||
|
Ok(format!(
|
||||||
|
"Some(EncodedAsset {{ etag: {:?}, bytes: include_bytes!({path_literal}) }})",
|
||||||
|
bytes_etag(&bytes),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rust_string_literal(value: &str) -> String {
|
||||||
|
let escaped = value
|
||||||
|
.chars()
|
||||||
|
.flat_map(char::escape_default)
|
||||||
|
.collect::<String>();
|
||||||
|
format!("\"{escaped}\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_etag(bytes: &[u8]) -> String {
|
||||||
|
format!("\"{}-{}\"", hex::encode(Sha256::digest(bytes)), bytes.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_type(path: &str) -> &'static str {
|
||||||
|
match Path::new(path)
|
||||||
|
.extension()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"html" => "text/html; charset=utf-8",
|
||||||
|
"css" => "text/css; charset=utf-8",
|
||||||
|
"js" => "text/javascript; charset=utf-8",
|
||||||
|
"json" | "map" => "application/json; charset=utf-8",
|
||||||
|
"svg" => "image/svg+xml",
|
||||||
|
"png" => "image/png",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
"ico" => "image/x-icon",
|
||||||
|
"wasm" => "application/wasm",
|
||||||
|
"woff" => "font/woff",
|
||||||
|
"woff2" => "font/woff2",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compressible_content_type(content_type: &str) -> bool {
|
||||||
|
content_type.starts_with("text/")
|
||||||
|
|| matches!(
|
||||||
|
content_type.split(';').next().unwrap_or_default(),
|
||||||
|
"application/json"
|
||||||
|
| "application/javascript"
|
||||||
|
| "application/wasm"
|
||||||
|
| "application/xml"
|
||||||
|
| "image/svg+xml"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_visible(command: &mut Command, description: &str) -> Result<(), String> {
|
||||||
|
let status = command
|
||||||
|
.status()
|
||||||
|
.map_err(|error| format!("failed to {description}: {error}"))?;
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("failed to {description} ({status})"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_output(command: &mut Command, description: &str) -> Result<Output, String> {
|
||||||
|
command
|
||||||
|
.output()
|
||||||
|
.map_err(|error| format!("failed to {description}: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_stdout(command: &mut Command, description: &str) -> Result<String, String> {
|
||||||
|
let output = run_output(command, description)?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(command_failure(description, &output));
|
||||||
|
}
|
||||||
|
String::from_utf8(output.stdout)
|
||||||
|
.map(|value| value.trim().to_owned())
|
||||||
|
.map_err(|error| format!("{description} returned non-UTF-8 output: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_failure(description: &str, output: &Output) -> String {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
|
||||||
|
let details = if stderr.is_empty() { stdout } else { stderr };
|
||||||
|
format!("failed to {description} ({}): {details}", output.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn _display_command(program: &OsStr, arguments: &[OsString]) -> String {
|
||||||
|
std::iter::once(program.to_string_lossy().into_owned())
|
||||||
|
.chain(
|
||||||
|
arguments
|
||||||
|
.iter()
|
||||||
|
.map(|argument| argument.to_string_lossy().into_owned()),
|
||||||
|
)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
|
||||||
@ -0,0 +1,848 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Output};
|
||||||
|
|
||||||
|
pub const BUILDER_VERSION: &str = "synctv-web-ui-v3";
|
||||||
|
pub const DEFAULT_CONFIG: &str = "web-ui.toml";
|
||||||
|
pub const LOCAL_CONFIG: &str = "web-ui.local.toml";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||||
|
pub struct WebUiConfig {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub source: WebUiSource,
|
||||||
|
#[serde(default)]
|
||||||
|
pub build: FlutterBuild,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(
|
||||||
|
tag = "kind",
|
||||||
|
rename_all = "kebab-case",
|
||||||
|
rename_all_fields = "kebab-case",
|
||||||
|
deny_unknown_fields
|
||||||
|
)]
|
||||||
|
pub enum WebUiSource {
|
||||||
|
Dist {
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
LocalProject {
|
||||||
|
path: PathBuf,
|
||||||
|
#[serde(default = "default_allow_dirty")]
|
||||||
|
allow_dirty: bool,
|
||||||
|
},
|
||||||
|
Git {
|
||||||
|
repository: String,
|
||||||
|
revision: String,
|
||||||
|
commit: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_allow_dirty() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
|
||||||
|
pub struct FlutterBuild {
|
||||||
|
pub flutter: String,
|
||||||
|
pub arguments: Vec<String>,
|
||||||
|
pub dart_defines: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FlutterBuild {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
flutter: "flutter".to_owned(),
|
||||||
|
arguments: vec![
|
||||||
|
"--release".to_owned(),
|
||||||
|
"--no-web-resources-cdn".to_owned(),
|
||||||
|
"--no-wasm-dry-run".to_owned(),
|
||||||
|
],
|
||||||
|
dart_defines: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LoadedConfig {
|
||||||
|
pub config: WebUiConfig,
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_config(
|
||||||
|
manifest_dir: &Path,
|
||||||
|
explicit: Option<&Path>,
|
||||||
|
legacy_dist: Option<&Path>,
|
||||||
|
) -> Result<LoadedConfig, String> {
|
||||||
|
if let Some(path) = legacy_dist {
|
||||||
|
return Ok(LoadedConfig {
|
||||||
|
config: WebUiConfig {
|
||||||
|
schema_version: 1,
|
||||||
|
source: WebUiSource::Dist {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
},
|
||||||
|
build: FlutterBuild::default(),
|
||||||
|
},
|
||||||
|
path: manifest_dir.join(LOCAL_CONFIG),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let path = explicit.map_or_else(
|
||||||
|
|| {
|
||||||
|
let local = manifest_dir.join(LOCAL_CONFIG);
|
||||||
|
if local.is_file() {
|
||||||
|
local
|
||||||
|
} else {
|
||||||
|
manifest_dir.join(DEFAULT_CONFIG)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Path::to_path_buf,
|
||||||
|
);
|
||||||
|
let text = fs::read_to_string(&path)
|
||||||
|
.map_err(|error| format!("failed to read Web UI config {}: {error}", path.display()))?;
|
||||||
|
let config: WebUiConfig = toml::from_str(&text)
|
||||||
|
.map_err(|error| format!("invalid Web UI config {}: {error}", path.display()))?;
|
||||||
|
if config.schema_version != 1 {
|
||||||
|
return Err(format!(
|
||||||
|
"unsupported Web UI config schema {} in {}",
|
||||||
|
config.schema_version,
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
validate_config(&config)?;
|
||||||
|
Ok(LoadedConfig { config, path })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_config(config: &WebUiConfig) -> Result<(), String> {
|
||||||
|
if config.build.flutter.trim().is_empty() {
|
||||||
|
return Err("build.flutter must not be empty".to_owned());
|
||||||
|
}
|
||||||
|
if config
|
||||||
|
.build
|
||||||
|
.arguments
|
||||||
|
.iter()
|
||||||
|
.any(|argument| argument.contains('\n') || argument.contains('\r'))
|
||||||
|
{
|
||||||
|
return Err("build.arguments must not contain line breaks".to_owned());
|
||||||
|
}
|
||||||
|
if let WebUiSource::Git {
|
||||||
|
repository,
|
||||||
|
revision,
|
||||||
|
commit,
|
||||||
|
} = &config.source
|
||||||
|
{
|
||||||
|
if repository.trim().is_empty() || revision.trim().is_empty() {
|
||||||
|
return Err("Git repository and revision must not be empty".to_owned());
|
||||||
|
}
|
||||||
|
if repository.contains(['\n', '\r']) || revision.contains(['\n', '\r']) {
|
||||||
|
return Err("Git source values must not contain line breaks".to_owned());
|
||||||
|
}
|
||||||
|
if repository
|
||||||
|
.split_once("://")
|
||||||
|
.and_then(|(_, remainder)| remainder.split('/').next())
|
||||||
|
.is_some_and(|authority| authority.contains('@'))
|
||||||
|
{
|
||||||
|
return Err("Git repository URLs must not contain credentials".to_owned());
|
||||||
|
}
|
||||||
|
if !is_full_commit(commit) {
|
||||||
|
return Err("Git commit must be a full lowercase SHA-1".to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_full_commit(value: &str) -> bool {
|
||||||
|
value.len() == 40
|
||||||
|
&& value
|
||||||
|
.as_bytes()
|
||||||
|
.iter()
|
||||||
|
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_path(config_path: &Path, path: &Path) -> PathBuf {
|
||||||
|
if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
config_path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new("."))
|
||||||
|
.join(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn collect_files(root: &Path, project: bool) -> Result<Vec<PathBuf>, String> {
|
||||||
|
if project {
|
||||||
|
if let Some(files) = git_project_files(root)? {
|
||||||
|
return Ok(files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_files_inner(root, root, project, &mut files)?;
|
||||||
|
files.sort();
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn project_watch_directories(root: &Path) -> Result<Vec<PathBuf>, String> {
|
||||||
|
let mut directories = BTreeSet::new();
|
||||||
|
collect_project_directories(root, root, &mut directories)?;
|
||||||
|
directories.remove(root);
|
||||||
|
Ok(directories.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_project_directories(
|
||||||
|
root: &Path,
|
||||||
|
directory: &Path,
|
||||||
|
directories: &mut BTreeSet<PathBuf>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
directories.insert(directory.to_path_buf());
|
||||||
|
let entries = fs::read_dir(directory)
|
||||||
|
.map_err(|error| format!("failed to read {}: {error}", directory.display()))?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to inspect an entry in {}: {error}",
|
||||||
|
directory.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() && !excluded_project_directory(&path) && path.starts_with(root) {
|
||||||
|
collect_project_directories(root, &path, directories)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_disjoint_directories(source: &Path, destination: &Path) -> Result<(), String> {
|
||||||
|
let source = source.canonicalize().map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to resolve Web UI source directory {}: {error}",
|
||||||
|
source.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let destination = resolve_existing_ancestor(destination)?;
|
||||||
|
if source == destination || source.starts_with(&destination) || destination.starts_with(&source)
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"Web UI export {} must not equal, contain, or be contained by source {}",
|
||||||
|
destination.display(),
|
||||||
|
source.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepare_git_source(
|
||||||
|
repository: &str,
|
||||||
|
revision: &str,
|
||||||
|
commit: &str,
|
||||||
|
cache_dir: &Path,
|
||||||
|
offline: bool,
|
||||||
|
force: bool,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
let repository_key = hex::encode(Sha256::digest(repository.as_bytes()));
|
||||||
|
let project = cache_dir
|
||||||
|
.join("git")
|
||||||
|
.join(&repository_key[..24])
|
||||||
|
.join(commit);
|
||||||
|
let git_dir = project.join(".git");
|
||||||
|
if !git_dir.is_dir() {
|
||||||
|
if offline {
|
||||||
|
return Err(format!(
|
||||||
|
"offline Web UI build requires cached Git commit {commit}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
fs::create_dir_all(&project).map_err(|error| {
|
||||||
|
format!("failed to create Git cache {}: {error}", project.display())
|
||||||
|
})?;
|
||||||
|
run(
|
||||||
|
Command::new("git").arg("init").arg(&project),
|
||||||
|
"initialize Web UI Git cache",
|
||||||
|
)?;
|
||||||
|
run(
|
||||||
|
Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&project)
|
||||||
|
.args(["remote", "add", "origin", repository]),
|
||||||
|
"configure Web UI Git remote",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_commit = command_succeeds(Command::new("git").arg("-C").arg(&project).args([
|
||||||
|
"cat-file",
|
||||||
|
"-e",
|
||||||
|
&format!("{commit}^{{commit}}"),
|
||||||
|
]));
|
||||||
|
let source_marker = project.join(".synctv-source");
|
||||||
|
let expected_marker =
|
||||||
|
format!("repository={repository}\nrevision={revision}\ncommit={commit}\n");
|
||||||
|
let source_is_validated =
|
||||||
|
fs::read_to_string(&source_marker).is_ok_and(|value| value == expected_marker);
|
||||||
|
if !has_commit || !source_is_validated || force {
|
||||||
|
if offline {
|
||||||
|
return Err(format!(
|
||||||
|
"offline Web UI build cannot validate uncached Git source {repository}@{revision} ({commit})"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
run(
|
||||||
|
Command::new("git").arg("-C").arg(&project).args([
|
||||||
|
"fetch",
|
||||||
|
"--depth=1",
|
||||||
|
"--no-tags",
|
||||||
|
"origin",
|
||||||
|
revision,
|
||||||
|
]),
|
||||||
|
"fetch Web UI Git revision",
|
||||||
|
)?;
|
||||||
|
let resolved = command_stdout(
|
||||||
|
Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&project)
|
||||||
|
.args(["rev-parse", "FETCH_HEAD^{commit}"]),
|
||||||
|
"resolve Web UI Git revision",
|
||||||
|
)?;
|
||||||
|
if resolved != commit {
|
||||||
|
return Err(format!(
|
||||||
|
"Web UI revision {revision} resolved to {resolved}, expected pinned commit {commit}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
fs::write(&source_marker, expected_marker).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to record validated Web UI Git source {}: {error}",
|
||||||
|
source_marker.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
run(
|
||||||
|
Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&project)
|
||||||
|
.args(["checkout", "--force", "--detach", commit]),
|
||||||
|
"check out Web UI Git commit",
|
||||||
|
)?;
|
||||||
|
Ok(project)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(command: &mut Command, description: &str) -> Result<(), String> {
|
||||||
|
let output = run_output(command, description)?;
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(command_failure(description, &output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_output(command: &mut Command, description: &str) -> Result<Output, String> {
|
||||||
|
command
|
||||||
|
.output()
|
||||||
|
.map_err(|error| format!("failed to {description}: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_stdout(command: &mut Command, description: &str) -> Result<String, String> {
|
||||||
|
let output = run_output(command, description)?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(command_failure(description, &output));
|
||||||
|
}
|
||||||
|
String::from_utf8(output.stdout)
|
||||||
|
.map(|value| value.trim().to_owned())
|
||||||
|
.map_err(|error| format!("{description} returned non-UTF-8 output: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_succeeds(command: &mut Command) -> bool {
|
||||||
|
command.output().is_ok_and(|output| output.status.success())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_failure(description: &str, output: &Output) -> String {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
|
||||||
|
let details = if stderr.is_empty() { stdout } else { stderr };
|
||||||
|
format!("failed to {description} ({}): {details}", output.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_existing_ancestor(path: &Path) -> Result<PathBuf, String> {
|
||||||
|
let absolute = if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
std::env::current_dir()
|
||||||
|
.map_err(|error| format!("failed to resolve current directory: {error}"))?
|
||||||
|
.join(path)
|
||||||
|
};
|
||||||
|
let absolute = normalize_path(&absolute);
|
||||||
|
let mut existing = absolute.as_path();
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
while !existing.exists() {
|
||||||
|
let name = existing.file_name().ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Web UI export path {} has no existing ancestor",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
missing.push(name.to_os_string());
|
||||||
|
existing = existing.parent().ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Web UI export path {} has no existing ancestor",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let mut resolved = existing.canonicalize().map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to resolve Web UI export ancestor {}: {error}",
|
||||||
|
existing.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
for name in missing.iter().rev() {
|
||||||
|
resolved.push(name);
|
||||||
|
}
|
||||||
|
Ok(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_path(path: &Path) -> PathBuf {
|
||||||
|
use std::path::Component;
|
||||||
|
|
||||||
|
let mut normalized = PathBuf::new();
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
Component::CurDir => {}
|
||||||
|
Component::ParentDir => {
|
||||||
|
normalized.pop();
|
||||||
|
}
|
||||||
|
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
|
||||||
|
normalized.push(component.as_os_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_project_files(root: &Path) -> Result<Option<Vec<PathBuf>>, String> {
|
||||||
|
let output = match Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(root)
|
||||||
|
.args([
|
||||||
|
"ls-files",
|
||||||
|
"--cached",
|
||||||
|
"--others",
|
||||||
|
"--exclude-standard",
|
||||||
|
"-z",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
Ok(output) if output.status.success() => output.stdout,
|
||||||
|
Ok(_) => return Ok(None),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(error) => {
|
||||||
|
return Err(format!(
|
||||||
|
"failed to enumerate Git files in {}: {error}",
|
||||||
|
root.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for encoded in output
|
||||||
|
.split(|byte| *byte == 0)
|
||||||
|
.filter(|path| !path.is_empty())
|
||||||
|
{
|
||||||
|
let relative = std::str::from_utf8(encoded).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"Git returned a non-UTF-8 Web UI path in {}: {error}",
|
||||||
|
root.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let path = root.join(relative);
|
||||||
|
if path.is_file() {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files.sort();
|
||||||
|
files.dedup();
|
||||||
|
Ok(Some(files))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_files_inner(
|
||||||
|
root: &Path,
|
||||||
|
directory: &Path,
|
||||||
|
project: bool,
|
||||||
|
files: &mut Vec<PathBuf>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let entries = fs::read_dir(directory)
|
||||||
|
.map_err(|error| format!("failed to read {}: {error}", directory.display()))?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to inspect an entry in {}: {error}",
|
||||||
|
directory.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
if project && excluded_project_directory(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
collect_files_inner(root, &path, project, files)?;
|
||||||
|
} else if path.is_file()
|
||||||
|
&& path.strip_prefix(root).is_ok()
|
||||||
|
&& (!project || !excluded_project_file(&path))
|
||||||
|
{
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn excluded_project_directory(path: &Path) -> bool {
|
||||||
|
matches!(
|
||||||
|
path.file_name().and_then(OsStr::to_str),
|
||||||
|
Some(".git" | ".dart_tool" | ".idea" | ".vscode" | "build" | "target")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn excluded_project_file(path: &Path) -> bool {
|
||||||
|
matches!(
|
||||||
|
path.file_name().and_then(OsStr::to_str),
|
||||||
|
Some(".DS_Store" | ".flutter-plugins" | ".flutter-plugins-dependencies" | ".packages")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hash_files(root: &Path, files: &[PathBuf]) -> Result<String, String> {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
for path in files {
|
||||||
|
let relative = path
|
||||||
|
.strip_prefix(root)
|
||||||
|
.map_err(|error| format!("failed to relativize {}: {error}", path.display()))?;
|
||||||
|
hasher.update(relative.to_string_lossy().as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
let bytes = fs::read(path)
|
||||||
|
.map_err(|error| format!("failed to read {}: {error}", path.display()))?;
|
||||||
|
hasher.update(bytes.len().to_le_bytes());
|
||||||
|
hasher.update(bytes);
|
||||||
|
}
|
||||||
|
Ok(hex::encode(hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_fingerprint(
|
||||||
|
source_identity: &str,
|
||||||
|
flutter_version: &str,
|
||||||
|
build: &FlutterBuild,
|
||||||
|
) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(BUILDER_VERSION.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(source_identity.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(flutter_version.as_bytes());
|
||||||
|
for argument in &build.arguments {
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(argument.as_bytes());
|
||||||
|
}
|
||||||
|
for (key, value) in &build.dart_defines {
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(key.as_bytes());
|
||||||
|
hasher.update(b"=");
|
||||||
|
hasher.update(value.as_bytes());
|
||||||
|
}
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_config_takes_precedence() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let directory = tempfile::tempdir()?;
|
||||||
|
fs::write(
|
||||||
|
directory.path().join(DEFAULT_CONFIG),
|
||||||
|
"schema-version=1\n[source]\nkind='dist'\npath='default'\n",
|
||||||
|
)?;
|
||||||
|
fs::write(
|
||||||
|
directory.path().join(LOCAL_CONFIG),
|
||||||
|
"schema-version=1\n[source]\nkind='dist'\npath='local'\n",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let loaded = load_config(directory.path(), None, None)?;
|
||||||
|
|
||||||
|
assert_eq!(loaded.path, directory.path().join(LOCAL_CONFIG));
|
||||||
|
assert!(matches!(
|
||||||
|
&loaded.config.source,
|
||||||
|
WebUiSource::Dist { path }
|
||||||
|
if resolve_path(&loaded.path, path) == directory.path().join("local")
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_is_stable_for_ordered_defines() {
|
||||||
|
let mut first = FlutterBuild::default();
|
||||||
|
first.dart_defines.insert("B".to_owned(), "2".to_owned());
|
||||||
|
first.dart_defines.insert("A".to_owned(), "1".to_owned());
|
||||||
|
let mut second = FlutterBuild::default();
|
||||||
|
second.dart_defines.insert("A".to_owned(), "1".to_owned());
|
||||||
|
second.dart_defines.insert("B".to_owned(), "2".to_owned());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
build_fingerprint("source", "flutter", &first),
|
||||||
|
build_fingerprint("source", "flutter", &second)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_hash_ignores_generated_build_output() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let directory = tempfile::tempdir()?;
|
||||||
|
fs::create_dir(directory.path().join("lib"))?;
|
||||||
|
fs::create_dir(directory.path().join("build"))?;
|
||||||
|
fs::write(directory.path().join("lib/main.dart"), "void main() {}")?;
|
||||||
|
fs::write(directory.path().join("build/output.js"), "generated")?;
|
||||||
|
fs::write(
|
||||||
|
directory.path().join(".flutter-plugins-dependencies"),
|
||||||
|
"generated",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let files = collect_files(directory.path(), true)?;
|
||||||
|
let initial_hash = hash_files(directory.path(), &files)?;
|
||||||
|
fs::write(directory.path().join("build/output.js"), "changed")?;
|
||||||
|
fs::write(
|
||||||
|
directory.path().join(".flutter-plugins-dependencies"),
|
||||||
|
"changed",
|
||||||
|
)?;
|
||||||
|
let next_files = collect_files(directory.path(), true)?;
|
||||||
|
|
||||||
|
assert_eq!(files, vec![directory.path().join("lib/main.dart")]);
|
||||||
|
assert_eq!(initial_hash, hash_files(directory.path(), &next_files)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_project_files_exclude_ignored_generated_inputs() -> Result<(), Box<dyn std::error::Error>>
|
||||||
|
{
|
||||||
|
let directory = tempfile::tempdir()?;
|
||||||
|
fs::create_dir(directory.path().join("lib"))?;
|
||||||
|
fs::create_dir(directory.path().join("generated"))?;
|
||||||
|
fs::write(directory.path().join("lib/main.dart"), "void main() {}")?;
|
||||||
|
fs::write(directory.path().join("lib/pending.dart"), "pending")?;
|
||||||
|
fs::write(directory.path().join("generated/output"), "generated")?;
|
||||||
|
fs::write(directory.path().join(".gitignore"), "generated/\n")?;
|
||||||
|
assert!(Command::new("git")
|
||||||
|
.arg("init")
|
||||||
|
.arg(directory.path())
|
||||||
|
.status()?
|
||||||
|
.success());
|
||||||
|
assert!(Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(directory.path())
|
||||||
|
.args(["add", ".gitignore", "lib/main.dart"])
|
||||||
|
.status()?
|
||||||
|
.success());
|
||||||
|
|
||||||
|
let files = collect_files(directory.path(), true)?;
|
||||||
|
let relative = files
|
||||||
|
.iter()
|
||||||
|
.map(|path| path.strip_prefix(directory.path()))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
relative,
|
||||||
|
vec![
|
||||||
|
Path::new(".gitignore"),
|
||||||
|
Path::new("lib/main.dart"),
|
||||||
|
Path::new("lib/pending.dart")
|
||||||
|
]
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_source_rejects_credentials_and_partial_commits() {
|
||||||
|
let config = WebUiConfig {
|
||||||
|
schema_version: 1,
|
||||||
|
source: WebUiSource::Git {
|
||||||
|
repository: "https://user:secret@example.com/repo".to_owned(),
|
||||||
|
revision: "main".to_owned(),
|
||||||
|
commit: "abc".to_owned(),
|
||||||
|
},
|
||||||
|
build: FlutterBuild::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(validate_config(&config).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_watch_directories_cover_new_files_without_generated_output(
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let directory = tempfile::tempdir()?;
|
||||||
|
let root = directory.path();
|
||||||
|
fs::create_dir_all(root.join("lib/empty"))?;
|
||||||
|
fs::create_dir_all(root.join("packages/player/lib"))?;
|
||||||
|
fs::create_dir_all(root.join("build/web"))?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
project_watch_directories(root)?,
|
||||||
|
vec![
|
||||||
|
root.join("lib"),
|
||||||
|
root.join("lib/empty"),
|
||||||
|
root.join("packages"),
|
||||||
|
root.join("packages/player"),
|
||||||
|
root.join("packages/player/lib")
|
||||||
|
]
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_must_be_disjoint_from_source() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let directory = tempfile::tempdir()?;
|
||||||
|
let source = directory.path().join("source");
|
||||||
|
let sibling = directory.path().join("export");
|
||||||
|
fs::create_dir_all(source.join("nested"))?;
|
||||||
|
|
||||||
|
assert!(ensure_disjoint_directories(&source, &source).is_err());
|
||||||
|
assert!(ensure_disjoint_directories(&source, &source.join("export")).is_err());
|
||||||
|
assert!(ensure_disjoint_directories(&source.join("nested"), &source).is_err());
|
||||||
|
ensure_disjoint_directories(&source, &sibling)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offline_git_source_requires_cached_commit() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let cache = tempfile::tempdir()?;
|
||||||
|
let error = prepare_git_source(
|
||||||
|
"https://example.invalid/app.git",
|
||||||
|
"main",
|
||||||
|
"a234567890abcdef0123456789abcdef01234567",
|
||||||
|
cache.path(),
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect_err("uncached offline source must fail");
|
||||||
|
|
||||||
|
assert!(error.contains("requires cached Git commit"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_source_rejects_revision_at_another_commit() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let repository = create_test_repository()?;
|
||||||
|
let revision = git_stdout(repository.path(), &["rev-parse", "HEAD"])?;
|
||||||
|
let cache = tempfile::tempdir()?;
|
||||||
|
let expected = if revision.starts_with('a') {
|
||||||
|
"b".repeat(40)
|
||||||
|
} else {
|
||||||
|
"a".repeat(40)
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = prepare_git_source(
|
||||||
|
&repository.path().to_string_lossy(),
|
||||||
|
&revision,
|
||||||
|
&expected,
|
||||||
|
cache.path(),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect_err("mismatched revision must fail");
|
||||||
|
|
||||||
|
assert!(error.contains("resolved to"));
|
||||||
|
assert!(error.contains("expected pinned commit"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cached_git_source_revalidates_a_changed_revision() -> Result<(), Box<dyn std::error::Error>>
|
||||||
|
{
|
||||||
|
let repository = create_test_repository()?;
|
||||||
|
let first = git_stdout(repository.path(), &["rev-parse", "HEAD"])?;
|
||||||
|
let cache = tempfile::tempdir()?;
|
||||||
|
prepare_git_source(
|
||||||
|
&repository.path().to_string_lossy(),
|
||||||
|
&first,
|
||||||
|
&first,
|
||||||
|
cache.path(),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
fs::write(repository.path().join("pubspec.yaml"), "name: changed\n")?;
|
||||||
|
assert!(Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repository.path())
|
||||||
|
.args(["add", "pubspec.yaml"])
|
||||||
|
.status()?
|
||||||
|
.success());
|
||||||
|
assert!(Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repository.path())
|
||||||
|
.args([
|
||||||
|
"-c",
|
||||||
|
"user.name=SyncTV Test",
|
||||||
|
"-c",
|
||||||
|
"user.email=test@example.invalid",
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
"changed fixture",
|
||||||
|
])
|
||||||
|
.status()?
|
||||||
|
.success());
|
||||||
|
let second = git_stdout(repository.path(), &["rev-parse", "HEAD"])?;
|
||||||
|
|
||||||
|
let error = prepare_git_source(
|
||||||
|
&repository.path().to_string_lossy(),
|
||||||
|
&second,
|
||||||
|
&first,
|
||||||
|
cache.path(),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect_err("a changed revision must be resolved again");
|
||||||
|
|
||||||
|
assert!(error.contains("resolved to"));
|
||||||
|
assert!(error.contains("expected pinned commit"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_test_repository() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
|
||||||
|
let repository = tempfile::tempdir()?;
|
||||||
|
assert!(Command::new("git")
|
||||||
|
.arg("init")
|
||||||
|
.arg(repository.path())
|
||||||
|
.status()?
|
||||||
|
.success());
|
||||||
|
fs::write(repository.path().join("pubspec.yaml"), "name: app\n")?;
|
||||||
|
assert!(Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repository.path())
|
||||||
|
.args(["add", "pubspec.yaml"])
|
||||||
|
.status()?
|
||||||
|
.success());
|
||||||
|
assert!(Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repository.path())
|
||||||
|
.args([
|
||||||
|
"-c",
|
||||||
|
"user.name=SyncTV Test",
|
||||||
|
"-c",
|
||||||
|
"user.email=test@example.invalid",
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
"fixture",
|
||||||
|
])
|
||||||
|
.status()?
|
||||||
|
.success());
|
||||||
|
Ok(repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_stdout(
|
||||||
|
repository: &Path,
|
||||||
|
arguments: &[&str],
|
||||||
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repository)
|
||||||
|
.args(arguments)
|
||||||
|
.output()?;
|
||||||
|
assert!(output.status.success());
|
||||||
|
Ok(String::from_utf8(output.stdout)?.trim().to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct Asset {
|
||||||
|
pub path: &'static str,
|
||||||
|
pub content_type: &'static str,
|
||||||
|
pub etag: &'static str,
|
||||||
|
pub bytes: &'static [u8],
|
||||||
|
pub brotli: Option<EncodedAsset>,
|
||||||
|
pub gzip: Option<EncodedAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct EncodedAsset {
|
||||||
|
pub etag: &'static str,
|
||||||
|
pub bytes: &'static [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/web_assets.rs"));
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "build_support.rs"]
|
||||||
|
mod build_support;
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
schema-version = 1
|
||||||
|
|
||||||
|
[source]
|
||||||
|
kind = "git"
|
||||||
|
repository = "https://github.com/synctv-org/synctv-app.git"
|
||||||
|
revision = "64235a3584e4fa97560629e34f0160cc44672898"
|
||||||
|
commit = "64235a3584e4fa97560629e34f0160cc44672898"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
flutter = "flutter"
|
||||||
|
arguments = ["--release", "--no-web-resources-cdn", "--no-wasm-dry-run"]
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
schema-version = 1
|
||||||
|
|
||||||
|
[source]
|
||||||
|
kind = "dist"
|
||||||
|
path = "dist"
|
||||||
Loading…
Reference in New Issue