Compare commits

..
Author SHA1 Message Date
anthropic-code-agent[bot]andashaduri c673bd95bb Add unit tests for SelfTest adaptive ETA algorithm
Co-authored-by: ashaduri <2302268+ashaduri@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ashaduri/gsmartcontrol/sessions/8b4b77f5-beb6-4c32-bf49-93d641714685
2026-03-22 17:04:36 +00:00
copilot-swe-agent[bot]andashaduri b2912e5b61 Remove redundant build/ from .gitignore (already covered by /build*)
Co-authored-by: ashaduri <2302268+ashaduri@users.noreply.github.com>
2026-03-07 15:35:28 +00:00
copilot-swe-agent[bot]andashaduri b1ccfd43e0 Fix narrowing conversion: use int instead of int8_t for remaining_segments
Co-authored-by: ashaduri <2302268+ashaduri@users.noreply.github.com>
2026-03-07 15:26:53 +00:00
copilot-swe-agent[bot]andashaduri dd0b9fe8ea Move gran to fallback path and fix adaptive ETA for NVMe drives
Co-authored-by: ashaduri <2302268+ashaduri@users.noreply.github.com>
2026-03-07 15:21:48 +00:00
anthropic-code-agent[bot]andashaduri d88090d6b6 Skip first segment in adaptive ETA to avoid skew from instant/partial progress
Co-authored-by: ashaduri <2302268+ashaduri@users.noreply.github.com>
2026-03-06 14:54:17 +00:00
anthropic-code-agent[bot]andashaduri 1c99ff8350 Implement adaptive ETA calculation based on observed segment durations
Co-authored-by: ashaduri <2302268+ashaduri@users.noreply.github.com>
2026-03-06 14:43:41 +00:00
copilot-swe-agent[bot]andashaduri 2ef695f8fb Fix misleading ETA: 0 sec during self-test when drive estimate is exceeded
Co-authored-by: ashaduri <2302268+ashaduri@users.noreply.github.com>
2026-03-04 22:05:23 +00:00
copilot-swe-agent[bot] d0a2852119 Initial plan 2026-03-04 21:54:35 +00:00
16 changed files with 227 additions and 448 deletions
-128
View File
@@ -1,128 +0,0 @@
name: Build AppImage
on:
# Triggers the workflow on push or pull request events but only for the main branch
push:
branches: [ main ]
pull_request:
branches: [ main ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
env:
BUILD_TYPE: RelWithDebInfo
jobs:
build-appimage:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Update Package Database
run: sudo apt-get update
- name: Install Dependencies
run: sudo apt-get install libgtkmm-3.0-dev gettext wget file libfuse2
- name: Download linuxdeploy
# Note: Using 'continuous' release for latest version. For reproducible builds,
# pin to a specific release tag and verify SHA256 checksum.
run: |
wget https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
chmod +x linuxdeploy-x86_64.AppImage
sudo mv linuxdeploy-x86_64.AppImage /usr/local/bin/linuxdeploy
- name: Download appimagetool
# Note: Using 'continuous' release for latest version. For reproducible builds,
# pin to a specific release tag and verify SHA256 checksum.
run: |
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage
sudo mv appimagetool-x86_64.AppImage /usr/local/bin/appimagetool
- name: Create Build Directory
run: cmake -E make_directory build-appimage
- name: Configure CMake for AppImage
shell: bash
working-directory: ${{github.workspace}}/build-appimage
env:
CC: gcc-11
CXX: g++-11
run: >
cmake $GITHUB_WORKSPACE
-DCMAKE_BUILD_TYPE=$BUILD_TYPE
-DCMAKE_INSTALL_PREFIX=/usr
-DAPP_BUILD_APPIMAGE=ON
-DCMAKE_TOOLCHAIN_FILE="$GITHUB_WORKSPACE/toolchains/linux-dev.cmake"
- name: Build
shell: bash
working-directory: ${{github.workspace}}/build-appimage
run: cmake --build . --config $BUILD_TYPE -j$(nproc)
- name: Install to AppDir
shell: bash
working-directory: ${{github.workspace}}/build-appimage
run: DESTDIR=${{github.workspace}}/AppDir cmake --install .
- name: Setup AppDir structure
shell: bash
working-directory: ${{github.workspace}}
run: |
# Copy desktop file and icon to AppDir root
cp AppDir/usr/share/applications/dev.shaduri.gsmartcontrol.desktop AppDir/
cp AppDir/usr/share/icons/hicolor/256x256/apps/gsmartcontrol.png AppDir/
# Create AppRun script
cat > AppDir/AppRun << 'EOF'
#!/bin/bash
SELF=$(readlink -f "$0")
HERE=${SELF%/*}
export LD_LIBRARY_PATH="$HERE/usr/lib:${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export PATH="$HERE/usr/bin:${PATH:+:$PATH}"
export GSETTINGS_SCHEMA_DIR="$HERE/usr/share/glib-2.0/schemas:${GSETTINGS_SCHEMA_DIR:+:$GSETTINGS_SCHEMA_DIR}"
export GDK_PIXBUF_MODULEDIR="$HERE/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders"
export GDK_PIXBUF_MODULE_FILE="$HERE/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache"
exec "$HERE/usr/bin/gsmartcontrol" "$@"
EOF
chmod +x AppDir/AppRun
- name: Bundle dependencies with linuxdeploy
shell: bash
working-directory: ${{github.workspace}}
run: |
linuxdeploy --appdir AppDir \
--executable AppDir/usr/bin/gsmartcontrol \
--desktop-file AppDir/dev.shaduri.gsmartcontrol.desktop \
--icon-file AppDir/gsmartcontrol.png
- name: Update GDK pixbuf cache
shell: bash
working-directory: ${{github.workspace}}
continue-on-error: true
run: |
if [ -d "AppDir/usr/lib/gdk-pixbuf-2.0" ]; then
gdk-pixbuf-query-loaders > AppDir/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache || true
fi
- name: Get version
id: version
shell: bash
run: |
VERSION=$(sed -n 's/.*CMAKE_PROJECT_VERSION[[:space:]]*"\([^"]*\)".*/\1/p' version.txt)
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Create AppImage
shell: bash
working-directory: ${{github.workspace}}
run: |
ARCH=x86_64 appimagetool AppDir GSmartControl-${{ steps.version.outputs.VERSION }}-x86_64.AppImage
- name: Upload AppImage artifact
uses: actions/upload-artifact@v4
with:
name: GSmartControl AppImage
path: ${{github.workspace}}/GSmartControl-*-x86_64.AppImage
-10
View File
@@ -175,16 +175,6 @@ build_main_workflow:
- x86_64
- i586
# AppImage build (using Ubuntu 22.04 as base)
- name: AppImage
paths:
- target_project: openSUSE:Tools
target_repository: xUbuntu_22.04
- target_project: Ubuntu:22.04
target_repository: universe
architectures:
- x86_64
- set_flags:
flags:
- type: publish
-1
View File
@@ -39,7 +39,6 @@ set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}")
# User-controlled build options
option(APP_BUILD_EXAMPLES "Build examples" OFF)
option(APP_BUILD_TESTS "Build tests" OFF)
option(APP_BUILD_APPIMAGE "Build for AppImage (portable data file paths)" OFF)
# Install documentation
+6 -24
View File
@@ -6,17 +6,8 @@
# Generate files
if (APP_BUILD_APPIMAGE)
set(APPIMAGE_DESKTOP_ID "dev.shaduri.gsmartcontrol")
set(APPIMAGE_EXEC_LINE "Exec=\"${CMAKE_INSTALL_FULL_BINDIR}/gsmartcontrol\"")
configure_file("gsmartcontrol.appdata.in.xml" "gsmartcontrol.appdata.xml" ESCAPE_QUOTES @ONLY)
configure_file("gsmartcontrol.in.desktop" "dev.shaduri.gsmartcontrol.desktop" ESCAPE_QUOTES @ONLY)
else()
set(APPIMAGE_DESKTOP_ID "gsmartcontrol")
set(APPIMAGE_EXEC_LINE "Exec=\"${CMAKE_INSTALL_FULL_BINDIR}/gsmartcontrol-root\"")
configure_file("gsmartcontrol.appdata.in.xml" "gsmartcontrol.appdata.xml" ESCAPE_QUOTES @ONLY)
configure_file("gsmartcontrol.in.desktop" "gsmartcontrol.desktop" ESCAPE_QUOTES @ONLY)
endif()
configure_file("gsmartcontrol.appdata.in.xml" "gsmartcontrol.appdata.xml" ESCAPE_QUOTES @ONLY)
configure_file("gsmartcontrol.in.desktop" "gsmartcontrol.desktop" ESCAPE_QUOTES @ONLY)
configure_file("gsmartcontrol-root.in.sh" "gsmartcontrol-root.sh" ESCAPE_QUOTES @ONLY)
configure_file("org.gsmartcontrol.in.policy" "org.gsmartcontrol.policy" ESCAPE_QUOTES @ONLY)
@@ -51,13 +42,8 @@ endif()
# Desktop file
if (NOT WIN32)
if (APP_BUILD_APPIMAGE)
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/dev.shaduri.gsmartcontrol.desktop"
DESTINATION "${CMAKE_INSTALL_DATADIR}/applications/")
else()
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/gsmartcontrol.desktop"
DESTINATION "${CMAKE_INSTALL_DATADIR}/applications/")
endif()
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/gsmartcontrol.desktop"
DESTINATION "${CMAKE_INSTALL_DATADIR}/applications/")
# Appdata file
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/gsmartcontrol.appdata.xml"
@@ -69,13 +55,9 @@ if (NOT WIN32)
# Man pages
install(FILES "man1/gsmartcontrol.1" DESTINATION "${CMAKE_INSTALL_MANDIR}/man1")
if (NOT APP_BUILD_APPIMAGE)
install(FILES "man1/gsmartcontrol.1" DESTINATION "${CMAKE_INSTALL_MANDIR}/man1" RENAME "gsmartcontrol-root.1")
endif()
install(FILES "man1/gsmartcontrol.1" DESTINATION "${CMAKE_INSTALL_MANDIR}/man1" RENAME "gsmartcontrol-root.1")
# Scripts (this goes to bin, not sbin, as it doesn't require root privileges before running)
if (NOT APP_BUILD_APPIMAGE)
install(PROGRAMS "${CMAKE_CURRENT_BINARY_DIR}/gsmartcontrol-root.sh" TYPE BIN RENAME "gsmartcontrol-root")
endif()
install(PROGRAMS "${CMAKE_CURRENT_BINARY_DIR}/gsmartcontrol-root.sh" TYPE BIN RENAME "gsmartcontrol-root")
endif()
+2 -2
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop">
<id>dev.shaduri.gsmartcontrol</id>
<id>gsmartcontrol</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0</project_license>
<name>GSmartControl</name>
@@ -14,7 +14,7 @@
on it.
</p>
</description>
<launchable type="desktop-id">@APPIMAGE_DESKTOP_ID@.desktop</launchable>
<launchable type="desktop-id">gsmartcontrol.desktop</launchable>
<url type="homepage">https://gsmartcontrol.shaduri.dev</url>
<screenshots>
<screenshot type="default">
+1 -2
View File
@@ -63,5 +63,4 @@ Icon=gsmartcontrol
#X-KDE-RootOnly=true
# Run with root permissions.
# For AppImage builds, execute the binary directly (AppImage does not support embedded sudo scripts)
@APPIMAGE_EXEC_LINE@
Exec="@CMAKE_INSTALL_FULL_BINDIR@/gsmartcontrol-root"
-106
View File
@@ -1,106 +0,0 @@
# GSmartControl AppImage Build
This directory contains scripts and configurations for building GSmartControl as an AppImage.
## What is AppImage?
AppImage is a format for distributing portable software on Linux without requiring installation. AppImages are self-contained applications that can run on most Linux distributions.
## Building the AppImage
### Prerequisites
You need to have the following installed:
- cmake (>= 3.14)
- g++ or clang with C++20 support
- GTK3 and gtkmm-3.0 development packages
- smartmontools (for runtime)
- linuxdeploy and appimagetool (the script will download them if not available)
On Ubuntu/Debian:
```bash
sudo apt-get install cmake g++ libgtkmm-3.0-dev gettext smartmontools
```
On Fedora:
```bash
sudo dnf install cmake gcc-c++ gtkmm30-devel gettext smartmontools
```
### Build Steps
From the repository root directory, run:
```bash
./packaging/appimage/build-appimage.sh
```
The script will:
1. Configure CMake with `-DAPP_BUILD_APPIMAGE=ON`
2. Build the application
3. Create an AppDir with the proper structure
4. Bundle dependencies using linuxdeploy
5. Create the final AppImage file
### Output
The script creates an AppImage file named `GSmartControl-<version>-<arch>.AppImage` in the repository root.
## Running the AppImage
**Important:** GSmartControl requires root privileges to access disk drives. You must run the AppImage with sudo:
```bash
sudo ./GSmartControl-<version>-x86_64.AppImage
```
### Why sudo is required?
- AppImage format does not support embedded privilege escalation scripts
- The binary must be executed directly as root to access `/dev/sd*` and `/dev/nvme*` devices
- This is different from traditional package installations which use the `gsmartcontrol-root` wrapper script
## AppImage-Specific Changes
When building with `-DAPP_BUILD_APPIMAGE=ON`, the following changes are applied:
1. **Data file paths**: Uses relative paths from binary location (`bin/../share/...`)
2. **Desktop file**:
- Uses rDNS format: `dev.shaduri.gsmartcontrol.desktop`
- Executes binary directly without `gsmartcontrol-root` wrapper
3. **Binary location**: Installed to `bin/` instead of `sbin/`
4. **No wrapper script**: `gsmartcontrol-root` is not installed
## Compatibility
The AppImage should work on:
- Fedora Atomic (Silverblue, Kinoite, etc.)
- Most modern Linux distributions with GTK3 support
- Systems with read-only root filesystems
Tested architectures:
- x86_64 (Intel/AMD 64-bit)
## Troubleshooting
### "Permission denied" when running AppImage
Make the AppImage executable:
```bash
chmod +x GSmartControl-*.AppImage
```
### "No drives detected"
Make sure to run with sudo:
```bash
sudo ./GSmartControl-*.AppImage
```
### GTK theme issues
You may need to install GTK3 themes on your system. The AppImage bundles the necessary libraries but uses system themes when available.
## Notes for Developers
- The AppImage build uses the same source code as regular builds
- The `BuildEnv::is_appimage_build()` function can be used to detect AppImage builds at runtime
- Icon and UI file paths are resolved at runtime relative to the binary location
- The AppImage includes all necessary GTK3 and gtkmm libraries
-147
View File
@@ -1,147 +0,0 @@
#!/bin/bash
###############################################################################
# License: BSD Zero Clause License file
# Copyright:
# (C) 2025 Alexander Shaduri <ashaduri@gmail.com>
###############################################################################
# Script to build GSmartControl AppImage
# This script should be run from the repository root directory
#
# Requirements:
# - cmake, wget, file
# - libgtkmm-3.0-dev (or equivalent GTK3 development packages)
# - libfuse2 (required to run linuxdeploy and appimagetool AppImages)
# On Ubuntu/Debian: sudo apt-get install libfuse2
# On Ubuntu 24.04+: sudo apt-get install libfuse2t64
set -e # Exit on error
set -u # Exit on undefined variable
# Configuration
APPDIR="${APPDIR:-AppDir}"
BUILD_DIR="${BUILD_DIR:-build-appimage}"
ARCH="${ARCH:-x86_64}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}Building GSmartControl AppImage${NC}"
echo "Architecture: $ARCH"
echo "Build directory: $BUILD_DIR"
echo "AppDir: $APPDIR"
echo
# Check dependencies
if ! command -v cmake &> /dev/null; then
echo -e "${RED}Error: cmake not found. Please install cmake.${NC}"
exit 1
fi
if ! command -v linuxdeploy &> /dev/null; then
echo -e "${YELLOW}Warning: linuxdeploy not found. Will attempt to download it.${NC}"
# Note: Using 'continuous' release for latest version. For reproducible builds,
# pin to a specific release tag and verify SHA256 checksum.
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage"
wget -N "$LINUXDEPLOY_URL" -O linuxdeploy
chmod +x linuxdeploy
LINUXDEPLOY="./linuxdeploy"
else
LINUXDEPLOY="linuxdeploy"
fi
if ! command -v appimagetool &> /dev/null; then
echo -e "${YELLOW}Warning: appimagetool not found. Will attempt to download it.${NC}"
# Note: Using 'continuous' release for latest version. For reproducible builds,
# pin to a specific release tag and verify SHA256 checksum.
APPIMAGETOOL_URL="https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${ARCH}.AppImage"
wget -N "$APPIMAGETOOL_URL" -O appimagetool
chmod +x appimagetool
APPIMAGETOOL="./appimagetool"
else
APPIMAGETOOL="appimagetool"
fi
# Clean previous build
echo -e "${GREEN}Cleaning previous build...${NC}"
rm -rf "$BUILD_DIR"
rm -rf "$APPDIR"
# Create build directory
mkdir -p "$BUILD_DIR"
cd "$BUILD_DIR"
# Configure with CMake for AppImage build
echo -e "${GREEN}Configuring CMake for AppImage build...${NC}"
cmake .. \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_INSTALL_PREFIX=/usr \
-DAPP_BUILD_APPIMAGE=ON \
-DCMAKE_TOOLCHAIN_FILE="../toolchains/linux-dev.cmake"
# Build
echo -e "${GREEN}Building...${NC}"
cmake --build . --config RelWithDebInfo -j$(nproc)
# Install to AppDir
echo -e "${GREEN}Installing to AppDir...${NC}"
DESTDIR="../$APPDIR" cmake --install .
cd ..
# Copy desktop file and icon to AppDir root (required by AppImage)
echo -e "${GREEN}Setting up AppDir structure...${NC}"
cp "$APPDIR/usr/share/applications/dev.shaduri.gsmartcontrol.desktop" "$APPDIR/"
cp "$APPDIR/usr/share/icons/hicolor/256x256/apps/gsmartcontrol.png" "$APPDIR/"
# Create AppRun script
cat > "$APPDIR/AppRun" << 'EOF'
#!/bin/bash
# AppRun script for GSmartControl AppImage
SELF=$(readlink -f "$0")
HERE=${SELF%/*}
# Export library paths
export LD_LIBRARY_PATH="$HERE/usr/lib:${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export PATH="$HERE/usr/bin:${PATH:+:$PATH}"
# Export GTK settings
export GSETTINGS_SCHEMA_DIR="$HERE/usr/share/glib-2.0/schemas:${GSETTINGS_SCHEMA_DIR:+:$GSETTINGS_SCHEMA_DIR}"
export GDK_PIXBUF_MODULEDIR="$HERE/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders"
export GDK_PIXBUF_MODULE_FILE="$HERE/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache"
# Run the application
exec "$HERE/usr/bin/gsmartcontrol" "$@"
EOF
chmod +x "$APPDIR/AppRun"
# Use linuxdeploy to bundle dependencies
echo -e "${GREEN}Bundling dependencies with linuxdeploy...${NC}"
$LINUXDEPLOY --appdir "$APPDIR" \
--executable "$APPDIR/usr/bin/gsmartcontrol" \
--desktop-file "$APPDIR/dev.shaduri.gsmartcontrol.desktop" \
--icon-file "$APPDIR/gsmartcontrol.png"
# Update GDK pixbuf cache
if [ -d "$APPDIR/usr/lib/gdk-pixbuf-2.0" ]; then
echo -e "${GREEN}Updating GDK pixbuf cache...${NC}"
gdk-pixbuf-query-loaders > "$APPDIR/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache" 2>/dev/null || true
fi
# Get version from version.txt (extract quoted CMAKE_PROJECT_VERSION value)
VERSION=$(sed -n 's/.*CMAKE_PROJECT_VERSION[[:space:]]*"\([^"]*\)".*/\1/p' version.txt | head -n1)
# Create AppImage
echo -e "${GREEN}Creating AppImage...${NC}"
ARCH=$ARCH $APPIMAGETOOL "$APPDIR" "GSmartControl-${VERSION}-${ARCH}.AppImage"
echo
echo -e "${GREEN}AppImage created successfully!${NC}"
echo -e "Output: ${GREEN}GSmartControl-${VERSION}-${ARCH}.AppImage${NC}"
echo
echo -e "${YELLOW}Note: The AppImage must be run with sudo to access disk drives.${NC}"
echo -e "Example: ${GREEN}sudo ./GSmartControl-${VERSION}-${ARCH}.AppImage${NC}"
+42 -2
View File
@@ -89,15 +89,46 @@ std::chrono::seconds SelfTest::get_remaining_seconds() const
{
using namespace std::literals;
// Use adaptive estimation if we have observed at least one completed segment.
// This works for all drive types including NVMe (which may not report total duration).
if (!segment_durations_.empty()) {
// Calculate average duration of observed segments
double sum = 0.0;
for (const auto& duration : segment_durations_) {
sum += duration;
}
const double avg_segment_duration = sum / segment_durations_.size();
// Estimate remaining time based on observed average and remaining segments
// remaining_percent_ goes from 100 (start) to 0 (end), in 10% decrements
const int remaining_segments = (remaining_percent_ + 9) / 10; // round up
const double estimated_remaining = avg_segment_duration * remaining_segments - timer_.elapsed();
const auto rem_rounded = static_cast<int64_t>(std::round(estimated_remaining));
if (rem_rounded < 0) {
return -1s; // estimate exhausted; return unknown
}
return std::chrono::seconds(rem_rounded);
}
// Fall back to drive's initial estimate when we don't have observed data yet
const std::chrono::seconds total = get_min_duration_seconds();
if (total <= 0s)
return -1s; // unknown
const double gran = (double(total.count()) / 9.); // seconds per 10%
// seconds per 10% (drive estimate)
const double gran = (double(total.count()) / 9.);
// since remaining_percent_ may be manually set to 100, we limit from the above.
const double rem_seconds_at_last_change = std::min(double(total.count()), gran * remaining_percent_ / 10.);
const double rem = rem_seconds_at_last_change - timer_.elapsed();
return std::chrono::seconds(std::max(int64_t(0), (int64_t)std::round(rem))); // don't return negative values.
const auto rem_rounded = static_cast<int64_t>(std::round(rem));
// If the estimated time for the current percentage has elapsed but the drive hasn't
// progressed, the drive's estimate was inaccurate. Return -1 (unknown) instead of 0
// to avoid misleading "ETA: 0 sec" which could persist for hours.
if (rem_rounded < 0) {
return -1s;
}
return std::chrono::seconds(rem_rounded);
}
@@ -492,6 +523,15 @@ hz::ExpectedVoid<SelfTestExecutionError> SelfTest::update(const std::shared_ptr<
// and reaches 00% on completion. That's 9 pieces.
if (status_ == SelfTestStatus::InProgress) {
if (remaining_percent_ != last_seen_percent_) {
// Record the duration of the completed segment for adaptive ETA calculation.
// Skip the first segment (typically 90→80) as it may be instant or partially
// completed when monitoring begins, which would skew the average.
if (first_segment_seen_) {
const double elapsed = timer_.elapsed();
segment_durations_.push_back(elapsed);
} else {
first_segment_seen_ = true; // Mark that we've seen the first transition
}
last_seen_percent_ = remaining_percent_;
timer_.start(); // restart the timer
}
+12 -1
View File
@@ -18,6 +18,7 @@ Copyright:
#include <cstdint>
#include <chrono>
#include <unordered_map>
#include <vector>
#include "storage_device.h"
#include "command_executor.h"
@@ -126,7 +127,15 @@ class SelfTest {
/// Get estimated time of completion for the test.
/// \return -1 if N/A or unknown. Note that 0 is a valid value.
/// The estimation uses an adaptive algorithm:
/// - Initially uses the drive's reported test duration estimate
/// - After completing one or more 10% segments, switches to using the observed
/// average segment duration to predict remaining time
/// - This provides more accurate ETAs when the drive's estimate is inaccurate
/// (e.g., under load or with drives that consistently under/overestimate)
/// \return -1 if N/A or unknown (including when the drive's estimated duration has been
/// exceeded without a percentage change, which means the estimate was inaccurate).
/// Note that 0 is a valid value meaning the test is finishing right now.
[[nodiscard]] std::chrono::seconds get_remaining_seconds() const;
@@ -180,6 +189,8 @@ class SelfTest {
std::chrono::seconds poll_in_seconds_ = std::chrono::seconds(-1); ///< The user is asked to poll after this much seconds have passed.
Glib::Timer timer_; ///< Counts time since the last percent change
std::vector<double> segment_durations_; ///< Actual durations of completed 10% segments (in seconds), for adaptive ETA calculation
bool first_segment_seen_ = false; ///< Whether we've observed the first percentage change (to skip the potentially instant/partial first segment)
};
+1
View File
@@ -15,6 +15,7 @@ endif()
add_library(applib_tests OBJECT)
target_sources(applib_tests PRIVATE
test_app_regex.cpp
test_selftest.cpp
test_smartctl_parser.cpp
test_smartctl_version_parser.cpp
)
+163
View File
@@ -0,0 +1,163 @@
/******************************************************************************
License: BSD Zero Clause License
Copyright:
(C) 2026 Alexander Shaduri <ashaduri@gmail.com>
******************************************************************************/
/// \file
/// \author Alexander Shaduri
/// \ingroup applib_tests
/// \weakgroup applib_tests
/// @{
#include "catch2/catch.hpp"
#include "applib/selftest.h"
#include "applib/storage_device.h"
#include <chrono>
TEST_CASE("SelfTest basic functionality", "[selftest]")
{
using namespace std::literals;
SECTION("Test type names are correct")
{
REQUIRE(SelfTest::get_test_displayable_name(SelfTest::TestType::ShortTest) != "[internal_error]");
REQUIRE(SelfTest::get_test_displayable_name(SelfTest::TestType::LongTest) != "[internal_error]");
REQUIRE(SelfTest::get_test_displayable_name(SelfTest::TestType::Conveyance) != "[internal_error]");
}
SECTION("Test status severity mapping")
{
REQUIRE(get_self_test_status_severity(SelfTestStatus::Unknown) == SelfTestStatusSeverity::None);
REQUIRE(get_self_test_status_severity(SelfTestStatus::CompletedNoError) == SelfTestStatusSeverity::None);
REQUIRE(get_self_test_status_severity(SelfTestStatus::ManuallyAborted) == SelfTestStatusSeverity::Warning);
REQUIRE(get_self_test_status_severity(SelfTestStatus::Interrupted) == SelfTestStatusSeverity::Warning);
REQUIRE(get_self_test_status_severity(SelfTestStatus::CompletedWithError) == SelfTestStatusSeverity::Error);
REQUIRE(get_self_test_status_severity(SelfTestStatus::InProgress) == SelfTestStatusSeverity::None);
REQUIRE(get_self_test_status_severity(SelfTestStatus::Reserved) == SelfTestStatusSeverity::None);
}
SECTION("Test not active by default")
{
auto device = std::make_shared<StorageDevice>("/dev/mock");
SelfTest test(device, SelfTest::TestType::ShortTest);
// Test should not be active immediately after construction
REQUIRE(test.is_active() == false);
REQUIRE(test.get_status() == SelfTestStatus::Unknown);
REQUIRE(test.get_remaining_percent() == -1);
}
SECTION("Remaining seconds returns unknown when not running")
{
auto device = std::make_shared<StorageDevice>("/dev/mock");
SelfTest test(device, SelfTest::TestType::ShortTest);
// When no test is running, remaining seconds should be -1 (unknown)
REQUIRE(test.get_remaining_seconds() == -1s);
}
SECTION("NVMe device without duration estimate")
{
auto device = std::make_shared<StorageDevice>("/dev/nvme0");
device->set_detected_type(StorageDeviceDetectedType::Nvme);
SelfTest test(device, SelfTest::TestType::ShortTest);
// NVMe devices don't report duration, should return -1
REQUIRE(test.get_min_duration_seconds() == -1s);
// Without a running test, remaining should also be -1
REQUIRE(test.get_remaining_seconds() == -1s);
}
SECTION("Test type is correctly stored")
{
auto device = std::make_shared<StorageDevice>("/dev/mock");
SelfTest short_test(device, SelfTest::TestType::ShortTest);
REQUIRE(short_test.get_test_type() == SelfTest::TestType::ShortTest);
SelfTest long_test(device, SelfTest::TestType::LongTest);
REQUIRE(long_test.get_test_type() == SelfTest::TestType::LongTest);
SelfTest conveyance_test(device, SelfTest::TestType::Conveyance);
REQUIRE(conveyance_test.get_test_type() == SelfTest::TestType::Conveyance);
}
SECTION("Poll time is initially unknown")
{
auto device = std::make_shared<StorageDevice>("/dev/mock");
SelfTest test(device, SelfTest::TestType::ShortTest);
// Before starting, poll time should be -1 (unknown)
REQUIRE(test.get_poll_in_seconds() == -1s);
}
}
TEST_CASE("SelfTest EXT enum helpers", "[selftest][enum_helpers]")
{
SECTION("Status enum to string conversion")
{
// Verify that enum helper works for common statuses
auto status_str = SelfTestStatusExt::get_displayable_name(SelfTestStatus::InProgress);
REQUIRE(!status_str.empty());
status_str = SelfTestStatusExt::get_displayable_name(SelfTestStatus::CompletedNoError);
REQUIRE(!status_str.empty());
status_str = SelfTestStatusExt::get_displayable_name(SelfTestStatus::Unknown);
REQUIRE(!status_str.empty());
}
SECTION("Status enum storable name")
{
// Verify storable names (for serialization/deserialization)
auto storable = SelfTestStatusExt::get_storable_name(SelfTestStatus::InProgress);
REQUIRE(storable == "in_progress");
storable = SelfTestStatusExt::get_storable_name(SelfTestStatus::ManuallyAborted);
REQUIRE(storable == "manually_aborted");
storable = SelfTestStatusExt::get_storable_name(SelfTestStatus::CompletedNoError);
REQUIRE(storable == "completed_no_error");
}
SECTION("Default value is Unknown")
{
REQUIRE(SelfTestStatusExt::default_value == SelfTestStatus::Unknown);
}
}
TEST_CASE("SelfTest support detection", "[selftest][support]")
{
SECTION("ATA device capabilities check")
{
auto device = std::make_shared<StorageDevice>("/dev/sda");
device->set_detected_type(StorageDeviceDetectedType::AtaSsd);
// Without capability properties, tests should not be supported
SelfTest short_test(device, SelfTest::TestType::ShortTest);
REQUIRE(short_test.is_supported() == false);
SelfTest long_test(device, SelfTest::TestType::LongTest);
REQUIRE(long_test.is_supported() == false);
}
SECTION("NVMe conveyance test unsupported")
{
auto device = std::make_shared<StorageDevice>("/dev/nvme0");
device->set_detected_type(StorageDeviceDetectedType::Nvme);
// Conveyance test is not supported on NVMe
SelfTest conveyance_test(device, SelfTest::TestType::Conveyance);
REQUIRE(conveyance_test.is_supported() == false);
}
}
/// @}
-6
View File
@@ -82,12 +82,6 @@ elseif ("${CMAKE_SYSTEM_NAME}" MATCHES "QNX")
set(CONFIG_KERNEL_QNX TRUE)
endif()
# AppImage build configuration
if (APP_BUILD_APPIMAGE)
set(CONFIG_APPIMAGE_BUILD TRUE)
message(STATUS "AppImage build enabled - using portable data file paths")
endif()
# Additionally, these macros are defined by OS / compilers:
-11
View File
@@ -26,7 +26,6 @@ Copyright:
#cmakedefine CONFIG_KERNEL_SOLARIS
#cmakedefine CONFIG_KERNEL_DARWIN
#cmakedefine CONFIG_KERNEL_QNX
#cmakedefine CONFIG_APPIMAGE_BUILD
#if defined CONFIG_KERNEL_WINDOWS32 || defined CONFIG_KERNEL_WINDOWS64
#define CONFIG_KERNEL_FAMILY_WINDOWS
@@ -184,16 +183,6 @@ struct BuildEnv {
#endif
}
/// Check if this is an AppImage build (portable data file paths).
static constexpr bool is_appimage_build()
{
#ifdef CONFIG_APPIMAGE_BUILD
return true;
#else
return false;
#endif
}
};
-2
View File
@@ -62,8 +62,6 @@ target_link_libraries(gsmartcontrol
if (WIN32)
install(TARGETS gsmartcontrol DESTINATION .)
elseif (APP_BUILD_APPIMAGE)
install(TARGETS gsmartcontrol DESTINATION "${CMAKE_INSTALL_BINDIR}/")
else()
install(TARGETS gsmartcontrol DESTINATION "${CMAKE_INSTALL_SBINDIR}/")
endif()
-6
View File
@@ -475,13 +475,7 @@ bool app_init_and_loop(int& argc, char**& argv)
hz::data_file_add_search_directory("icons", application_dir / "icons");
hz::data_file_add_search_directory("ui", application_dir / "ui");
hz::data_file_add_search_directory("doc", application_dir / "doc");
} else if constexpr(BuildEnv::is_appimage_build()) {
// AppImage: Use relative paths from binary location (bin/../share/...)
hz::data_file_add_search_directory("icons", application_dir.parent_path() / "share" / BuildEnv::package_name() / "icons");
hz::data_file_add_search_directory("ui", application_dir.parent_path() / "share" / BuildEnv::package_name() / "ui");
hz::data_file_add_search_directory("doc", application_dir.parent_path() / "share" / "doc" / BuildEnv::package_name());
} else {
// Traditional Unix installation: Use absolute paths from BuildEnv
hz::data_file_add_search_directory("icons", hz::fs_path_from_string(BuildEnv::package_pkgdata_dir()) / BuildEnv::package_name() / "icons"); // /usr/share/program_name/icons
hz::data_file_add_search_directory("ui", hz::fs_path_from_string(BuildEnv::package_pkgdata_dir()) / BuildEnv::package_name() / "ui"); // /usr/share/program_name/ui
hz::data_file_add_search_directory("doc", hz::fs_path_from_string(BuildEnv::package_doc_dir())); // /usr/share/doc/[packages/]gsmartcontrol