forked from 1b-agent/opencadstudio-upstream
refactor: move resource/IO root modules under io/
Relocate three root-level modules into io/, where they belong by concern: - update_check.rs -> io/update_check.rs (its own doc-comment notes it mirrors how crate::io wraps blocking calls in async). - patterns.rs, linetypes.rs -> io/ (standard resource catalogs parsed from asset files and populated into the document — IO-shaped work). Fix the moved files' include_str! paths (one level deeper), add the mod declarations to io/mod.rs, drop them from lib.rs/main.rs, and re-point all crate:: references to crate::io::. No behavior change. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
bbdbd0850b
commit
415dbe08d4
15 files changed
+18
-21
No files matched your search
@@ -0,0 +1,426 @@
|
||||
//! OpenCADStudio linetype catalog — loaded from `assets/linetypes/OpenCADStudio.lin`.
|
||||
//!
|
||||
//! Call [`populate_document`] to add all standard linetypes to a new document.
|
||||
//! Linetypes that already exist in the document are skipped.
|
||||
//!
|
||||
//! [`complex_lt`] returns the complex segment catalog for CPU-side rendering.
|
||||
|
||||
use rustc_hash::FxHashMap as HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use acadrust::tables::linetype::{LineType, LineTypeElement};
|
||||
use acadrust::{CadDocument, TableEntry};
|
||||
|
||||
// ── Complex linetype types ────────────────────────────────────────────────
|
||||
|
||||
/// One element in a complex linetype pattern.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum LtSegment {
|
||||
/// Draw a dash of this length (world units).
|
||||
Dash(f32),
|
||||
/// Skip a gap of this length (world units).
|
||||
Space(f32),
|
||||
/// Draw a dot (zero-length element, rendered as a tiny mark).
|
||||
Dot,
|
||||
/// Draw a shape at the current pen position. Embedded SHAPE elements are
|
||||
/// no longer rendered (the LFF font set ships no shape file).
|
||||
Shape {
|
||||
/// Shape name (retained for round-tripping; not drawn).
|
||||
name: String,
|
||||
/// X offset along the linetype direction (can be negative).
|
||||
x: f32,
|
||||
/// Y offset perpendicular to the linetype direction.
|
||||
y: f32,
|
||||
/// Scale factor applied to the CXF coordinates.
|
||||
scale: f32,
|
||||
/// Optional rotation in degrees (0 = along the line).
|
||||
rot_deg: f32,
|
||||
},
|
||||
/// Draw a text string at the current pen position.
|
||||
Text {
|
||||
/// The string to render.
|
||||
text: String,
|
||||
/// Text style / font name.
|
||||
style: String,
|
||||
/// X offset along the linetype direction.
|
||||
x: f32,
|
||||
/// Y offset perpendicular to the linetype direction.
|
||||
y: f32,
|
||||
/// Height scale factor.
|
||||
scale: f32,
|
||||
/// Rotation in degrees (0 = along the line).
|
||||
rot_deg: f32,
|
||||
},
|
||||
}
|
||||
|
||||
/// A complex linetype — ordered elements for one pattern repeat.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ComplexLt {
|
||||
pub segments: Vec<LtSegment>,
|
||||
}
|
||||
|
||||
static COMPLEX_CATALOG: OnceLock<HashMap<String, ComplexLt>> = OnceLock::new();
|
||||
|
||||
/// Look up a complex linetype by name (case-insensitive).
|
||||
/// Returns `None` for simple (dash-only) linetypes or unknown names.
|
||||
pub fn complex_lt(name: &str) -> Option<&'static ComplexLt> {
|
||||
COMPLEX_CATALOG
|
||||
.get_or_init(|| parse_complex(LIN_SOURCE))
|
||||
.get(&name.to_ascii_uppercase())
|
||||
}
|
||||
|
||||
const LIN_SOURCE: &str = include_str!("../../assets/linetypes/OpenCADStudio.lin");
|
||||
|
||||
// ── Pattern art extraction ────────────────────────────────────────────────
|
||||
|
||||
/// Extract the ASCII-art portion of a LIN description.
|
||||
///
|
||||
/// LIN descriptions look like `"Dashed __ __ __ __"` or `"ISO dot . . . ."`.
|
||||
/// This function returns the trailing pattern string (e.g. `"__ __ __ __"`).
|
||||
pub fn extract_pattern(desc: &str) -> String {
|
||||
// Patterns that use dashes start with `__`.
|
||||
if let Some(pos) = desc.find("__") {
|
||||
let start = desc[..pos].rfind(' ').map(|p| p + 1).unwrap_or(0);
|
||||
return desc[start..].trim().to_string();
|
||||
}
|
||||
// Dot-only patterns: look for `. .` sequence.
|
||||
if let Some(pos) = desc.find(". .") {
|
||||
let start = desc[..pos].rfind(' ').map(|p| p + 1).unwrap_or(0);
|
||||
return desc[start..].trim().to_string();
|
||||
}
|
||||
// Fallback: return the whole description.
|
||||
desc.trim().to_string()
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Add all standard OpenCADStudio linetypes to `doc`, skipping existing ones.
|
||||
pub fn populate_document(doc: &mut CadDocument) {
|
||||
for mut lt in parse(LIN_SOURCE) {
|
||||
if !doc.line_types.contains(<.name) {
|
||||
lt.set_handle(doc.allocate_handle());
|
||||
doc.line_types.add(lt).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Parser ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parse a `.lin` file and return all simple linetypes found.
|
||||
fn parse(src: &str) -> Vec<LineType> {
|
||||
let mut result = Vec::new();
|
||||
let mut current: Option<(String, String)> = None; // (name, description)
|
||||
|
||||
for raw in src.lines() {
|
||||
let line = raw.trim();
|
||||
|
||||
// Skip comments and blank lines.
|
||||
if line.is_empty() || line.starts_with(";;") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix('*') {
|
||||
// New pattern header: *NAME,Description
|
||||
if let Some((name, desc)) = rest.split_once(',') {
|
||||
current = Some((name.trim().to_string(), desc.trim().to_string()));
|
||||
}
|
||||
} else if line.to_ascii_uppercase().starts_with('A') {
|
||||
// Element line: A,v1,v2,...
|
||||
let Some((name, desc)) = current.take() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let after_a = match line[1..].trim_start().strip_prefix(',') {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let elements = parse_elements(after_a);
|
||||
let pattern_length: f64 = elements.iter().map(|e| e.length.abs()).sum();
|
||||
|
||||
let mut lt = LineType::new(&name);
|
||||
lt.description = desc;
|
||||
lt.pattern_length = pattern_length;
|
||||
for e in elements {
|
||||
lt.add_element(e);
|
||||
}
|
||||
result.push(lt);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse the comma-separated element values after `A,`.
|
||||
/// Complex elements `[...]` are skipped; only plain numbers are used.
|
||||
fn parse_elements(s: &str) -> Vec<LineTypeElement> {
|
||||
let mut elements = Vec::new();
|
||||
let mut chars = s.chars().peekable();
|
||||
let mut token = String::new();
|
||||
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c == '[' {
|
||||
// Skip entire complex element including nested brackets.
|
||||
let mut depth = 0usize;
|
||||
for ch in chars.by_ref() {
|
||||
if ch == '[' {
|
||||
depth += 1;
|
||||
} else if ch == ']' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Consume trailing comma if present.
|
||||
if chars.peek() == Some(&',') {
|
||||
chars.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if c == ',' {
|
||||
chars.next();
|
||||
push_element(&token, &mut elements);
|
||||
token.clear();
|
||||
} else {
|
||||
token.push(c);
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
push_element(&token, &mut elements);
|
||||
|
||||
elements
|
||||
}
|
||||
|
||||
fn push_element(token: &str, out: &mut Vec<LineTypeElement>) {
|
||||
let t = token.trim();
|
||||
if t.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(v) = t.parse::<f64>() {
|
||||
let elem = if v > 0.0 {
|
||||
LineTypeElement::dash(v)
|
||||
} else if v < 0.0 {
|
||||
LineTypeElement::space(v.abs())
|
||||
} else {
|
||||
LineTypeElement::dot()
|
||||
};
|
||||
out.push(elem);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Complex linetype parser ───────────────────────────────────────────────
|
||||
|
||||
/// Parse the LIN source and return a catalog of complex linetypes.
|
||||
/// A linetype is "complex" when its A-line contains at least one `[SHAPE,...]`
|
||||
/// element. Simple (dash-only) linetypes are excluded.
|
||||
fn parse_complex(src: &str) -> HashMap<String, ComplexLt> {
|
||||
let mut catalog: HashMap<String, ComplexLt> = HashMap::default();
|
||||
let mut current: Option<(String, String)> = None; // (name, description)
|
||||
|
||||
for raw in src.lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with(";;") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix('*') {
|
||||
if let Some((name, desc)) = rest.split_once(',') {
|
||||
current = Some((name.trim().to_string(), desc.trim().to_string()));
|
||||
}
|
||||
} else if line.to_ascii_uppercase().starts_with('A') {
|
||||
let Some((name, _desc)) = current.take() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let after_a = match line[1..].trim_start().strip_prefix(',') {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Only collect linetypes that contain a shape element.
|
||||
if !after_a.contains('[') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let segments = parse_complex_elements(after_a);
|
||||
if segments.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
catalog.insert(name.to_ascii_uppercase(), ComplexLt { segments });
|
||||
}
|
||||
}
|
||||
catalog
|
||||
}
|
||||
|
||||
/// Parse complex A-line elements into `LtSegment`s.
|
||||
/// Shape elements: `[SHAPENAME,fontfile,x=v,y=v,s=v,r=v]`
|
||||
/// Text elements: `["TEXT",font,...]` — skipped (returns no segment).
|
||||
fn parse_complex_elements(s: &str) -> Vec<LtSegment> {
|
||||
let mut segs = Vec::new();
|
||||
let mut chars = s.chars().peekable();
|
||||
let mut token = String::new();
|
||||
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c == '[' {
|
||||
// Flush any pending numeric token.
|
||||
push_lt_segment(&token, &mut segs);
|
||||
token.clear();
|
||||
|
||||
// Read the entire bracketed element.
|
||||
let mut depth = 0usize;
|
||||
let mut inner = String::new();
|
||||
for ch in chars.by_ref() {
|
||||
if ch == '[' {
|
||||
depth += 1;
|
||||
if depth > 1 {
|
||||
inner.push(ch);
|
||||
}
|
||||
} else if ch == ']' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
inner.push(ch);
|
||||
} else {
|
||||
inner.push(ch);
|
||||
}
|
||||
}
|
||||
// Consume trailing comma.
|
||||
if chars.peek() == Some(&',') {
|
||||
chars.next();
|
||||
}
|
||||
|
||||
if let Some(seg) = parse_shape_element(&inner) {
|
||||
segs.push(seg);
|
||||
}
|
||||
} else if c == ',' {
|
||||
chars.next();
|
||||
push_lt_segment(&token, &mut segs);
|
||||
token.clear();
|
||||
} else {
|
||||
token.push(c);
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
push_lt_segment(&token, &mut segs);
|
||||
segs
|
||||
}
|
||||
|
||||
/// Parse a bracketed linetype element.
|
||||
/// Handles both shape (`SHAPENAME,...`) and text (`"string",...`) elements.
|
||||
fn parse_shape_element(inner: &str) -> Option<LtSegment> {
|
||||
let inner = inner.trim();
|
||||
if inner.starts_with('"') {
|
||||
return parse_text_element(inner);
|
||||
}
|
||||
|
||||
let mut parts = inner.split(',');
|
||||
let name = parts.next()?.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut x = 0.0f32;
|
||||
let mut y = 0.0f32;
|
||||
let mut scale = 1.0f32;
|
||||
let mut rot_deg = 0.0f32;
|
||||
|
||||
for part in parts {
|
||||
let p = part.trim();
|
||||
if let Some(v) = p.strip_prefix("x=").or_else(|| p.strip_prefix("X=")) {
|
||||
x = v.parse().unwrap_or(0.0);
|
||||
} else if let Some(v) = p.strip_prefix("y=").or_else(|| p.strip_prefix("Y=")) {
|
||||
y = v.parse().unwrap_or(0.0);
|
||||
} else if let Some(v) = p.strip_prefix("s=").or_else(|| p.strip_prefix("S=")) {
|
||||
scale = v.parse().unwrap_or(1.0);
|
||||
} else if let Some(v) = p
|
||||
.strip_prefix("r=")
|
||||
.or_else(|| p.strip_prefix("R="))
|
||||
.or_else(|| p.strip_prefix("u="))
|
||||
.or_else(|| p.strip_prefix("U="))
|
||||
{
|
||||
rot_deg = v.parse().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
Some(LtSegment::Shape {
|
||||
name,
|
||||
x,
|
||||
y,
|
||||
scale,
|
||||
rot_deg,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `"TEXT",STYLE,x=v,y=v,s=v,r=v` into an `LtSegment::Text`.
|
||||
fn parse_text_element(inner: &str) -> Option<LtSegment> {
|
||||
// Find the closing quote.
|
||||
let rest = inner.strip_prefix('"')?;
|
||||
let end = rest.find('"')?;
|
||||
let text = rest[..end].to_string();
|
||||
let after = rest[end + 1..].trim().trim_start_matches(',');
|
||||
|
||||
let mut parts = after.split(',');
|
||||
let style = parts
|
||||
.next()
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
let style = if style.is_empty() {
|
||||
"Standard".to_string()
|
||||
} else {
|
||||
style
|
||||
};
|
||||
|
||||
let mut x = 0.0f32;
|
||||
let mut y = 0.0f32;
|
||||
let mut scale = 1.0f32;
|
||||
let mut rot_deg = 0.0f32;
|
||||
|
||||
for part in parts {
|
||||
let p = part.trim();
|
||||
if let Some(v) = p.strip_prefix("x=").or_else(|| p.strip_prefix("X=")) {
|
||||
x = v.parse().unwrap_or(0.0);
|
||||
} else if let Some(v) = p.strip_prefix("y=").or_else(|| p.strip_prefix("Y=")) {
|
||||
y = v.parse().unwrap_or(0.0);
|
||||
} else if let Some(v) = p.strip_prefix("s=").or_else(|| p.strip_prefix("S=")) {
|
||||
scale = v.parse().unwrap_or(1.0);
|
||||
} else if let Some(v) = p
|
||||
.strip_prefix("r=")
|
||||
.or_else(|| p.strip_prefix("R="))
|
||||
.or_else(|| p.strip_prefix("u="))
|
||||
.or_else(|| p.strip_prefix("U="))
|
||||
{
|
||||
rot_deg = v.parse().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
Some(LtSegment::Text {
|
||||
text,
|
||||
style,
|
||||
x,
|
||||
y,
|
||||
scale,
|
||||
rot_deg,
|
||||
})
|
||||
}
|
||||
|
||||
fn push_lt_segment(token: &str, out: &mut Vec<LtSegment>) {
|
||||
let t = token.trim();
|
||||
if t.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(v) = t.parse::<f32>() {
|
||||
let seg = if v > 0.0 {
|
||||
LtSegment::Dash(v)
|
||||
} else if v < 0.0 {
|
||||
LtSegment::Space(v.abs())
|
||||
} else {
|
||||
LtSegment::Dot
|
||||
};
|
||||
out.push(seg);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -11,6 +11,9 @@ pub mod print_to_printer;
|
||||
pub mod step;
|
||||
pub mod stl;
|
||||
pub mod xref;
|
||||
pub mod linetypes;
|
||||
pub mod patterns;
|
||||
pub mod update_check;
|
||||
|
||||
use crate::scene::DerivedCaches;
|
||||
use acadrust::entities::{Dimension, EntityType};
|
||||
@@ -879,7 +882,7 @@ mod layer_roundtrip_tests {
|
||||
// round-trip through `ext`, and return whether every one survived.
|
||||
fn roundtrip_layers(ext: &str, count: usize) -> bool {
|
||||
let mut doc = CadDocument::new();
|
||||
crate::linetypes::populate_document(&mut doc);
|
||||
crate::io::linetypes::populate_document(&mut doc);
|
||||
let names: Vec<String> = (0..count).map(|n| format!("Layer{}", n + 1)).collect();
|
||||
for name in &names {
|
||||
let mut dl = DocLayer::new(name);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! OpenCADStudio hatch pattern catalog — loaded from `assets/patterns/OpenCADStudio.pat`.
|
||||
//!
|
||||
//! PAT line format: `angle, x0, y0, dx, dy [, dash1, dash2, ...]`
|
||||
//! - `angle` line direction in degrees
|
||||
//! - `x0, y0` origin of the first line in the family
|
||||
//! - `dx, dy` step vector to the next parallel line
|
||||
//! - `dash…` positive = dash length, negative = gap length (omit = solid)
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const PAT_SRC: &str = include_str!("../../assets/patterns/OpenCADStudio.pat");
|
||||
|
||||
// ── Public types ──────────────────────────────────────────────────────────
|
||||
|
||||
/// One line family inside a hatch pattern.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PatLineDef {
|
||||
/// Line direction in degrees.
|
||||
pub angle_deg: f32,
|
||||
/// Origin of the first line in the family.
|
||||
pub x0: f32,
|
||||
pub y0: f32,
|
||||
/// Step vector to the next parallel line.
|
||||
pub dx: f32,
|
||||
pub dy: f32,
|
||||
/// Dash/gap sequence (positive = dash, negative = gap, empty = solid).
|
||||
pub dashes: Vec<f32>,
|
||||
}
|
||||
|
||||
/// One parsed hatch pattern.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PatternDef {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub lines: Vec<PatLineDef>,
|
||||
}
|
||||
|
||||
// ── Static catalog ────────────────────────────────────────────────────────
|
||||
|
||||
static CATALOG: OnceLock<Vec<PatternDef>> = OnceLock::new();
|
||||
|
||||
/// All parsed patterns, in file order.
|
||||
pub fn catalog() -> &'static [PatternDef] {
|
||||
CATALOG.get_or_init(|| parse(PAT_SRC))
|
||||
}
|
||||
|
||||
// ── Geometry helper ───────────────────────────────────────────────────────
|
||||
|
||||
// ── Parser ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse(src: &str) -> Vec<PatternDef> {
|
||||
let mut result = Vec::new();
|
||||
let mut cur_name = String::new();
|
||||
let mut cur_desc = String::new();
|
||||
let mut cur_lines: Vec<PatLineDef> = Vec::new();
|
||||
let mut in_pattern = false;
|
||||
|
||||
let flush = |name: &mut String,
|
||||
desc: &mut String,
|
||||
lines: &mut Vec<PatLineDef>,
|
||||
result: &mut Vec<PatternDef>| {
|
||||
if !lines.is_empty() {
|
||||
result.push(PatternDef {
|
||||
name: std::mem::take(name),
|
||||
description: std::mem::take(desc),
|
||||
lines: std::mem::take(lines),
|
||||
});
|
||||
}
|
||||
*name = String::new();
|
||||
*desc = String::new();
|
||||
};
|
||||
|
||||
for raw in src.lines() {
|
||||
let line = raw.trim();
|
||||
|
||||
// Skip blanks and comments.
|
||||
if line.is_empty() || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix('*') {
|
||||
// Flush previous pattern.
|
||||
flush(&mut cur_name, &mut cur_desc, &mut cur_lines, &mut result);
|
||||
in_pattern = true;
|
||||
|
||||
// Header: *NAME[,Description]
|
||||
if let Some((name, desc)) = rest.split_once(',') {
|
||||
cur_name = name.trim().to_string();
|
||||
cur_desc = desc.trim().to_string();
|
||||
} else {
|
||||
cur_name = rest.trim().to_string();
|
||||
cur_desc = String::new();
|
||||
}
|
||||
} else if in_pattern {
|
||||
// Pattern line: angle, x0, y0, dx, dy [, d1, d2, ...]
|
||||
// Strip inline comments.
|
||||
let data = line.split(';').next().unwrap_or("").trim();
|
||||
if data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<f32> = data
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse().ok())
|
||||
.collect();
|
||||
|
||||
if parts.len() >= 5 {
|
||||
cur_lines.push(PatLineDef {
|
||||
angle_deg: parts[0],
|
||||
x0: parts[1],
|
||||
y0: parts[2],
|
||||
dx: parts[3],
|
||||
dy: parts[4],
|
||||
dashes: parts[5..].to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush last pattern.
|
||||
flush(&mut cur_name, &mut cur_desc, &mut cur_lines, &mut result);
|
||||
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// One-shot update check.
|
||||
//
|
||||
// `check_for_update()` runs on a background thread (joined inside an
|
||||
// async wrapper, mirroring how the rest of `crate::io` wraps blocking
|
||||
// work for iced's `Task::perform`). It hits the GitHub releases API and
|
||||
// returns `Some(UpdateInfo)` when a newer release is available, or
|
||||
// `None` when up to date / on network failure / on parse error.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const RELEASES_API: &str =
|
||||
"https://api.github.com/repos/HakanSeven12/OpenCADStudio/releases/latest";
|
||||
pub const RELEASES_PAGE: &str =
|
||||
"https://github.com/HakanSeven12/OpenCADStudio/releases/latest";
|
||||
|
||||
/// Minimum age before a freshly-published release is offered to the user.
|
||||
/// GitHub Actions takes ~15 min to build and attach the platform binaries
|
||||
/// after a tag is pushed, so suppressing notifications for the first 30
|
||||
/// minutes prevents users from clicking through to a release page whose
|
||||
/// asset list is still empty.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MIN_RELEASE_AGE_SECS: u64 = 30 * 60;
|
||||
|
||||
/// What `check_for_update` reports when a newer release exists.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateInfo {
|
||||
/// `tag_name` with the leading `v` stripped (e.g. `0.3.7`).
|
||||
pub version: String,
|
||||
/// Release notes / markdown body from the GitHub release. May be empty
|
||||
/// when the release was published without notes.
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn check_for_update() -> Option<UpdateInfo> {
|
||||
std::thread::spawn(fetch_latest_if_outdated)
|
||||
.join()
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// On wasm there is no background thread or blocking HTTP client; the web build
|
||||
/// skips the self-update check (the page is always served fresh).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[allow(dead_code)]
|
||||
pub async fn check_for_update() -> Option<UpdateInfo> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn fetch_latest_if_outdated() -> Option<UpdateInfo> {
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(std::time::Duration::from_secs(5)))
|
||||
.build()
|
||||
.into();
|
||||
let body = agent
|
||||
.get(RELEASES_API)
|
||||
.header("User-Agent", concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION")))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.call()
|
||||
.ok()?
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.ok()?;
|
||||
let latest = extract_string_field(&body, "tag_name")?
|
||||
.trim_start_matches('v')
|
||||
.to_string();
|
||||
if latest == env!("CARGO_PKG_VERSION") {
|
||||
return None;
|
||||
}
|
||||
// Suppress the notification until the release is old enough for the
|
||||
// Actions build to have published binaries.
|
||||
if let Some(published) = extract_string_field(&body, "published_at")
|
||||
.as_deref()
|
||||
.and_then(parse_iso8601_utc)
|
||||
{
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_secs();
|
||||
if now.saturating_sub(published) < MIN_RELEASE_AGE_SECS {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// Release notes are optional; treat missing as empty.
|
||||
let notes = extract_string_field(&body, "body").unwrap_or_default();
|
||||
Some(UpdateInfo { version: latest, body: notes })
|
||||
}
|
||||
|
||||
/// Parse a GitHub timestamp like `2026-05-29T12:34:56Z` into UNIX seconds.
|
||||
/// Only handles the fixed `YYYY-MM-DDTHH:MM:SSZ` format the GitHub API
|
||||
/// emits; returns `None` for anything else.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn parse_iso8601_utc(s: &str) -> Option<u64> {
|
||||
let b = s.as_bytes();
|
||||
if b.len() != 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T'
|
||||
|| b[13] != b':' || b[16] != b':' || b[19] != b'Z'
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let year: i32 = s.get(0..4)?.parse().ok()?;
|
||||
let month: u32 = s.get(5..7)?.parse().ok()?;
|
||||
let day: u32 = s.get(8..10)?.parse().ok()?;
|
||||
let hour: u32 = s.get(11..13)?.parse().ok()?;
|
||||
let minute: u32 = s.get(14..16)?.parse().ok()?;
|
||||
let second: u32 = s.get(17..19)?.parse().ok()?;
|
||||
if !(1..=12).contains(&month)
|
||||
|| !(1..=31).contains(&day)
|
||||
|| hour >= 24
|
||||
|| minute >= 60
|
||||
|| second >= 60
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Howard Hinnant's days_from_civil — converts a proleptic-Gregorian
|
||||
// (Y, M, D) to a count of days since 1970-01-01.
|
||||
let y = if month <= 2 { year - 1 } else { year };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = (y - era * 400) as u32;
|
||||
let m = month as i32;
|
||||
let d = day as i32;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy as u32;
|
||||
let days_since_epoch = era as i64 * 146097 + doe as i64 - 719468;
|
||||
if days_since_epoch < 0 {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
days_since_epoch as u64 * 86_400
|
||||
+ hour as u64 * 3_600
|
||||
+ minute as u64 * 60
|
||||
+ second as u64,
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimal extractor for a top-level string field in the releases JSON.
|
||||
/// Avoids pulling in `serde_json` for two fields. Handles standard JSON
|
||||
/// string escapes (`\"`, `\\`, `\n`, `\r`, `\t`, `\/`) which are all the
|
||||
/// GitHub release body uses in practice.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn extract_string_field(body: &str, field: &str) -> Option<String> {
|
||||
let key = format!("\"{}\":\"", field);
|
||||
let start = body.find(&key)? + key.len();
|
||||
// Walk to the closing unescaped `"`, JSON-unescaping as we go.
|
||||
let mut out = String::new();
|
||||
let bytes = body.as_bytes();
|
||||
let mut i = start;
|
||||
while i < bytes.len() {
|
||||
let b = bytes[i];
|
||||
if b == b'"' {
|
||||
return Some(out);
|
||||
}
|
||||
if b == b'\\' && i + 1 < bytes.len() {
|
||||
match bytes[i + 1] {
|
||||
b'"' => out.push('"'),
|
||||
b'\\' => out.push('\\'),
|
||||
b'/' => out.push('/'),
|
||||
b'n' => out.push('\n'),
|
||||
b'r' => out.push('\r'),
|
||||
b't' => out.push('\t'),
|
||||
b'u' => {
|
||||
// \uXXXX — decode 4 hex digits. Surrogate pairs (BMP only
|
||||
// for now) are uncommon in release notes; skip on parse
|
||||
// failure.
|
||||
if i + 5 < bytes.len() {
|
||||
let hex = std::str::from_utf8(&bytes[i + 2..i + 6]).ok()?;
|
||||
if let Ok(code) = u32::from_str_radix(hex, 16) {
|
||||
if let Some(c) = char::from_u32(code) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
i += 6;
|
||||
continue;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
other => {
|
||||
// Unknown escape — keep the literal pair, GitHub doesn't emit these.
|
||||
out.push('\\');
|
||||
out.push(other as char);
|
||||
}
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// UTF-8 multi-byte chars: push the whole code-point so we don't
|
||||
// bisect a sequence.
|
||||
let ch = body[i..].chars().next()?;
|
||||
out.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
None
|
||||
}
|
||||
Reference in new issue
Block a user