feat: add runtime build-ID management to increment and track application build numbers

- Implemented a new module for managing build IDs at runtime.
- Introduced functions to find the repository root, check for stale locks, and increment the build ID.
- Ensured atomic writes to the build ID file with proper locking mechanisms.
- The build version string is formatted as "0.1.0+build.{build_id}" for display purposes.
This commit is contained in:
2026-07-21 05:41:27 +03:00
parent cbe03f74a3
commit bc89eefa88
9 changed files with 417 additions and 115 deletions
+40 -1
View File
@@ -331,6 +331,8 @@ pub struct HcieIcedApp {
pub brush_category: crate::panels::brushes::BrushCategory,
/// Context menu screen position (x, y). None means closed.
pub canvas_context_menu: Option<(f32, f32)>,
/// Runtime build version string (e.g. `"0.1.0+build.992"`), incremented on each launch.
pub runtime_version: String,
/// Dirty flag set whenever fg/bg/recent colors change, so colors can be
/// persisted to disk (egui persists these under the `hcie_colors` key).
pub colors_dirty: bool,
@@ -507,6 +509,12 @@ pub struct IcedDocument {
/// Transformed vector shape being previewed during a drag (not yet committed
/// to the engine). Kept in the document so the overlay canvas can draw it.
pub vector_drag_preview: Option<hcie_engine_api::VectorShape>,
/// True while the user is holding Shift during a rotation drag (enables snap visual feedback).
pub rotation_snap_active: bool,
/// The snapped angle (radians) currently displayed in the snap indicator overlay.
pub rotation_snap_angle: f32,
/// True while the user is holding Shift during a resize drag (enables aspect-lock visual feedback).
pub resize_snap_active: bool,
/// Cached layer thumbnails keyed by layer ID — (thumb_w, thumb_h, RGBA pixels).
/// Regenerated only for dirty layers in `refresh_panel_caches()`.
pub layer_thumbnails: std::collections::HashMap<u64, (u32, u32, Vec<u8>)>,
@@ -1679,6 +1687,9 @@ impl HcieIcedApp {
vector_cycle_index: 0,
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
vector_drag_preview: None,
rotation_snap_active: false,
rotation_snap_angle: 0.0,
resize_snap_active: false,
layer_thumbnails: std::collections::HashMap::new(),
thumb_gen: 0,
};
@@ -1786,6 +1797,7 @@ impl HcieIcedApp {
show_dock_profile_dialog: false,
_language: i18n::Language::default(),
brush_category: crate::panels::brushes::BrushCategory::All,
runtime_version: crate::build_info::runtime_build_version(),
colors_dirty: false,
color_dragging: false,
pending_drag_color: None,
@@ -5270,6 +5282,9 @@ impl HcieIcedApp {
vector_cycle_index: 0,
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
vector_drag_preview: None,
rotation_snap_active: false,
rotation_snap_angle: 0.0,
resize_snap_active: false,
layer_thumbnails: std::collections::HashMap::new(),
thumb_gen: 0,
};
@@ -5769,7 +5784,7 @@ impl HcieIcedApp {
let radius = self.settings.tool_settings.vector_radius;
let points = self.settings.tool_settings.vector_points;
let sides = self.settings.tool_settings.vector_sides;
let fill_color = color;
let fill_color = self.bg_color;
let shape = match self.tool_state.active_tool {
Tool::VectorRect => Some(hcie_engine_api::VectorShape::Rect {
@@ -6411,6 +6426,24 @@ impl HcieIcedApp {
self.vector_drag_last_bounds = Some((x1, y1, x2, y2));
self.vector_drag_last_angle = Some(angle);
self.settings.tool_settings.vector_angle = angle.to_degrees();
// Track snap state for visual overlay feedback
let is_rotate = matches!(
session.handle,
hcie_engine_api::VectorEditHandle::Rotate
);
let is_resize = matches!(
session.handle,
hcie_engine_api::VectorEditHandle::TopLeft
| hcie_engine_api::VectorEditHandle::TopRight
| hcie_engine_api::VectorEditHandle::BottomLeft
| hcie_engine_api::VectorEditHandle::BottomRight
);
self.documents[self.active_doc].rotation_snap_active =
is_rotate && self.modifiers.shift();
self.documents[self.active_doc].rotation_snap_angle = angle;
self.documents[self.active_doc].resize_snap_active =
is_resize && self.modifiers.shift();
}
}
Message::VectorSelectDragEnd => {
@@ -6448,6 +6481,8 @@ impl HcieIcedApp {
self.documents[self.active_doc].vector_drag_preview = None;
self.documents[self.active_doc].vector_edit_handle =
hcie_engine_api::VectorEditHandle::None;
self.documents[self.active_doc].rotation_snap_active = false;
self.documents[self.active_doc].resize_snap_active = false;
self.refresh_composite_if_needed();
}
}
@@ -8076,6 +8111,9 @@ impl HcieIcedApp {
vector_cycle_index: 0,
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
vector_drag_preview: None,
rotation_snap_active: false,
rotation_snap_angle: 0.0,
resize_snap_active: false,
layer_thumbnails: std::collections::HashMap::new(),
thumb_gen: 0,
});
@@ -9237,6 +9275,7 @@ impl HcieIcedApp {
self.active_menu,
&self.title_search,
self.show_panel_list,
&self.runtime_version,
);
// Toolbox — 36px strip (single column) or 72px (2 columns) on the left edge
@@ -0,0 +1,126 @@
//! Runtime build-ID management.
//!
//! **Purpose:** Reads, atomically increments, and writes `build.id` on every application startup
//! so each launch receives a unique, monotonically increasing build number — without requiring
//! a wrapper script or a separate build step.
//! **Logic & Workflow:** Locates the repository root by walking upward from `CARGO_MANIFEST_DIR`
//! until a `Cargo.toml` containing `[workspace]` is found, then reads/increments/writes `build.id`
//! under a file lock. Exposes the resulting version string for title-bar display.
//! **Side Effects / Dependencies:** Performs one file-system write per startup; concurrent launches
//! are serialized via an advisory directory lock.
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Maximum age (seconds) before a lock is considered stale and forcefully removed.
const LOCK_STALE_SECS: u64 = 30;
/// File-system lock directory used to serialize concurrent increments.
const LOCK_DIR: &str = ".build-id.lock";
/// Finds the repository root by walking upward from the compile-time manifest directory.
///
/// **Returns:** The repository root `PathBuf`.
/// **Side Effects / Dependencies:** Panics if the workspace root cannot be located.
fn find_repo_root() -> PathBuf {
let manifest_dir = PathBuf::from(
std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is required"),
);
let mut candidate = manifest_dir.clone();
loop {
let tombstone = candidate.join("Cargo.toml");
if tombstone.exists() {
if let Ok(content) = fs::read_to_string(&tombstone) {
if content.contains("[workspace]") {
return candidate;
}
}
}
if !candidate.pop() {
panic!("could not locate repository root above {:?}", manifest_dir);
}
}
}
/// Returns current Unix timestamp in seconds, or 0 on clock error.
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Checks whether a lock directory is stale (older than `LOCK_STALE_SECS`).
fn is_lock_stale(lock_dir: &Path) -> bool {
let ts_file = lock_dir.join("ts");
if let Ok(ts_str) = fs::read_to_string(&ts_file) {
if let Ok(ts) = ts_str.trim().parse::<u64>() {
return unix_now().saturating_sub(ts) > LOCK_STALE_SECS;
}
}
// No timestamp file or unparseable → treat as stale.
true
}
/// Acquires an advisory directory lock, increments the counter, and releases the lock.
///
/// **Returns:** The new (post-increment) build ID.
/// **Side Effects / Dependencies:** Creates and removes a temporary lock directory.
fn increment_build_id(root: &Path) -> u64 {
let lock_dir = root.join(LOCK_DIR);
let counter_file = root.join("build.id");
// Busy-wait with stale-lock detection.
loop {
match fs::create_dir(&lock_dir) {
Ok(()) => {
// Write timestamp for stale-lock detection by other instances.
let _ = fs::write(
lock_dir.join("ts"),
format!("{}\n", unix_now()),
);
break;
}
Err(_) => {
if is_lock_stale(&lock_dir) {
let _ = fs::remove_dir_all(&lock_dir);
continue;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
}
// Read current value.
let current: u64 = fs::read_to_string(&counter_file)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
let next = current + 1;
// Atomic write: write to temp file, then rename.
let tmp = counter_file.with_extension("id.tmp");
fs::write(&tmp, format!("{}\n", next)).expect("failed to write build.id tmp");
fs::rename(&tmp, &counter_file).expect("failed to rename build.id");
// Release lock.
let _ = fs::remove_dir_all(&lock_dir);
next
}
/// Reads, increments, and returns the build version string at runtime.
///
/// **Returns:** A version string like `"0.1.0+build.992"`.
/// **Side Effects / Dependencies:** Writes to `build.id` on every call (intended for startup only).
pub fn runtime_build_version() -> String {
let root = find_repo_root();
let build_id = increment_build_id(&root);
let base_version = hcie_build_info::VERSION
.split("+build.")
.next()
.unwrap_or("0.1.0");
format!("{base_version}+build.{build_id}")
}
@@ -97,6 +97,12 @@ struct OverlayProgram {
vector_edit_handle: hcie_engine_api::VectorEditHandle,
/// Transformed vector shape being previewed during a drag (not yet committed).
vector_drag_preview: Option<hcie_engine_api::VectorShape>,
/// True while the user is holding Shift during a rotation drag (enables snap visual feedback).
rotation_snap_active: bool,
/// The snapped angle (radians) currently displayed in the snap indicator overlay.
rotation_snap_angle: f32,
/// True while the user is holding Shift during a resize drag (enables aspect-lock visual feedback).
resize_snap_active: bool,
}
/// Overlay state — tracks hover and cursor position for crosshair.
@@ -1352,6 +1358,123 @@ impl canvas::Program<Message> for OverlayProgram {
origin_y,
state.hovered_vector_handle,
);
// ── Snap indicator overlay ──────────────────────────────────
// When Shift is held during rotation/resize, draw a visual indicator
// to confirm snapping is active.
let cx = (x1 + x2) / 2.0;
let cy = (y1 + y2) / 2.0;
if self.rotation_snap_active {
// Draw a dashed arc from center showing the snapped angle
let snap_angle = self.rotation_snap_angle;
let indicator_radius = 40.0_f32.min((x2 - x1).abs().min((y2 - y1).abs()) * 0.4);
let arc_segments = 48;
let snap_color = iced::Color::from_rgba(0.0, 0.8, 1.0, 0.85);
// Draw arc from 0 to snapped angle
let arc_path = Path::new(|b| {
let start_x = cx + indicator_radius;
let start_y = cy;
b.move_to(Point::new(
origin_x + start_x * self.zoom,
origin_y + start_y * self.zoom,
));
for i in 1..=arc_segments {
let t = i as f32 / arc_segments as f32;
let a = snap_angle * t;
let px = cx + indicator_radius * a.cos();
let py = cy + indicator_radius * a.sin();
b.line_to(Point::new(
origin_x + px * self.zoom,
origin_y + py * self.zoom,
));
}
});
frame.stroke(
&arc_path,
Stroke {
style: canvas::stroke::Style::Solid(snap_color),
width: 2.0 / self.zoom.max(1.0),
..Default::default()
},
);
// Draw radial line from center to arc end
let end_x = cx + indicator_radius * snap_angle.cos();
let end_y = cy + indicator_radius * snap_angle.sin();
let radial_line = Path::line(
Point::new(origin_x + cx * self.zoom, origin_y + cy * self.zoom),
Point::new(
origin_x + end_x * self.zoom,
origin_y + end_y * self.zoom,
),
);
frame.stroke(
&radial_line,
Stroke {
style: canvas::stroke::Style::Solid(snap_color),
width: 1.5 / self.zoom.max(1.0),
line_dash: canvas::LineDash {
segments: &[6.0, 4.0],
offset: 0,
},
..Default::default()
},
);
// Small dot at arc end
frame.fill(
&Path::circle(
Point::new(
origin_x + end_x * self.zoom,
origin_y + end_y * self.zoom,
),
3.0,
),
snap_color,
);
}
if self.resize_snap_active {
// Draw corner brackets at the shape corners to indicate aspect-lock
let bracket_size = 12.0_f32;
let bracket_color = iced::Color::from_rgba(1.0, 0.6, 0.0, 0.85);
let corners = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)];
let cos_a = self.selected_vector_angle.cos();
let sin_a = self.selected_vector_angle.sin();
let rotate = |px: f32, py: f32| -> (f32, f32) {
let dx = px - cx;
let dy = py - cy;
(cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a)
};
for (_i, &(px, py)) in corners.iter().enumerate() {
let (rpx, rpy) = rotate(px, py);
let sx = origin_x + rpx * self.zoom;
let sy = origin_y + rpy * self.zoom;
// Direction from center toward corner
let dir_x = (rpx - cx).signum();
let dir_y = (rpy - cy).signum();
let bs = bracket_size / self.zoom.max(1.0);
let bracket = Path::new(|b| {
// Horizontal segment
b.move_to(Point::new(sx + dir_x * bs * self.zoom, sy));
b.line_to(Point::new(sx, sy));
// Vertical segment
b.line_to(Point::new(sx, sy + dir_y * bs * self.zoom));
});
frame.stroke(
&bracket,
Stroke {
style: canvas::stroke::Style::Solid(bracket_color),
width: 2.0 / self.zoom.max(1.0),
..Default::default()
},
);
}
}
}
// Draw gradient endpoint preview line.
@@ -1717,6 +1840,9 @@ pub fn view<'a>(
selected_vector_angle: sel_vec_angle,
vector_edit_handle: doc.vector_edit_handle,
vector_drag_preview: vector_preview,
rotation_snap_active: doc.rotation_snap_active,
rotation_snap_angle: doc.rotation_snap_angle,
resize_snap_active: doc.resize_snap_active,
};
let overlay_canvas = canvas(overlay_program)
@@ -6,6 +6,7 @@ mod ai_chat;
mod ai_script;
mod app;
mod brush_import;
mod build_info;
mod canvas;
mod cli;
mod color_picker;
@@ -27,8 +27,6 @@ pub(crate) const MENU_LEFT_INSET: f32 = 6.0;
/// Unified menu/title bar height shared with dropdown placement.
pub(crate) const MENU_BAR_HEIGHT: f32 = 30.0;
const APP_VERSION: &str = hcie_build_info::VERSION;
/// Returns the clamped left edge for a menu dropdown.
pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_width: f32) -> f32 {
let natural = MENU_LEFT_INSET
@@ -54,6 +52,7 @@ pub fn view<'a>(
active_menu: Option<usize>,
title_search: &'a str,
show_panel_list: bool,
runtime_version: &'a str,
) -> Element<'a, Message> {
// ── App icon ──
let icon = text("H")
@@ -164,7 +163,7 @@ pub fn view<'a>(
// ── Document info — centered: "HCIE v{version} — New Project.psd *" ──
let title_str = format!(
"HCIE v{}{}{}",
APP_VERSION,
runtime_version,
doc_name,
if modified { " *" } else { "" }
);