diff --git a/CMakeLists.txt b/CMakeLists.txt index 36d1559a8..17343d04d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -114,6 +114,8 @@ set(CFDESKTOP_STATIC_LIBS CFDesktopMain CFDesktopUi cf_desktop_render + cfipc + cfcrash ) # Sources compiled directly into the DLL (with CFDESKTOP_EXPORTS defined). @@ -163,7 +165,7 @@ add_library(CFDesktop::lib ALIAS CFDesktop_shared) # Main executable — thin shell that calls into the DLL # ============================================================ add_executable(CFDesktop main.cpp) -target_link_libraries(CFDesktop PRIVATE CFDesktop_shared) +target_link_libraries(CFDesktop PRIVATE CFDesktop_shared cfipc) log_info("Desktop Buildings" "Configuring Desktop Buildings Done") diff --git a/base/CMakeLists.txt b/base/CMakeLists.txt index a9bf0409b..2e453941a 100644 --- a/base/CMakeLists.txt +++ b/base/CMakeLists.txt @@ -12,3 +12,5 @@ add_subdirectory(logger) add_subdirectory(file_operations) add_subdirectory(config_manager) add_subdirectory(ascii_art) +add_subdirectory(ipc) +add_subdirectory(crash_handler) diff --git a/base/crash_handler/CMakeLists.txt b/base/crash_handler/CMakeLists.txt new file mode 100644 index 000000000..587c73656 --- /dev/null +++ b/base/crash_handler/CMakeLists.txt @@ -0,0 +1,20 @@ +# CrashHandler module — async-signal-safe crash capture (Phase 1). +# +# STATIC lib; deliberately NOT linked to Qt: the signal handler path must stay +# free of Qt/global-object machinery (async-signal-safety). Uses only libc +# (backtrace/sigaction on Linux; SetUnhandledExceptionFilter arrives in a later +# phase on Windows). No Q_OBJECT, so AUTOMOC is not involved. +add_library(cfcrash STATIC) + +target_sources(cfcrash +PRIVATE + src/crash_handler.cpp + src/crash_report.cpp + src/crash_signal_handler.cpp +) + +target_include_directories(cfcrash PUBLIC + $ +) + +add_library(CFDesktop::crash ALIAS cfcrash) diff --git a/base/crash_handler/include/cfcrash/crash_handler.h b/base/crash_handler/include/cfcrash/crash_handler.h new file mode 100644 index 000000000..c99946a53 --- /dev/null +++ b/base/crash_handler/include/cfcrash/crash_handler.h @@ -0,0 +1,139 @@ +/** + * @file crash_handler.h + * @brief Process-wide crash handler: signal capture and report finalization. + * + * On install(), registers POSIX signal handlers (Linux) that write a minimal + * async-signal-safe raw snapshot (a @c .pending file in the crashes dir) and + * _exit(). + * The next boot calls finalizePendingReports() to fold each @c .pending (plus + * the tail of the logger file) into a finalized @c *.json report, then prunes + * to a retention cap. Symbol resolution and a crash-reporter UI are Phase 2. + * + * Not a QObject: the signal path is pure C and must not touch Qt. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash + */ + +#pragma once + +#include +#include + +#include "cfcrash/crash_report.h" + +namespace cf::crash { + +/** + * @brief Process-wide singleton crash handler. + * + * @note The signal handler is async-signal-safe; install/finalize are not. + * @warning None + * @since 0.19.0 + * @ingroup crash + */ +class CrashHandler { + public: + /** + * @brief Returns the process-wide singleton instance. + * + * @return Reference to the singleton CrashHandler. + * @throws None + * @note Created on first access; lives until process exit. + * @warning None + * @since 0.19.0 + * @ingroup crash + */ + static CrashHandler& instance(); + + /** + * @brief Registers crash signal handlers and prepares the crashes dir. + * + * @param[in] crashes_dir Directory for @c .pending / @c .json reports. + * @param[in] logger_path This run's logger file; written into each + * @c .pending so finalize() tails the crashed + * run's own log. Empty to omit. + * @return True if handlers registered; false on failure (best-effort). + * @throws None + * @note Creates crashes_dir if missing. + * @warning Call after QCoreApplication exists so the dir is resolvable. + * @since 0.19.0 + * @ingroup crash + */ + bool install(const std::string& crashes_dir, const std::string& logger_path = ""); + + /** + * @brief Restores default signal disposition (test-only). + * + * @throws None + * @note No-op if install() was not called. + * @warning None + * @since 0.19.0 + * @ingroup crash + */ + void uninstall(); + + /** + * @brief Folds each @c .pending snapshot into a finalized @c .json report. + * + * @param[in] logger_path Logger file whose tail becomes last_logs. + * @param[in] tail_lines Number of trailing log lines to capture. + * @return Count of reports finalized. + * @throws None + * @note Deletes each @c .pending after writing its @c .json; prunes. + * @warning None + * @since 0.19.0 + * @ingroup crash + */ + std::size_t finalizePendingReports(const std::string& logger_path, + std::size_t tail_lines = kDefaultTailLogLines); + + /** + * @brief Deletes oldest @c .json reports beyond the retention cap. + * + * @param[in] max_keep Maximum reports to keep. + * @return Count of reports deleted. + * @throws None + * @note Sorts by modification time; youngest kept. + * @warning None + * @since 0.19.0 + * @ingroup crash + */ + std::size_t pruneReports(std::size_t max_keep = kDefaultMaxReports); + + /** + * @brief Sets the crashes directory without installing handlers. + * + * @param[in] crashes_dir Directory for reports. + * @return None + * @throws None + * @note For finalize/prune tests that do not want handlers armed. + * @warning None + * @since 0.19.0 + * @ingroup crash + */ + void setCrashesDir(const std::string& crashes_dir); + + private: + /** + * @brief Constructs the crash handler. + * + * @throws None + * @note Private; access via instance(). + * @warning None + * @since 0.19.0 + * @ingroup crash + */ + CrashHandler(); + + /// @brief Directory where @c .pending / @c .json reports are stored. + std::string crashes_dir_; + + /// @brief True once signal handlers have been armed. + bool installed_{false}; +}; + +} // namespace cf::crash diff --git a/base/crash_handler/include/cfcrash/crash_report.h b/base/crash_handler/include/cfcrash/crash_report.h new file mode 100644 index 000000000..30dfff79c --- /dev/null +++ b/base/crash_handler/include/cfcrash/crash_report.h @@ -0,0 +1,89 @@ +/** + * @file crash_report.h + * @brief Crash report data model and JSON serialization. + * + * Defines the CrashReport struct assembled on the next boot from a raw signal + * snapshot. The signal handler itself writes only a minimal async-signal-safe + * @c .pending file; this struct folds that snapshot with the logger tail into + * a finalized JSON report. Symbol resolution is deferred to Phase 2, so + * raw_frames holds bare addresses. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash + */ + +#pragma once + +#include +#include +#include +#include + +namespace cf::crash { + +/// @brief Default number of trailing log lines captured into a report. +inline constexpr std::size_t kDefaultTailLogLines{50}; + +/// @brief Default maximum number of finalized reports kept on disk. +inline constexpr std::size_t kDefaultMaxReports{20}; + +/** + * @brief A finalized crash report. + * + * Aggregates the raw signal snapshot (timestamp/pid/signal/raw stack + * addresses) with the tail of the logger file, ready for JSON storage. + * + * @note Plain struct; serialize via toJson(). + * @warning None + * @since 0.19.0 + * @ingroup crash + */ +struct CrashReport { + /// @brief Wall-clock time of the crash, in seconds since the epoch. + std::int64_t timestamp{0}; + + /// @brief Process id of the crashed shell. + std::int64_t pid{0}; + + /// @brief Signal number (e.g. SIGSEGV=11). 0 when no signal applies. + int signal{0}; + + /// @brief Human-readable signal name ("SIGSEGV", "SIGABRT", ...). + std::string signal_name; + + /// @brief Bare stack frame addresses as hex strings; resolved in Phase 2. + std::vector raw_frames; + + /// @brief Tail of the logger file captured at finalize time. + std::vector last_logs; + + /** + * @brief Serializes the report to a pretty multi-line JSON string. + * + * @return JSON text (UTF-8); array elements are one per line. + * @throws None + * @note Strings are JSON-escaped. + * @warning None + * @since 0.19.0 + * @ingroup crash + */ + std::string toJson() const; +}; + +/** + * @brief Maps a POSIX signal number to its canonical name. + * + * @param[in] signo Signal number. + * @return "SIGSEGV" etc., or "UNKNOWN" if unrecognized. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup crash + */ +std::string signalName(int signo); + +} // namespace cf::crash diff --git a/base/crash_handler/src/crash_handler.cpp b/base/crash_handler/src/crash_handler.cpp new file mode 100644 index 000000000..e9f5d34c3 --- /dev/null +++ b/base/crash_handler/src/crash_handler.cpp @@ -0,0 +1,202 @@ +/** + * @file crash_handler.cpp + * @brief Implementation of the CrashHandler singleton (normal-context half). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash + */ + +#include "cfcrash/crash_handler.h" + +#include "cfcrash/crash_report.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace cf::crash { + +// Platform-specific signal arming lives in crash_signal_handler.cpp. +bool installSignalHandlers(const std::string& dir, const std::string& logger_path); +void uninstallSignalHandlers(); + +namespace { + +namespace fs = std::filesystem; + +/// @brief Reads the trailing tail_lines lines of a file. +std::vector tailLines(const fs::path& path, std::size_t tail_lines) { + std::vector all; + std::ifstream in(path); + if (!in) { + return all; + } + std::string line; + while (std::getline(in, line)) { + all.push_back(std::move(line)); + } + if (all.size() <= tail_lines) { + return all; + } + return {all.end() - static_cast(tail_lines), all.end()}; +} + +/// @brief Parses one .pending raw snapshot; returns the file stem on success. +/// @param[out] pending_logger The crashed run's logger path (may be empty). +bool parsePending(const fs::path& path, CrashReport& out, std::string& stem, + std::string& pending_logger) { + std::ifstream in(path); + if (!in) { + return false; + } + std::string header; + if (!std::getline(in, header) || header != "CFDESKTOP_CRASH_V1") { + return false; + } + enum class Section { kFields, kFrames } section = Section::kFields; + std::string line; + while (std::getline(in, line)) { + if (line.empty()) { + continue; + } + if (section == Section::kFrames) { + if (line.starts_with("0x")) { + out.raw_frames.push_back(line); + } + continue; + } + if (line.starts_with("frames=")) { + section = Section::kFrames; + continue; + } + const auto eq = line.find('='); + if (eq == std::string::npos) { + continue; + } + const std::string key = line.substr(0, eq); + const std::string val = line.substr(eq + 1); + if (key == "signal") { + out.signal = std::atoi(val.c_str()); + } else if (key == "signal_name") { + out.signal_name = val; + } else if (key == "pid") { + out.pid = std::atoll(val.c_str()); + } else if (key == "timestamp") { + out.timestamp = std::atoll(val.c_str()); + } else if (key == "logger") { + pending_logger = val; + } + } + if (out.signal_name.empty()) { + out.signal_name = signalName(out.signal); + } + stem = path.stem().string(); + return true; +} + +} // namespace + +CrashHandler& CrashHandler::instance() { + static CrashHandler handler; + return handler; +} + +CrashHandler::CrashHandler() = default; + +void CrashHandler::setCrashesDir(const std::string& crashes_dir) { + crashes_dir_ = crashes_dir; + std::error_code ec; + fs::create_directories(crashes_dir_, ec); +} + +bool CrashHandler::install(const std::string& crashes_dir, const std::string& logger_path) { + setCrashesDir(crashes_dir); + const bool ok = installSignalHandlers(crashes_dir, logger_path); + installed_ = ok; + return ok; +} + +void CrashHandler::uninstall() { + uninstallSignalHandlers(); + installed_ = false; +} + +std::size_t CrashHandler::finalizePendingReports(const std::string& logger_path, + std::size_t tail_lines) { + if (crashes_dir_.empty()) { + return 0; + } + + std::size_t count = 0; + std::error_code ec; + for (auto& entry : fs::directory_iterator(crashes_dir_, ec)) { + if (ec) { + break; + } + if (!entry.is_regular_file() || entry.path().extension() != ".pending") { + continue; + } + CrashReport report; + std::string stem; + std::string pending_logger; + if (!parsePending(entry.path(), report, stem, pending_logger)) { + continue; + } + // Prefer the crashed run's own logger path (captured in the .pending); + // fall back to the caller-supplied path for snapshots without one. + const std::string effective_logger = pending_logger.empty() ? logger_path : pending_logger; + report.last_logs = tailLines(effective_logger, tail_lines); + + const auto out_path = fs::path(crashes_dir_) / (stem + ".json"); + std::ofstream of(out_path); + if (of) { + of << report.toJson(); + of.close(); + ++count; + } + fs::remove(entry.path(), ec); + } + if (count > 0) { + pruneReports(); + } + return count; +} + +std::size_t CrashHandler::pruneReports(std::size_t max_keep) { + if (crashes_dir_.empty()) { + return 0; + } + std::vector> reports; + std::error_code ec; + for (auto& entry : fs::directory_iterator(crashes_dir_, ec)) { + if (ec) { + break; + } + if (!entry.is_regular_file() || entry.path().extension() != ".json") { + continue; + } + reports.emplace_back(entry.last_write_time(ec), entry.path()); + } + if (reports.size() <= max_keep) { + return 0; + } + std::sort(reports.begin(), reports.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + const std::size_t to_remove = reports.size() - max_keep; + std::size_t removed = 0; + for (std::size_t i = 0; i < to_remove; ++i) { + if (fs::remove(reports[i].second, ec)) { + ++removed; + } + } + return removed; +} + +} // namespace cf::crash diff --git a/base/crash_handler/src/crash_report.cpp b/base/crash_handler/src/crash_report.cpp new file mode 100644 index 000000000..04c32c7cf --- /dev/null +++ b/base/crash_handler/src/crash_report.cpp @@ -0,0 +1,103 @@ +/** + * @file crash_report.cpp + * @brief Implementation of CrashReport JSON serialization. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash + */ + +#include "cfcrash/crash_report.h" + +#include +#include +#include +#include + +namespace cf::crash { +namespace { + +/// @brief Escapes a string for embedding inside a JSON string literal. +std::string escapeJson(std::string_view s) { + std::string out; + out.reserve(s.size() + 2); + for (char c : s) { + switch (c) { + case '"': + out += "\\\""; + break; + case '\\': + out += "\\\\"; + break; + case '\n': + out += "\\n"; + break; + case '\r': + out += "\\r"; + break; + case '\t': + out += "\\t"; + break; + default: + out.push_back(c); + break; + } + } + return out; +} + +/// @brief Renders a string vector as a pretty JSON array (one element/line). +std::string toJsonArray(const std::vector& v) { + if (v.empty()) { + return "[]"; + } + std::string out = "["; + for (std::size_t i = 0; i < v.size(); ++i) { + out += "\n \""; + out += escapeJson(v[i]); + out += '"'; + out += (i + 1 == v.size()) ? "" : ","; + } + out += "\n ]"; + return out; +} + +} // namespace + +std::string CrashReport::toJson() const { + std::string out = "{\n"; + out += std::format(" \"timestamp\": {},\n", timestamp); + out += std::format(" \"pid\": {},\n", pid); + out += std::format(" \"signal\": {},\n", signal); + out += std::format(" \"signal_name\": \"{}\",\n", escapeJson(signal_name)); + out += " \"raw_frames\": "; + out += toJsonArray(raw_frames); + out += ",\n \"last_logs\": "; + out += toJsonArray(last_logs); + out += '\n'; + out += "}\n"; + return out; +} + +std::string signalName(int signo) { + switch (signo) { + case SIGSEGV: + return "SIGSEGV"; + case SIGABRT: + return "SIGABRT"; + case SIGFPE: + return "SIGFPE"; + case SIGILL: + return "SIGILL"; +#ifdef SIGBUS + case SIGBUS: + return "SIGBUS"; +#endif + default: + return "UNKNOWN"; + } +} + +} // namespace cf::crash diff --git a/base/crash_handler/src/crash_signal_handler.cpp b/base/crash_handler/src/crash_signal_handler.cpp new file mode 100644 index 000000000..7f28454a1 --- /dev/null +++ b/base/crash_handler/src/crash_signal_handler.cpp @@ -0,0 +1,276 @@ +/** + * @file crash_signal_handler.cpp + * @brief Async-signal-safe crash signal handler (Linux) + Win32 stub. + * + * @warning The Linux handler runs in signal context. It may only call + * async-signal-safe functions: write/open/close/backtrace/time/ + * getpid/sigaction/_exit, plus the arithmetic helpers below. Do NOT + * add Qt, malloc, stdio, std::string, or any locking here -- doing so + * is undefined behavior (the CI triple-compiler matrix is what + * reliably catches such regressions). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash + */ + +#include +#include +#include +#include + +#if defined(__unix__) || defined(__linux__) +# include +# include +# include +# include +# include +#endif + +namespace cf::crash { +// Defined here, declared by crash_handler.cpp via forward declaration. +bool installSignalHandlers(const std::string& dir, const std::string& logger_path); +void uninstallSignalHandlers(); +} // namespace cf::crash + +namespace { +#if defined(__unix__) || defined(__linux__) + +/// @brief Capacity of the captured path buffers (async-signal-safe reads). +inline constexpr std::size_t kPathCap = 512; + +/// @brief Crashes dir captured at install() time; read by the handler. +char g_crashes_dir[kPathCap] = {}; + +/// @brief Whether g_crashes_dir has been populated. +bool g_dir_set = false; + +/// @brief Logger file path captured at install() time; written into the +/// .pending so finalize() can tail the crashed run's own log. +char g_logger_path[kPathCap] = {}; + +/// @brief Whether g_logger_path has been populated. +bool g_logger_set = false; + +/// @brief Alternate stack size: 64 KiB stays clear of glibc 2.34+ runtime +/// SIGSTKSZ surprises and gives backtrace() headroom. +inline constexpr std::size_t kAltStackSize = 1U << 16; + +/// @brief Maximum stack frames captured per crash. +inline constexpr int kMaxFrames = 64; + +/// @brief Writes a buffer fully, tolerating partial writes. Async-signal-safe. +void writeAll(int fd, const char* data, std::size_t len) { + while (len > 0) { + ssize_t n = ::write(fd, data, len); + if (n <= 0) { + return; + } + data += n; + len -= static_cast(n); + } +} + +/// @brief Writes a C string. Async-signal-safe. +void writeStr(int fd, const char* s) { + writeAll(fd, s, std::strlen(s)); +} + +/// @brief Writes an unsigned decimal. Async-signal-safe (pure arithmetic). +void writeDecimal(int fd, unsigned long v) { + char buf[24]; + char* p = buf + sizeof(buf); + if (v == 0) { + *--p = '0'; + } + while (v > 0) { + *--p = static_cast('0' + (v % 10U)); + v /= 10U; + } + writeAll(fd, p, static_cast(buf + sizeof(buf) - p)); +} + +/// @brief Writes a pointer as lowercase hex with an 0x prefix. Async-signal-safe. +void writeHexPtr(int fd, std::uintptr_t v) { + writeStr(fd, "0x"); + if (v == 0) { + writeStr(fd, "0"); + return; + } + char buf[2 * sizeof(std::uintptr_t)]; + char* p = buf + sizeof(buf); + while (v > 0) { + unsigned d = static_cast(v & 0xFU); + *--p = static_cast(d < 10U ? ('0' + d) : ('a' + (d - 10U))); + v >>= 4; + } + writeAll(fd, p, static_cast(buf + sizeof(buf) - p)); +} + +/// @brief Maps a signal number to its name. Async-signal-safe (no allocation). +const char* signalNameAS(int signo) { + switch (signo) { + case SIGSEGV: + return "SIGSEGV"; + case SIGABRT: + return "SIGABRT"; + case SIGFPE: + return "SIGFPE"; + case SIGBUS: + return "SIGBUS"; + case SIGILL: + return "SIGILL"; + default: + return "UNKNOWN"; + } +} + +/// @brief Appends a C string to a fixed buffer. Async-signal-safe. +void appendChars(char* dst, std::size_t cap, std::size_t& i, const char* src) { + for (std::size_t k = 0; src[k] != '\0' && i + 1U < cap; ++k) { + dst[i++] = src[k]; + } +} + +/// @brief Appends an unsigned decimal to a fixed buffer. Async-signal-safe. +void appendDecimal(char* dst, std::size_t cap, std::size_t& i, unsigned long v) { + char buf[24]; + char* p = buf + sizeof(buf); + if (v == 0) { + *--p = '0'; + } + while (v > 0) { + *--p = static_cast('0' + (v % 10U)); + v /= 10U; + } + while (p < buf + sizeof(buf) && i + 1U < cap) { + dst[i++] = *p++; + } +} + +/// @brief The crash signal handler. Async-signal-safe; writes a .pending file. +extern "C" void cfCrashHandleSignal(int signo) { + // Restore the default disposition so a fault inside the handler cannot + // recurse. + struct sigaction sa{}; + sa.sa_handler = SIG_DFL; + sigemptyset(&sa.sa_mask); + ::sigaction(signo, &sa, nullptr); + + char path[kPathCap + 96]; + std::size_t i = 0; + if (g_dir_set) { + appendChars(path, sizeof(path), i, g_crashes_dir); + } + appendChars(path, sizeof(path), i, "/cfdesktop-"); + appendDecimal(path, sizeof(path), i, static_cast(::getpid())); + appendChars(path, sizeof(path), i, "-"); + appendDecimal(path, sizeof(path), i, static_cast(signo)); + appendChars(path, sizeof(path), i, ".pending"); + path[i] = '\0'; + + int fd = ::open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd >= 0) { + writeStr(fd, "CFDESKTOP_CRASH_V1\n"); + + writeStr(fd, "signal="); + writeDecimal(fd, static_cast(signo)); + writeStr(fd, "\n"); + + writeStr(fd, "signal_name="); + writeStr(fd, signalNameAS(signo)); + writeStr(fd, "\n"); + + writeStr(fd, "pid="); + writeDecimal(fd, static_cast(::getpid())); + writeStr(fd, "\n"); + + writeStr(fd, "timestamp="); + writeDecimal(fd, static_cast(::time(nullptr))); + writeStr(fd, "\n"); + + // Record this run's logger file so finalize() on the next boot tails + // the crashed run's own log (log_filename() is per-launch). + if (g_logger_set) { + writeStr(fd, "logger="); + writeStr(fd, g_logger_path); + writeStr(fd, "\n"); + } + + void* frames[kMaxFrames]; + int n = ::backtrace(frames, kMaxFrames); + writeStr(fd, "frames="); + writeDecimal(fd, static_cast(n)); + writeStr(fd, "\n"); + for (int k = 0; k < n; ++k) { + writeHexPtr(fd, reinterpret_cast(frames[k])); + writeStr(fd, "\n"); + } + ::close(fd); + } + + ::_exit(128 + signo); +} + +/// @brief Signals armed by install(). +int g_armed_signals[] = {SIGSEGV, SIGABRT, SIGFPE, SIGBUS, SIGILL}; + +/// @brief Alternate stack storage; allocated once, leaked (process lifetime). +char* g_altstack_mem = nullptr; + +#endif // defined(__unix__) || defined(__linux__) +} // namespace + +namespace cf::crash { + +#if defined(__unix__) || defined(__linux__) +bool installSignalHandlers(const std::string& dir, const std::string& logger_path) { + std::strncpy(g_crashes_dir, dir.c_str(), sizeof(g_crashes_dir) - 1U); + g_crashes_dir[sizeof(g_crashes_dir) - 1U] = '\0'; + g_dir_set = true; + std::strncpy(g_logger_path, logger_path.c_str(), sizeof(g_logger_path) - 1U); + g_logger_path[sizeof(g_logger_path) - 1U] = '\0'; + g_logger_set = !logger_path.empty(); + + // Allocate an alternate stack so a stack overflow (SIGSEGV) still has room + // to run the handler. Leaked on purpose: the handler needs it until exit. + g_altstack_mem = static_cast(std::malloc(kAltStackSize)); + if (g_altstack_mem != nullptr) { + stack_t ss{}; + ss.ss_sp = g_altstack_mem; + ss.ss_size = kAltStackSize; + ss.ss_flags = 0; + ::sigaltstack(&ss, nullptr); + } + + struct sigaction sa{}; + sa.sa_handler = cfCrashHandleSignal; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESETHAND | SA_ONSTACK; + for (int s : g_armed_signals) { + ::sigaction(s, &sa, nullptr); + } + return true; +} + +void uninstallSignalHandlers() { + for (int s : g_armed_signals) { + struct sigaction sa{}; + sa.sa_handler = SIG_DFL; + sigemptyset(&sa.sa_mask); + ::sigaction(s, &sa, nullptr); + } +} +#else +bool installSignalHandlers(const std::string&, const std::string&) { + // Phase 1 targets Linux. SetUnhandledExceptionFilter / std::set_terminate + // land here in a later phase. + return false; +} + +void uninstallSignalHandlers() {} +#endif + +} // namespace cf::crash diff --git a/base/ipc/CMakeLists.txt b/base/ipc/CMakeLists.txt new file mode 100644 index 000000000..15de88a11 --- /dev/null +++ b/base/ipc/CMakeLists.txt @@ -0,0 +1,28 @@ +# IPC module — single-instance signaling + future IPC framework +# STATIC lib; line-based QLocalSocket protocol (QtCore-only, suits 6ULL rule: +# no D-Bus). Used by the single-instance lock to let a second shell instance +# ask the running one to raise itself. +add_library(cfipc STATIC) + +target_sources(cfipc +PRIVATE + src/ipc_server.cpp + src/ipc_client.cpp + src/ipc_message.cpp + src/ipc_message_registry.cpp + # Listed explicitly so AUTOMOC scans the Q_OBJECT in ipc_server.h + # (headers reached only via include dirs are not auto-discovered). + ${CMAKE_CURRENT_SOURCE_DIR}/include/cfipc/ipc_server.h +) + +target_include_directories(cfipc PUBLIC + $ +) + +target_link_libraries(cfipc +PUBLIC + Qt6::Core + Qt6::Network +) + +add_library(CFDesktop::ipc ALIAS cfipc) diff --git a/base/ipc/include/cfipc/ipc_client.h b/base/ipc/include/cfipc/ipc_client.h new file mode 100644 index 000000000..9916e3f28 --- /dev/null +++ b/base/ipc/include/cfipc/ipc_client.h @@ -0,0 +1,71 @@ +/** + * @file ipc_client.h + * @brief One-shot IPC client for sending a message to the running shell. + * + * Provides blocking send() overloads that connect to the running shell's + * IPCServer socket, write one JSON message, and disconnect. Used by a + * second shell instance (which failed to acquire the single-instance + * lock) to ask the running one to raise itself before exiting. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup ipc + */ + +#pragma once + +#include "cfipc/ipc_message.h" + +#include +#include +#include + +namespace cf::ipc { + +/** + * @brief Static one-shot IPC client. + * + * All methods are blocking and need no event loop, so they are safe to + * call directly from main() before QApplication::exec(). + * + * @note None + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ +class IPCClient { + public: + /** + * @brief Sends a fully-formed message to the running shell. + * + * @param[in] socket_path Path of the running shell's IPC socket. + * @param[in] msg The message to serialize and send. + * @return True if the message was written; false on connect failure. + * @throws None + * @note Blocks up to ~200 ms per phase (connect / write). + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + static bool send(const QString& socket_path, const IPCMessage& msg); + + /** + * @brief Convenience overload: builds a message from type + payload. + * + * @param[in] socket_path Path of the running shell's IPC socket. + * @param[in] type Message type tag. + * @param[in] payload Optional JSON payload (defaults to empty). + * @return True if the message was written; false on connect failure. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + static bool send(const QString& socket_path, const QString& type, + const QJsonObject& payload = {}); +}; + +} // namespace cf::ipc diff --git a/base/ipc/include/cfipc/ipc_message.h b/base/ipc/include/cfipc/ipc_message.h new file mode 100644 index 000000000..bd25d7771 --- /dev/null +++ b/base/ipc/include/cfipc/ipc_message.h @@ -0,0 +1,71 @@ +/** + * @file ipc_message.h + * @brief A single IPC message: a type tag plus a JSON payload. + * + * Wire format is one compact JSON object per line, newline-terminated: + * @code + * {"type":"raise","payload":{...}} + * @endcode + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup ipc + */ + +#pragma once + +#include +#include +#include +#include + +namespace cf::ipc { + +/** + * @brief One inter-process message. + * + * Composed of a type tag (selects the handler) and an arbitrary JSON + * payload. Serializes to a single compact JSON line for transport. + * + * @note None + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ +struct IPCMessage { + /// @brief Handler selector (e.g. "raise", "notify"). + QString type; + + /// @brief Arbitrary JSON body; may be empty. + QJsonObject payload; + + /** + * @brief Serializes the message to a compact JSON line. + * + * @return UTF-8 JSON bytes (no trailing newline). + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + QByteArray toJson() const; + + /** + * @brief Parses one JSON line into a message. + * + * @param[in] line Raw bytes received (trailing newline tolerated). + * @return The parsed message, or std::nullopt if the line is not a + * valid object or has an empty type. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + static std::optional fromJson(const QByteArray& line); +}; + +} // namespace cf::ipc diff --git a/base/ipc/include/cfipc/ipc_message_registry.h b/base/ipc/include/cfipc/ipc_message_registry.h new file mode 100644 index 000000000..caff04f19 --- /dev/null +++ b/base/ipc/include/cfipc/ipc_message_registry.h @@ -0,0 +1,87 @@ +/** + * @file ipc_message_registry.h + * @brief Type-routed dispatch for incoming IPC messages. + * + * Holds a map of message type to handler callable. The IPCServer looks + * up each received message here and invokes the matching handler. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup ipc + */ + +#pragma once + +#include "cfipc/ipc_message.h" + +#include +#include +#include +#include + +namespace cf::ipc { + +/** + * @brief Process-wide registry of IPC message handlers. + * + * Callers register a handler per message type; the IPCServer dispatches + * each incoming message by looking its type up here. + * + * @note None + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ +class IPCMessageRegistry { + public: + /// @brief Callable invoked for a matching message payload. + using Handler = std::function; + + /** + * @brief Returns the process-wide singleton instance. + * + * @return Reference to the singleton registry. + * @throws None + * @note Created on first access. + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + static IPCMessageRegistry& instance(); + + /** + * @brief Registers or replaces a handler for a message type. + * + * @param[in] type Message type to handle. + * @param[in] handler Callable invoked when a matching message arrives. + * @throws None + * @note A later registration for the same type replaces the prior. + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + void registerHandler(const QString& type, Handler handler); + + /** + * @brief Routes a message to its registered handler. + * + * @param[in] msg The message to dispatch. + * @return True if a handler was found and invoked; false otherwise. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + bool dispatch(const IPCMessage& msg) const; + + private: + IPCMessageRegistry() = default; + + /// @brief Map of message type to handler. + QHash handlers_; +}; + +} // namespace cf::ipc diff --git a/base/ipc/include/cfipc/ipc_server.h b/base/ipc/include/cfipc/ipc_server.h new file mode 100644 index 000000000..c943ce048 --- /dev/null +++ b/base/ipc/include/cfipc/ipc_server.h @@ -0,0 +1,133 @@ +/** + * @file ipc_server.h + * @brief Single-instance IPC server for the desktop shell. + * + * Listens on a QLocalServer socket so a second shell instance can signal + * the running one. Uses a line-based protocol: each message is a + * newline-terminated token. The only token defined in this phase is + * "raise", which asks the running shell to come to the foreground. + * + * The server is a process-wide singleton; the early-session boot chain + * starts it, and the desktop entity connects to its signals. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup ipc + */ + +#pragma once + +#include +#include +#include + +namespace cf::ipc { + +/** + * @brief Process-wide singleton IPC server. + * + * Owns a QLocalServer and emits signals in response to messages received + * from peer (second-instance) clients. The shell connects to the signals + * to react to activation requests. + * + * @note Thread-affine: must be created and used on the main (Qt event + * loop) thread. + * @warning None + * @since 0.19.0 + * @ingroup ipc + * + * @code + * auto& server = cf::ipc::IPCServer::instance(); + * server.start("/tmp/cfdesktop-shell.ipc"); + * QObject::connect(&server, &cf::ipc::IPCServer::raiseRequested, + * [](){ /* bring shell to front *\/ }); + * @endcode + */ +class IPCServer : public QObject { + Q_OBJECT + + public: + /** + * @brief Returns the process-wide singleton instance. + * + * @return Reference to the singleton IPCServer. + * @throws None + * @note Created on first access; lives until process exit. + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + static IPCServer& instance(); + + /** + * @brief Starts listening on the given socket path. + * + * Removes any stale socket file left by a crashed previous instance + * before listening. + * + * @param[in] socket_path Filesystem path for the QLocalServer socket. + * @return True if listening started; false if the port is taken. + * @throws None + * @note Safe to call once; idempotent calls close and re-listen. + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + bool start(const QString& socket_path); + + /** + * @brief Stops listening and closes the server socket. + * + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + void stop(); + + signals: + /** + * @brief Emitted when a peer sends the "raise" token. + * + * The shell should bring its main window to the foreground. + * + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + void raiseRequested(); + + private slots: + /** + * @brief Handles an incoming peer connection. + * + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + void onNewConnection(); + + private: + /** + * @brief Constructs the IPC server. + * + * @throws None + * @note Private; access via instance(). + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + IPCServer(); + + /// @brief The underlying Qt local server. + QLocalServer server_; +}; + +} // namespace cf::ipc diff --git a/base/ipc/include/cfipc/service_locator.h b/base/ipc/include/cfipc/service_locator.h new file mode 100644 index 000000000..03469366c --- /dev/null +++ b/base/ipc/include/cfipc/service_locator.h @@ -0,0 +1,108 @@ +/** + * @file service_locator.h + * @brief In-process service registry for typed lookup by name. + * + * A small type-erased map from name to shared_ptr, so subsystems can + * publish and discover services without hard references to each other. + * Process-wide singleton; not for cross-process use (that is what the + * IPC message layer is for). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup ipc + */ + +#pragma once + +#include +#include +#include + +namespace cf::ipc { + +/** + * @brief Process-wide typed service registry. + * + * @note Thread-unsafe by default; callers synchronize if needed. + * @warning None + * @since 0.19.0 + * @ingroup ipc + * + * @code + * cf::ipc::ServiceLocator::instance().registerService( + * "logger", std::make_shared()); + * auto log = cf::ipc::ServiceLocator::instance().resolve("logger"); + * @endcode + */ +class ServiceLocator { + public: + /** + * @brief Returns the process-wide singleton instance. + * + * @return Reference to the singleton service locator. + * @throws None + * @note Created on first access. + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + static ServiceLocator& instance() { + static ServiceLocator locator; + return locator; + } + + /** + * @brief Publishes a service instance under a name. + * + * @param[in] name Lookup key. + * @param[in] service The service instance to store. + * @throws None + * @note Re-registering a name replaces the prior instance. + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + template void registerService(const QString& name, std::shared_ptr service) { + services_[name] = std::static_pointer_cast(service); + } + + /** + * @brief Resolves a previously published service. + * + * @param[in] name Lookup key. + * @return The stored service, or nullptr if none is registered. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + template std::shared_ptr resolve(const QString& name) const { + const auto it = services_.constFind(name); + if (it == services_.cend()) { + return nullptr; + } + return std::static_pointer_cast(it.value()); + } + + /** + * @brief Drops all registered services. + * + * @throws None + * @note Intended for tests; clears the shared_ptr entries. + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + void clear() { services_.clear(); } + + private: + ServiceLocator() = default; + + /// @brief Type-erased service storage. + QHash> services_; +}; + +} // namespace cf::ipc diff --git a/base/ipc/src/ipc_client.cpp b/base/ipc/src/ipc_client.cpp new file mode 100644 index 000000000..b601c0470 --- /dev/null +++ b/base/ipc/src/ipc_client.cpp @@ -0,0 +1,55 @@ +/** + * @file ipc_client.cpp + * @brief Implementation of the one-shot IPC client. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup ipc + */ + +#include "cfipc/ipc_client.h" + +#include + +#ifdef Q_OS_WIN +# include +#endif + +namespace cf::ipc { + +namespace { +/// @brief Connects, writes raw bytes, disconnects. Best-effort, blocking. +/// @note Yields Windows foreground privilege afterward so the running +/// instance may activate its window. +bool sendRaw(const QString& socket_path, const QByteArray& data) { + QLocalSocket socket; + socket.connectToServer(socket_path); + if (!socket.waitForConnected(200)) { + return false; + } + socket.write(data); + socket.waitForBytesWritten(200); + socket.disconnectFromServer(); + if (socket.state() != QLocalSocket::UnconnectedState) { + socket.waitForDisconnected(200); + } + +#ifdef Q_OS_WIN + AllowSetForegroundWindow(ASFW_ANY); +#endif + + return true; +} +} // namespace + +bool IPCClient::send(const QString& socket_path, const IPCMessage& msg) { + return sendRaw(socket_path, msg.toJson() + '\n'); +} + +bool IPCClient::send(const QString& socket_path, const QString& type, const QJsonObject& payload) { + return send(socket_path, IPCMessage{type, payload}); +} + +} // namespace cf::ipc diff --git a/base/ipc/src/ipc_message.cpp b/base/ipc/src/ipc_message.cpp new file mode 100644 index 000000000..7e14ed0df --- /dev/null +++ b/base/ipc/src/ipc_message.cpp @@ -0,0 +1,41 @@ +/** + * @file ipc_message.cpp + * @brief Implementation of IPCMessage serialization. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup ipc + */ + +#include "cfipc/ipc_message.h" + +#include +#include + +namespace cf::ipc { + +QByteArray IPCMessage::toJson() const { + QJsonObject obj; + obj["type"] = type; + obj["payload"] = payload; + return QJsonDocument(obj).toJson(QJsonDocument::Compact); +} + +std::optional IPCMessage::fromJson(const QByteArray& line) { + const QJsonDocument doc = QJsonDocument::fromJson(line.trimmed()); + if (!doc.isObject()) { + return std::nullopt; + } + const QJsonObject obj = doc.object(); + IPCMessage msg; + msg.type = obj.value("type").toString(); + msg.payload = obj.value("payload").toObject(); + if (msg.type.isEmpty()) { + return std::nullopt; + } + return msg; +} + +} // namespace cf::ipc diff --git a/base/ipc/src/ipc_message_registry.cpp b/base/ipc/src/ipc_message_registry.cpp new file mode 100644 index 000000000..6cc1e7b1f --- /dev/null +++ b/base/ipc/src/ipc_message_registry.cpp @@ -0,0 +1,34 @@ +/** + * @file ipc_message_registry.cpp + * @brief Implementation of the IPC message handler registry. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup ipc + */ + +#include "cfipc/ipc_message_registry.h" + +namespace cf::ipc { + +IPCMessageRegistry& IPCMessageRegistry::instance() { + static IPCMessageRegistry registry; + return registry; +} + +void IPCMessageRegistry::registerHandler(const QString& type, Handler handler) { + handlers_[type] = std::move(handler); +} + +bool IPCMessageRegistry::dispatch(const IPCMessage& msg) const { + const auto it = handlers_.constFind(msg.type); + if (it == handlers_.cend()) { + return false; + } + it.value()(msg.payload); + return true; +} + +} // namespace cf::ipc diff --git a/base/ipc/src/ipc_server.cpp b/base/ipc/src/ipc_server.cpp new file mode 100644 index 000000000..1214d4a1c --- /dev/null +++ b/base/ipc/src/ipc_server.cpp @@ -0,0 +1,81 @@ +/** + * @file ipc_server.cpp + * @brief Implementation of the single-instance IPC server. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup ipc + */ + +#include "cfipc/ipc_server.h" + +#include "cfipc/ipc_message.h" +#include "cfipc/ipc_message_registry.h" + +#include +#include + +namespace cf::ipc { + +IPCServer& IPCServer::instance() { + static IPCServer server; + return server; +} + +IPCServer::IPCServer() { + connect(&server_, &QLocalServer::newConnection, this, &IPCServer::onNewConnection); + // Route the "raise" message type to our signal; other types are owned + // by whoever registered them in the registry. + IPCMessageRegistry::instance().registerHandler( + "raise", [this](const QJsonObject&) { emit raiseRequested(); }); +} + +bool IPCServer::start(const QString& socket_path) { + // Reclaim a stale socket left behind by a crashed previous instance. + // QLocalServer::removeServer unlinks the file iff nothing is listening. + QLocalServer::removeServer(socket_path); + return server_.listen(socket_path); +} + +void IPCServer::stop() { + server_.close(); +} + +void IPCServer::onNewConnection() { + QLocalSocket* conn = server_.nextPendingConnection(); + if (conn == nullptr) { + return; + } + // The server owns the connection for its lifetime; auto-delete on close. + conn->setParent(this); + + // Line-based JSON framing: one IPCMessage per newline. Drain on every + // readyRead, on disconnect, and once up front. The disconnect + up-front + // drains matter on Windows named pipes: a one-shot client that writes and + // closes immediately can deliver the final bytes without a separate + // readyRead, and bytes may already be buffered before this slot is wired. + auto drain = [conn]() { + while (conn->canReadLine()) { + const auto msg = IPCMessage::fromJson(conn->readLine()); + if (msg.has_value()) { + IPCMessageRegistry::instance().dispatch(*msg); + } + } + }; + connect(conn, &QLocalSocket::readyRead, this, drain); + connect(conn, &QLocalSocket::disconnected, this, [conn, drain]() { + // A one-shot peer can deliver final bytes without a readyRead (notably + // on Windows named pipes); force Qt to pull them before draining. + conn->waitForReadyRead(100); + drain(); + conn->deleteLater(); + }); + // Bytes may have arrived (and the peer gone) before this slot was wired; + // pull and drain now. + conn->waitForReadyRead(100); + drain(); +} + +} // namespace cf::ipc diff --git a/document/status/current.md b/document/status/current.md index c78e2babd..214ffa94b 100644 --- a/document/status/current.md +++ b/document/status/current.md @@ -49,10 +49,10 @@ description: CFDesktop 项目进度的唯一事实来源与全局导航。 1. **MS2 状态栏**(顶部时间 + 系统图标)— ✅ 功能落地(`StatusBar` 实现 + 注册 PanelManager + 主题跟随 + MD3 美化;offscreen 启动通过,待真机视觉确认) 2. **MS3 任务栏**(底部居中图标条 + hover 动画)— ✅ 最小切片完成(`CenteredTaskbar` 注册 Bottom 面板 + `TaskbarIcon` 居中图标/hover 放大/自绘 ripple/运行指示器;`appClicked` 已接 `AppLaunchService::launch` 真启动并记 PID,运行指示器经 `WindowManager` 窗口追踪联动——见 `desktop/ui/CFDesktopEntity.cpp:133-180`) -3. **MS4 应用启动器**(网格 + 搜索 + QProcess + 双态)— 🚧 进行中(双态框架 + 网格 + 搜索框落地:`LaunchKind`/`IBuiltinPanel`/`BuiltinPanelRegistry` 把 `builtin:` hack 正式化、消灭 if 链,calculator 双态,`HardwareTier::prefer_inprocess_apps` 按 Low/High 裁决;`AppLauncher` 网格 + 搜索 QLineEdit 实时过滤;`DesktopEntryIndex` 扫 XDG `.desktop`(firefox 等);Noter 移植成第二个独立 App。仅余入场/退场动画。详见 [milestone_04](../todo/desktop/milestone_04_app_launcher.md)) +3. **MS4 应用启动器**(网格 + 搜索 + QProcess + 双态)— 🚧 进行中(双态框架 + 网格 + 搜索框落地:`LaunchKind`/`IBuiltinPanel`/`BuiltinPanelRegistry` 把 `builtin:` hack 正式化、消灭 if 链,calculator 双态,`HardwareTier::prefer_inprocess_apps` 按 Low/High 裁决;`AppLauncher` 网格 + 搜索 QLineEdit 实时过滤;`DesktopEntryIndex` 扫 XDG `.desktop`(firefox 等);Noter 移植成第二个独立 App。入场/退场动画已完成(`enter_fade_`/`enter_slide_` + `exit_fade_`/`exit_slide_` 对称实现)。详见 [milestone_04](../todo/desktop/milestone_04_app_launcher.md)) 4. **MS5 窗口管理**(窗口状态机 + 任务栏联动)— 🚧 进行中(追踪+联动切片跑通:`WindowManager` 追踪外部窗口 + `IWindow::pid()` + Taskbar 运行指示器联动;靠 PID 匹配,直接启动(xterm)可靠、间接启动(xdg-open)受限。**2026-07-05 状态机 + 联动 + FloatingPolicy 落地**:`WindowState` 状态机 (`isValidTransition` + `applyState`)、`IWindow` 加 `minimize/maximize/restore` 非纯虚默认 no-op(Windows 三态全实现 `ShowWindow`;WSL X11 minimize+restore 走 ICCCM `WM_CHANGE_STATE` ClientMessage、maximize 留 no-op 因 XWayland EWMH 不可靠)、WindowManager 查询 (`getWindowInfo`/`getAllWindowInfos`/`findWindowByPid`) + `windowStateChanged`/`windowInfoUpdated` 信号、Taskbar 点击运行图标走 raise/minimize toggle(Normal→minimize、Minimized→restore+raise、其他→仅 raise,不再二次启动)、`FloatingPolicy` 新窗口初始位置(居中+24px 级联+缩进 work area)、WindowInfo 补 `icon_hint`/`z_index`/`is_always_on_top`/`created_at`;FloatingPolicy 13 例 + WindowManager 14 例(FakeWindow+QSignalSpy)单测全绿。**装饰 overlay (策略 A) DEFERRED 到 EGLFS/Wayland-compositor 阶段**——WSL X11/Win32 外部窗口自带原生标题栏、overlay 重影;EGLFS 后端将原生持有装饰权。详见 [milestone_05](../todo/desktop/milestone_05_window_management.md)) -闭环达成后按需推进:CrashHandler、IPC、EGLFS 嵌入式后端、输入抽象层、P2/P3 控件。 +闭环达成后按需推进:CrashHandler(Phase1 ✅ 信号捕获 + async-signal-safe 报告落盘,`base/crash_handler/` cfcrash + `CrashHandlerStage` + 4 单测,详见 [06_infrastructure.md](../todo/desktop/06_infrastructure.md))、IPC(✅ single-instance raise + IPCMessage/ServiceLocator 框架)、EGLFS 嵌入式后端、输入抽象层、P2/P3 控件。 > **HWTier 优先级决策(2026-06-26)**:检测 + 评分 + 策略覆写**已完成**(Phase 1,`base/system/hardware_tier/`:`IHardwareCollector / Scorer / Assessor / Policy` 全套就绪)。故 [`summary.md`](../todo/desktop/summary.md) 与 [`06_infrastructure.md`](../todo/desktop/06_infrastructure.md) 里「HWTier 0% 🔴、上线前必做」的表述**已过时**——那针对的是 embedded/production 上线。当前 demo / 可见桌面路线:**CapabilityPolicy 策略引擎**(把档位转成动效/渲染/内存降级策略并接入 Shell)**延后**,开发期一律按 High Tier;待 embedded pivot 再接。 @@ -68,6 +68,8 @@ description: CFDesktop 项目进度的唯一事实来源与全局导航。 - **2026-07(App 抽离到 CFDeskit + 发现机制重构)**:5 个独立 app(calculator/noter/alarm_clock/calendar/system_state)抽到独立仓 [CFDeskit](https://github.com/Awesome-Embedded-Learning-Studio/CFDeskit);主仓 `apps/` 源码全删(PR #22),改**运行时部署模式**——app 由 CFDeskit build + install 到 `/apps//`,`AppDiscoverer` 改扫 `active_root/apps`(`Apps` PathType 小写,原 `/../apps/` 已弃),空目录 → `log_info` 不报错。system_state **vendoring cfbase probe**(STATIC `mini_system_probe` lib,零 `libcfbase.so` 依赖,完全独立演化)。**ABI 部署隔离**:app rpath `$ORIGIN/..` 找 `apps/libquarkwidgets.so`(独立于 desktop `bin/` 那份),QuarkWidgets 加版本导出(PR #2 `quarkwidgets_runtime_abi_version`)+ CFDeskit `abi_check.hpp` 启动自检(mismatch `qFatal`)。源码开发停放 `third_party/apps/`(gitignore,clone 任意 app 仓一视同仁)。calculator parser 单测随源码迁 CFDeskit(15 TEST 全过)。主仓零 app 代码,desktop 本体纯净。注:`LoadKind`/`IBuiltinPanel`/`BuiltinPanelRegistry` 双态机制保留(builtin 仅 about)。 - **2026-07(主仓重组:cfbase + desktop/base 合并 + desktop/ 平铺)**:删 `desktop/` 包装层——`base/`(cfbase) + 原 `desktop/base/`(logger/config/path/filesystem/fundamental/ascii_art) 合并成新顶层 `base/`(cfbase 子目录化,desktop/base 6 模块同居);`desktop/ui` → `ui/`、`desktop/main` → `main/`(升顶层);`desktop/export.h` + `desktop/main.cpp` → 顶层;`CFDesktop_shared` 组装搬到顶层 CMakeLists。源码**零改动**(内部全相对 include,平移后路径不变,实测 `#include "desktop/"` = 0);CMake 重组(base/ 合并 + 顶层 `add_subdirectory(ui/main)` + CFDesktop_shared + exe,补 `enable_testing()` 顶层)。三层架构图 `base → ui → desktop` 改 `base → ui → main`。cfbase 名副其实(真正的 base 层,含探针 + 桌面基础设施)。验证:build 全绿 + ctest 全过 + desktop 启动正常。 - **2026-07(MS5 窗口状态机 + 任务栏 raise/minimize + FloatingPolicy)**:补齐 milestone_05 除装饰外的全部验收项。`WindowState` 状态机(`isValidTransition`+`applyState`,Normal↔Minimized/Maximized、Closed 终态)+ WindowManager 查询/信号(`getWindowInfo`/`getAllWindowInfos`/`findWindowByPid` + `windowStateChanged`/`windowInfoUpdated`);`IWindow` 加 `minimize/maximize/restore` 非纯虚默认 no-op——Windows 三态全实现(`ShowWindow`),WSL X11 实现 minimize+restore(ICCCM 4.1.4 `WM_CHANGE_STATE` ClientMessage → root + SubstructureRedirectMask,即 `XIconifyWindow`/xdotool 路径)、maximize 留默认 no-op(XWayland 下 EWMH `_NET_WM_STATE` 不可靠);`onWindowCame` 同步填 `windows_` map(原来只填 `window_infos_` 致 `find_window` 对外部窗口失效) + destroyed lambda 先发 Closed 再 erase;Taskbar 点击运行图标走 raise/minimize toggle(不再二次启动);`FloatingPolicy` 新窗口初始位置(居中+24px 级联+缩进 work area,镜像 `WindowPlacementPolicy`);WindowInfo 补 `icon_hint`/`z_index`/`is_always_on_top`/`created_at`。单测:FloatingPolicy 13 例 + WindowManager 14 例(FakeWindow+QSignalSpy)。**装饰 overlay (策略 A) DEFERRED 到 EGLFS 阶段**(WSL X11/Win32 外部窗口自带原生标题栏、overlay 重影;EGLFS/Wayland-compositor 后端将原生持有装饰权)。详见 [milestone_05](../todo/desktop/milestone_05_window_management.md)。 +- **2026-07(MS6 Step A 桌面时钟小组件)**:新增桌面 widget 框架——`WidgetBase`(可拖拽基类,press-drag-move + 阈值)+ `WidgetContainer`(QObject 注册表)+ `ClockWidget`(Material 圆角卡片 HH:mm + 日期,1s 刷新,`ThemeManager::themeChanged` 跟随),落 `ui/components/desktop_widget/`(target `cfdesktop_desktop_widget`,link QuarkWidgets),挂 `CFDesktopEntity` 桌面(icon_layer 之上)。范式复用:主题色/字体照 statusbar(`queryColor(SURFACE/ON_SURFACE/ON_SURFACE_VARIANT)` + `queryTargetFont(TYPOGRAPHY_DISPLAY_LARGE/BODY_MEDIUM)`),圆角卡片照 AppLauncher(`QPainterPath addRoundedRect + fillPath`,无 elevation 阴影)。`ControlCenter` 下拉面板属 Step B。详见 [milestone_06](../todo/desktop/milestone_06_widget_control_center.md)。(⚠️ 后续:`ClockWidget`/`WidgetContainer` 随 HomePage 主屏迁移取代删除,见下条;`WidgetBase` 保留。) +- **2026-07(MS6 重定向:迁移 CCIMX HomePage 主屏 + 多页左右滑桌面)**:Step A 自由时钟 widget 范式与主屏冲突,改迁移 CCIMXDesktop `HomePage` 作默认桌面。落 `ui/components/home_page/`(target `cfdesktop_home_page`):`AnalogClockWidget`(QPainter 模拟表盘)+ `DigitalTimeWidget`(hh:mm:ss+日期)+ `CardStackWidget`(垂直滑动卡片堆,1/4 高阈值 + QPropertyAnimation slide/fade,搬自 CCIMX)+ `HomeCardManager` + `GlobalClockSources`(1s 时钟源)+ `DateCard`(占位)+ `HomePage`(左右布局)+ `PageStackWidget`(水平切页)。颜色/字体 MD3 化(`queryColor`/`queryTargetFont` token)。**桌面改多页**:`PageStackWidget`(QStackedWidget)装 HomePage(页0 信息流主屏)↔ icon_layer(页1 Win11 图标桌面),水平拖拽 1/4 宽切页 + pos 插值动画。**修叠加 bug**:HomePage 透明透显 icon_layer → 多页独立显示。**手势分区靠 Qt 冒泡**:HomePage/时钟不 accept press → 冒泡 PageStack(水平);`CardStackWidget` accept → 垂直卡片。删 Step A 的 `ClockWidget`/`WidgetContainer`(HomePage 取代),`WidgetBase` 保留;`icon_layer` 作页1(全 connect 保留,几何改本地坐标)。WSL boot 验证通过;视觉/滑动待用户确认。Step B:复杂卡片(UserInfo/Net/Weather 依赖 CCIMX 基建)+ 控制中心 + 应用页。**Step B1 已落地(2026-07-08)**:`CardStackWidget` 加 Memory/CPU/Disk 实时数据卡(进度条 primary/surfaceVariant + cfbase `getSystemMemoryInfo`/`getCPUProfileInfo`/`std::filesystem::space`,2/5/10s 刷新),`home_page` link cfbase。**CPU probe 移出 UI 线程(2026-07-08,commit 8af7650)**:CPU 使用率测量固有需 100ms 采样窗口(`getCpuUsage` 两次读 `/proc/stat` 算 delta),原在 UI 线程每 tick 卡 ~100ms;改 `QtConcurrent::run` 跑在线程池 + `QFutureWatcher::finished` 回 UI 线程更新;`in_flight_` 防 probe 堆积、worker 仅值捕获 probe 不捕获 `this`、watcher 作 card child 随销毁(在飞 future 被 detach 不 UAF)。memory/disk probe 一并 async(统一范式)。**Step B2 视觉照搬 CCIMX(2026-07-08)**:重做视觉——表盘白径向渐变+8px 黑厚边+黑针+**红秒针**+12/3/6/9 数字(draw-by-draw 照搬)、数字时间白字 40/15(Linux/WSL 无 Helvetica Neue,改用 `painter.font()` 系统默认字体)、卡片从自绘 paintEvent 模板改为 **QSS 渐变**(`DateCard` 蓝渐变 `#4A90E2→#1E3C72`;`SystemUsageCard` 深灰渐变 `#5D6D7E→#2C3E50`+QProgressBar chunk `#1abc9c`+`QGraphicsDropShadowEffect` 阴影)、布局全 0 margin+精确 stretch(1,1/5,2/3,1)+右下渐变面板;**去 MD3 主题化**(硬编码 QSS,Phase G 再接)。`SystemUsageCard` 配置式(title+probe+interval)统一 Memory/CPU/Disk 三卡(替独立卡),数据源仍 cfbase。**Step C 全量迁移(2026-07-08,commit 53a5620+d15f8d)**:卡栈补齐至 CCIMX 对等——`UserInfoCard`(绿渐变,POSIX `getpwuid`/`gethostname`/`uname` 填真实桌面数据,头像画首字母圆;CFDesktop 无 DesktopUserInfo 子系统,不假装有 phone/email)、`ModernCalendarWidget`(QCalendarWidget 子类,自绘 cell+日期标记,照搬 paintCell;去 CCIMX 导航箭头 PNG 用 Qt 默认)、`GaugeWidget`(自绘半圆仪表盘,逐行照搬 CCIMX;顺手修 min/max 初始化笔误 + drawNeedle 除零)+ `DiskGaugeCard`(替 Disk 的 QProgressBar 为 gauge,沿用 async probe)——卡栈顺序对齐 CCIMX(UserInfo/Calendar/Date/Disk/Memory)+ CPU 作附加卡。右下渐变占位换 2 列 gadget 网格:`NetStatusCard`(cfbase `getNetworkInfo` → reachability/transport/IPv4,青绿渐变,替 CCIMX NetAbilityScanner)、`LocalTempCard`(读 `/sys/class/thermal/thermal_zone*` 真实温度,WSL2 无 zone 则诚实 N/A;替 CCIMX 的 ICM20608 传感器——桌面无此硬件,CCIMX 在 x86 亦用随机数假数据)。两 gadget 独立(不继承 AppCardWidget/DesktopToast,该框架 CFDesktop 无)。MS6 HomePage 主屏迁移至此全部完成;仅余 Step B2 控制中心 + 应用页。 - **已达成**:Milestone 1「桌面骨架可见」;Phase 0 / 1 / 2 / A(CI) / 6 / G(Widget) / H(显示后端)(详见 [SUMMARY.md](../todo/done/SUMMARY.md)) ## 新人入门 diff --git a/document/todo/desktop/06_infrastructure.md b/document/todo/desktop/06_infrastructure.md index 5c5b1433a..4eb090964 100644 --- a/document/todo/desktop/06_infrastructure.md +++ b/document/todo/desktop/06_infrastructure.md @@ -62,44 +62,38 @@ description: "状态: 🚧 部分完成 (~50%),预计周期: 4~5 周" ### CrashHandler 崩溃处理与自动重启 -#### Day 1-2: 崩溃捕获核心 -- [ ] 创建 CrashHandler 类 - - [ ] 单例模式 - - [ ] 应用启动时注册 -- [ ] 实现信号捕获(Linux) - - [ ] `SIGSEGV`(段错误) - - [ ] `SIGABRT`(中止) - - [ ] `SIGFPE`(浮点异常) - - [ ] `SIGBUS`(总线错误) - - [ ] `SIGILL`(非法指令) -- [ ] 实现异常捕获(Windows) +> **Phase 1 已落地(2026-07-08,feat/shell-foundation)**:信号捕获 + async-signal-safe raw 快照 + 下次启动 finalize 成 JSON 报告(含 logger tail 的 lastLogs)+ 20 份保留。落 `base/crash_handler/`(STATIC lib `cfcrash`,不 link Qt),early_session `CrashHandlerStage`(Stage 4,Logger 之后)注册。**订正**:报告路径跟 logger 同根 `/crashes/`(非 `~/.cache/...`);lastLogs 不在 handler 内取(logger 异步线程,UB),改为 defer finalize。符号解析 / CrashReporter 弹窗 / Watchdog 属 Phase 2。 + +#### Day 1-2: 崩溃捕获核心 — ✅ Phase 1 完成(Linux) +- [x] 创建 CrashHandler 类(`base/crash_handler/include/cfcrash/crash_handler.h`) + - [x] 单例模式(`CrashHandler::instance()`) + - [x] 应用启动时注册(`CrashHandlerStage`,early_session Stage 4) +- [x] 实现信号捕获(Linux,`sigaction` + `sigaltstack` + `SA_ONSTACK`/`SA_RESETHAND`) + - [x] `SIGSEGV`(段错误) + - [x] `SIGABRT`(中止) + - [x] `SIGFPE`(浮点异常) + - [x] `SIGBUS`(总线错误) + - [x] `SIGILL`(非法指令) +- [ ] 实现异常捕获(Windows)— Phase 1 占位 stub,完整版 Phase 2 - [ ] `SetUnhandledExceptionFilter` - [ ] `std::set_terminate` -- [ ] 实现堆栈回溯 - - [ ] Linux: `backtrace()` / `backtrace_symbols()` - - [ ] Windows: `StackWalk64` - - [ ] 符号解析(addr2line 集成) -- [ ] 编写单元测试 - -#### Day 3: 崩溃信息存储 -- [ ] 定义 CrashReport 结构 - - [ ] `timestamp`(崩溃时间) - - [ ] `processName`(进程名称) - - [ ] `processId`(进程 ID) - - [ ] `signal`(崩溃信号) - - [ ] `stackTrace`(堆栈回溯) - - [ ] `registers`(寄存器状态) - - [ ] `lastLogs`(最近 50 条日志) - - [ ] `systemInfo`(系统信息/HWTier) -- [ ] 实现崩溃报告存储 - - [ ] 存储路径 `~/.cache/CFDesktop/crashes/` - - [ ] JSON 格式序列化 - - [ ] Mini dump 支持(Windows) -- [ ] 实现崩溃历史管理 - - [ ] 最多保留 20 个报告 - - [ ] 按时间排序 - - [ ] 清理过期报告(30 天) -- [ ] 编写单元测试 +- [x] 实现堆栈回溯(Linux `backtrace()` 写**裸地址**到 `.pending`) + - [ ] `backtrace_symbols()` + addr2line 符号解析 — **defer Phase 2**(handler 内非 async-signal-safe) + - [ ] Windows: `StackWalk64` — Phase 2 +- [x] 编写单元测试(`test/crash/` 4 测:report 序列化 / retention / finalize 组装 / fork+SIGSEGV 端到端) + +#### Day 3: 崩溃信息存储 — ✅ Phase 1 完成 +- [x] 定义 CrashReport 结构(`crash_report.h`:timestamp / pid / signal / signal_name / raw_frames / last_logs) + - [ ] processName / registers / systemInfo(HWTier)字段 — Phase 2 增项 +- [x] 实现崩溃报告存储 + - [x] 存储路径 `/crashes/`(跟 logger 同根,**订正**早期 `~/.cache/...`) + - [x] JSON 格式序列化(`CrashReport::toJson()`) + - [ ] Mini dump 支持(Windows)— Phase 2 +- [x] 实现崩溃历史管理 + - [x] 最多保留 20 个报告(`pruneReports`) + - [x] 按 mtime 排序删最旧 + - [ ] 清理过期报告(30 天)— Phase 2(可选) +- [x] 编写单元测试 #### Day 4: CrashReporter 崩溃报告弹窗 - [ ] 创建 CrashReporter 独立进程 diff --git a/document/todo/desktop/CRASH_HANDLE_STEP.md b/document/todo/desktop/CRASH_HANDLE_STEP.md index 8952909d5..132b5a6f7 100644 --- a/document/todo/desktop/CRASH_HANDLE_STEP.md +++ b/document/todo/desktop/CRASH_HANDLE_STEP.md @@ -7,6 +7,12 @@ description: "评估日期:2026-03-30,整体基建就绪度约 60%,核心 > 评估日期:2026-03-30 +> **更新(2026-07-08):Phase 1 已落地** 于 `feat/shell-foundation`(`base/crash_handler/`,lib `cfcrash` + early_session `CrashHandlerStage` + `test/crash/` 4 单测)。实施时对本评估做了三处订正: +> 1. **Logger `flush_sync()` 并非信号安全** — logger 是异步后台线程,signal handler 内调用即 UB。Phase 1 改为 **defer-to-finalize**:handler 只写 async-signal-safe 裸快照(`.pending`:pid/signal/裸地址),下次启动 `CrashHandlerStage` 时 tail logger 日志文件组装 `lastLogs`(参 breakpad/crashpad 同路)。 +> 2. **落地路径不是 `desktop/base/infrastructure/`**(重组前结构),而是 `base/crash_handler/`(07 月 base 平铺重组后)。 +> 3. **报告路径不是 `~/.cache/CFDesktop/crashes/`**,而是 `/crashes/` — 跟 logger 同根(`app_runtime_dir() == QCoreApplication::applicationDirPath()`),finalize tail 日志与报告同目录树。 +> 详见 [06_infrastructure.md](06_infrastructure.md) CrashHandler 段(Phase 1 完成项已勾选,Phase 2 项保留)。 + ## 结论:部分就绪,建议分阶段实施 整体**可复用相邻基建**就绪度约 **60%**(指 Logger/ConfigStore/Platform/System/InitChain/ScopeGuard 等可复用底座);**崩溃捕获功能本身为 0%**。Phase 1(信号捕获 + backtrace + JSON 落盘,零依赖)可直接开始;Phase 2(CrashReporter 独立进程 + Watchdog)依赖 IPC 基建(0%,见 [06_infrastructure.md](06_infrastructure.md)),须与 IPC 同批落地。 diff --git a/document/todo/desktop/milestone_06_widget_control_center.md b/document/todo/desktop/milestone_06_widget_control_center.md index d974995e0..361fbdbbd 100644 --- a/document/todo/desktop/milestone_06_widget_control_center.md +++ b/document/todo/desktop/milestone_06_widget_control_center.md @@ -5,12 +5,14 @@ description: "预计周期: 7-10 天,前置依赖: Milestone 2: 状态栏 (下 # Milestone 6: 小组件 + 控制中心 -> **状态**: ⬜ 待开始 +> **状态**: 🚧 进行中(Step A ✅ 2026-07-08;Step B 控制中心待做) > **预计周期**: 7-10 天 > **前置依赖**: [Milestone 2: 状态栏](milestone_02_status_bar.md) (下拉触发点) > **可与 MS3/MS4/MS5 并行** > **目标**: 桌面上有时钟小组件,从状态栏下拉打开简易控制中心 (亮度/音量/主题切换) > +> **Step A 已落地(2026-07-08,feat/shell-foundation)**:`WidgetBase`(可拖拽基类,press-drag-move + 阈值)+ `WidgetContainer`(QObject 注册表,mount 到 host widget)+ `ClockWidget`(Material 圆角卡片 `surfaceContainer` + `displayLarge` HH:mm + `bodyMedium` 日期,coarse 1s 刷新,`ThemeManager::themeChanged` 跟随),落 [`ui/components/desktop_widget/`](../../../ui/components/desktop_widget/)(target `cfdesktop_desktop_widget`,link `QuarkWidgets::quarkwidgets`),挂 `CFDesktopEntity` 桌面(icon_layer 之上)。**路径订正**:本文档原 `desktop/ui/widget/...` 已过时(07 月重组升顶层),实际落 `ui/components/`(与 statusbar/taskbar 同级独立 target)。**Step B 待做**:`ControlCenter` 下拉面板(Slider/Switch/Button + slide/fade 动画)+ `StatusBar` 新增 `timeClicked()` 信号 + mousePressEvent 命中。 +> > 📌 **路径核对(2026-06-29)**:本文档中所有 bare `ui/...` 路径(ThemeManager / Material 控件 / 动画工厂 / 阴影系统)均已随 Phase 2 抽离迁移至 **QuarkWidgets 子模块** `third_party/QuarkWidgets/ui/...`(CMake 目标 `QuarkWidgets::quarkwidgets`)。`desktop/ui/...` 仍为本仓自有。 --- diff --git a/main.cpp b/main.cpp index e8c96397e..be051dec6 100644 --- a/main.cpp +++ b/main.cpp @@ -5,6 +5,7 @@ #include #include +#include "cfipc/ipc_client.h" #include "ui/widget/material/application/material_application.h" namespace { @@ -32,7 +33,10 @@ int main(int argc, char* argv[]) { // screen fight over geometry and WindowManagers cross-track each other's // windows. if (!acquireSingleInstanceLock()) { - qWarning("CFDesktop: another instance is already running; exiting."); + // Ask the running shell to bring itself to front, then exit. Best-effort: + // if no listener is around (crashed mid-boot) send fails silently. + cf::ipc::IPCClient::send(QDir::tempPath() + QStringLiteral("/cfdesktop-shell.ipc"), + QStringLiteral("raise")); return 0; } diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index ebb0d2310..1b55f86c2 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -40,7 +40,7 @@ target_include_directories(CFDesktopMain PUBLIC ) target_link_libraries(CFDesktopMain PUBLIC - cfbase cflogger cfasciiart cffilesystem cffundamental cfpath cfconfig + cfbase cflogger cfasciiart cffilesystem cffundamental cfpath cfconfig cfipc cfcrash cf_desktop_ui_widget_init_session Qt6::Core Qt6::Gui Qt6::Widgets ) diff --git a/main/early_session/impl/CMakeLists.txt b/main/early_session/impl/CMakeLists.txt index dda618513..742e1c759 100644 --- a/main/early_session/impl/CMakeLists.txt +++ b/main/early_session/impl/CMakeLists.txt @@ -7,6 +7,8 @@ set(IMPL_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/logger_stage.cpp ${CMAKE_CURRENT_SOURCE_DIR}/early_config_stage.cpp ${CMAKE_CURRENT_SOURCE_DIR}/desktop_backbone_setup.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ipc_stage.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/crash_handler_stage.cpp ) # Return to parent via PARENT_SCOPE diff --git a/main/early_session/impl/crash_handler_stage.cpp b/main/early_session/impl/crash_handler_stage.cpp new file mode 100644 index 000000000..ccb86a84c --- /dev/null +++ b/main/early_session/impl/crash_handler_stage.cpp @@ -0,0 +1,51 @@ +/** + * @file crash_handler_stage.cpp + * @brief Implementation of the crash handler initialization stage. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup early_session + */ + +#include "crash_handler_stage.h" + +#include "cfcrash/crash_handler.h" +#include "early_handle/early_handle.h" + +#include +#include +#include +#include + +namespace cf::desktop::early_stage { + +namespace { +/// @brief Crash reports live next to the executable, alongside the logger. +/// @note Matches app_runtime_dir() == QCoreApplication::applicationDirPath(). +constexpr QLatin1StringView kCrashesSubdir{"/crashes"}; +} // namespace + +CrashHandlerStage::BootResult CrashHandlerStage::run_session() { + const QString dir = QCoreApplication::applicationDirPath() + kCrashesSubdir; + // Hand this run's logger path to install() so the handler records it in + // each .pending; finalize() then tails the crashed run's own log. + const QString logger_path = EarlyHandle::instance().early_settings().get_boot_logger_path(); + if (!cf::crash::CrashHandler::instance().install(dir.toStdString(), + logger_path.toStdString())) { + // Non-critical: handler arming is best-effort and platform-dependent. + qWarning("CrashHandlerStage: signal handlers not armed on this platform"); + } + + // Fold any .pending left by a previous crashed run into a finalized .json. + const auto finalized = + cf::crash::CrashHandler::instance().finalizePendingReports(logger_path.toStdString()); + if (finalized > 0) { + qInfo("CrashHandlerStage: finalized %lu crash report(s) from previous run", + static_cast(finalized)); + } + return BootResult::OK; +} + +} // namespace cf::desktop::early_stage diff --git a/main/early_session/impl/crash_handler_stage.h b/main/early_session/impl/crash_handler_stage.h new file mode 100644 index 000000000..0f9ef87b5 --- /dev/null +++ b/main/early_session/impl/crash_handler_stage.h @@ -0,0 +1,77 @@ +/** + * @file crash_handler_stage.h + * @brief Crash handler initialization stage for the early boot sequence. + * + * Defines the stage that arms the crash signal handlers and folds any raw + * crash snapshot left by a previous (crashed) run into a finalized report. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup early_session + */ + +#pragma once +#include "init_chain/init_stage.h" + +#include +#include + +namespace cf::desktop::early_stage { + +/** + * @brief Arms crash signal capture and finalizes prior crash reports. + * + * Installs the async-signal-safe handlers so a SIGSEGV/SIGABRT/... drops a + * raw @c .pending snapshot, then folds any leftover @c .pending (from a + * previous run) into a finalized @c .json report using the logger tail as + * last_logs. Must run after the logger stage so the logger path is known. + * + * @note Non-critical: failure does not halt boot (best-effort). + * @warning None + * @since 0.19.0 + * @ingroup early_session + */ +class CrashHandlerStage : public IEarlyStage { + public: + CrashHandlerStage() = default; + + /** + * @brief Returns the name of this stage. + * + * @return Stage name as a string view. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup early_session + */ + std::string_view name() const override { return "Crash Handler Boot"; } + + /** + * @brief Returns the expected position in the boot sequence. + * + * @return Expected boot position (4 = fifth stage, after IPC). + * @throws None + * @note Must run after the logger and IPC stages. + * @warning None + * @since 0.19.0 + * @ingroup early_session + */ + std::optional atExpectedStageBootup() const override { return 4; } + + /** + * @brief Arms handlers and finalizes prior reports. + * + * @return Result of the boot operation. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup early_session + */ + BootResult run_session() override; +}; + +} // namespace cf::desktop::early_stage diff --git a/main/early_session/impl/ipc_stage.cpp b/main/early_session/impl/ipc_stage.cpp new file mode 100644 index 000000000..ee8f47bc3 --- /dev/null +++ b/main/early_session/impl/ipc_stage.cpp @@ -0,0 +1,39 @@ +/** + * @file ipc_stage.cpp + * @brief Implementation of the IPC server initialization stage. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup early_session + */ + +#include "ipc_stage.h" + +#include "cfipc/ipc_server.h" + +#include +#include + +namespace cf::desktop::early_stage { + +namespace { +/// @brief Socket path shared with the single-instance lock and client. +/// @note Sits next to the QLockFile so both live in $TMPDIR. +constexpr QLatin1StringView kIpcSocketName{"/cfdesktop-shell.ipc"}; +} // namespace + +IpcStage::BootResult IpcStage::run_session() { + const QString path = QDir::tempPath() + kIpcSocketName; + if (!cf::ipc::IPCServer::instance().start(path)) { + // Non-critical: a failed listener just means second-instance raise + // will not work; the lock itself still prevents double boot. + qWarning("IpcStage: failed to listen on %s", qPrintable(path)); + return BootResult::FAILED; + } + qInfo("IpcStage: listening on %s", qPrintable(path)); + return BootResult::OK; +} + +} // namespace cf::desktop::early_stage diff --git a/main/early_session/impl/ipc_stage.h b/main/early_session/impl/ipc_stage.h new file mode 100644 index 000000000..ff91ee65b --- /dev/null +++ b/main/early_session/impl/ipc_stage.h @@ -0,0 +1,76 @@ +/** + * @file ipc_stage.h + * @brief IPC server initialization stage for the early boot sequence. + * + * Defines the stage that starts the single-instance IPC server so a + * second shell instance can signal the running one (e.g. to raise it). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup early_session + */ + +#pragma once +#include "init_chain/init_stage.h" + +#include +#include + +namespace cf::desktop::early_stage { + +/** + * @brief Starts the single-instance IPC server. + * + * Brings up the QLocalServer-based IPC server during early boot so that + * a second shell instance can ask this one to raise itself. Must run + * after the logger stage so failures are observable. + * + * @note Non-critical: failure to start does not halt boot. + * @warning None + * @since 0.19.0 + * @ingroup early_session + */ +class IpcStage : public IEarlyStage { + public: + IpcStage() = default; + + /** + * @brief Returns the name of this stage. + * + * @return Stage name as a string view. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup early_session + */ + std::string_view name() const override { return "IPC Server Boot"; } + + /** + * @brief Returns the expected position in the boot sequence. + * + * @return Expected boot position (3 = fourth stage, after logger). + * @throws None + * @note Must run after the logger stage. + * @warning None + * @since 0.19.0 + * @ingroup early_session + */ + std::optional atExpectedStageBootup() const override { return 3; } + + /** + * @brief Starts the IPC server. + * + * @return Result of the boot operation. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup early_session + */ + BootResult run_session() override; +}; + +} // namespace cf::desktop::early_stage diff --git a/main/init/init_early_session.cpp b/main/init/init_early_session.cpp index f4c6e98ef..bc2edacb3 100644 --- a/main/init/init_early_session.cpp +++ b/main/init/init_early_session.cpp @@ -1,7 +1,9 @@ #include "early_session/impl/console_signal_stage.h" +#include "early_session/impl/crash_handler_stage.h" #include "early_session/impl/desktop_backbone_setup.h" #include "early_session/impl/early_config_stage.h" #include "early_session/impl/early_welcome_impl.h" +#include "early_session/impl/ipc_stage.h" #include "early_session/impl/logger_stage.h" #include "init.h" #include "init_chain/init_chain.h" @@ -23,7 +25,15 @@ void RunEarlyInit() { early_runner.register_stage_execute_before( []() { return std::make_unique(); }); - /* Stage 3: Check the Desktop Backbones, if failed, we sucks */ + /* Stage 3: IPC server boots (single-instance raise channel) */ + early_runner.register_stage_execute_before( + []() { return std::make_unique(); }); + + /* Stage 4: Crash handler arms signals + finalizes prior crash reports */ + early_runner.register_stage_execute_before( + []() { return std::make_unique(); }); + + /* Stage 5: Check the Desktop Backbones, if failed, we sucks */ early_runner.register_stage_execute_before( []() { return std::make_unique(); }); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 829eb2946..f08b36a95 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -47,6 +47,12 @@ add_subdirectory(logger) # Add System Test add_subdirectory(system) +# Add IPC tests subdirectory +add_subdirectory(ipc) + +# Add CrashHandler tests subdirectory +add_subdirectory(crash) + # NOTE: ui tests now live in the QuarkWidgets submodule (third_party/QuarkWidgets/test). # Add desktop tests diff --git a/test/crash/CMakeLists.txt b/test/crash/CMakeLists.txt new file mode 100644 index 000000000..d19ad21b0 --- /dev/null +++ b/test/crash/CMakeLists.txt @@ -0,0 +1,40 @@ +# CrashHandler module unit tests using GoogleTest +include(add_gtest_executable) + +log_info("crash_tests" "Configured CrashHandler unit tests:") + +add_gtest_executable( + TEST_NAME crash_report_test + SOURCE_FILE crash_report_test.cpp + LINK_LIBRARIES cfcrash;GTest::gtest;GTest::gtest_main + LABELS "crash;unit" + LOG_MODULE crash_tests + INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR} +) + +add_gtest_executable( + TEST_NAME crash_retention_test + SOURCE_FILE crash_retention_test.cpp + LINK_LIBRARIES cfcrash;GTest::gtest;GTest::gtest_main + LABELS "crash;unit" + LOG_MODULE crash_tests + INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR} +) + +add_gtest_executable( + TEST_NAME crash_finalize_test + SOURCE_FILE crash_finalize_test.cpp + LINK_LIBRARIES cfcrash;GTest::gtest;GTest::gtest_main + LABELS "crash;unit" + LOG_MODULE crash_tests + INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR} +) + +add_gtest_executable( + TEST_NAME crash_signal_test + SOURCE_FILE crash_signal_test.cpp + LINK_LIBRARIES cfcrash;GTest::gtest;GTest::gtest_main + LABELS "crash;unit;transport" + LOG_MODULE crash_tests + INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR} +) diff --git a/test/crash/crash_finalize_test.cpp b/test/crash/crash_finalize_test.cpp new file mode 100644 index 000000000..08ad47365 --- /dev/null +++ b/test/crash/crash_finalize_test.cpp @@ -0,0 +1,91 @@ +/** + * @file crash_finalize_test.cpp + * @brief Unit tests for folding .pending snapshots into .json reports. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash + */ + +#include "cfcrash/crash_handler.h" +#include "crash_test_util.h" + +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +TEST(CrashFinalize, PendingFoldsIntoJsonWithLoggerTail) { + const auto dir = crash_test::uniqueDir("cfcrash_finalize"); + + { + std::ofstream f(dir / "cfdesktop-999-11.pending"); + f << "CFDESKTOP_CRASH_V1\n"; + f << "signal=11\n"; + f << "signal_name=SIGSEGV\n"; + f << "pid=999\n"; + f << "timestamp=1720000000\n"; + f << "frames=2\n"; + f << "0x7f1000\n"; + f << "0x7f2000\n"; + } + const auto logger = dir / "app.log"; + { + std::ofstream f(logger); + for (int i = 0; i < 60; ++i) { + f << "log line " << i << "\n"; + } + } + + cf::crash::CrashHandler::instance().setCrashesDir(dir.string()); + const auto count = + cf::crash::CrashHandler::instance().finalizePendingReports(logger.string(), 50); + + EXPECT_EQ(count, 1U); + EXPECT_TRUE(fs::exists(dir / "cfdesktop-999-11.json")); + EXPECT_FALSE(fs::exists(dir / "cfdesktop-999-11.pending")); + + std::string json; + { + // Scope the stream so it closes before remove_all() — Windows refuses + // to delete a file still held open, unlike Linux. + std::ifstream in(dir / "cfdesktop-999-11.json"); + json.assign((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + } + + EXPECT_NE(json.find("\"signal\": 11"), std::string::npos); + EXPECT_NE(json.find("\"pid\": 999"), std::string::npos); + EXPECT_NE(json.find("\"signal_name\": \"SIGSEGV\""), std::string::npos); + EXPECT_NE(json.find("\"0x7f1000\""), std::string::npos); + EXPECT_NE(json.find("\"0x7f2000\""), std::string::npos); + + // Tail of 50 from 60 lines => lines 10..59 retained, 0..9 dropped. + EXPECT_NE(json.find("\"log line 10\""), std::string::npos); + EXPECT_NE(json.find("\"log line 59\""), std::string::npos); + EXPECT_EQ(json.find("\"log line 9\""), std::string::npos); + + fs::remove_all(dir); +} + +TEST(CrashFinalize, IgnoresFilesWithoutValidHeader) { + const auto dir = crash_test::uniqueDir("cfcrash_finalize"); + { + std::ofstream(dir / "garbage.pending") << "not a crash file\n"; + } + const auto logger = dir / "app.log"; + std::ofstream(logger) << "single line\n"; + + cf::crash::CrashHandler::instance().setCrashesDir(dir.string()); + const auto count = + cf::crash::CrashHandler::instance().finalizePendingReports(logger.string(), 50); + + EXPECT_EQ(count, 0U); + // Garbage file left untouched (not a recognized snapshot). + EXPECT_TRUE(fs::exists(dir / "garbage.pending")); + fs::remove_all(dir); +} diff --git a/test/crash/crash_report_test.cpp b/test/crash/crash_report_test.cpp new file mode 100644 index 000000000..5d5326337 --- /dev/null +++ b/test/crash/crash_report_test.cpp @@ -0,0 +1,61 @@ +/** + * @file crash_report_test.cpp + * @brief Unit tests for CrashReport JSON serialization. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash + */ + +#include "cfcrash/crash_report.h" + +#include +#include +#include + +TEST(CrashReport, SerializesAllFields) { + cf::crash::CrashReport r; + r.timestamp = 1720000000; + r.pid = 12345; + r.signal = 11; + r.signal_name = "SIGSEGV"; + r.raw_frames = {"0x7f0100", "0x7f0200"}; + r.last_logs = {"line a", "line b"}; + + const std::string json = r.toJson(); + + EXPECT_NE(json.find("\"timestamp\": 1720000000"), std::string::npos); + EXPECT_NE(json.find("\"pid\": 12345"), std::string::npos); + EXPECT_NE(json.find("\"signal\": 11"), std::string::npos); + EXPECT_NE(json.find("\"signal_name\": \"SIGSEGV\""), std::string::npos); + EXPECT_NE(json.find("\"0x7f0100\""), std::string::npos); + EXPECT_NE(json.find("\"line a\""), std::string::npos); +} + +TEST(CrashReport, EmptyArraysRenderAsBrackets) { + cf::crash::CrashReport r; + const std::string json = r.toJson(); + + EXPECT_NE(json.find("\"raw_frames\": []"), std::string::npos); + EXPECT_NE(json.find("\"last_logs\": []"), std::string::npos); +} + +TEST(CrashReport, EscapesSpecialCharsInLogs) { + cf::crash::CrashReport r; + r.last_logs = {"has \"quote\" and \\slash and \ttab"}; + const std::string json = r.toJson(); + + EXPECT_NE(json.find("\\\"quote\\\""), std::string::npos); + EXPECT_NE(json.find("\\\\slash"), std::string::npos); + EXPECT_NE(json.find("\\t"), std::string::npos); +} + +TEST(CrashReport, SignalNameMapping) { + EXPECT_EQ(cf::crash::signalName(SIGSEGV), "SIGSEGV"); + EXPECT_EQ(cf::crash::signalName(SIGABRT), "SIGABRT"); + EXPECT_EQ(cf::crash::signalName(SIGFPE), "SIGFPE"); + EXPECT_EQ(cf::crash::signalName(SIGILL), "SIGILL"); + EXPECT_EQ(cf::crash::signalName(99999), "UNKNOWN"); +} diff --git a/test/crash/crash_retention_test.cpp b/test/crash/crash_retention_test.cpp new file mode 100644 index 000000000..537e34e57 --- /dev/null +++ b/test/crash/crash_retention_test.cpp @@ -0,0 +1,73 @@ +/** + * @file crash_retention_test.cpp + * @brief Unit tests for the crash report retention policy. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash + */ + +#include "cfcrash/crash_handler.h" +#include "crash_test_util.h" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { +/// @brief Writes an empty .json and stamps its mtime to now + age seconds. +void touchJsonWithMtime(const fs::path& path, int age_seconds) { + std::ofstream(path) << "{}"; + const auto tp = fs::file_time_type::clock::now() + std::chrono::seconds(age_seconds); + fs::last_write_time(path, tp); +} +} // namespace + +TEST(CrashRetention, PrunesOldestBeyondCap) { + const auto dir = crash_test::uniqueDir("cfcrash_retention"); + // 25 reports, i=0 oldest .. i=24 newest. + for (int i = 0; i < 25; ++i) { + touchJsonWithMtime(dir / (std::to_string(i) + ".json"), i); + } + + cf::crash::CrashHandler::instance().setCrashesDir(dir.string()); + const auto removed = cf::crash::CrashHandler::instance().pruneReports(20); + + EXPECT_EQ(removed, 5U); + + std::size_t remaining = 0; + for (const auto& entry : fs::directory_iterator(dir)) { + if (entry.path().extension() == ".json") { + ++remaining; + } + } + EXPECT_EQ(remaining, 20U); + + // Oldest five removed; newest twenty kept. + EXPECT_FALSE(fs::exists(dir / "0.json")); + EXPECT_FALSE(fs::exists(dir / "4.json")); + EXPECT_TRUE(fs::exists(dir / "5.json")); + EXPECT_TRUE(fs::exists(dir / "24.json")); + + fs::remove_all(dir); +} + +TEST(CrashRetention, KeepsAllWhenUnderCap) { + const auto dir = crash_test::uniqueDir("cfcrash_retention"); + for (int i = 0; i < 5; ++i) { + touchJsonWithMtime(dir / (std::to_string(i) + ".json"), i); + } + + cf::crash::CrashHandler::instance().setCrashesDir(dir.string()); + const auto removed = cf::crash::CrashHandler::instance().pruneReports(20); + + EXPECT_EQ(removed, 0U); + fs::remove_all(dir); +} diff --git a/test/crash/crash_signal_test.cpp b/test/crash/crash_signal_test.cpp new file mode 100644 index 000000000..4d0b76e70 --- /dev/null +++ b/test/crash/crash_signal_test.cpp @@ -0,0 +1,105 @@ +/** + * @file crash_signal_test.cpp + * @brief End-to-end tests: forked children arm the handler then crash for + * real (raised signal, null-pointer deref, abort, stack overflow). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash + */ + +#include "cfcrash/crash_handler.h" +#include "crash_test_util.h" + +#include +#include +#include +#include +#include + +#if defined(__linux__) +# include +# include +#endif + +namespace fs = std::filesystem; + +#if defined(__linux__) +namespace { +/// @brief Infinite recursion that touches a stack buffer each frame so the +/// compiler cannot tail-call it into a loop. Used to exhaust the stack +/// and prove sigaltstack keeps the handler runnable. +[[noreturn]] void crashRecurse() { + volatile char buf[8192]; + buf[0] = static_cast('x'); // touch + defeat tail-call optimization + (void)buf; + crashRecurse(); +} + +/// @brief Forks a child that arms the handler, runs @p crash (which must not +/// return), and verifies the child exits 128+@p signo and leaves a +/// @c .pending snapshot. Asserts the handler _exit()ed rather than the +/// process being raw-killed by the signal (which would show as +/// WIFSIGNALED) -- the latter would mean the handler never ran. +void expectCrashCaught(std::function crash, int signo) { + const auto dir = crash_test::uniqueDir("cfcrash_signal"); + + const pid_t pid = ::fork(); + ASSERT_GE(pid, 0); + if (pid == 0) { + // Child: arm the handler, then trigger the fault. The handler writes + // .pending and _exit(128+signo) -- the child never returns normally. + cf::crash::CrashHandler::instance().install(dir.string()); + crash(); + ::_exit(0); // unreachable if crash() faults + } + + int status = 0; + ASSERT_GT(::waitpid(pid, &status, 0), -1); + ASSERT_TRUE(WIFEXITED(status)) << "handler should _exit(); raw WIFSIGNALED means it never ran"; + EXPECT_EQ(WEXITSTATUS(status), 128 + signo); + + bool found_pending = false; + for (const auto& entry : fs::directory_iterator(dir)) { + if (entry.path().extension() == ".pending") { + found_pending = true; + } + } + EXPECT_TRUE(found_pending) << "crash handler did not write a .pending file"; + + fs::remove_all(dir); +} +} // namespace + +TEST(CrashSignal, RaisedSignalIsCaught) { + expectCrashCaught([] { ::raise(SIGSEGV); }, SIGSEGV); +} + +TEST(CrashSignal, NullPointerDereferenceIsCaught) { + expectCrashCaught( + [] { + // Real MMU fault: write through a null pointer. volatile keeps the + // store from being optimized away as dead UB. + volatile int* p = nullptr; + *p = 42; + }, + SIGSEGV); +} + +TEST(CrashSignal, AbortCallIsCaught) { + expectCrashCaught([] { std::abort(); }, SIGABRT); +} + +TEST(CrashSignal, StackOverflowIsCaughtViaAltStack) { + // Stack exhaustion -> SIGSEGV with the main stack full. Without + // sigaltstack the handler could not run and the child would be raw-killed + // (WIFSIGNALED). Passing here proves the alternate stack works. + expectCrashCaught([] { crashRecurse(); }, SIGSEGV); +} +#else +TEST(CrashSignal, DisabledOnNonLinux) { + GTEST_SKIP() << "fork-based crash tests are Linux-only (Phase 1 focus)"; +} +#endif diff --git a/test/crash/crash_test_util.h b/test/crash/crash_test_util.h new file mode 100644 index 000000000..b807012f3 --- /dev/null +++ b/test/crash/crash_test_util.h @@ -0,0 +1,45 @@ +/** + * @file crash_test_util.h + * @brief Shared helpers for CrashHandler unit tests (unique temp dirs). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash + */ + +#pragma once + +#include +#include + +#ifdef _WIN32 +# include +#else +# include +#endif + +namespace crash_test { + +/// @brief Returns the current process id (portable across Linux/Windows). +inline unsigned long currentPid() { +#ifdef _WIN32 + return static_cast(_getpid()); +#else + return static_cast(::getpid()); +#endif +} + +/// @brief Creates a fresh per-test temp directory tagged with pid + counter. +inline std::filesystem::path uniqueDir(const char* tag) { + static unsigned counter = 0; + ++counter; + auto dir = + std::filesystem::temp_directory_path() / + (std::string(tag) + "_" + std::to_string(currentPid()) + "_" + std::to_string(counter)); + std::filesystem::create_directories(dir); + return dir; +} + +} // namespace crash_test diff --git a/test/ipc/CMakeLists.txt b/test/ipc/CMakeLists.txt new file mode 100644 index 000000000..bf3909158 --- /dev/null +++ b/test/ipc/CMakeLists.txt @@ -0,0 +1,38 @@ +# IPC module unit tests using GoogleTest +include(add_gtest_executable) + +log_info("ipc_tests" "Configured IPC unit tests:") + +add_gtest_executable( + TEST_NAME ipc_message_test + SOURCE_FILE ipc_message_test.cpp + LINK_LIBRARIES cfipc;Qt6::Core;GTest::gtest;GTest::gtest_main + LABELS "ipc;unit" + LOG_MODULE ipc_tests +) + +add_gtest_executable( + TEST_NAME ipc_registry_test + SOURCE_FILE ipc_registry_test.cpp + LINK_LIBRARIES cfipc;Qt6::Core;GTest::gtest;GTest::gtest_main + LABELS "ipc;unit" + LOG_MODULE ipc_tests +) + +add_gtest_executable( + TEST_NAME ipc_service_locator_test + SOURCE_FILE ipc_service_locator_test.cpp + LINK_LIBRARIES cfipc;Qt6::Core;GTest::gtest;GTest::gtest_main + LABELS "ipc;unit" + LOG_MODULE ipc_tests +) + +# Transport test owns its main() (QCoreApplication for QLocalSocket), +# so it links gtest (not gtest_main) and adds Qt6::Network. +add_gtest_executable( + TEST_NAME ipc_transport_test + SOURCE_FILE ipc_transport_test.cpp + LINK_LIBRARIES cfipc;Qt6::Core;Qt6::Network;GTest::gtest + LABELS "ipc;unit;transport" + LOG_MODULE ipc_tests +) diff --git a/test/ipc/ipc_message_test.cpp b/test/ipc/ipc_message_test.cpp new file mode 100644 index 000000000..830f206df --- /dev/null +++ b/test/ipc/ipc_message_test.cpp @@ -0,0 +1,38 @@ +/** + * @file ipc_message_test.cpp + * @brief Unit tests for IPCMessage JSON serialization. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup ipc + */ + +#include "cfipc/ipc_message.h" + +#include + +TEST(IPCMessage, RoundTripsTypeAndPayload) { + cf::ipc::IPCMessage msg; + msg.type = "raise"; + msg.payload["x"] = 42; + msg.payload["name"] = "test"; + + const auto parsed = cf::ipc::IPCMessage::fromJson(msg.toJson()); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->type.toStdString(), "raise"); + EXPECT_EQ(parsed->payload.value("x").toInt(), 42); + EXPECT_EQ(parsed->payload.value("name").toString().toStdString(), "test"); +} + +TEST(IPCMessage, RejectsEmptyType) { + const QByteArray wire = R"({"type":"","payload":{}})"; + EXPECT_FALSE(cf::ipc::IPCMessage::fromJson(wire).has_value()); +} + +TEST(IPCMessage, RejectsMalformedJson) { + EXPECT_FALSE(cf::ipc::IPCMessage::fromJson("not json").has_value()); + EXPECT_FALSE(cf::ipc::IPCMessage::fromJson("").has_value()); + EXPECT_FALSE(cf::ipc::IPCMessage::fromJson("[]").has_value()); // array, not object +} diff --git a/test/ipc/ipc_registry_test.cpp b/test/ipc/ipc_registry_test.cpp new file mode 100644 index 000000000..de22a0cde --- /dev/null +++ b/test/ipc/ipc_registry_test.cpp @@ -0,0 +1,38 @@ +/** + * @file ipc_registry_test.cpp + * @brief Unit tests for IPCMessageRegistry dispatch. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup ipc + */ + +#include "cfipc/ipc_message_registry.h" + +#include + +TEST(IPCMessageRegistry, DispatchesToRegisteredHandler) { + auto& reg = cf::ipc::IPCMessageRegistry::instance(); + int hits = 0; + QJsonObject captured; + reg.registerHandler("unit_test_type", [&](const QJsonObject& p) { + captured = p; + ++hits; + }); + + cf::ipc::IPCMessage msg; + msg.type = "unit_test_type"; + msg.payload["v"] = 7; + + EXPECT_TRUE(reg.dispatch(msg)); + EXPECT_EQ(hits, 1); + EXPECT_EQ(captured.value("v").toInt(), 7); +} + +TEST(IPCMessageRegistry, ReturnsFalseForUnknownType) { + cf::ipc::IPCMessage msg; + msg.type = "definitely_unregistered_type_xyz"; + EXPECT_FALSE(cf::ipc::IPCMessageRegistry::instance().dispatch(msg)); +} diff --git a/test/ipc/ipc_service_locator_test.cpp b/test/ipc/ipc_service_locator_test.cpp new file mode 100644 index 000000000..424169637 --- /dev/null +++ b/test/ipc/ipc_service_locator_test.cpp @@ -0,0 +1,45 @@ +/** + * @file ipc_service_locator_test.cpp + * @brief Unit tests for the ServiceLocator typed registry. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup ipc + */ + +#include "cfipc/service_locator.h" + +#include +#include + +namespace { +struct Foo { + int v; +}; +} // namespace + +TEST(ServiceLocator, RoundTripsRegisteredInstance) { + auto& loc = cf::ipc::ServiceLocator::instance(); + loc.clear(); + loc.registerService("foo", std::make_shared(Foo{42})); + + const auto got = loc.resolve("foo"); + ASSERT_NE(got, nullptr); + EXPECT_EQ(got->v, 42); +} + +TEST(ServiceLocator, ReturnsNullForUnknownName) { + auto& loc = cf::ipc::ServiceLocator::instance(); + loc.clear(); + EXPECT_EQ(loc.resolve("missing"), nullptr); +} + +TEST(ServiceLocator, ReRegisterReplacesInstance) { + auto& loc = cf::ipc::ServiceLocator::instance(); + loc.clear(); + loc.registerService("foo", std::make_shared(Foo{1})); + loc.registerService("foo", std::make_shared(Foo{99})); + EXPECT_EQ(loc.resolve("foo")->v, 99); +} diff --git a/test/ipc/ipc_transport_test.cpp b/test/ipc/ipc_transport_test.cpp new file mode 100644 index 000000000..2b120d87a --- /dev/null +++ b/test/ipc/ipc_transport_test.cpp @@ -0,0 +1,85 @@ +/** + * @file ipc_transport_test.cpp + * @brief Integration test: IPCClient send -> IPCServer -> registry dispatch. + * + * Spins a QEventLoop so the server's async readyRead can deliver the + * message the blocking client just wrote. Uses a custom main() to provide + * the QCoreApplication that QLocalSocket requires. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup ipc + */ + +#include "cfipc/ipc_client.h" +#include "cfipc/ipc_message_registry.h" +#include "cfipc/ipc_server.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace { +/// @brief Builds a unique socket path per test to avoid collisions. +QString uniquePath() { + static std::atomic counter{0}; + return QDir::tempPath() + "/cfdesktop-ipc-test-" + + QString::number(QCoreApplication::applicationPid()) + "-" + + QString::number(counter.fetch_add(1)) + ".ipc"; +} +} // namespace + +class IPCTransportTest : public ::testing::Test { + protected: + void SetUp() override { + path_ = uniquePath(); + ASSERT_TRUE(cf::ipc::IPCServer::instance().start(path_)); + } + void TearDown() override { + cf::ipc::IPCServer::instance().stop(); + QFile::remove(path_); + } + QString path_; +}; + +TEST_F(IPCTransportTest, ClientMessageDispatchesOnServer) { +#ifdef Q_OS_WIN + GTEST_SKIP() << "Skipped on Windows: this test runs the one-shot client and the " + "server in the same thread, but Windows named pipes give the server " + "no chance to accept and read before the fire-and-forget client " + "disconnects. Production single-instance-raise uses separate " + "processes (each spinning its own event loop), which avoids the race."; +#else + QJsonObject received; + QEventLoop loop; + cf::ipc::IPCMessageRegistry::instance().registerHandler("transport_test", + [&](const QJsonObject& p) { + received = p; + loop.quit(); + }); + // Timeout fallback so the test cannot hang if delivery fails. + QTimer::singleShot(2000, &loop, &QEventLoop::quit); + + QJsonObject payload; + payload["hello"] = "world"; + ASSERT_TRUE(cf::ipc::IPCClient::send(path_, "transport_test", payload)); + + loop.exec(); + EXPECT_EQ(received.value("hello").toString().toStdString(), "world"); +#endif +} + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/ui/CFDesktopEntity.cpp b/ui/CFDesktopEntity.cpp index 1c733bdc8..d8ad8c770 100644 --- a/ui/CFDesktopEntity.cpp +++ b/ui/CFDesktopEntity.cpp @@ -5,6 +5,7 @@ #include "IDesktopPropertyStrategy.h" #include "aex/weak_ptr/weak_ptr.h" #include "cfconfig.hpp" +#include "cfipc/ipc_server.h" #include "cflog.h" #include "components/DisplayServerBackendFactory.h" #include "components/IDisplayServerBackend.h" @@ -14,6 +15,8 @@ #include "components/builtin_apps/builtin_panel_registry.h" #include "components/desktop_icon_layer/desktop_icon_layer.h" #include "components/desktop_icon_layer/desktop_shortcut_store.h" +#include "components/home_page/home_page.h" +#include "components/home_page/page_stack_widget.h" #include "components/launcher/app_discoverer.h" #include "components/launcher/app_launch_service.h" #include "components/launcher/app_launcher.h" @@ -155,6 +158,16 @@ CFDesktopEntity::CFDesktopEntity() std::move(api.release_func)); } log::tracef("Desktop Entity is created"); + + // Single-instance raise: a second shell signals via IPC to bring us to front. + QObject::connect(&cf::ipc::IPCServer::instance(), &cf::ipc::IPCServer::raiseRequested, this, + [this]() { + if (desktop_entity_ != nullptr) { + desktop_entity_->showNormal(); + desktop_entity_->raise(); + desktop_entity_->activateWindow(); + } + }); } CFDesktopEntity::~CFDesktopEntity() { @@ -227,6 +240,22 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { // availableGeometry() like the launcher popup does. ── auto* icon_layer = new cf::desktop::desktop_component::DesktopIconLayer(desktop_entity_); + // Desktop pages: HomePage (information-flow home, page 0) and the icon grid + // (Win11-style, page 1) stacked and flipped with a horizontal swipe. The + // icon layer is still wired below (its signals stay); as page 1 it no longer + // overlaps the home page. HomePage and the clocks do not accept mouse press, + // so it bubbles to PageStackWidget for the horizontal swipe, while + // CardStackWidget (inside HomePage) keeps vertical card swipe. + auto* home_page = new cf::desktop::desktop_component::HomePage(); + auto* page_stack = new cf::desktop::desktop_component::PageStackWidget(desktop_entity_); + page_stack->addWidget(home_page); + page_stack->addWidget(icon_layer); + page_stack->setGeometry(panel_mgr->availableGeometry()); + QObject::connect(panel_mgr, &PanelManager::availableGeometryChanged, desktop_entity_, + [page_stack, panel_mgr](const QRect&) { + page_stack->setGeometry(panel_mgr->availableGeometry()); + }); + // Connect PanelManager geometry changes to ShellLayer. The wallpaper shell // spans the FULL host geometry (not the panel-reduced central rect) so it // renders continuously behind the top/bottom bars; each bar composites a @@ -468,10 +497,17 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { // until a valid geometry arrives, so showing early does not flash tiles at // (0,0). The first panel_mgr->relayout() below delivers the real rect. ── icon_layer->setShortcuts(desktop_shortcuts, apps); - QObject::connect( - panel_mgr, &PanelManager::availableGeometryChanged, desktop_entity_, - [icon_layer](const QRect& avail) { icon_layer->onAvailableGeometryChanged(avail); }); - icon_layer->onAvailableGeometryChanged(panel_mgr->availableGeometry()); // seed geometry + // icon_layer is now a page inside PageStackWidget (local coords 0,0), so + // feed it the page size rather than the desktop-relative available rect. + QObject::connect(panel_mgr, &PanelManager::availableGeometryChanged, desktop_entity_, + [icon_layer](const QRect& avail) { + icon_layer->onAvailableGeometryChanged( + QRect(0, 0, avail.width(), avail.height())); + }); + { + const QRect seed = panel_mgr->availableGeometry(); + icon_layer->onAvailableGeometryChanged(QRect(0, 0, seed.width(), seed.height())); + } QObject::connect(icon_layer, &cf::desktop::desktop_component::DesktopIconLayer::appClicked, this, launch_app); // Persist any layout change (drag swap / drag-off-remove / launcher add). @@ -480,7 +516,7 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { [](const QList& shortcuts) { cf::desktop::desktop_component::DesktopShortcutStore::save(shortcuts); }); - icon_layer->show(); + // icon_layer is shown by PageStackWidget when its page becomes current. QObject::connect(taskbar, &cf::desktop::desktop_component::CenteredTaskbar::launcherRequested, this, [app_launcher, panel_mgr]() { diff --git a/ui/CMakeLists.txt b/ui/CMakeLists.txt index cc62bfc84..133d5d516 100644 --- a/ui/CMakeLists.txt +++ b/ui/CMakeLists.txt @@ -36,6 +36,7 @@ PRIVATE cf_desktop_ui_platform # Link platform submodule cf_desktop_render # Link render backend abstraction cfconfig # ConfigStore: window_management.constrain_to_workarea toggle + cfipc # IPCServer: single-instance raise signal QuarkWidgets::quarkwidgets # For qt_format.h (std::formatter) ) diff --git a/ui/components/CMakeLists.txt b/ui/components/CMakeLists.txt index 6c6236bbd..d1f651961 100644 --- a/ui/components/CMakeLists.txt +++ b/ui/components/CMakeLists.txt @@ -12,6 +12,8 @@ add_subdirectory(taskbar) add_subdirectory(launcher) add_subdirectory(desktop_icon_layer) add_subdirectory(builtin_apps) +add_subdirectory(desktop_widget) +add_subdirectory(home_page) # Create interface library for convenience add_library(cf_desktop_components STATIC) @@ -43,5 +45,7 @@ PRIVATE cfdesktop_desktop_icon_layer cfdesktop_builtin_apps cfdesktop_window_placement + cfdesktop_desktop_widget + cfdesktop_home_page Qt6::Core Qt6::Gui Qt6::Widgets ) diff --git a/ui/components/desktop_widget/CMakeLists.txt b/ui/components/desktop_widget/CMakeLists.txt new file mode 100644 index 000000000..6ead87d63 --- /dev/null +++ b/ui/components/desktop_widget/CMakeLists.txt @@ -0,0 +1,17 @@ +# Desktop widget framework: draggable WidgetBase (future free-positionable widgets). +add_library(cfdesktop_desktop_widget STATIC + widget_base.cpp +) + +target_include_directories(cfdesktop_desktop_widget PUBLIC + $ + $ + $ +) + +target_link_libraries(cfdesktop_desktop_widget +PUBLIC + QuarkWidgets::quarkwidgets # ThemeManager, color/typography tokens +PRIVATE + Qt6::Widgets +) diff --git a/ui/components/desktop_widget/widget_base.cpp b/ui/components/desktop_widget/widget_base.cpp new file mode 100644 index 000000000..dc9b8d943 --- /dev/null +++ b/ui/components/desktop_widget/widget_base.cpp @@ -0,0 +1,61 @@ +/** + * @file widget_base.cpp + * @brief Implementation of the draggable WidgetBase. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup desktop_widget + */ + +#include "widget_base.h" + +#include + +namespace cf::desktop::desktop_component { + +namespace { +/// @brief Manhattan distance the cursor must travel before a press becomes a +/// drag, so a static click does not nudge the widget. +inline constexpr int kDragThresholdPx = 5; +} // namespace + +WidgetBase::WidgetBase(QWidget* parent) : QWidget(parent) {} + +void WidgetBase::setEditable(bool editable) { + editable_ = editable; +} + +bool WidgetBase::isEditable() const { + return editable_; +} + +void WidgetBase::mousePressEvent(QMouseEvent* event) { + if (editable_ && event->button() == Qt::LeftButton) { + drag_anchor_ = event->pos(); + dragging_ = false; + } + QWidget::mousePressEvent(event); +} + +void WidgetBase::mouseMoveEvent(QMouseEvent* event) { + if (editable_ && (event->buttons() & Qt::LeftButton) != 0) { + const QPoint delta = event->pos() - drag_anchor_; + if (!dragging_ && delta.manhattanLength() >= kDragThresholdPx) { + dragging_ = true; + } + if (dragging_) { + move(pos() + delta); + emit widgetMoved(pos()); + } + } + QWidget::mouseMoveEvent(event); +} + +void WidgetBase::mouseReleaseEvent(QMouseEvent* event) { + dragging_ = false; + QWidget::mouseReleaseEvent(event); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/desktop_widget/widget_base.h b/ui/components/desktop_widget/widget_base.h new file mode 100644 index 000000000..027cde4ba --- /dev/null +++ b/ui/components/desktop_widget/widget_base.h @@ -0,0 +1,174 @@ +/** + * @file widget_base.h + * @brief Draggable base class for free-positionable desktop widgets. + * + * WidgetBase defines the common contract (id / name / default size) and a + * press-drag-move interaction for desktop widgets that float on the shell + * (clock, future system widgets). Concrete widgets implement paintEvent and + * the metadata accessors; dragging is handled here. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup desktop_widget + */ + +#pragma once + +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +/** + * @brief Base class for free-positionable desktop widgets. + * + * Subclass to add a paintEvent and supply widgetId/widgetName/defaultSize. + * Dragging moves the widget under the cursor and emits widgetMoved. + * + * @note Dragging only active when editable is true (default). + * @warning None + * @since 0.19.0 + * @ingroup desktop_widget + */ +class WidgetBase : public QWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the widget base. + * @param[in] parent Owning Qt parent widget. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup desktop_widget + */ + explicit WidgetBase(QWidget* parent = nullptr); + + /** + * @brief Destroys the widget base. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup desktop_widget + */ + ~WidgetBase() override = default; + + /** + * @brief Returns the stable identifier of this widget. + * @return Widget id (e.g. "clock"). + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup desktop_widget + */ + virtual QString widgetId() const = 0; + + /** + * @brief Returns the human-readable name of this widget. + * @return Display name. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup desktop_widget + */ + virtual QString widgetName() const = 0; + + /** + * @brief Returns the preferred size for this widget. + * @return Default size in pixels. + * @throws None + * @note Used by WidgetContainer to resize on addWidget. + * @warning None + * @since 0.19.0 + * @ingroup desktop_widget + */ + virtual QSize defaultSize() const = 0; + + /** + * @brief Enables or disables drag-to-move. + * @param[in] editable True to allow dragging. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup desktop_widget + */ + void setEditable(bool editable); + + /** + * @brief Returns whether dragging is currently allowed. + * @return True if editable. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup desktop_widget + */ + bool isEditable() const; + + signals: + /** + * @brief Emitted while the widget is dragged to a new position. + * @param[in] pos New top-left position (parent coordinates). + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup desktop_widget + */ + void widgetMoved(const QPoint& pos); + + protected: + /** + * @brief Records the press anchor when the left button goes down. + * @param[in] event Mouse press event. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup desktop_widget + */ + void mousePressEvent(QMouseEvent* event) override; + + /** + * @brief Moves the widget by the cursor delta once a drag threshold passes. + * @param[in] event Mouse move event. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup desktop_widget + */ + void mouseMoveEvent(QMouseEvent* event) override; + + /** + * @brief Ends an in-progress drag. + * @param[in] event Mouse release event. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup desktop_widget + */ + void mouseReleaseEvent(QMouseEvent* event) override; + + private: + bool editable_{true}; ///< Whether drag-to-move is enabled. + QPoint drag_anchor_{}; ///< Press position (local coords) at drag start. + bool dragging_{false}; ///< Whether a drag has actually begun. +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/CMakeLists.txt b/ui/components/home_page/CMakeLists.txt new file mode 100644 index 000000000..b60944dd5 --- /dev/null +++ b/ui/components/home_page/CMakeLists.txt @@ -0,0 +1,35 @@ +# Desktop home page: clock column + swipeable card stack (migrated from CCIMX). +find_package(Qt6 QUIET COMPONENTS Concurrent) + +add_library(cfdesktop_home_page STATIC + global_clock_sources.cpp + analog_clock_widget.cpp + digital_time_widget.cpp + card_stack_widget.cpp + home_card_manager.cpp + date_card.cpp + system_usage_card.cpp + user_info_card.cpp + net_status_card.cpp + local_temp_card.cpp + gauge_widget.cpp + disk_gauge_card.cpp + modern_calendar_widget.cpp + home_page.cpp + page_stack_widget.cpp +) + +target_include_directories(cfdesktop_home_page PUBLIC + $ + $ + $ +) + +target_link_libraries(cfdesktop_home_page +PUBLIC + QuarkWidgets::quarkwidgets # ThemeManager, color/typography tokens +PRIVATE + Qt6::Widgets + Qt6::Concurrent # off-thread system probes (QtConcurrent::run) + cfbase # memory/cpu probes +) diff --git a/ui/components/home_page/analog_clock_widget.cpp b/ui/components/home_page/analog_clock_widget.cpp new file mode 100644 index 000000000..fcf1520d2 --- /dev/null +++ b/ui/components/home_page/analog_clock_widget.cpp @@ -0,0 +1,197 @@ +/** + * @file analog_clock_widget.cpp + * @brief Implementation of the AnalogClockWidget dial. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#include "analog_clock_widget.h" + +#include "global_clock_sources.h" + +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +namespace { +// Fixed dial geometry, ported verbatim from CCIMXDesktop. The painter is +// scaled so the dial fits the widget. +inline constexpr int kDialSize = 200; +inline constexpr int kOuterCircleRadius = 95; +inline constexpr int kThickBorderWidth = 8; + +inline constexpr int kHourTickLength = 10; +inline constexpr int kMinuteTickLength = 5; +inline constexpr int kHourTickWidth = 2; +inline constexpr int kMinuteTickWidth = 1; + +inline constexpr int kCenterDotRadius = 4; +inline constexpr int kHourHandLength = 50; +inline constexpr int kMinuteHandLength = 70; +inline constexpr int kSecondHandLength = 80; +inline constexpr int kHandWidth = 7; + +inline constexpr int kNumberDistanceFromCenter = 70; +inline constexpr int kNumberFontSize = 14; + +inline constexpr double kHourRotationPerHour = 30.0; +inline constexpr double kMinuteRotationPerMinute = 6.0; +inline constexpr double kSecondRotationPerSecond = 6.0; +} // namespace + +AnalogClockWidget::AnalogClockWidget(QWidget* parent) : QWidget(parent) { + setAttribute(Qt::WA_TranslucentBackground); + current_time_ = QTime::currentTime(); + connect(&GlobalClockSources::instance(), &GlobalClockSources::timeUpdate, this, + &AnalogClockWidget::onTimeUpdate); +} + +void AnalogClockWidget::onTimeUpdate(const QTime& time) { + current_time_ = time; + update(); +} + +void AnalogClockWidget::paintEvent(QPaintEvent* /*event*/) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + + const int side = qMin(width(), height()); + p.translate(width() / 2.0, height() / 2.0); + p.scale(side / static_cast(kDialSize), side / static_cast(kDialSize)); + + drawBackground(&p); + drawNumbers(&p); + drawTicks(&p); + drawHands(&p); + drawCenterDot(&p); +} + +void AnalogClockWidget::drawBackground(QPainter* p) { + p->save(); + p->setPen(Qt::NoPen); + + // 8px black thick outer border. + p->setPen(QPen(QColor(0, 0, 0), kThickBorderWidth)); + p->setBrush(Qt::NoBrush); + p->drawEllipse(QPoint(0, 0), kOuterCircleRadius - kThickBorderWidth / 2, + kOuterCircleRadius - kThickBorderWidth / 2); + + // White radial gradient dial face. + QRadialGradient gradient(0, 0, kOuterCircleRadius); + gradient.setColorAt(0.0, QColor(255, 255, 255)); + gradient.setColorAt(1.0, QColor(230, 230, 230)); + p->setBrush(gradient); + p->setPen(Qt::NoPen); + p->drawEllipse(QPoint(0, 0), kOuterCircleRadius, kOuterCircleRadius); + + // Thin gray rim. + p->setPen(QPen(QColor(200, 200, 200), 2)); + p->setBrush(Qt::NoBrush); + p->drawEllipse(QPoint(0, 0), kOuterCircleRadius, kOuterCircleRadius); + p->restore(); +} + +void AnalogClockWidget::drawNumbers(QPainter* p) { + p->save(); + QFont font = p->font(); + font.setPointSize(kNumberFontSize); + p->setFont(font); + p->setPen(Qt::black); + + struct NumberInfo { + int number; + double angle_degree; + }; + static constexpr NumberInfo numbers[] = { + {12, 0.0}, + {3, 90.0}, + {6, 180.0}, + {9, 270.0}, + }; + for (const auto& num : numbers) { + const double radians = qDegreesToRadians(num.angle_degree); + const double x = kNumberDistanceFromCenter * qSin(radians); + const double y = -kNumberDistanceFromCenter * qCos(radians); + const QRectF rect(x - 10, y - 10, 20, 20); + p->drawText(rect, Qt::AlignCenter, QString::number(num.number)); + } + p->restore(); +} + +void AnalogClockWidget::drawTicks(QPainter* p) { + p->save(); + p->setPen(QPen(Qt::black, kHourTickWidth)); + for (int i = 0; i < 12; ++i) { + p->drawLine(0, -(kOuterCircleRadius - kHourTickLength), 0, -kOuterCircleRadius); + p->rotate(kHourRotationPerHour); + } + p->setPen(QPen(Qt::gray, kMinuteTickWidth)); + for (int i = 0; i < 60; ++i) { + if (i % 5 != 0) { + p->drawLine(0, -(kOuterCircleRadius - kMinuteTickLength), 0, -kOuterCircleRadius); + } + p->rotate(kMinuteRotationPerMinute); + } + p->restore(); +} + +void AnalogClockWidget::drawHands(QPainter* p) { + // Hour hand (black). + p->save(); + p->setPen(Qt::NoPen); + p->setBrush(Qt::black); + p->rotate(kHourRotationPerHour * (current_time_.hour() % 12) + current_time_.minute() / 2.0); + static const QPoint hour_hand[4] = { + QPoint(0, kHandWidth), + QPoint(-4, 0), + QPoint(0, -kHourHandLength), + QPoint(4, 0), + }; + p->drawConvexPolygon(hour_hand, 4); + p->restore(); + + // Minute hand (black). + p->save(); + p->setPen(Qt::NoPen); + p->setBrush(Qt::black); + p->rotate(kMinuteRotationPerMinute * current_time_.minute() + current_time_.second() / 10.0); + static const QPoint minute_hand[4] = { + QPoint(0, kHandWidth), + QPoint(-3, 0), + QPoint(0, -kMinuteHandLength), + QPoint(3, 0), + }; + p->drawConvexPolygon(minute_hand, 4); + p->restore(); + + // Second hand (red). + p->save(); + p->setPen(Qt::NoPen); + p->setBrush(Qt::red); + p->rotate(kSecondRotationPerSecond * current_time_.second()); + static const QPoint second_hand[4] = { + QPoint(0, kHandWidth / 2), + QPoint(-2, 0), + QPoint(0, -kSecondHandLength), + QPoint(2, 0), + }; + p->drawConvexPolygon(second_hand, 4); + p->restore(); +} + +void AnalogClockWidget::drawCenterDot(QPainter* p) { + p->save(); + p->setPen(Qt::NoPen); + p->setBrush(Qt::black); + p->drawEllipse(QPoint(0, 0), kCenterDotRadius, kCenterDotRadius); + p->restore(); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/analog_clock_widget.h b/ui/components/home_page/analog_clock_widget.h new file mode 100644 index 000000000..e43fa0b59 --- /dev/null +++ b/ui/components/home_page/analog_clock_widget.h @@ -0,0 +1,73 @@ +/** + * @file analog_clock_widget.h + * @brief Analog clock dial (hands + ticks + numerals) painted with QPainter. + * + * Ported verbatim from CCIMXDesktop ClockWidget: hardcoded geometry + classic + * palette (white radial gradient dial, 8px black border, black hands, red + * second hand, 12/3/6/9 numerals). No MD3 theming (Phase G re-themes later). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#pragma once + +#include +#include + +namespace cf::desktop::desktop_component { + +/** + * @brief Analog clock with hour/minute/second hands. + * + * @note Subscribes to GlobalClockSources for the 1 s tick. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ +class AnalogClockWidget : public QWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the analog clock. + * @param[in] parent Owning Qt parent widget. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + explicit AnalogClockWidget(QWidget* parent = nullptr); + + protected: + /** + * @brief Paints the dial, numerals, ticks, hands, and center dot. + * @param[in] event Paint event (unused). + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void paintEvent(QPaintEvent* event) override; + + private: + /// @brief Caches the latest time and repaints. + /// @param[in] time Current time. + void onTimeUpdate(const QTime& time); + + void drawBackground(QPainter* p); ///< Dial gradient + thick border. + void drawNumbers(QPainter* p); ///< 12 / 3 / 6 / 9 numerals. + void drawTicks(QPainter* p); ///< Hour and minute tick marks. + void drawHands(QPainter* p); ///< Hour, minute, second hands. + void drawCenterDot(QPainter* p); ///< Pivot dot. + + QTime current_time_; ///< Latest received time. +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/card_stack_widget.cpp b/ui/components/home_page/card_stack_widget.cpp new file mode 100644 index 000000000..525c32577 --- /dev/null +++ b/ui/components/home_page/card_stack_widget.cpp @@ -0,0 +1,219 @@ +/** + * @file card_stack_widget.cpp + * @brief Implementation of the swipeable CardStackWidget. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#include "card_stack_widget.h" + +#include +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +QGraphicsOpacityEffect* CardStackWidget::ensureOpacity(QWidget* w) { + auto* eff = qobject_cast(w->graphicsEffect()); + if (eff == nullptr) { + eff = new QGraphicsOpacityEffect(w); + w->setGraphicsEffect(eff); + } + return eff; +} + +CardStackWidget::CardStackWidget(QWidget* parent) + : QStackedWidget(parent), current_animation_(new QPropertyAnimation(this)), + next_animation_(new QPropertyAnimation(this)), group_(new QParallelAnimationGroup(this)) { + current_animation_->setPropertyName("pos"); + next_animation_->setPropertyName("pos"); + group_->addAnimation(current_animation_); + group_->addAnimation(next_animation_); +} + +void CardStackWidget::setCardTransitionMode(CardTransitionMode mode) { + transition_mode_ = mode; +} + +CardStackWidget::CardTransitionMode CardStackWidget::cardTransitionMode() const { + return transition_mode_; +} + +void CardStackWidget::setAnimationDuration(int duration_ms) { + animation_duration_ = duration_ms; +} + +int CardStackWidget::animationDuration() const { + return animation_duration_; +} + +void CardStackWidget::slideTo(int new_index) { + slideTo(new_index, currentIndex() > new_index); +} + +void CardStackWidget::mousePressEvent(QMouseEvent* event) { + start_pos_ = event->pos(); + dragging_ = true; +} + +void CardStackWidget::mouseMoveEvent(QMouseEvent* event) { + if (count() == 0 || !dragging_) { + return; + } + const int dy = event->pos().y() - start_pos_.y(); + if (qAbs(dy) < 5) { + return; + } + QWidget* current_widget = currentWidget(); + const int index = currentIndex(); + QWidget* other_wid = nullptr; + + if (dy > 0 && index > 0) { + other_wid = widget(index - 1); + other_wid->move(0, -height() + dy); // slide down reveals the previous card + } else if (dy < 0 && index < count() - 1) { + other_wid = widget(index + 1); + other_wid->move(0, height() + dy); // slide up reveals the next card + } + + if (other_wid != nullptr) { + other_wid->resize(current_widget->size()); + other_wid->show(); + other_wid->raise(); + } + current_widget->move(0, dy); +} + +void CardStackWidget::mouseReleaseEvent(QMouseEvent* event) { + if (count() == 0 || !dragging_) { + return; + } + dragging_ = false; + + const int dy = event->pos().y() - start_pos_.y(); + const int threshold = height() / 4; // 1/4 height commit threshold + const int idx = currentIndex(); + + if (dy > threshold) { + if (idx > 0) { + slideTo(idx - 1, false); + } else { + bounceBack(dy, idx); + } + } else if (dy < -threshold) { + if (idx < count() - 1) { + slideTo(idx + 1, true); + } else { + bounceBack(dy, idx); + } + } else { + bounceBack(dy, idx); + } +} + +void CardStackWidget::bounceBack(int dy, int idx) { + QWidget* curr = currentWidget(); + + current_animation_->stop(); + current_animation_->setTargetObject(curr); + current_animation_->setPropertyName("pos"); + current_animation_->setDuration(animation_duration_); + current_animation_->setStartValue(curr->pos()); + current_animation_->setEndValue(QPoint(0, 0)); + + next_animation_->stop(); + next_animation_->setTargetObject(nullptr); + hide_after_animation_.clear(); + + if (dy > 0 && idx > 0) { + QWidget* prev = widget(idx - 1); + next_animation_->setTargetObject(prev); + next_animation_->setPropertyName("pos"); + next_animation_->setDuration(animation_duration_); + next_animation_->setStartValue(prev->pos()); + next_animation_->setEndValue(QPoint(0, -height())); + hide_after_animation_ = prev; + } else if (dy < 0 && idx < count() - 1) { + QWidget* next = widget(idx + 1); + next_animation_->setTargetObject(next); + next_animation_->setPropertyName("pos"); + next_animation_->setDuration(animation_duration_); + next_animation_->setStartValue(next->pos()); + next_animation_->setEndValue(QPoint(0, height())); + hide_after_animation_ = next; + } + if (next_animation_->targetObject() != nullptr) { + group_->start(); + } else { + current_animation_->start(); + } +} + +void CardStackWidget::slideTo(int new_index, bool slide_up) { + if (new_index < 0 || new_index >= count()) { + return; + } + if (new_index == currentIndex()) { + return; + } + + QWidget* curr = currentWidget(); + QWidget* next = widget(new_index); + const int h = height(); + + next->move(0, slide_up ? h : -h); + next->show(); + next->raise(); + + next_animation_->stop(); + next_animation_->setTargetObject(next); + next_animation_->setDuration(animation_duration_); + next_animation_->setStartValue(next->pos()); + next_animation_->setEndValue(QPoint(0, 0)); + next_animation_->setPropertyName("pos"); + + if (transition_mode_ == CardTransitionMode::Fade) { + auto* eff = ensureOpacity(curr); + eff->setOpacity(1.0); + + current_animation_->stop(); + current_animation_->setTargetObject(eff); + current_animation_->setPropertyName("opacity"); + current_animation_->setDuration(animation_duration_); + current_animation_->setStartValue(1.0); + current_animation_->setEndValue(0.0); + } else { + current_animation_->stop(); + current_animation_->setTargetObject(curr); + current_animation_->setPropertyName("pos"); + current_animation_->setDuration(animation_duration_); + current_animation_->setStartValue(curr->pos()); + current_animation_->setEndValue(QPoint(0, slide_up ? -h : h)); + } + + hide_after_animation_ = curr; + + disconnect(group_, nullptr, nullptr, nullptr); + connect(group_, &QParallelAnimationGroup::finished, this, [this, new_index]() { + if (hide_after_animation_) { + hide_after_animation_->hide(); + hide_after_animation_->move(0, 0); + if (auto* eff = qobject_cast( + hide_after_animation_->graphicsEffect())) { + eff->setOpacity(1.0); + } + hide_after_animation_.clear(); + } + setCurrentIndex(new_index); + }); + + group_->start(); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/card_stack_widget.h b/ui/components/home_page/card_stack_widget.h new file mode 100644 index 000000000..662eecede --- /dev/null +++ b/ui/components/home_page/card_stack_widget.h @@ -0,0 +1,179 @@ +/** + * @file card_stack_widget.h + * @brief QStackedWidget with vertical swipe + slide/fade card transitions. + * + * Migrated from CCIMXDesktop CardStackWidget. A finger/mouse drag tracks the + * neighboring card in real time; on release, a drag past 1/4 of the height + * commits the switch (animated), otherwise the current card bounces back. + * Droped from the original: the auto-switch timer, the bounded-property + * macros, and the QMutex (the widget is single-threaded UI). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#pragma once + +#include +#include +#include + +class QPropertyAnimation; +class QParallelAnimationGroup; +class QGraphicsOpacityEffect; +class QMouseEvent; + +namespace cf::desktop::desktop_component { + +/** + * @brief Stacked widget with swipe-to-switch and slide/fade transitions. + * + * @note Single-threaded (UI thread only). + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ +class CardStackWidget : public QStackedWidget { + Q_OBJECT + + public: + /** + * @brief Transition animation style between cards. + * @since 0.19.0 + * @ingroup home_page + */ + enum class CardTransitionMode { + Slide, ///< Slide the cards vertically. + Fade, ///< Cross-fade opacity. + }; + + /** + * @brief Constructs the card stack. + * @param[in] parent Owning Qt parent widget. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + explicit CardStackWidget(QWidget* parent = nullptr); + + /** + * @brief Sets the transition animation mode. + * @param[in] mode Slide or Fade. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void setCardTransitionMode(CardTransitionMode mode); + + /** + * @brief Returns the current transition mode. + * @return Slide or Fade. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + CardTransitionMode cardTransitionMode() const; + + /** + * @brief Animates a transition to the given card index. + * @param[in] new_index Target card index. + * @return None + * @throws None + * @note Direction is inferred from the current index. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void slideTo(int new_index); + + /** + * @brief Sets the transition animation duration. + * @param[in] duration_ms Duration in milliseconds. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void setAnimationDuration(int duration_ms); + + /** + * @brief Returns the animation duration. + * @return Duration in milliseconds. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + int animationDuration() const; + + protected: + /** + * @brief Records the drag start position on left-button press. + * @param[in] event Mouse press event. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void mousePressEvent(QMouseEvent* event) override; + + /** + * @brief Tracks the drag, shifting the neighboring card in real time. + * @param[in] event Mouse move event. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void mouseMoveEvent(QMouseEvent* event) override; + + /** + * @brief Commits the switch past 1/4 height, otherwise bounces back. + * @param[in] event Mouse release event. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void mouseReleaseEvent(QMouseEvent* event) override; + + private: + /// @brief Animated transition with explicit direction. + void slideTo(int new_index, bool slide_up); + + /// @brief Animates the current card back to its rest position. + void bounceBack(int dy, int index); + + /// @brief Lazily attaches an opacity effect to a widget and returns it. + QGraphicsOpacityEffect* ensureOpacity(QWidget* w); + + bool dragging_{false}; ///< Drag in progress. + QPoint start_pos_; ///< Press position (local). + QPropertyAnimation* current_animation_; ///< Animation for outgoing card. + QPropertyAnimation* next_animation_; ///< Animation for incoming card. + QParallelAnimationGroup* group_; ///< Parallel runner. + QPointer hide_after_animation_; ///< Card to hide once finished. + int animation_duration_{350}; ///< Transition duration (ms). + CardTransitionMode transition_mode_{CardTransitionMode::Fade}; ///< Active mode. +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/date_card.cpp b/ui/components/home_page/date_card.cpp new file mode 100644 index 000000000..b324ac9cd --- /dev/null +++ b/ui/components/home_page/date_card.cpp @@ -0,0 +1,73 @@ +/** + * @file date_card.cpp + * @brief Implementation of the DateCard. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#include "date_card.h" + +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +namespace { +/// @brief Blue gradient QSS (ported from CCIMX DateShowCard). +inline constexpr const char* kCardQss = R"( + #DateCardWidget { + border-radius: 20px; + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #4A90E2, stop:1 #1E3C72); + } + #DateCardWidget QLabel { color: white; } + #title_label { font-size: 32px; } + #day_label { font-size: 48px; font-weight: bold; } + #full_text_label { font-size: 16px; } +)"; +} // namespace + +DateCard::DateCard(QWidget* parent) : QWidget(parent) { + setAttribute(Qt::WA_StyledBackground); // required for QSS background on a QWidget + setObjectName("DateCardWidget"); + setStyleSheet(kCardQss); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(24, 20, 24, 20); + layout->setSpacing(6); + + title_label_ = new QLabel("DATE", this); + title_label_->setObjectName("title_label"); + day_label_ = new QLabel(this); + day_label_->setObjectName("day_label"); + full_text_label_ = new QLabel(this); + full_text_label_->setObjectName("full_text_label"); + + layout->addWidget(title_label_, 0, Qt::AlignLeft); + layout->addStretch(1); + layout->addWidget(day_label_, 0, Qt::AlignCenter); + layout->addWidget(full_text_label_, 0, Qt::AlignCenter); + layout->addStretch(1); + + auto* shadow = new QGraphicsDropShadowEffect(this); + shadow->setBlurRadius(16); + shadow->setOffset(0, 4); + shadow->setColor(QColor(0, 0, 0, 160)); + setGraphicsEffect(shadow); + + refresh(); +} + +void DateCard::refresh() { + const QDate today = QDate::currentDate(); + day_label_->setText(today.toString("dd")); + full_text_label_->setText(today.toString("yyyy MMM dd ddd")); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/date_card.h b/ui/components/home_page/date_card.h new file mode 100644 index 000000000..9b88035d2 --- /dev/null +++ b/ui/components/home_page/date_card.h @@ -0,0 +1,56 @@ +/** + * @file date_card.h + * @brief Blue-gradient date card (CCIMX DateShowCard style). + * + * Ported from CCIMXDesktop DateShowCard: blue gradient background (#4A90E2 -> + * #1E3C72) + drop shadow + white QLabels (title / day / full text). No MD3 + * theming (Phase G re-themes later). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#pragma once + +#include + +class QLabel; + +namespace cf::desktop::desktop_component { + +/** + * @brief Date display card (title + day number + full date), blue gradient. + * + * @note Refreshes the date once at construction. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ +class DateCard : public QWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the date card. + * @param[in] parent Owning Qt parent widget. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + explicit DateCard(QWidget* parent = nullptr); + + private: + /// @brief Recomputes day / full-date strings from the system date. + void refresh(); + + QLabel* title_label_; ///< "DATE" heading. + QLabel* day_label_; ///< Two-digit day number. + QLabel* full_text_label_; ///< "yyyy MMM dd ddd". +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/digital_time_widget.cpp b/ui/components/home_page/digital_time_widget.cpp new file mode 100644 index 000000000..5b1f1d02b --- /dev/null +++ b/ui/components/home_page/digital_time_widget.cpp @@ -0,0 +1,71 @@ +/** + * @file digital_time_widget.cpp + * @brief Implementation of the DigitalTimeWidget. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#include "digital_time_widget.h" + +#include "global_clock_sources.h" + +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +DigitalTimeWidget::DigitalTimeWidget(QWidget* parent) : QWidget(parent) { + setAttribute(Qt::WA_TranslucentBackground); + stored_time_ = QTime::currentTime(); + connect(&GlobalClockSources::instance(), &GlobalClockSources::timeUpdate, this, + &DigitalTimeWidget::onTimeUpdate); +} + +void DigitalTimeWidget::onTimeUpdate(const QTime& time) { + stored_time_ = time; + update(); +} + +void DigitalTimeWidget::paintEvent(QPaintEvent* /*event*/) { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + + const QTime time = stored_time_; + const QString time_text = time.toString("hh:mm:ss"); + + const QDate date = QDate::currentDate(); + const QString date_text = date.toString("yyyy MMM dd ddd"); + + QFont time_font = painter.font(); + time_font.setPointSize(40); + time_font.setBold(true); + painter.setFont(time_font); + const int time_text_height = QFontMetrics(time_font).height(); + + QRect time_rect = rect(); + time_rect.setHeight(time_text_height); + time_rect.moveTop(rect().center().y() - time_text_height); // vertically centered + + painter.setPen(Qt::white); + painter.drawText(time_rect, Qt::AlignCenter, time_text); + + QFont date_font = painter.font(); + date_font.setPointSize(15); + date_font.setWeight(QFont::Light); + painter.setFont(date_font); + const int date_text_height = QFontMetrics(date_font).height(); + + QRect date_rect = rect(); + date_rect.setHeight(date_text_height); + date_rect.moveTop(time_rect.bottom() + 10); + + painter.drawText(date_rect, Qt::AlignCenter, date_text); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/digital_time_widget.h b/ui/components/home_page/digital_time_widget.h new file mode 100644 index 000000000..6d3fa6a54 --- /dev/null +++ b/ui/components/home_page/digital_time_widget.h @@ -0,0 +1,66 @@ +/** + * @file digital_time_widget.h + * @brief Digital HH:mm:ss + date display driven by GlobalClockSources. + * + * Ported verbatim from CCIMXDesktop DigitalTimeWidget: white Helvetica Neue + * (40pt Bold time / 15pt Light date), no MD3 theming (Phase G re-themes later). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#pragma once + +#include +#include + +namespace cf::desktop::desktop_component { + +/** + * @brief Digital time (HH:mm:ss) with a date line beneath. + * + * @note Subscribes to GlobalClockSources for the 1 s tick. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ +class DigitalTimeWidget : public QWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the digital time widget. + * @param[in] parent Owning Qt parent widget. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + explicit DigitalTimeWidget(QWidget* parent = nullptr); + + protected: + /** + * @brief Paints the time and date text. + * @param[in] event Paint event (unused). + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void paintEvent(QPaintEvent* event) override; + + private: + /// @brief Updates the cached time and repaints on each tick. + /// @param[in] time Current time. + void onTimeUpdate(const QTime& time); + + QTime stored_time_; ///< Latest received time. +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/disk_gauge_card.cpp b/ui/components/home_page/disk_gauge_card.cpp new file mode 100644 index 000000000..3673dc407 --- /dev/null +++ b/ui/components/home_page/disk_gauge_card.cpp @@ -0,0 +1,106 @@ +/** + * @file disk_gauge_card.cpp + * @brief Implementation of DiskGaugeCard (CCIMX DiskUsageCardWidget style). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#include "disk_gauge_card.h" + +#include "gauge_widget.h" + +#include +#include +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +namespace { +/// @brief Dark gradient QSS (ported from CCIMX DiskUsageCardWidget). +inline constexpr const char* kCardQss = R"( + #DiskGaugeCard { + border-radius: 16px; + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #5D6D7E, stop:1 #2C3E50); + } + #DiskGaugeCard QLabel { color: #ECF0F1; } + #title_label { font-size: 32px; font-weight: bold; } + #detail_label { font-size: 16px; } +)"; +} // namespace + +DiskGaugeCard::DiskGaugeCard(const QString& title, SystemUsageCard::Probe probe, int interval_ms, + QWidget* parent) + : QWidget(parent), probe_(std::move(probe)) { + setAttribute(Qt::WA_StyledBackground); + setObjectName("DiskGaugeCard"); + setStyleSheet(kCardQss); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(16, 16, 16, 16); + layout->setSpacing(8); + + title_label_ = new QLabel(title, this); + title_label_->setObjectName("title_label"); + layout->addWidget(title_label_, 0, Qt::AlignLeft); + + gauge_ = new GaugeWidget(this); + gauge_->setRange(0, 100); + gauge_->setTitle(title); + gauge_->setUnit("%"); + layout->addWidget(gauge_, 1, Qt::AlignCenter); + + detail_label_ = new QLabel(this); + detail_label_->setObjectName("detail_label"); + detail_label_->setAlignment(Qt::AlignCenter); + layout->addWidget(detail_label_, 0, Qt::AlignCenter); + + auto* shadow = new QGraphicsDropShadowEffect(this); + shadow->setBlurRadius(12); + shadow->setOffset(0, 4); + shadow->setColor(QColor(0, 0, 0, 160)); + setGraphicsEffect(shadow); + + // Same off-thread poll pattern as SystemUsageCard. + probe_watcher_ = new QFutureWatcher(this); + connect(probe_watcher_, &QFutureWatcher::finished, this, [this]() { + applySample(probe_watcher_->result()); + in_flight_ = false; + }); + + refresh(); // first (async) sample + + auto* timer = new QTimer(this); + timer->setTimerType(Qt::CoarseTimer); + connect(timer, &QTimer::timeout, this, [this]() { refresh(); }); + timer->start(interval_ms); +} + +void DiskGaugeCard::refresh() { + if (!probe_ || in_flight_) { + return; + } + in_flight_ = true; + probe_watcher_->setFuture(QtConcurrent::run([probe = probe_]() { return probe(); })); +} + +void DiskGaugeCard::applySample(const UsageSample& sample) { + int pct = sample.pct; + if (pct < 0) { + pct = 0; + } + if (pct > 100) { + pct = 100; + } + gauge_->update_value(pct); + detail_label_->setText(sample.detail); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/disk_gauge_card.h b/ui/components/home_page/disk_gauge_card.h new file mode 100644 index 000000000..cb338d1b7 --- /dev/null +++ b/ui/components/home_page/disk_gauge_card.h @@ -0,0 +1,71 @@ +/** + * @file disk_gauge_card.h + * @brief Disk-usage card with a circular gauge (CCIMX DiskUsageCardWidget style). + * + * Dark-gradient card holding a GaugeWidget (animated needle) plus a multi-line + * detail label. Polls a probe callable off the UI thread (same async pattern as + * SystemUsageCard) so the filesystem stat never blocks the desktop. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#pragma once + +#include "system_usage_card.h" // UsageSample + Probe alias + +#include + +class QLabel; + +namespace cf::desktop::desktop_component { + +class GaugeWidget; + +/** + * @brief Disk-usage card rendered with a circular gauge. + * + * @note Polls the probe off the UI thread (QtConcurrent); theme-less + * (hardcoded QSS, CCIMX style). + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ +class DiskGaugeCard : public QWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the disk gauge card. + * @param[in] title Card title (e.g. "Disk"). + * @param[in] probe Callable returning the current UsageSample. + * @param[in] interval_ms Poll interval in milliseconds. + * @param[in] parent Owning Qt parent widget. + * @throws None + * @note Polls once at construction, then every interval_ms. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + DiskGaugeCard(const QString& title, SystemUsageCard::Probe probe, int interval_ms, + QWidget* parent = nullptr); + + private: + /// @brief Kicks the probe off the UI thread; skips when a probe is in flight. + void refresh(); + + /// @brief Applies a fetched sample to the gauge + detail label (UI thread only). + void applySample(const UsageSample& sample); + + QLabel* title_label_; ///< Card title. + QLabel* detail_label_; ///< Multi-line usage detail. + GaugeWidget* gauge_; ///< Animated percentage gauge. + SystemUsageCard::Probe probe_; ///< Sample source. + QFutureWatcher* probe_watcher_; ///< Marshals results back to the UI thread. + bool in_flight_{false}; ///< True while a probe is pending (UI thread only). +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/gauge_widget.cpp b/ui/components/home_page/gauge_widget.cpp new file mode 100644 index 000000000..9a29b45ba --- /dev/null +++ b/ui/components/home_page/gauge_widget.cpp @@ -0,0 +1,172 @@ +/** + * @file gauge_widget.cpp + * @brief Implementation of the GaugeWidget (ported verbatim from CCIMX). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#include "gauge_widget.h" + +#include +#include +#include + +namespace cf::desktop::desktop_component { + +GaugeWidget::GaugeWidget(QWidget* parent) : QWidget(parent) { + setMinimumSize(WIDGET_MIN_WIDTH, WIDGET_MIN_HEIGHT); +} + +void GaugeWidget::update_value(const double value) { + auto* ani = new QPropertyAnimation(this, "value"); + ani->setDuration(ANIMATION_DURATION); + ani->setStartValue(current_value); + ani->setEndValue(value); + ani->setEasingCurve(QEasingCurve::InOutCubic); + ani->start(QAbstractAnimation::DeleteWhenStopped); +} + +void GaugeWidget::paintEvent(QPaintEvent* /*event*/) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + const int side = qMin(width(), height()); + // Move to the widget center; the dial is drawn in the reference + // coordinate system and scaled to fit. + p.translate(width() / 2.0, height() / 2.0); + p.scale(side / WIDGET_MIN_WIDTH, side / WIDGET_MIN_HEIGHT); + + drawBackground(p); + drawArc(p); + drawTicks(p); + drawLabels(p); + drawNeedle(p); + drawCenter(p); + drawTexts(p); +} + +void GaugeWidget::drawBackground(QPainter& p) { + const float left_top_x = -WIDGET_MIN_WIDTH / 2; + const float left_top_y = -WIDGET_MIN_HEIGHT / 2; + const float right_bottom_x = WIDGET_MIN_WIDTH / 2; + const float right_bottom_y = WIDGET_MIN_HEIGHT / 2; + + QPen border_pen(BOARD_COLOR, BOARD_LEN); + p.setPen(border_pen); + p.setBrush(Qt::NoBrush); + p.drawEllipse(-WIDGET_MIN_WIDTH / 2, -WIDGET_MIN_HEIGHT / 2, WIDGET_MIN_WIDTH, + WIDGET_MIN_HEIGHT); + + QLinearGradient bg(left_top_x, left_top_y, right_bottom_x, right_bottom_y); + bg.setColorAt(0, FROM_BOARD_COLOR); + bg.setColorAt(1, TO_BOARD_COLOR); + p.setBrush(bg); + p.setPen(Qt::NoPen); + p.drawEllipse(left_top_x + BOARD_LEN, left_top_y + BOARD_LEN, WIDGET_MIN_WIDTH - 2 * BOARD_LEN, + WIDGET_MIN_HEIGHT - 2 * BOARD_LEN); + + QRadialGradient glow(0, 0, WIDGET_MIN_WIDTH / 2); + glow.setColorAt(0, QColor(255, 255, 255, 80)); + glow.setColorAt(1, Qt::transparent); + p.setBrush(glow); + p.drawEllipse(left_top_x + BOARD_LEN, left_top_y + BOARD_LEN, WIDGET_MIN_WIDTH - 2 * BOARD_LEN, + WIDGET_MIN_HEIGHT - 2 * BOARD_LEN); +} + +void GaugeWidget::drawArc(QPainter& p) { + QConicalGradient cg(0, 0, START_ANGLE); + cg.setColorAt(0.0, START_COLOR); + cg.setColorAt(1.0, END_COLOR); + QPen arc_pen(cg, COLOR_GRAD_WIDTH, Qt::SolidLine, Qt::FlatCap); + p.setPen(arc_pen); + const QRectF arc_rect(-COLOR_RADIUS, -COLOR_RADIUS, 2 * COLOR_RADIUS, 2 * COLOR_RADIUS); + p.drawArc(arc_rect, START_ANGLE * 16, -TOTAL_ANGLE * 16); +} + +void GaugeWidget::drawTicks(QPainter& p) { + static constexpr int EACH_ANGLE = TOTAL_ANGLE / (TICK_CNT - 1); + for (int i = 0; i <= TICK_CNT - 1; ++i) { + p.save(); + const double ang = START_ANGLE + i * EACH_ANGLE; + p.rotate(ang); + if (i % SUB_MAIN_RATE == 0) { + p.setPen(QPen(TICK_COLOR, MAIN_TICK_WIDTH)); + p.drawLine(0, -COLOR_RADIUS + MAIN_TICK_LENGTH, 0, -COLOR_RADIUS); + } else { + p.setPen(QPen(TICK_COLOR, SUB_TICK_WIDTH)); + p.drawLine(0, -COLOR_RADIUS + SUB_TICK_LENGTH, 0, -COLOR_RADIUS); + } + p.restore(); + } +} + +void GaugeWidget::drawLabels(QPainter& p) { + p.setFont(label_font); + static constexpr int EACH_ANGLE = TOTAL_ANGLE / (TICK_CNT - 1); + static constexpr short MAIN_TICKS_N = TICK_CNT / EACH_ANGLE; + for (int i = 0; i <= MAIN_TICKS_N - 1; ++i) { + p.save(); + const double ang = START_ANGLE + i * TOTAL_ANGLE / 10; + p.rotate(ang); + p.translate(0, -70); + p.rotate(-ang); + const int val = min_value + i * (max_value - min_value) / 10; + p.setPen(LABEL_COLOR); + p.drawText(QRectF(-15, -10, 30, 20), Qt::AlignCenter, QString::number(val)); + p.restore(); + } +} + +void GaugeWidget::drawNeedle(QPainter& p) { + static constexpr int start = 225; + static constexpr int tol = 270; + const double range = max_value - min_value; + const double ratio = (range != 0) ? double(current_value - min_value) / range : 0.0; + const double needle_ang = start + ratio * tol; + + // Drop shadow. + p.save(); + p.rotate(needle_ang); + p.translate(2, 2); + p.setBrush(QColor(0, 0, 0, 80)); + p.setPen(Qt::NoPen); + static const QPointF shadow[3] = {{0, -70}, {-5, 0}, {5, 0}}; + p.drawConvexPolygon(shadow, 3); + p.restore(); + + // Needle body. + p.save(); + p.rotate(needle_ang); + QLinearGradient ng(0, -70, 0, 0); + ng.setColorAt(0, QColor(255, 120, 120)); + ng.setColorAt(1, QColor(180, 0, 0)); + p.setBrush(ng); + p.setPen(Qt::darkRed); + static const QPointF needle[3] = {{0, -70}, {-5, 0}, {5, 0}}; + p.drawConvexPolygon(needle, 3); + p.restore(); +} + +void GaugeWidget::drawCenter(QPainter& p) { + QRadialGradient cg2(0, 0, 10); + cg2.setColorAt(0, Qt::white); + cg2.setColorAt(1, QColor(200, 200, 200)); + p.setBrush(cg2); + p.setPen(Qt::gray); + p.drawEllipse(-7, -7, 14, 14); +} + +void GaugeWidget::drawTexts(QPainter& p) { + p.setPen(Qt::black); + p.setFont(title_font); + p.drawText(QRectF(-40, -60, 80, 20), Qt::AlignCenter, title); + p.setFont(value_font); + const QString txt = QString::number(current_value, 'f', 0) + " " + unit; + p.drawText(QRectF(-60, 60, 120, 30), Qt::AlignCenter, txt); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/gauge_widget.h b/ui/components/home_page/gauge_widget.h new file mode 100644 index 000000000..e6eb6dfd4 --- /dev/null +++ b/ui/components/home_page/gauge_widget.h @@ -0,0 +1,273 @@ +/** + * @file gauge_widget.h + * @brief Circular gauge widget (ported verbatim from CCIMX GaugeWidget). + * + * A self-painted semicircular gauge: gradient arc, ticks + labels, animated + * needle, center hub, title + value text. Used by the disk-usage card to + * render a percentage as a dial instead of a linear bar. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#pragma once + +#include +#include +#include + +namespace cf::desktop::desktop_component { + +/** + * @brief Circular gauge that renders a numeric value as a dial. + * + * Drawn entirely in paintEvent (background, gradient arc, ticks, labels, + * needle, center, texts). The needle animates to a new value over + * ANIMATION_DURATION ms via a QPropertyAnimation on the @c value property. + * + * @note Visual constants mirror CCIMX GaugeWidget exactly; theme-less. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ +class GaugeWidget : public QWidget { + Q_OBJECT + /// @brief Animated needle value (drives the needle angle). + Q_PROPERTY(double value READ value WRITE setValue NOTIFY valueChanged) + + public: + /// @brief Default minimum value of the gauge. + static constexpr int DEF_MIN_VALUE{0}; + + /// @brief Default maximum value of the gauge. + static constexpr int DEF_MAX_VALUE{100}; + + /// @brief Font size for the title text. + static constexpr short TITLE_FONT_SZ{12}; + + /// @brief Font size for the current value text. + static constexpr short CURVAL_FOUT_SZ{10}; + + /// @brief Duration of the needle animation in milliseconds. + static constexpr int ANIMATION_DURATION{500}; + + /// @brief Reference widget width (the dial is scaled to fit the widget). + static constexpr float WIDGET_MIN_WIDTH{200}; + + /// @brief Reference widget height (the dial is scaled to fit the widget). + static constexpr float WIDGET_MIN_HEIGHT{200}; + + /// @brief Base color for the gauge board outline. + static constexpr QColor BOARD_COLOR = QColor(160, 160, 160); + + /// @brief Gradient start color for the board background. + static constexpr QColor FROM_BOARD_COLOR = QColor(190, 190, 190); + + /// @brief Gradient end color for the board background. + static constexpr QColor TO_BOARD_COLOR = QColor(110, 110, 110); + + /// @brief Width of the board outline. + static constexpr short BOARD_LEN = 1; + + /// @brief Start angle for the arc (degrees, 0 at 3 o'clock, CCW positive). + static constexpr short START_ANGLE{225}; + + /// @brief Total sweep angle of the gauge arc (degrees). + static constexpr short TOTAL_ANGLE{270}; + + /// @brief Width of the color gradient arc pen. + static constexpr short COLOR_GRAD_WIDTH{8}; + + /// @brief Radius of the color gradient arc. + static constexpr short COLOR_RADIUS{90}; + + /// @brief Gradient start color of the arc (low end). + static constexpr QColor START_COLOR = QColor(255, 0, 0); + + /// @brief Gradient end color of the arc (high end). + static constexpr QColor END_COLOR = QColor(0, 255, 0); + + /// @brief Total number of ticks around the arc. + static constexpr int TICK_CNT{55}; + + /// @brief Pen width of main ticks. + static constexpr short MAIN_TICK_WIDTH{2}; + + /// @brief Pen width of sub ticks. + static constexpr short SUB_TICK_WIDTH{1}; + + /// @brief Length of main ticks. + static constexpr short MAIN_TICK_LENGTH{8}; + + /// @brief Length of sub ticks. + static constexpr short SUB_TICK_LENGTH{5}; + + /// @brief Number of sub ticks per main tick. + static constexpr short SUB_MAIN_RATE{5}; + + /// @brief Color of the ticks. + static constexpr QColor TICK_COLOR = QColor(255, 255, 255); + + /// @brief Font size for the tick labels. + static constexpr short LABEL_FONT_SZ{6}; + + /// @brief Color for the tick labels. + static constexpr QColor LABEL_COLOR = QColor(255, 255, 255); + + /** + * @brief Constructs the gauge. + * @param[in] parent Owning Qt parent widget. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + explicit GaugeWidget(QWidget* parent = nullptr); + + /** + * @brief Sets the gauge value range. + * @param[in] min Minimum value (needle at the arc start). + * @param[in] max Maximum value (needle at the arc end). + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void setRange(int min, int max) { + min_value = min; + max_value = max; + update(); + } + + /** + * @brief Sets the title text drawn at the top of the dial. + * @param[in] title Title text. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void setTitle(const QString& title) { + this->title = title; + update(); + } + + /** + * @brief Sets the unit text drawn beside the value. + * @param[in] unit Unit text (e.g. "%"). + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void setUnit(const QString& unit) { + this->unit = unit; + update(); + } + + /** + * @brief Returns the current gauge value. + * @return Current displayed value. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + */ + double value() const { return current_value; } + + /** + * @brief Animates the needle to a new value. + * @param[in] value New value to display. + * @return None + * @throws None + * @note Uses a QPropertyAnimation over ANIMATION_DURATION ms. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void update_value(double value); + + signals: + /** + * @brief Emitted when the gauge value changes. + * @param[in] value The new value. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void valueChanged(double value); + + protected: + /** + * @brief Renders the gauge. + * @param[in] event Paint event (unused). + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void paintEvent(QPaintEvent* event) override; + + private: + /** + * @brief Sets the value internally and emits valueChanged. + * @param[in] val New value. + * @return None + * @throws None + * @note Driven by the QPropertyAnimation; not for manual calls. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void setValue(double val) { + if (qFuzzyCompare(current_value, val)) { + return; + } + current_value = val; + emit valueChanged(val); + update(); + } + + /// @brief Draws the board background + radial glow. + void drawBackground(QPainter& p); + /// @brief Draws the red-to-green conical gradient arc. + void drawArc(QPainter& p); + /// @brief Draws the tick marks around the arc. + void drawTicks(QPainter& p); + /// @brief Draws numeric labels at the main ticks. + void drawLabels(QPainter& p); + /// @brief Draws the needle (with drop shadow) at the current value. + void drawNeedle(QPainter& p); + /// @brief Draws the center hub. + void drawCenter(QPainter& p); + /// @brief Draws the title and value+unit texts. + void drawTexts(QPainter& p); + + double min_value{DEF_MIN_VALUE}; ///< Minimum value of the gauge. + double max_value{DEF_MAX_VALUE}; ///< Maximum value of the gauge. + double current_value{0.0}; ///< Current displayed value. + + QFont title_font{"Arial", TITLE_FONT_SZ, QFont::Bold}; ///< Title font. + QFont value_font{"Arial", CURVAL_FOUT_SZ, QFont::Bold}; ///< Value font. + QFont label_font{"Arial", LABEL_FONT_SZ}; ///< Label font. + + QString title; ///< Title text. + QString unit; ///< Unit text. +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/global_clock_sources.cpp b/ui/components/home_page/global_clock_sources.cpp new file mode 100644 index 000000000..0794404e4 --- /dev/null +++ b/ui/components/home_page/global_clock_sources.cpp @@ -0,0 +1,31 @@ +/** + * @file global_clock_sources.cpp + * @brief Implementation of the GlobalClockSources singleton. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#include "global_clock_sources.h" + +#include +#include + +namespace cf::desktop::desktop_component { + +GlobalClockSources& GlobalClockSources::instance() { + static GlobalClockSources source; + return source; +} + +GlobalClockSources::GlobalClockSources() : QObject(nullptr) { + auto* timer = new QTimer(this); + timer->setTimerType(Qt::CoarseTimer); + connect(timer, &QTimer::timeout, this, [this]() { emit timeUpdate(QTime::currentTime()); }); + timer->start(1000); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/global_clock_sources.h b/ui/components/home_page/global_clock_sources.h new file mode 100644 index 000000000..6dbc407eb --- /dev/null +++ b/ui/components/home_page/global_clock_sources.h @@ -0,0 +1,72 @@ +/** + * @file global_clock_sources.h + * @brief Process-wide clock tick source emitting timeUpdate each second. + * + * Migrated from CCIMXDesktop GlobalClockSources. A single coarse 1 s QTimer + * drives every clock-displaying widget (analog clock, digital time) so they + * tick in sync instead of each running its own timer. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#pragma once + +#include + +class QTime; + +namespace cf::desktop::desktop_component { + +/** + * @brief Singleton clock source emitting a 1 s tick. + * + * @note Coarse timer; idletime cost is one tick per second, never a loop. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ +class GlobalClockSources : public QObject { + Q_OBJECT + + public: + /** + * @brief Returns the process-wide singleton. + * @return Reference to the singleton instance. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + static GlobalClockSources& instance(); + + signals: + /** + * @brief Emitted every second with the current wall-clock time. + * @param[in] time Current time. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void timeUpdate(const QTime& time); + + private: + /** + * @brief Constructs the clock source and starts the timer. + * @throws None + * @note Private; access via instance(). + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + GlobalClockSources(); +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/home_card_manager.cpp b/ui/components/home_page/home_card_manager.cpp new file mode 100644 index 000000000..1f5d2008d --- /dev/null +++ b/ui/components/home_page/home_card_manager.cpp @@ -0,0 +1,41 @@ +/** + * @file home_card_manager.cpp + * @brief Implementation of the HomeCardManager. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#include "home_card_manager.h" + +#include +#include + +namespace cf::desktop::desktop_component { + +HomeCardManager::HomeCardManager(QStackedWidget* stack, QObject* parent) + : QObject(parent), stack_(stack) {} + +void HomeCardManager::installCard(QWidget* card) { + if (stack_ == nullptr || card == nullptr) { + return; + } + card->setParent(stack_); + stack_->addWidget(card); +} + +void HomeCardManager::removeCard(QWidget* card) { + if (stack_ == nullptr || card == nullptr) { + return; + } + stack_->removeWidget(card); +} + +int HomeCardManager::cardCount() const { + return stack_ != nullptr ? stack_->count() : 0; +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/home_card_manager.h b/ui/components/home_page/home_card_manager.h new file mode 100644 index 000000000..18489c309 --- /dev/null +++ b/ui/components/home_page/home_card_manager.h @@ -0,0 +1,87 @@ +/** + * @file home_card_manager.h + * @brief Registry that installs cards into a CardStackWidget. + * + * Migrated from CCIMXDesktop HomeCardManager. A thin wrapper around + * QStackedWidget add/remove so HomePage code does not touch the stack directly. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#pragma once + +#include + +class QStackedWidget; +class QWidget; + +namespace cf::desktop::desktop_component { + +/** + * @brief Manages the card list shown in a CardStackWidget. + * + * @note UI-thread only (no mutex; QStackedWidget is single-threaded). + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ +class HomeCardManager : public QObject { + Q_OBJECT + + public: + /** + * @brief Constructs the manager bound to a stacked widget. + * @param[in] stack The CardStackWidget (as QStackedWidget) to manage. + * @param[in] parent Owning Qt parent. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + HomeCardManager(QStackedWidget* stack, QObject* parent = nullptr); + + /** + * @brief Appends a card to the stack. + * @param[in] card Card widget to install. + * @return None + * @throws None + * @note Reparents the card to the stack. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void installCard(QWidget* card); + + /** + * @brief Removes a card from the stack. + * @param[in] card Card widget to remove. + * @return None + * @throws None + * @note Does not delete the card. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void removeCard(QWidget* card); + + /** + * @brief Returns the number of installed cards. + * @return Card count. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + int cardCount() const; + + private: + QStackedWidget* stack_; ///< The managed stacked widget. +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/home_page.cpp b/ui/components/home_page/home_page.cpp new file mode 100644 index 000000000..a3bac3341 --- /dev/null +++ b/ui/components/home_page/home_page.cpp @@ -0,0 +1,137 @@ +/** + * @file home_page.cpp + * @brief Implementation of the HomePage screen. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#include "home_page.h" + +#include "analog_clock_widget.h" +#include "card_stack_widget.h" +#include "date_card.h" +#include "digital_time_widget.h" +#include "disk_gauge_card.h" +#include "home_card_manager.h" +#include "local_temp_card.h" +#include "modern_calendar_widget.h" +#include "net_status_card.h" +#include "system/cpu/cfcpu_profile.h" +#include "system/memory/memory_info.h" +#include "system_usage_card.h" +#include "user_info_card.h" + +#include +#include +#include +#include + +#include + +namespace cf::desktop::desktop_component { + +namespace { +/// @brief Bytes per gigabyte. +inline constexpr double kBytesPerGB = 1024.0 * 1024.0 * 1024.0; + +/// @brief Memory usage sample (cfbase getSystemMemoryInfo). +UsageSample memorySample() { + cf::MemoryInfo info{}; + cf::getSystemMemoryInfo(info); + const quint64 total = info.physical.total_bytes; + const quint64 avail = info.physical.available_bytes; + const quint64 used = (total > avail) ? total - avail : 0; + const int pct = (total > 0) ? static_cast((used * 100U) / total) : 0; + const QString detail = QStringLiteral("%1 / %2 GB") + .arg(static_cast(used) / kBytesPerGB, 0, 'f', 1) + .arg(static_cast(total) / kBytesPerGB, 0, 'f', 1); + return {pct, detail}; +} + +/// @brief CPU usage sample (cfbase getCPUProfileInfo). +UsageSample cpuSample() { + const auto result = cf::getCPUProfileInfo(); + if (!result.has_value()) { + return {0, QStringLiteral("--")}; + } + const auto& p = result.value(); + const int pct = static_cast(p.cpu_usage_percentage); + return {pct, QStringLiteral("%1 cores · %2 MHz").arg(p.logical_cnt).arg(p.current_frequecy)}; +} + +/// @brief Disk usage sample (std::filesystem::space of "/"). +UsageSample diskSample() { + std::error_code ec; + const auto sp = std::filesystem::space("/", ec); + if (ec) { + return {0, QStringLiteral("--")}; + } + const quint64 total = sp.capacity; + const quint64 avail = sp.available; + const quint64 used = (total > avail) ? total - avail : 0; + const int pct = (total > 0) ? static_cast((used * 100U) / total) : 0; + const QString detail = QStringLiteral("%1 / %2 GB") + .arg(static_cast(used) / kBytesPerGB, 0, 'f', 0) + .arg(static_cast(total) / kBytesPerGB, 0, 'f', 0); + return {pct, detail}; +} +} // namespace + +HomePage::HomePage(QWidget* parent) : QWidget(parent) { + setAttribute(Qt::WA_TranslucentBackground); + + auto* outer = new QHBoxLayout(this); + outer->setContentsMargins(0, 0, 0, 0); + outer->setSpacing(0); + + // Left column: analog clock (5) above digital time (2). + auto* left = new QWidget(this); + auto* left_layout = new QVBoxLayout(left); + left_layout->setContentsMargins(0, 0, 0, 0); + left_layout->setSpacing(0); + analog_clock_ = new AnalogClockWidget(left); + digital_time_ = new DigitalTimeWidget(left); + left_layout->addWidget(analog_clock_, 5); + left_layout->addWidget(digital_time_, 2); + + // Right column: card stack (3) above a gadget grid (1) — net status + local + // temp, mirroring CCIMX's right-bottom NetCardGadget / LocalWeatherCard. + auto* right = new QWidget(this); + auto* right_layout = new QVBoxLayout(right); + right_layout->setContentsMargins(0, 0, 0, 0); + right_layout->setSpacing(0); + card_stack_ = new CardStackWidget(right); + auto* bottom_grid = new QWidget(right); + auto* grid_layout = new QGridLayout(bottom_grid); + grid_layout->setContentsMargins(2, 2, 2, 2); + grid_layout->setSpacing(3); + grid_layout->addWidget(new NetStatusCard(bottom_grid), 0, 0); + grid_layout->addWidget(new LocalTempCard(bottom_grid), 0, 1); + right_layout->addWidget(card_stack_, 3); + right_layout->addWidget(bottom_grid, 1); + + outer->addWidget(left); + outer->addWidget(right); + outer->setStretchFactor(left, 1); + outer->setStretchFactor(right, 1); + + // Card-stack order mirrors CCIMX (UserInfo, Calendar, Date, Disk, Memory); + // CPU is an extra card reusing the SystemUsageCard renderer. + card_manager_ = std::make_unique(card_stack_); + card_manager_->installCard(new UserInfoCard(card_stack_)); + card_manager_->installCard(new ModernCalendarWidget(card_stack_)); + card_manager_->installCard(new DateCard(card_stack_)); + card_manager_->installCard(new DiskGaugeCard("Disk", diskSample, 10000, card_stack_)); + card_manager_->installCard(new SystemUsageCard("Memory", memorySample, 2000, card_stack_)); + card_manager_->installCard(new SystemUsageCard("CPU", cpuSample, 5000, card_stack_)); +} + +HomeCardManager* HomePage::cardManager() const { + return card_manager_.get(); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/home_page.h b/ui/components/home_page/home_page.h new file mode 100644 index 000000000..a2be43008 --- /dev/null +++ b/ui/components/home_page/home_page.h @@ -0,0 +1,71 @@ +/** + * @file home_page.h + * @brief Desktop home screen: clock column + swipeable card stack. + * + * Migrated from CCIMXDesktop HomePage. Layout is rewritten as hand-coded + * QLayout (CFDesktop uses no .ui files): left column holds the analog clock + * (5/7) above the digital time (2/7); right column holds the CardStackWidget + * (3/4) above a placeholder app-grid area (1/4, Step B). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#pragma once + +#include +#include + +namespace cf::desktop::desktop_component { + +class AnalogClockWidget; +class DigitalTimeWidget; +class CardStackWidget; +class HomeCardManager; + +/** + * @brief The desktop home screen widget. + * + * @note Owns the clock column and the card stack; does not subscribe to + * GlobalClockSources itself (each clock widget does). + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ +class HomePage : public QWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the home page and installs the default cards. + * @param[in] parent Owning Qt parent widget. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + explicit HomePage(QWidget* parent = nullptr); + + /** + * @brief Returns the card manager (for Step B card registration). + * @return Pointer to the HomeCardManager. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + HomeCardManager* cardManager() const; + + private: + AnalogClockWidget* analog_clock_; ///< Left-top analog dial. + DigitalTimeWidget* digital_time_; ///< Left-bottom digital time. + CardStackWidget* card_stack_; ///< Right-top swipeable stack. + std::unique_ptr card_manager_; ///< Stack registry. +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/local_temp_card.cpp b/ui/components/home_page/local_temp_card.cpp new file mode 100644 index 000000000..315151e5b --- /dev/null +++ b/ui/components/home_page/local_temp_card.cpp @@ -0,0 +1,126 @@ +/** + * @file local_temp_card.cpp + * @brief Implementation of LocalTempCard (Linux thermal subsystem). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#include "local_temp_card.h" + +#include +#include +#include +#include + +#include +#include +#include + +namespace cf::desktop::desktop_component { + +namespace { +/// @brief Warm gradient QSS (temperature card). +inline constexpr const char* kCardQss = R"( + #LocalTempCard { + border-radius: 20px; + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #FFB347, stop:1 #FF6B6B); + padding: 12px; + } + #LocalTempCard QLabel { color: white; } + #value_label { font-size: 40px; font-weight: bold; } + #detail_label { font-size: 16px; } +)"; + +/// @brief Reads one thermal-zone file's contents, trimmed. +std::string readZoneFile(const std::filesystem::path& path) { + std::ifstream in(path); + std::string line; + if (in) { + std::getline(in, line); + } + // Trim trailing whitespace. + while (!line.empty() && (line.back() == '\n' || line.back() == '\r' || line.back() == ' ')) { + line.pop_back(); + } + return line; +} +} // namespace + +LocalTempCard::LocalTempCard(QWidget* parent) : QWidget(parent) { + setAttribute(Qt::WA_StyledBackground); + setObjectName("LocalTempCard"); + setStyleSheet(kCardQss); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(20, 16, 20, 16); + layout->setSpacing(6); + + value_label_ = new QLabel(this); + value_label_->setObjectName("value_label"); + detail_label_ = new QLabel(this); + detail_label_->setObjectName("detail_label"); + + layout->addWidget(value_label_, 0, Qt::AlignLeft); + layout->addStretch(1); + layout->addWidget(detail_label_, 0, Qt::AlignLeft); + + auto* shadow = new QGraphicsDropShadowEffect(this); + shadow->setBlurRadius(10); + shadow->setOffset(0, 4); + shadow->setColor(QColor(0, 0, 0, 160)); + setGraphicsEffect(shadow); + + refresh(); + + timer_ = new QTimer(this); + timer_->setTimerType(Qt::CoarseTimer); + connect(timer_, &QTimer::timeout, this, [this]() { refresh(); }); + timer_->start(5000); +} + +void LocalTempCard::refresh() { + namespace fs = std::filesystem; + const fs::path kThermalRoot{"/sys/class/thermal"}; + + std::error_code ec; + if (!fs::exists(kThermalRoot, ec)) { + value_label_->setText(QStringLiteral("N/A")); + detail_label_->setText(QStringLiteral("No thermal subsystem")); + return; + } + + // Take the first thermal_zone* with a valid temperature reading. + for (const auto& entry : fs::directory_iterator(kThermalRoot, ec)) { + const std::string name = entry.path().filename().string(); + if (name.rfind("thermal_zone", 0) != 0) { + continue; + } + const std::string raw = readZoneFile(entry.path() / "temp"); + if (raw.empty()) { + continue; + } + try { + const long millideg = std::stol(raw); + value_label_->setText(QStringLiteral("%1 C").arg(millideg / 1000.0, 0, 'f', 1)); + } catch (...) { + continue; // Malformed reading; try the next zone. + } + std::string zone_type = readZoneFile(entry.path() / "type"); + if (zone_type.empty()) { + zone_type = name; + } + detail_label_->setText(QString::fromStdString(zone_type)); + return; + } + + // No usable zone (common on WSL2, which lacks thermal zones). + value_label_->setText(QStringLiteral("N/A")); + detail_label_->setText(QStringLiteral("No thermal zone exposed")); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/local_temp_card.h b/ui/components/home_page/local_temp_card.h new file mode 100644 index 000000000..678d15a53 --- /dev/null +++ b/ui/components/home_page/local_temp_card.h @@ -0,0 +1,61 @@ +/** + * @file local_temp_card.h + * @brief Local-temperature gadget card (CCIMX LocalWeatherCard desktop analog). + * + * Warm-gradient card showing a real local temperature read from the Linux + * thermal subsystem (/sys/class/thermal/thermal_zone*). CCIMX's LocalWeatherCard + * reads an ICM20608 hardware IMU (embedded-only) and fakes random values on + * x86; CFDesktop has neither sensor, so this card reads the genuine kernel + * thermal zone instead, and reports "N/A" honestly where no zone exists (e.g. + * WSL2, which does not expose thermal zones). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#pragma once + +#include + +class QLabel; +class QTimer; + +namespace cf::desktop::desktop_component { + +/** + * @brief Bottom-grid local-temperature card backed by the Linux thermal subsystem. + * + * @note Theme-less (hardcoded QSS, CCIMX style). Polls the thermal zone on a + * coarse timer. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ +class LocalTempCard : public QWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the card. + * @param[in] parent Owning Qt parent widget. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + explicit LocalTempCard(QWidget* parent = nullptr); + + private: + /// @brief Re-reads the thermal zone and updates the labels. + void refresh(); + + QLabel* value_label_; ///< Temperature (e.g. "52.3 C") or "N/A". + QLabel* detail_label_; ///< Thermal-zone label (e.g. "x86_pkg_temp"). + QTimer* timer_; ///< Poll timer. +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/modern_calendar_widget.cpp b/ui/components/home_page/modern_calendar_widget.cpp new file mode 100644 index 000000000..a7d95f09d --- /dev/null +++ b/ui/components/home_page/modern_calendar_widget.cpp @@ -0,0 +1,139 @@ +/** + * @file modern_calendar_widget.cpp + * @brief Implementation of ModernCalendarWidget (ported from CCIMX). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#include "modern_calendar_widget.h" + +#include +#include +#include + +namespace cf::desktop::desktop_component { + +ModernCalendarWidget::ModernCalendarWidget(QWidget* parent) : QCalendarWidget(parent) { + setVerticalHeaderFormat(QCalendarWidget::NoVerticalHeader); + setNavigationBarVisible(true); + setFirstDayOfWeek(Qt::Sunday); + styleNavigationBar(); + setStyleSheet(globalModeQss()); +} + +void ModernCalendarWidget::setDarkMode(bool dark) { + dark_mode = dark; + setStyleSheet(globalModeQss(dark)); + update(); +} + +void ModernCalendarWidget::setColorForDate(const QDate& date, const QColor& color) { + date_colors_[date] = color; + updateCell(date); +} + +void ModernCalendarWidget::popColorForDate(const QDate& date) { + date_colors_.remove(date); + updateCell(date); +} + +void ModernCalendarWidget::styleNavigationBar() { + auto* nav_bar = this->findChild("qt_calendar_navigationbar"); + if (nav_bar) { + nav_bar->setStyleSheet("QWidget { background-color: transparent; border: none; }"); + } + + auto* month_btn = this->findChild("qt_calendar_monthbutton"); + auto* year_btn = this->findChild("qt_calendar_yearbutton"); + if (month_btn && year_btn) { + const QString button_style = "QToolButton { color: #3F51B5; font: bold 16px;" + " background: transparent; border: none; }"; + month_btn->setStyleSheet(button_style); + year_btn->setStyleSheet(button_style); + } +} + +void ModernCalendarWidget::paintCell(QPainter* painter, const QRect& rect, const QDate date) const { + painter->save(); + + const bool is_today = date == QDate::currentDate(); + const bool is_selected = date == selectedDate(); + const QColor event_color = date_colors_.value(date, Qt::transparent); + const int radius = std::min(rect.width(), rect.height()); + const QPointF center = rect.center(); + + const QColor base_bg_color = dark_mode ? QColor(30, 30, 30) : QColor(240, 240, 240); + const QColor today_outline_color(0x3F51B5); + const QColor selected_fg_color = Qt::white; + const QColor default_fg_color = dark_mode ? QColor(220, 220, 220) : Qt::black; + + painter->setRenderHint(QPainter::Antialiasing); + painter->setBrush(base_bg_color); + painter->setPen(Qt::NoPen); + painter->drawRoundedRect(rect.adjusted(4, 4, -4, -4), 8, 8); + + // State indicator priority: selected > event > today. + if (is_selected) { + painter->setBrush(today_outline_color); + painter->drawEllipse(center, radius / 2.3, radius / 2.3); + } else if (event_color != Qt::transparent) { + painter->setBrush(event_color); + painter->drawEllipse(center, radius / 2.8, radius / 2.8); + } else if (is_today) { + painter->setBrush(Qt::NoBrush); + painter->setPen(QPen(today_outline_color, 1.5)); + painter->drawEllipse(center, radius / 2.5, radius / 2.5); + } + + QFont font = painter->font(); + if (is_selected) { + font.setBold(true); + } + painter->setFont(font); + painter->setPen(is_selected ? selected_fg_color : default_fg_color); + + if (is_today && !is_selected && event_color == Qt::transparent) { + painter->drawText(rect.adjusted(0, 4, 0, rect.height() / 3), Qt::AlignCenter, "Today"); + } else { + painter->drawText(rect.adjusted(0, 4, 0, 0), Qt::AlignCenter, QString::number(date.day())); + } + + painter->restore(); +} + +QString ModernCalendarWidget::globalModeQss(bool is_dark_mode) { + if (is_dark_mode) { + return QStringLiteral(R"( +QCalendarWidget QWidget { alternate-background-color: transparent; } +QCalendarWidget QAbstractItemView { + background-color: #121212; color: #eeeeee; border: 1px solid #333; + selection-background-color: #3F51B5; selection-color: white; font: 14px 'Segoe UI'; +} +QCalendarWidget QToolButton:hover { background-color: #1E1E1E; border-radius: 4px; } +QCalendarWidget QToolButton::menu-indicator { image: none; } +QCalendarWidget QMenu { background-color: #1E1E1E; border: 1px solid #333; color: #eee; font: 14px 'Segoe UI'; } +QCalendarWidget QMenu::item:selected { background-color: #3F51B5; color: white; } + )"); + } + return QStringLiteral(R"( +QCalendarWidget QWidget { alternate-background-color: transparent; } +QCalendarWidget QAbstractItemView { + selection-background-color: transparent; outline: none; gridline-color: transparent; + font-size: 15px; +} +QCalendarWidget QToolButton:hover { background-color: #E3F2FD; border-radius: 4px; } +QCalendarWidget QAbstractItemView { + background-color: white; color: #333; font: 14px 'Segoe UI'; border: 1px solid #C5CAE9; + selection-background-color: #3F51B5; selection-color: white; outline: none; padding: 4px; +} +QCalendarWidget QToolButton::menu-indicator { image: none; } +QCalendarWidget QMenu { background-color: white; border: 1px solid #C5CAE9; padding: 4px; font: 14px 'Segoe UI'; color: #333; } +QCalendarWidget QMenu::item:selected { background-color: #3F51B5; color: white; } + )"); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/modern_calendar_widget.h b/ui/components/home_page/modern_calendar_widget.h new file mode 100644 index 000000000..c5f337d0b --- /dev/null +++ b/ui/components/home_page/modern_calendar_widget.h @@ -0,0 +1,133 @@ +/** + * @file modern_calendar_widget.h + * @brief Styled QCalendarWidget with colored date markers (ported from CCIMX). + * + * A QCalendarWidget subclass that paints each cell itself (rounded background, + * selection / today / event dots) and exposes a date->color marker map. Visual + * constants mirror CCIMX ModernCalendarWidget; nav-bar arrow icons fall back to + * Qt defaults (CCIMX used bundled PNGs that are not present here). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#pragma once + +#include +#include +#include +#include + +class QString; + +namespace cf::desktop::desktop_component { + +/** + * @brief QCalendarWidget with custom cell painting and date markers. + * + * @note Theme-less (hardcoded QSS, CCIMX style); a dark variant is available + * via setDarkMode(). + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ +class ModernCalendarWidget : public QCalendarWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the calendar. + * @param[in] parent Owning Qt parent widget. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + explicit ModernCalendarWidget(QWidget* parent = nullptr); + + /** + * @brief Switches between the light and dark visual variants. + * @param[in] dark_mode True for the dark variant. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void setDarkMode(bool dark_mode); + + /** + * @brief Reports whether the dark variant is active. + * @return True if dark mode is on. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + */ + bool isDarkMode() const { return dark_mode; } + + /** + * @brief Marks a date with a color dot. + * @param[in] date Date to mark. + * @param[in] color Marker color. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void setColorForDate(const QDate& date, const QColor& color); + + /** + * @brief Removes the marker for a date. + * @param[in] date Date whose marker to remove. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void popColorForDate(const QDate& date); + + protected: + /** + * @brief Paints a single calendar cell. + * @param[in] painter Painter to draw with. + * @param[in] rect Cell rectangle. + * @param[in] date Date the cell represents. + * @return None + * @throws None + * @note Selection takes priority, then an event marker, then today. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void paintCell(QPainter* painter, const QRect& rect, const QDate date) const override; + + private: + /// @brief Restyles the navigation bar (transparent background). + void styleNavigationBar(); + + /** + * @brief Builds the QSS for the current mode. + * @param[in] is_dark_mode True for the dark variant. + * @return The QSS string. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + */ + static QString globalModeQss(bool is_dark_mode = false); + + bool dark_mode{false}; ///< True when the dark variant is active. + QHash date_colors_; ///< Date -> marker color. +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/net_status_card.cpp b/ui/components/home_page/net_status_card.cpp new file mode 100644 index 000000000..821149071 --- /dev/null +++ b/ui/components/home_page/net_status_card.cpp @@ -0,0 +1,137 @@ +/** + * @file net_status_card.cpp + * @brief Implementation of NetStatusCard (CCIMX NetCardGadget style). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#include "net_status_card.h" + +#include "system/network/network.h" + +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +namespace { +/// @brief Teal gradient QSS (ported from CCIMX NetCardGadget). +inline constexpr const char* kCardQss = R"( + #NetStatusCard { + border-radius: 20px; + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #00B894, stop:1 #00695C); + padding: 12px; + } + #NetStatusCard QLabel { color: white; } + #status_label { font-size: 36px; font-weight: bold; } + #detail_label { font-size: 16px; } +)"; + +/// @brief Maps a cfbase Reachability to a short display word. +QString reachabilityLabel(cf::Reachability r) { + switch (r) { + case cf::Reachability::Online: + return QStringLiteral("Online"); + case cf::Reachability::Site: + return QStringLiteral("LAN"); + case cf::Reachability::Local: + return QStringLiteral("Local only"); + case cf::Reachability::Disconnected: + return QStringLiteral("Offline"); + case cf::Reachability::Unknown: + break; + } + return QStringLiteral("Unknown"); +} + +/// @brief Maps a cfbase TransportMedium to a short display word. +QString transportLabel(cf::TransportMedium m) { + switch (m) { + case cf::TransportMedium::WiFi: + return QStringLiteral("Wi-Fi"); + case cf::TransportMedium::Ethernet: + return QStringLiteral("Ethernet"); + case cf::TransportMedium::Cellular: + return QStringLiteral("Cellular"); + case cf::TransportMedium::Bluetooth: + return QStringLiteral("Bluetooth"); + case cf::TransportMedium::Unknown: + break; + } + return QStringLiteral("Unknown link"); +} + +/// @brief Finds the first non-loopback, up interface with an IPv4 address. +QString firstIPv4Detail(const cf::NetworkInfo& info) { + for (const cf::InterfaceInfo& iface : info.interfaces) { + const bool is_loopback = (iface.flags.value & cf::InterfaceFlags::kIsLoopBack) != 0; + const bool is_up = (iface.flags.value & cf::InterfaceFlags::kIsUp) != 0; + if (is_loopback || !is_up) { + continue; + } + const auto ip = iface.firstIPv4(); + if (ip.has_value()) { + return QStringLiteral("%1 · %2") + .arg(QString::fromStdString(iface.name)) + .arg(QString::fromStdString(ip->toString())); + } + } + return QStringLiteral("No IPv4 address"); +} +} // namespace + +NetStatusCard::NetStatusCard(QWidget* parent) : QWidget(parent) { + setAttribute(Qt::WA_StyledBackground); + setObjectName("NetStatusCard"); + setStyleSheet(kCardQss); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(20, 16, 20, 16); + layout->setSpacing(6); + + status_label_ = new QLabel(this); + status_label_->setObjectName("status_label"); + detail_label_ = new QLabel(this); + detail_label_->setObjectName("detail_label"); + detail_label_->setWordWrap(true); + + layout->addWidget(status_label_, 0, Qt::AlignLeft); + layout->addStretch(1); + layout->addWidget(detail_label_, 0, Qt::AlignLeft); + + auto* shadow = new QGraphicsDropShadowEffect(this); + shadow->setBlurRadius(10); + shadow->setOffset(0, 4); + shadow->setColor(QColor(0, 0, 0, 160)); + setGraphicsEffect(shadow); + + refresh(); + + timer_ = new QTimer(this); + timer_->setTimerType(Qt::CoarseTimer); + connect(timer_, &QTimer::timeout, this, [this]() { refresh(); }); + timer_->start(5000); +} + +void NetStatusCard::refresh() { + const auto result = cf::getNetworkInfo(); + if (!result.has_value()) { + status_label_->setText(reachabilityLabel(cf::Reachability::Unknown)); + detail_label_->setText(QStringLiteral("Network backend unavailable")); + return; + } + const cf::NetworkInfo& info = result.value(); + status_label_->setText(reachabilityLabel(info.status.reachability)); + detail_label_->setText(QStringLiteral("%1\n%2") + .arg(transportLabel(info.status.transportMedium)) + .arg(firstIPv4Detail(info))); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/net_status_card.h b/ui/components/home_page/net_status_card.h new file mode 100644 index 000000000..cee1709c9 --- /dev/null +++ b/ui/components/home_page/net_status_card.h @@ -0,0 +1,59 @@ +/** + * @file net_status_card.h + * @brief Network-status gadget card (CCIMX NetCardGadget style, cfbase-backed). + * + * Teal-gradient card showing the current reachability (Online / Site / Local / + * Disconnected), the transport medium, and the first non-loopback IPv4 address. + * Data comes from cfbase getNetworkInfo() instead of CCIMX's NetAbilityScanner; + * the card is standalone (no AppCardWidget / DesktopToast dependency). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#pragma once + +#include + +class QLabel; +class QTimer; + +namespace cf::desktop::desktop_component { + +/** + * @brief Bottom-grid network status card backed by cfbase. + * + * @note Theme-less (hardcoded QSS, CCIMX style). Polls cfbase on a coarse + * timer (the probe is a fast getifaddrs-style snapshot). + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ +class NetStatusCard : public QWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the card. + * @param[in] parent Owning Qt parent widget. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + explicit NetStatusCard(QWidget* parent = nullptr); + + private: + /// @brief Re-queries cfbase and updates the labels. + void refresh(); + + QLabel* status_label_; ///< Big reachability word (Online / Local / ...). + QLabel* detail_label_; ///< Transport medium + interface + IPv4. + QTimer* timer_; ///< Poll timer. +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/page_stack_widget.cpp b/ui/components/home_page/page_stack_widget.cpp new file mode 100644 index 000000000..60272e698 --- /dev/null +++ b/ui/components/home_page/page_stack_widget.cpp @@ -0,0 +1,198 @@ +/** + * @file page_stack_widget.cpp + * @brief Implementation of the horizontal-swipe PageStackWidget. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#include "page_stack_widget.h" + +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +PageStackWidget::PageStackWidget(QWidget* parent) + : QStackedWidget(parent), current_animation_(new QPropertyAnimation(this)), + next_animation_(new QPropertyAnimation(this)), group_(new QParallelAnimationGroup(this)) { + current_animation_->setPropertyName("pos"); + next_animation_->setPropertyName("pos"); + group_->addAnimation(current_animation_); + group_->addAnimation(next_animation_); +} + +void PageStackWidget::setAnimationDuration(int duration_ms) { + animation_duration_ = duration_ms; +} + +int PageStackWidget::animationDuration() const { + return animation_duration_; +} + +void PageStackWidget::slideToPage(int new_index) { + // Moving to a higher index flips the next page in from the right (to_left). + slideToPage(new_index, currentIndex() < new_index); +} + +void PageStackWidget::toNextPage() { + if (currentIndex() < count() - 1) { + slideToPage(currentIndex() + 1, true); + } +} + +void PageStackWidget::toPrevPage() { + if (currentIndex() > 0) { + slideToPage(currentIndex() - 1, false); + } +} + +void PageStackWidget::mousePressEvent(QMouseEvent* event) { + start_pos_ = event->pos(); + dragging_ = true; +} + +void PageStackWidget::mouseMoveEvent(QMouseEvent* event) { + if (count() == 0 || !dragging_) { + return; + } + const int dx = event->pos().x() - start_pos_.x(); + if (qAbs(dx) < 5) { + return; + } + QWidget* current_widget = currentWidget(); + const int index = currentIndex(); + QWidget* other_wid = nullptr; + + if (dx > 0 && index > 0) { + other_wid = widget(index - 1); + other_wid->move(-width() + dx, 0); // right drag reveals the previous (left) page + } else if (dx < 0 && index < count() - 1) { + other_wid = widget(index + 1); + other_wid->move(width() + dx, 0); // left drag reveals the next (right) page + } + + if (other_wid != nullptr) { + other_wid->resize(current_widget->size()); + other_wid->show(); + other_wid->raise(); + } + current_widget->move(dx, 0); +} + +void PageStackWidget::mouseReleaseEvent(QMouseEvent* event) { + if (count() == 0 || !dragging_) { + return; + } + dragging_ = false; + + const int dx = event->pos().x() - start_pos_.x(); + const int threshold = width() / 4; // 1/4 width commit threshold + const int idx = currentIndex(); + + if (dx > threshold) { + if (idx > 0) { + slideToPage(idx - 1, false); // right drag -> previous page + } else { + bounceBack(dx, idx); + } + } else if (dx < -threshold) { + if (idx < count() - 1) { + slideToPage(idx + 1, true); // left drag -> next page + } else { + bounceBack(dx, idx); + } + } else { + bounceBack(dx, idx); + } +} + +void PageStackWidget::bounceBack(int dx, int idx) { + QWidget* curr = currentWidget(); + + current_animation_->stop(); + current_animation_->setTargetObject(curr); + current_animation_->setPropertyName("pos"); + current_animation_->setDuration(animation_duration_); + current_animation_->setStartValue(curr->pos()); + current_animation_->setEndValue(QPoint(0, 0)); + + next_animation_->stop(); + next_animation_->setTargetObject(nullptr); + hide_after_animation_.clear(); + + if (dx > 0 && idx > 0) { + QWidget* prev = widget(idx - 1); + next_animation_->setTargetObject(prev); + next_animation_->setPropertyName("pos"); + next_animation_->setDuration(animation_duration_); + next_animation_->setStartValue(prev->pos()); + next_animation_->setEndValue(QPoint(-width(), 0)); + hide_after_animation_ = prev; + } else if (dx < 0 && idx < count() - 1) { + QWidget* next = widget(idx + 1); + next_animation_->setTargetObject(next); + next_animation_->setPropertyName("pos"); + next_animation_->setDuration(animation_duration_); + next_animation_->setStartValue(next->pos()); + next_animation_->setEndValue(QPoint(width(), 0)); + hide_after_animation_ = next; + } + if (next_animation_->targetObject() != nullptr) { + group_->start(); + } else { + current_animation_->start(); + } +} + +void PageStackWidget::slideToPage(int new_index, bool to_left) { + if (new_index < 0 || new_index >= count()) { + return; + } + if (new_index == currentIndex()) { + return; + } + + QWidget* curr = currentWidget(); + QWidget* next = widget(new_index); + const int w = width(); + + next->move(to_left ? w : -w, 0); + next->show(); + next->raise(); + + next_animation_->stop(); + next_animation_->setTargetObject(next); + next_animation_->setDuration(animation_duration_); + next_animation_->setStartValue(next->pos()); + next_animation_->setEndValue(QPoint(0, 0)); + next_animation_->setPropertyName("pos"); + + current_animation_->stop(); + current_animation_->setTargetObject(curr); + current_animation_->setPropertyName("pos"); + current_animation_->setDuration(animation_duration_); + current_animation_->setStartValue(curr->pos()); + current_animation_->setEndValue(QPoint(to_left ? -w : w, 0)); + + hide_after_animation_ = curr; + + disconnect(group_, nullptr, nullptr, nullptr); + connect(group_, &QParallelAnimationGroup::finished, this, [this, new_index]() { + if (hide_after_animation_) { + hide_after_animation_->hide(); + hide_after_animation_->move(0, 0); + hide_after_animation_.clear(); + } + setCurrentIndex(new_index); + }); + + group_->start(); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/page_stack_widget.h b/ui/components/home_page/page_stack_widget.h new file mode 100644 index 000000000..effb23bc7 --- /dev/null +++ b/ui/components/home_page/page_stack_widget.h @@ -0,0 +1,164 @@ +/** + * @file page_stack_widget.h + * @brief QStackedWidget with horizontal swipe + slide page transitions. + * + * Holds the desktop pages (HomePage, icon layer) and flips between them with a + * horizontal drag: a drag past 1/4 of the width commits the switch (animated), + * otherwise the current page bounces back. Mirrors CardStackWidget (which does + * the same for vertical card swipes); the two coexist by Qt event propagation + * -- HomePage and its clock widgets do not accept mouse press, so it bubbles + * here, while CardStackWidget accepts press and keeps the gesture. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#pragma once + +#include +#include +#include + +class QPropertyAnimation; +class QParallelAnimationGroup; +class QMouseEvent; + +namespace cf::desktop::desktop_component { + +/** + * @brief Stacked widget of desktop pages with horizontal swipe transitions. + * + * @note Single-threaded (UI thread only). + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ +class PageStackWidget : public QStackedWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the page stack. + * @param[in] parent Owning Qt parent widget. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + explicit PageStackWidget(QWidget* parent = nullptr); + + /** + * @brief Animates a transition to the given page index. + * @param[in] new_index Target page index. + * @return None + * @throws None + * @note Direction is inferred from the current index. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void slideToPage(int new_index); + + /** + * @brief Sets the transition animation duration. + * @param[in] duration_ms Duration in milliseconds. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void setAnimationDuration(int duration_ms); + + /** + * @brief Returns the animation duration. + * @return Duration in milliseconds. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + int animationDuration() const; + + /** + * @brief Animates to the next (higher-index) page if any. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void toNextPage(); + + /** + * @brief Animates to the previous (lower-index) page if any. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void toPrevPage(); + + protected: + /** + * @brief Records the drag start position on left-button press. + * @param[in] event Mouse press event. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void mousePressEvent(QMouseEvent* event) override; + + /** + * @brief Tracks the drag, shifting the neighboring page in real time. + * @param[in] event Mouse move event. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void mouseMoveEvent(QMouseEvent* event) override; + + /** + * @brief Commits the switch past 1/4 width, otherwise bounces back. + * @param[in] event Mouse release event. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void mouseReleaseEvent(QMouseEvent* event) override; + + private: + /// @brief Animated transition with explicit direction. + void slideToPage(int new_index, bool to_left); + + /// @brief Animates the current page back to its rest position. + void bounceBack(int dx, int index); + + bool dragging_{false}; ///< Drag in progress. + QPoint start_pos_; ///< Press position (local). + QPropertyAnimation* current_animation_; ///< Animation for outgoing page. + QPropertyAnimation* next_animation_; ///< Animation for incoming page. + QParallelAnimationGroup* group_; ///< Parallel runner. + QPointer hide_after_animation_; ///< Page to hide once finished. + int animation_duration_{300}; ///< Transition duration (ms). +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/system_usage_card.cpp b/ui/components/home_page/system_usage_card.cpp new file mode 100644 index 000000000..48c8c8d6b --- /dev/null +++ b/ui/components/home_page/system_usage_card.cpp @@ -0,0 +1,124 @@ +/** + * @file system_usage_card.cpp + * @brief Implementation of the SystemUsageCard. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#include "system_usage_card.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +namespace { +/// @brief Dark gradient QSS (ported from CCIMX MemoryUsageCard / DiskUsageCard). +inline constexpr const char* kCardQss = R"( + #SystemUsageCard { + border-radius: 16px; + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #5D6D7E, stop:1 #2C3E50); + } + #SystemUsageCard QLabel { color: #ECF0F1; } + #title_label { font-size: 32px; font-weight: bold; } + #value_label { font-size: 48px; font-weight: bold; } + #detail_label { font-size: 16px; } + QProgressBar { + border-radius: 7px; + background-color: #34495e; + min-height: 18px; + max-height: 18px; + } + QProgressBar::chunk { + background-color: #1abc9c; + border-radius: 7px; + } +)"; +} // namespace + +SystemUsageCard::SystemUsageCard(const QString& title, Probe probe, int interval_ms, + QWidget* parent) + : QWidget(parent), probe_(std::move(probe)) { + setAttribute(Qt::WA_StyledBackground); // required for QSS background on a QWidget + setObjectName("SystemUsageCard"); + setStyleSheet(kCardQss); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(24, 20, 24, 20); + layout->setSpacing(8); + + title_label_ = new QLabel(title, this); + title_label_->setObjectName("title_label"); + value_label_ = new QLabel(this); + value_label_->setObjectName("value_label"); + bar_ = new QProgressBar(this); + bar_->setRange(0, 100); + bar_->setTextVisible(false); + detail_label_ = new QLabel(this); + detail_label_->setObjectName("detail_label"); + + layout->addWidget(title_label_, 0, Qt::AlignLeft); + layout->addStretch(1); + layout->addWidget(value_label_, 0, Qt::AlignCenter); + layout->addStretch(1); + layout->addWidget(bar_); + layout->addWidget(detail_label_, 0, Qt::AlignLeft); + + auto* shadow = new QGraphicsDropShadowEffect(this); + shadow->setBlurRadius(20); + shadow->setOffset(0, 5); + shadow->setColor(QColor(0, 0, 0, 100)); + setGraphicsEffect(shadow); + + // The CPU probe sleeps ~100 ms internally to measure usage; running it on + // the UI thread would freeze the desktop each tick. QtConcurrent::run moves + // it to the thread pool, and the watcher delivers the result back here. + probe_watcher_ = new QFutureWatcher(this); + connect(probe_watcher_, &QFutureWatcher::finished, this, [this]() { + applySample(probe_watcher_->result()); + in_flight_ = false; + }); + + refresh(); // first (async) sample + + timer_ = new QTimer(this); + timer_->setTimerType(Qt::CoarseTimer); + connect(timer_, &QTimer::timeout, this, [this]() { refresh(); }); + timer_->start(interval_ms); +} + +void SystemUsageCard::refresh() { + // Skip when no probe or a previous probe is still in flight — never queue + // overlapping probes (a slow CPU sample must not stack up behind itself). + if (!probe_ || in_flight_) { + return; + } + in_flight_ = true; + // Capture the probe by value so the worker never dereferences `this`. + probe_watcher_->setFuture(QtConcurrent::run([probe = probe_]() { return probe(); })); +} + +void SystemUsageCard::applySample(const UsageSample& sample) { + int pct = sample.pct; + if (pct < 0) { + pct = 0; + } + if (pct > 100) { + pct = 100; + } + bar_->setValue(pct); + value_label_->setText(QStringLiteral("%1%").arg(pct)); + detail_label_->setText(sample.detail); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/system_usage_card.h b/ui/components/home_page/system_usage_card.h new file mode 100644 index 000000000..1b29ea9fc --- /dev/null +++ b/ui/components/home_page/system_usage_card.h @@ -0,0 +1,83 @@ +/** + * @file system_usage_card.h + * @brief Reusable usage card (title + percentage + progress bar + detail). + * + * A dark-gradient card (CCIMX MemoryUsageCard / DiskUsageCardWidget style) + * parameterized by a probe callable and a poll interval, so Memory / CPU / + * Disk share one implementation. Probe returns {percentage, detail line}. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#pragma once + +#include +#include + +#include + +class QLabel; +class QProgressBar; +class QTimer; +template class QFutureWatcher; + +namespace cf::desktop::desktop_component { + +/// @brief A single usage sample returned by a SystemUsageCard probe. +struct UsageSample { + int pct; ///< Usage percentage (0-100). + QString detail; ///< Human-readable line (e.g. "7.9 / 12.7 GB"). +}; + +/** + * @brief Reusable system-usage card driven by a probe callable. + * + * @note Polls the probe off the UI thread (QtConcurrent) on a coarse timer; + * the card itself is theme-less (hardcoded QSS, CCIMX style). + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ +class SystemUsageCard : public QWidget { + Q_OBJECT + + public: + /// @brief Callable returning the current UsageSample. + using Probe = std::function; + + /** + * @brief Constructs the usage card. + * @param[in] title Card title (e.g. "Memory"). + * @param[in] probe Callable returning the current sample. + * @param[in] interval_ms Poll interval in milliseconds. + * @param[in] parent Owning Qt parent widget. + * @throws None + * @note Polls once at construction, then every interval_ms. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + SystemUsageCard(const QString& title, Probe probe, int interval_ms, QWidget* parent = nullptr); + + private: + /// @brief Kicks the probe off the UI thread; skips when a probe is in flight. + void refresh(); + + /// @brief Applies a fetched sample to the labels + bar (UI thread only). + void applySample(const UsageSample& sample); + + QLabel* title_label_; ///< Card title. + QLabel* value_label_; ///< Percentage text. + QProgressBar* bar_; ///< Progress bar. + QLabel* detail_label_; ///< Detail line. + Probe probe_; ///< Sample source. + QTimer* timer_; ///< Poll timer. + QFutureWatcher* probe_watcher_; ///< Marshals probe results back to the UI thread. + bool in_flight_ = false; ///< True while a probe is pending (UI thread only). +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/user_info_card.cpp b/ui/components/home_page/user_info_card.cpp new file mode 100644 index 000000000..d2d81f510 --- /dev/null +++ b/ui/components/home_page/user_info_card.cpp @@ -0,0 +1,130 @@ +/** + * @file user_info_card.cpp + * @brief Implementation of UserInfoCard (CCIMX UserInfoCard style). + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#include "user_info_card.h" + +#include +#include +#include +#include +#include + +#if defined(_WIN32) +# include +# include +#else +# include +# include +#endif + +namespace cf::desktop::desktop_component { + +namespace { +/// @brief Green-to-teal gradient QSS (ported from CCIMX UserInfoCard). +inline constexpr const char* kCardQss = R"( + #UserInfoCard { border-radius: 20px; + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #43e97b, stop:1 #38f9d7); + } + #UserInfoCard QLabel { color: white; } + #avatar_label { border-radius: 36px; background: rgba(255,255,255,0.95); + color: #43e97b; font-size: 44px; font-weight: bold; } + #name_label { font-size: 40px; font-weight: bold; } + #host_label, #os_label { font-size: 20px; } +)"; + +/// @brief Reads the login user's display name (POSIX GECOS or Windows session user). +QString readUserName() { +#if defined(_WIN32) + wchar_t buf[UNLEN + 1] = {}; + DWORD len = UNLEN + 1; + if (GetUserNameW(buf, &len)) { + return QString::fromWCharArray(buf); + } + return QStringLiteral("user"); +#else + const struct passwd* pw = getpwuid(getuid()); + if (pw != nullptr) { + if (pw->pw_gecos != nullptr && pw->pw_gecos[0] != '\0') { + const QString gecos = QString::fromLocal8Bit(pw->pw_gecos); + const int comma = gecos.indexOf(','); + const QString first = (comma >= 0) ? gecos.left(comma) : gecos; + if (!first.trimmed().isEmpty()) { + return first.trimmed(); + } + } + if (pw->pw_name != nullptr && pw->pw_name[0] != '\0') { + return QString::fromLocal8Bit(pw->pw_name); + } + } + return QStringLiteral("user"); +#endif +} + +/// @brief Reads the machine hostname (cross-platform via QSysInfo). +QString readHostName() { + return QSysInfo::machineHostName(); +} + +/// @brief Reads the OS name and architecture (cross-platform via QSysInfo). +QString readOsLine() { + return QSysInfo::prettyProductName(); +} +} // namespace + +UserInfoCard::UserInfoCard(QWidget* parent) : QWidget(parent) { + setAttribute(Qt::WA_StyledBackground); + setObjectName("UserInfoCard"); + setStyleSheet(kCardQss); + + auto* main = new QHBoxLayout(this); + main->setContentsMargins(24, 20, 24, 20); + main->setSpacing(20); + + avatar_label_ = new QLabel(this); + avatar_label_->setObjectName("avatar_label"); + avatar_label_->setFixedSize(72, 72); + avatar_label_->setAlignment(Qt::AlignCenter); + main->addWidget(avatar_label_, 0, Qt::AlignVCenter); + + auto* info = new QVBoxLayout; + info->setSpacing(6); + name_label_ = new QLabel(this); + name_label_->setObjectName("name_label"); + host_label_ = new QLabel(this); + host_label_->setObjectName("host_label"); + os_label_ = new QLabel(this); + os_label_->setObjectName("os_label"); + info->addWidget(name_label_); + info->addWidget(host_label_); + info->addWidget(os_label_); + info->addStretch(); + main->addLayout(info, 1); + + auto* shadow = new QGraphicsDropShadowEffect(this); + shadow->setBlurRadius(30); + shadow->setOffset(0, 6); + shadow->setColor(QColor(0, 0, 0, 80)); + setGraphicsEffect(shadow); + + refresh(); +} + +void UserInfoCard::refresh() { + const QString name = readUserName(); + name_label_->setText(name); + host_label_->setText(readHostName()); + os_label_->setText(readOsLine()); + // Use the first character (uppercased) as the avatar initial. + const QChar initial = name.isEmpty() ? QChar('?') : name.at(0).toUpper(); + avatar_label_->setText(initial); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/home_page/user_info_card.h b/ui/components/home_page/user_info_card.h new file mode 100644 index 000000000..a80db6968 --- /dev/null +++ b/ui/components/home_page/user_info_card.h @@ -0,0 +1,68 @@ +/** + * @file user_info_card.h + * @brief User-profile card (CCIMX UserInfoCard style, desktop-data variant). + * + * Green-gradient card showing an avatar (the user's initial), the OS user name, + * the hostname, and the OS/kernel line. Visual mirrors CCIMX UserInfoCard; the + * data comes from POSIX (getpwuid / gethostname / uname) instead of CCIMX's + * DesktopUserInfo, since CFDesktop has no user-profile subsystem and a generic + * desktop has no real phone/email to display. + * + * @author CFDesktop Team + * @date 2026-07-08 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup home_page + */ + +#pragma once + +#include + +class QLabel; + +namespace cf::desktop::desktop_component { + +/** + * @brief User-profile card backed by local POSIX data. + * + * @note Theme-less (hardcoded QSS, CCIMX style). Gathers its own data at + * construction; call refresh() to re-read after a locale/user change. + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ +class UserInfoCard : public QWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the card and reads the current user info. + * @param[in] parent Owning Qt parent widget. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + explicit UserInfoCard(QWidget* parent = nullptr); + + /** + * @brief Re-reads the OS user info and updates the labels. + * @return None + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup home_page + */ + void refresh(); + + private: + QLabel* avatar_label_; ///< Circular avatar showing the user's initial. + QLabel* name_label_; ///< OS user name. + QLabel* host_label_; ///< Hostname line. + QLabel* os_label_; ///< OS / kernel line. +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/launcher/launcher_tile.cpp b/ui/components/launcher/launcher_tile.cpp index cad709d86..a568f3723 100644 --- a/ui/components/launcher/launcher_tile.cpp +++ b/ui/components/launcher/launcher_tile.cpp @@ -218,6 +218,12 @@ void LauncherTile::mousePressEvent(QMouseEvent* event) { press_pos_ = event->position(); drag_state_ = DragState::Pressed; long_press_timer_->start(); + // Accept so the press does not bubble up to PageStackWidget -- a tile + // is being tapped/dragged, the page must not swipe under it. The icon + // grid's empty cells (not on a tile) still bubble, so swiping the grid + // background returns to the home page. + event->accept(); + return; } QWidget::mousePressEvent(event); } diff --git a/ui/render/CMakeLists.txt b/ui/render/CMakeLists.txt index d76faf60c..7e1080eb1 100644 --- a/ui/render/CMakeLists.txt +++ b/ui/render/CMakeLists.txt @@ -1,5 +1,5 @@ -# Render backend abstraction -# Provides RenderBackend interface, capabilities, and factory +# Render backend capabilities +# Provides BackendCapabilities used by IWindowBackend. add_library(cf_desktop_render STATIC) @@ -7,9 +7,7 @@ target_sources(cf_desktop_render PUBLIC FILE_SET cf_desktop_render_headers TYPE HEADERS BASE_DIRS ${CMAKE_CURRENT_SOURCE_DIR} FILES - render_backend.h backend_capabilities.h - render_backend_factory.h ) target_include_directories(cf_desktop_render PUBLIC diff --git a/ui/render/render_backend.h b/ui/render/render_backend.h deleted file mode 100644 index 2a1481403..000000000 --- a/ui/render/render_backend.h +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @file render_backend.h - * @brief Abstract interface for platform-specific rendering backends. - * - * RenderBackend encapsulates the low-level rendering hardware - * initialization and lifecycle. Concrete implementations wrap the - * platform's native rendering API (EGL/OpenGL ES on embedded Linux, - * Win32 + D3D/OpenGL on Windows, etc.). - * - * The shell does not draw through RenderBackend directly — Qt RHI or - * QPainter is still used for actual rendering. RenderBackend's job is to - * set up the rendering surface, provide capability information, and - * manage the native display handle. - * - * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) - * @date 2026-03-29 - * @version 0.1 - * @since 0.11 - * @ingroup render - */ - -#pragma once - -#include "backend_capabilities.h" -#include - -namespace cf::desktop::render { - -/** - * @brief Abstract rendering backend interface. - * - * Provides lifecycle management and capability queries for the - * platform-specific rendering hardware. Each supported platform - * (EGLFS, Windows, Wayland, X11) provides its own implementation. - * - * @note Implementations must be safe to construct but should not - * access hardware until initialize() is called. - * @ingroup render - */ -class RenderBackend { - public: - virtual ~RenderBackend() = default; - - /** - * @brief Initializes the rendering backend and hardware resources. - * - * Must be called exactly once before any other method. - * - * @return True if initialization succeeded, false on failure. - */ - virtual bool initialize() = 0; - - /** - * @brief Releases all hardware resources held by the backend. - * - * Safe to call even if initialize() was never called or failed. - */ - virtual void shutdown() = 0; - - /** - * @brief Queries the capabilities of this rendering backend. - * - * @return BackendCapabilities describing what the backend supports. - */ - virtual BackendCapabilities capabilities() const = 0; - - /** - * @brief Returns the current screen/ output size in pixels. - * - * On multi-output setups this returns the primary output size. - * - * @return Screen dimensions in device pixels. - */ - virtual QSize screenSize() const = 0; - - /** - * @brief Returns the platform-native display handle. - * - * The type depends on the platform: - * - Linux EGLFS: EGLDisplay - * - Windows: HWND or device context - * - Wayland: wl_display* - * - X11: Display* - * - * @return Opaque native handle, or nullptr if not initialized. - */ - virtual void* nativeHandle() const = 0; -}; - -} // namespace cf::desktop::render diff --git a/ui/render/render_backend_factory.h b/ui/render/render_backend_factory.h deleted file mode 100644 index 7ca4c6983..000000000 --- a/ui/render/render_backend_factory.h +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @file render_backend_factory.h - * @brief Factory for creating the appropriate RenderBackend instance. - * - * Selects and instantiates the correct RenderBackend implementation - * based on the current platform and runtime environment. - * - * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) - * @date 2026-03-29 - * @version 0.1 - * @since 0.11 - * @ingroup render - */ - -#pragma once - -#include "render_backend.h" -#include - -namespace cf::desktop::render { - -/** - * @brief Factory that creates a RenderBackend suitable for the current - * platform and environment. - * - * The selection logic follows this priority: - * 1. Environment variable CFDESKTOP_RENDER_BACKEND (force override) - * 2. Platform detection via Qt platform name and system probes - * 3. Default: platform-specific default backend - * - * @note The returned backend is created but **not initialized**. - * The caller must call initialize() before use. - * @ingroup render - */ -class RenderBackendFactory { - public: - /** - * @brief Creates a RenderBackend appropriate for the current environment. - * - * @return A unique_ptr to the created backend, or nullptr if no suitable - * backend is available. - */ - static std::unique_ptr create(); - - /** - * @brief Creates a specific RenderBackend by name. - * - * @param[in] backendName One of: "eglfs", "linuxfb", "windows", - * "wayland", "x11". - * @return A unique_ptr to the created backend, or nullptr if the - * name is unrecognized. - */ - static std::unique_ptr createByName(const char* backendName); -}; - -} // namespace cf::desktop::render