mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-08-13 10:04:13 +02:00
Merge pull request #647 from ispyisail/feature-diagnostic-logging-crash
Add crash-time ring flush and diagnostics export UI (discussion #644, steps 4-5)
This commit is contained in:
@@ -116,10 +116,14 @@ set(QET_RES_FILES
|
||||
set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/cli_export.cpp
|
||||
${QET_DIR}/sources/cli_export.h
|
||||
${QET_DIR}/sources/logging/crashhandler.cpp
|
||||
${QET_DIR}/sources/logging/crashhandler.h
|
||||
${QET_DIR}/sources/logging/logring.cpp
|
||||
${QET_DIR}/sources/logging/logring.h
|
||||
${QET_DIR}/sources/logging/qetlogger.cpp
|
||||
${QET_DIR}/sources/logging/qetlogger.h
|
||||
${QET_DIR}/sources/logging/ui/diagnosticsreportdialog.cpp
|
||||
${QET_DIR}/sources/logging/ui/diagnosticsreportdialog.h
|
||||
${QET_DIR}/sources/pdf_links.cpp
|
||||
${QET_DIR}/sources/pdf_links.h
|
||||
${QET_DIR}/sources/import/edz/edzarchive.cpp
|
||||
|
||||
+4
-2
@@ -174,7 +174,8 @@ HEADERS += $$files(sources/*.h) \
|
||||
$$files(sources/svg/*.h) \
|
||||
$$files(sources/import/edz/*.h) \
|
||||
$$files(sources/import/edz/lzma/*.h) \
|
||||
$$files(sources/logging/*.h)
|
||||
$$files(sources/logging/*.h) \
|
||||
$$files(sources/logging/ui/*.h)
|
||||
|
||||
SOURCES += $$files(sources/*.cpp) \
|
||||
$$files(sources/editor/*.cpp) \
|
||||
@@ -221,7 +222,8 @@ SOURCES += $$files(sources/*.cpp) \
|
||||
$$files(sources/svg/*.cpp) \
|
||||
$$files(sources/import/edz/*.cpp) \
|
||||
$$files(sources/import/edz/lzma/*.c) \
|
||||
$$files(sources/logging/*.cpp)
|
||||
$$files(sources/logging/*.cpp) \
|
||||
$$files(sources/logging/ui/*.cpp)
|
||||
|
||||
|
||||
# Needed for use promote QTreeWidget in terminalstripeditor.ui
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "crashhandler.h"
|
||||
|
||||
#include "logring.h"
|
||||
#include "../qetversion.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QSysInfo>
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
#include <fcntl.h>
|
||||
#include <io.h>
|
||||
#include <share.h>
|
||||
#include <sys/stat.h>
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <cerrno>
|
||||
#include <csignal>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
// Everything the handler touches is preallocated here and filled in by
|
||||
// install() (normal context, runs once at startup) -- nothing under the
|
||||
// actual signal/exception path may allocate or touch QString/Qt.
|
||||
const LogRing *g_ring = nullptr;
|
||||
char g_dump_path[1024] = {};
|
||||
char g_header[1024] = {};
|
||||
int g_header_len = 0;
|
||||
|
||||
// Guards against two threads crashing at once, or the handler itself
|
||||
// faulting while dumping: only the first crash writes a dump. See
|
||||
// crashhandler.h invariant 4.
|
||||
std::atomic<bool> g_already_dumped{false};
|
||||
|
||||
#ifndef Q_OS_WIN
|
||||
|
||||
// A stack-overflow SIGSEGV leaves no usable stack for a handler to run
|
||||
// on at all, hence the alternate signal stack (invariant: sized well
|
||||
// above any known SIGSTKSZ so this doesn't depend on
|
||||
// sysconf(_SC_SIGSTKSZ), which some libc versions require at runtime
|
||||
// rather than offering as a compile-time constant).
|
||||
char g_altstack[65536];
|
||||
|
||||
const int kHandledSignals[] = {SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL};
|
||||
|
||||
void restoreDefaultAndReraise(int sig)
|
||||
{
|
||||
struct sigaction sa {};
|
||||
sa.sa_handler = SIG_DFL;
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sa.sa_flags = 0;
|
||||
sigaction(sig, &sa, nullptr);
|
||||
raise(sig);
|
||||
}
|
||||
|
||||
void signalHandler(int sig)
|
||||
{
|
||||
if (g_already_dumped.exchange(true, std::memory_order_acq_rel)) {
|
||||
// Not the first crash (concurrent fault on another thread, or
|
||||
// this handler faulting while dumping): skip straight to
|
||||
// restore-and-re-raise rather than risk a second, interleaved
|
||||
// write to the same file.
|
||||
restoreDefaultAndReraise(sig);
|
||||
return;
|
||||
}
|
||||
|
||||
// open/write/close are all on the POSIX async-signal-safe function
|
||||
// list; nothing else is called here.
|
||||
const int fd = ::open(g_dump_path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
if (fd >= 0) {
|
||||
if (g_header_len > 0) {
|
||||
::write(fd, g_header, static_cast<size_t>(g_header_len));
|
||||
}
|
||||
if (g_ring) {
|
||||
g_ring->dumpToFd(fd);
|
||||
}
|
||||
::close(fd);
|
||||
}
|
||||
|
||||
restoreDefaultAndReraise(sig);
|
||||
}
|
||||
|
||||
#else // Q_OS_WIN
|
||||
|
||||
LONG WINAPI windowsExceptionFilter(EXCEPTION_POINTERS *)
|
||||
{
|
||||
bool expected = false;
|
||||
if (!g_already_dumped.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) {
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
|
||||
int fd = -1;
|
||||
errno_t err = _sopen_s(&fd, g_dump_path,
|
||||
_O_WRONLY | _O_CREAT | _O_TRUNC | _O_BINARY,
|
||||
_SH_DENYWR, _S_IREAD | _S_IWRITE);
|
||||
if (err == 0 && fd >= 0) {
|
||||
if (g_header_len > 0) {
|
||||
_write(fd, g_header, g_header_len);
|
||||
}
|
||||
if (g_ring) {
|
||||
g_ring->dumpToFd(fd);
|
||||
}
|
||||
_close(fd);
|
||||
}
|
||||
|
||||
// Do not suppress Windows Error Reporting / an attached debugger --
|
||||
// same invariant as re-raising on POSIX (see crashhandler.h,
|
||||
// invariant 3).
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
void CrashHandler::install(const LogRing *ring, const QString &dump_path)
|
||||
{
|
||||
g_ring = ring;
|
||||
|
||||
const QByteArray path_utf8 = dump_path.toUtf8();
|
||||
std::strncpy(g_dump_path, path_utf8.constData(), sizeof(g_dump_path) - 1);
|
||||
|
||||
const QByteArray header = QByteArray("QET crash dump\n")
|
||||
+ "Version: " + QetVersion::displayedVersion().toUtf8() + "\n"
|
||||
+ "Git: " GIT_COMMIT_SHA "\n"
|
||||
+ "OS: " + QSysInfo::prettyProductName().toUtf8() + " (" + QSysInfo::currentCpuArchitecture().toUtf8() + ")\n"
|
||||
+ "Qt: " QT_VERSION_STR "\n"
|
||||
+ "---\n";
|
||||
g_header_len = qMin<int>(header.size(), static_cast<int>(sizeof(g_header)) - 1);
|
||||
std::memcpy(g_header, header.constData(), static_cast<size_t>(g_header_len));
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
SetUnhandledExceptionFilter(windowsExceptionFilter);
|
||||
#else
|
||||
stack_t ss;
|
||||
ss.ss_sp = g_altstack;
|
||||
ss.ss_size = sizeof(g_altstack);
|
||||
ss.ss_flags = 0;
|
||||
sigaltstack(&ss, nullptr);
|
||||
|
||||
struct sigaction sa {};
|
||||
sa.sa_handler = signalHandler;
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sa.sa_flags = SA_ONSTACK;
|
||||
|
||||
for (int sig : kHandledSignals) {
|
||||
sigaction(sig, &sa, nullptr);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef CRASHHANDLER_H
|
||||
#define CRASHHANDLER_H
|
||||
|
||||
#include <QString>
|
||||
|
||||
class LogRing;
|
||||
|
||||
/**
|
||||
@brief The CrashHandler class
|
||||
Discussion #644, step 4: on a fatal crash, flush the in-memory
|
||||
LogRing to a fixed file before the process dies, so the last N log
|
||||
lines leading up to the crash survive it -- today they only exist in
|
||||
memory and are lost with the process.
|
||||
|
||||
This is the highest-risk piece of the whole logging rework (the
|
||||
discussion's own words: "lands last, behind its own switch"), so its
|
||||
invariants are worth restating plainly:
|
||||
|
||||
1. The handler must never block. It takes no locks -- LogRing itself
|
||||
is lock-free for exactly this reason (see logring.h). A handler
|
||||
that can hang is worse than no handler: it turns a clean crash
|
||||
(which at least produces a core dump) into a hung process that has
|
||||
to be force-killed, producing neither a core dump nor a ring dump.
|
||||
2. The handler must never allocate. Under heap corruption -- a
|
||||
plausible *cause* of the very crash being handled -- malloc may
|
||||
itself deadlock or fault. Every buffer this code touches at crash
|
||||
time (the dump path, the header, the ring's own storage) is
|
||||
preallocated by install(), which runs once at startup in normal
|
||||
(non-signal) context.
|
||||
3. The handler must not swallow the crash. After writing the dump it
|
||||
restores the default disposition for the signal and re-raises, so
|
||||
the OS still produces a core dump (POSIX) / Windows Error
|
||||
Reporting still sees the exception. A handler that "fixed" the
|
||||
crash by not re-raising would destroy the post-mortem evidence a
|
||||
core dump provides.
|
||||
4. Only the *first* crash writes a dump. An atomic test-and-set
|
||||
guards against two threads faulting simultaneously (or the handler
|
||||
itself faulting while dumping) producing an interleaved or
|
||||
truncated file; every crash after the first goes straight to
|
||||
restore-and-re-raise.
|
||||
|
||||
Tested in this environment: POSIX/Linux only (sigaction, sigaltstack,
|
||||
SIGSEGV/SIGABRT/SIGBUS/SIGFPE/SIGILL). The Windows path
|
||||
(SetUnhandledExceptionFilter) and macOS-specific behaviour (signal
|
||||
handling itself is POSIX and shares the Linux code path, but sandbox
|
||||
profiles can affect where the dump file may be written) are
|
||||
implemented per the discussion's guidance but could not be exercised
|
||||
here -- there is no Windows or macOS build available in this sandbox.
|
||||
Please sanity-check both before relying on them in the field.
|
||||
*/
|
||||
class CrashHandler
|
||||
{
|
||||
public:
|
||||
/// Installs the crash handler. Must be called from normal
|
||||
/// (non-signal) startup code, after the LogRing it will dump
|
||||
/// exists, and only once. `ring` must outlive the process (in
|
||||
/// practice: the LogRing owned by QetLogger's function-local
|
||||
/// static instance, which is never destroyed before exit).
|
||||
/// `dump_path` is resolved and copied into a fixed-size internal
|
||||
/// buffer here; nothing under the actual signal/exception path
|
||||
/// touches QString.
|
||||
static void install(const LogRing *ring, const QString &dump_path);
|
||||
|
||||
private:
|
||||
CrashHandler() = delete;
|
||||
};
|
||||
|
||||
#endif // CRASHHANDLER_H
|
||||
+99
-38
@@ -17,75 +17,136 @@
|
||||
*/
|
||||
#include "logring.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
/**
|
||||
@brief LogRing::LogRing
|
||||
Preallocates all kCapacityEntries slots up front -- the only
|
||||
allocation this class ever does.
|
||||
*/
|
||||
LogRing::LogRing()
|
||||
{
|
||||
m_entries.resize(kCapacityEntries);
|
||||
}
|
||||
#ifdef Q_OS_WIN
|
||||
#include <io.h>
|
||||
#else
|
||||
#include <cerrno>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
static_assert(std::atomic<int>::is_always_lock_free,
|
||||
"LogRing::Entry::length must be a lock-free atomic<int> -- "
|
||||
"dumpToFd() reads it from a signal handler and must never block.");
|
||||
|
||||
namespace {
|
||||
|
||||
/**
|
||||
@brief LogRing::append
|
||||
@param line one already-formatted log line (no further formatting
|
||||
is done here). Truncated to kEntryBytes - 1 bytes if longer, with a
|
||||
trailing marker, so the stored entry is always a complete,
|
||||
independently-readable line.
|
||||
@brief writeAllSignalSafe
|
||||
Loops until length bytes have been written to fd or an unrecoverable
|
||||
error occurs. write(2) may write fewer bytes than requested and may
|
||||
return EINTR -- both are *more* likely from inside a signal handler
|
||||
than in normal code, so a single write() call is not enough here.
|
||||
Async-signal-safe: only calls write(2)/errno, nothing else.
|
||||
*/
|
||||
void LogRing::append(const QByteArray &line)
|
||||
void writeAllSignalSafe(int fd, const char *data, int length) noexcept
|
||||
{
|
||||
int remaining = length;
|
||||
const char *p = data;
|
||||
|
||||
while (remaining > 0) {
|
||||
#ifdef Q_OS_WIN
|
||||
const int n = _write(fd, p, static_cast<unsigned int>(remaining));
|
||||
if (n <= 0) {
|
||||
return;
|
||||
}
|
||||
#else
|
||||
const ssize_t n = ::write(fd, p, static_cast<size_t>(remaining));
|
||||
if (n < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
return; // unrecoverable -- give up silently, never block/throw
|
||||
}
|
||||
if (n == 0) {
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
p += n;
|
||||
remaining -= static_cast<int>(n);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
LogRing::LogRing() :
|
||||
// The sized constructor value-initialises each Entry in place; unlike
|
||||
// resize(), it doesn't require Entry to be move/copy-constructible,
|
||||
// which std::atomic<int> deliberately never is. The only allocation
|
||||
// this class ever does.
|
||||
m_entries(kCapacityEntries)
|
||||
{
|
||||
}
|
||||
|
||||
void LogRing::append(const QByteArray &line) noexcept
|
||||
{
|
||||
static const char kMarker[] = "...[ring-truncated]\n";
|
||||
const int marker_len = static_cast<int>(sizeof(kMarker)) - 1;
|
||||
|
||||
QMutexLocker locker(&m_mutex);
|
||||
const quint64 idx = m_write_cursor.fetch_add(1, std::memory_order_relaxed);
|
||||
Entry &slot = m_entries[static_cast<size_t>(idx % static_cast<quint64>(kCapacityEntries))];
|
||||
|
||||
Entry &slot = m_entries[static_cast<size_t>(m_next_index)];
|
||||
// Zero the length first so a concurrent reader landing on this exact
|
||||
// slot mid-copy sees "not ready" rather than the previous lap's
|
||||
// (now-being-overwritten) content at a stale length.
|
||||
slot.length.store(0, std::memory_order_relaxed);
|
||||
|
||||
int len;
|
||||
if (line.size() < kEntryBytes) {
|
||||
std::memcpy(slot.data, line.constData(), static_cast<size_t>(line.size()));
|
||||
slot.length = line.size();
|
||||
len = line.size();
|
||||
} else {
|
||||
const int keep = kEntryBytes - marker_len;
|
||||
std::memcpy(slot.data, line.constData(), static_cast<size_t>(keep));
|
||||
std::memcpy(slot.data + keep, kMarker, static_cast<size_t>(marker_len));
|
||||
slot.length = kEntryBytes;
|
||||
len = kEntryBytes;
|
||||
}
|
||||
|
||||
m_next_index = (m_next_index + 1) % kCapacityEntries;
|
||||
if (m_count < kCapacityEntries) {
|
||||
++m_count;
|
||||
}
|
||||
slot.length.store(len, std::memory_order_release);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief LogRing::snapshot
|
||||
@return the entries currently held, oldest first. Safe to call from
|
||||
normal (non-signal) code only.
|
||||
*/
|
||||
QVector<QByteArray> LogRing::snapshot() const
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
const quint64 cursor = m_write_cursor.load(std::memory_order_acquire);
|
||||
const quint64 cap = static_cast<quint64>(kCapacityEntries);
|
||||
const quint64 count = (cursor < cap) ? cursor : cap;
|
||||
const quint64 start = (cursor < cap) ? 0 : (cursor - cap);
|
||||
|
||||
QVector<QByteArray> result;
|
||||
result.reserve(m_count);
|
||||
result.reserve(static_cast<int>(count));
|
||||
|
||||
const int start = (m_count < kCapacityEntries) ? 0 : m_next_index;
|
||||
for (int i = 0; i < m_count; ++i) {
|
||||
const Entry &slot = m_entries[static_cast<size_t>((start + i) % kCapacityEntries)];
|
||||
result.append(QByteArray(slot.data, slot.length));
|
||||
for (quint64 i = 0; i < count; ++i) {
|
||||
const Entry &slot = m_entries[static_cast<size_t>((start + i) % cap)];
|
||||
const int len = slot.length.load(std::memory_order_acquire);
|
||||
if (len > 0) {
|
||||
result.append(QByteArray(slot.data, len));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void LogRing::dumpToFd(int fd) const noexcept
|
||||
{
|
||||
const quint64 cursor = m_write_cursor.load(std::memory_order_acquire);
|
||||
const quint64 cap = static_cast<quint64>(kCapacityEntries);
|
||||
const quint64 count = (cursor < cap) ? cursor : cap;
|
||||
const quint64 start = (cursor < cap) ? 0 : (cursor - cap);
|
||||
|
||||
for (quint64 i = 0; i < count; ++i) {
|
||||
const Entry &slot = m_entries[static_cast<size_t>((start + i) % cap)];
|
||||
const int len = slot.length.load(std::memory_order_acquire);
|
||||
if (len > 0) {
|
||||
writeAllSignalSafe(fd, slot.data, len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void LogRing::clear()
|
||||
{
|
||||
QMutexLocker locker(&m_mutex);
|
||||
m_next_index = 0;
|
||||
m_count = 0;
|
||||
m_write_cursor.store(0, std::memory_order_relaxed);
|
||||
for (auto &entry : m_entries) {
|
||||
entry.length.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
+33
-25
@@ -19,31 +19,33 @@
|
||||
#define LOGRING_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QMutex>
|
||||
#include <QVector>
|
||||
#include <atomic>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
@brief The LogRing class
|
||||
Fixed-capacity, always-on in-memory ring of the most recent log
|
||||
lines. Discussion #644 (step 3): the ring exists as forward-compatible
|
||||
infrastructure for a future crash-flush (step 4, not implemented
|
||||
here) as well as an on-demand "what just happened" snapshot, so its
|
||||
entries are stored pre-formatted as plain bytes in storage
|
||||
preallocated once at construction -- append() never allocates.
|
||||
lines, preallocated once at construction -- append() never
|
||||
allocates.
|
||||
|
||||
Entries are fixed-size slots rather than a byte-packed ring: with
|
||||
kCapacityEntries * kEntryBytes chosen to land exactly on the 2 MiB
|
||||
budget, this keeps wraparound trivial (whole-slot overwrite, so a
|
||||
slot is always either fully the old entry or fully the new one --
|
||||
no torn entries) at the cost of truncating any single line to
|
||||
kEntryBytes, independently of the logger's own (larger) per-message
|
||||
truncation.
|
||||
Lock-free by construction, not just "thread-safe": step 4 (see
|
||||
crashhandler.h) reads this ring from inside a POSIX signal handler,
|
||||
where taking any lock is unsafe -- if the crashing thread happens to
|
||||
be the one that already holds it (or any other thread does and never
|
||||
gets scheduled again), the handler hangs forever, and you lose both
|
||||
the ring dump *and* the core dump. So there is no mutex here at all:
|
||||
append() claims a slot with a single atomic fetch-add, and
|
||||
dumpToFd()/snapshot() read the preallocated entries directly.
|
||||
|
||||
Thread-safe via a plain QMutex. This is *not* the lock-free design
|
||||
discussion #644 specifies for a signal-handler crash path (step 4)
|
||||
-- no signal handler is installed by this code, so nothing calls
|
||||
into the ring from inside a signal context.
|
||||
Accepted tradeoff: if dumpToFd() runs while another thread is
|
||||
mid-append into the exact slot being read (only possible in the
|
||||
crash-handler case, and only for at most one slot), that one entry
|
||||
may be read torn -- part old content, part new. Every other entry is
|
||||
unaffected. This is deliberate: the alternative (a seqlock or similar
|
||||
to detect and retry torn reads) adds real complexity for a window
|
||||
that, per discussion #644, is not worth trading "the handler must
|
||||
never block" against.
|
||||
*/
|
||||
class LogRing
|
||||
{
|
||||
@@ -54,24 +56,30 @@ class LogRing
|
||||
LogRing();
|
||||
|
||||
/// Append one already-formatted, already-truncated log line.
|
||||
/// Bytes beyond kEntryBytes - 1 are dropped with a truncation marker.
|
||||
void append(const QByteArray &line);
|
||||
/// Bytes beyond kEntryBytes are dropped with a truncation marker.
|
||||
/// Never allocates, never blocks. Safe to call from any normal
|
||||
/// (non-signal) thread concurrently.
|
||||
void append(const QByteArray &line) noexcept;
|
||||
|
||||
/// Snapshot of the entries currently held, oldest first.
|
||||
/// Snapshot of the entries currently held, oldest first. Normal
|
||||
/// (non-signal) context only.
|
||||
QVector<QByteArray> snapshot() const;
|
||||
|
||||
/// Async-signal-safe: writes every entry currently held to fd via
|
||||
/// write(2) only -- no allocation, no Qt, no locks. May write a
|
||||
/// torn entry under the rare race described above; never blocks.
|
||||
void dumpToFd(int fd) const noexcept;
|
||||
|
||||
void clear();
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
char data[kEntryBytes] = {};
|
||||
int length = 0;
|
||||
char data[kEntryBytes];
|
||||
std::atomic<int> length{0}; // 0 = not yet written this lap
|
||||
};
|
||||
|
||||
mutable QMutex m_mutex;
|
||||
std::vector<Entry> m_entries; // preallocated once, capacity fixed
|
||||
int m_next_index = 0;
|
||||
int m_count = 0;
|
||||
std::atomic<quint64> m_write_cursor{0}; // monotonically increasing
|
||||
};
|
||||
|
||||
#endif // LOGRING_H
|
||||
|
||||
@@ -17,11 +17,14 @@
|
||||
*/
|
||||
#include "qetlogger.h"
|
||||
|
||||
#include "crashhandler.h"
|
||||
#include "../qetapp.h"
|
||||
#include "../qetversion.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QSysInfo>
|
||||
#include <cstdio>
|
||||
|
||||
namespace {
|
||||
@@ -96,6 +99,24 @@ void QetLogger::init()
|
||||
m_file_output_ok = ensureFileOpenLocked();
|
||||
}
|
||||
|
||||
void QetLogger::installCrashHandler()
|
||||
{
|
||||
if (m_disabled) {
|
||||
return;
|
||||
}
|
||||
CrashHandler::install(&m_ring, crashDumpPath());
|
||||
}
|
||||
|
||||
QString QetLogger::crashDumpPath() const
|
||||
{
|
||||
return m_log_dir % QStringLiteral("/crash_dump.log");
|
||||
}
|
||||
|
||||
QString QetLogger::currentLogFilePath() const
|
||||
{
|
||||
return m_log_dir % QStringLiteral("/") % m_base_name % QStringLiteral(".log");
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetLogger::ensureFileOpenLocked
|
||||
Caller must hold m_file_mutex. Opens the current session's log file
|
||||
@@ -110,7 +131,7 @@ bool QetLogger::ensureFileOpenLocked()
|
||||
|
||||
QDir().mkpath(m_log_dir);
|
||||
|
||||
const QString path = m_log_dir % QStringLiteral("/") % m_base_name % QStringLiteral(".log");
|
||||
const QString path = currentLogFilePath();
|
||||
|
||||
const QFileInfo info(path);
|
||||
if (info.exists() && info.isSymLink()) {
|
||||
@@ -143,7 +164,7 @@ void QetLogger::rotateLocked()
|
||||
{
|
||||
m_file.close();
|
||||
|
||||
const QString base_path = m_log_dir % QStringLiteral("/") % m_base_name % QStringLiteral(".log");
|
||||
const QString base_path = currentLogFilePath();
|
||||
|
||||
for (int i = kRotationKeep; i >= 1; --i) {
|
||||
const QString from = (i == 1) ? base_path : rotatedPath(i - 1);
|
||||
@@ -333,3 +354,78 @@ void QetLogger::pruneOldLogFiles(int days)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 5: getting the data back out ----------------------------------
|
||||
|
||||
bool QetLogger::hasPendingCrashDump() const
|
||||
{
|
||||
if (m_disabled) {
|
||||
return false;
|
||||
}
|
||||
const QFileInfo info(crashDumpPath());
|
||||
return info.exists() && info.isFile() && info.size() > 0;
|
||||
}
|
||||
|
||||
QByteArray QetLogger::pendingCrashDumpContents() const
|
||||
{
|
||||
QFile file(crashDumpPath());
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return QByteArray();
|
||||
}
|
||||
return redact(file.readAll());
|
||||
}
|
||||
|
||||
void QetLogger::clearPendingCrashDump()
|
||||
{
|
||||
QFile::remove(crashDumpPath());
|
||||
}
|
||||
|
||||
QByteArray QetLogger::buildDiagnosticsReport() const
|
||||
{
|
||||
QByteArray header;
|
||||
header += "QElectroTech diagnostics report\n";
|
||||
header += "Generated: " % QDateTime::currentDateTime().toString(Qt::ISODate) % "\n";
|
||||
header += "Version: " % QetVersion::displayedVersion() % "\n";
|
||||
header += "Git: " GIT_COMMIT_SHA "\n";
|
||||
header += "OS: " % QSysInfo::prettyProductName() % " (" % QSysInfo::currentCpuArchitecture() % ")\n";
|
||||
header += "Qt: " QT_VERSION_STR "\n";
|
||||
header += "---\n";
|
||||
|
||||
QByteArray body;
|
||||
QFile file(currentLogFilePath());
|
||||
if (file.open(QIODevice::ReadOnly)) {
|
||||
body = file.readAll();
|
||||
} else {
|
||||
// Fall back to the in-memory ring if the file itself can't be
|
||||
// read (e.g. file output already failed this session).
|
||||
for (const QByteArray &line : m_ring.snapshot()) {
|
||||
body += line;
|
||||
}
|
||||
}
|
||||
|
||||
return redact(header + body);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetLogger::redact
|
||||
Replaces the user's home directory with "~" wherever it appears.
|
||||
Applied before a crash dump or a diagnostics report is ever shown to
|
||||
the user: both are destined to be attached to a public bug tracker,
|
||||
and an absolute path under the home directory leaks the account name
|
||||
(discussion #644's privacy section: "/home/laurent/... leaks a
|
||||
username"). This is the one redaction implemented here; the
|
||||
discussion's fancier "optionally redact project filenames too" is
|
||||
not attempted -- reliably telling a project path apart from
|
||||
arbitrary log text is a much fuzzier problem than a literal prefix
|
||||
match against a known directory.
|
||||
*/
|
||||
QByteArray QetLogger::redact(const QByteArray &input)
|
||||
{
|
||||
const QByteArray home = QDir::homePath().toUtf8();
|
||||
if (home.isEmpty()) {
|
||||
return input;
|
||||
}
|
||||
QByteArray out = input;
|
||||
out.replace(home, QByteArrayLiteral("~"));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -48,11 +48,22 @@
|
||||
- Step 3: every formatted line is also appended to an in-memory
|
||||
LogRing (see logring.h) -- always on, fixed capacity, allocation-
|
||||
free on the hot path.
|
||||
- Step 4: installCrashHandler() wires the ring up to CrashHandler
|
||||
(see crashhandler.h), so a SIGSEGV/SIGABRT/SIGBUS/SIGFPE/SIGILL (or,
|
||||
on Windows, an unhandled structured exception) flushes the ring to
|
||||
a fixed crash-dump file before the process dies.
|
||||
- Step 5: hasPendingCrashDump()/pendingCrashDumpContents()/
|
||||
clearPendingCrashDump() let startup code (see QETApp::checkBackupFiles())
|
||||
notice and offer an unretrieved crash dump from the *previous* run;
|
||||
buildDiagnosticsReport() is the equivalent for a manual "save a
|
||||
report right now" action on the *current*, still-running session.
|
||||
Both go through redact() before ever reaching the user, since both
|
||||
are destined for a public bug tracker.
|
||||
|
||||
Deliberately NOT included in this step (see discussion #644): no
|
||||
signal handler / crash-flush (step 4), no diagnostics export UI
|
||||
(step 5), no log categories, no session header, no repeat collapsing
|
||||
or rate limiting. Those are independent, separately-scoped follow-ups.
|
||||
Deliberately NOT included: log categories, a full session header
|
||||
beyond what the crash dump/report already carry, repeat collapsing,
|
||||
rate limiting. Those are listed in discussion #644 under "best
|
||||
practices worth building in", not part of the numbered steps.
|
||||
|
||||
Escape hatch: if QET_LOG_DISABLE=1 is set in the environment at
|
||||
init() time, this class does nothing beyond a minimal, independent
|
||||
@@ -73,6 +84,11 @@ class QetLogger
|
||||
/// session's log filename, and opens the file.
|
||||
void init();
|
||||
|
||||
/// Step 4: installs the crash handler (see crashhandler.h). Must
|
||||
/// be called after init() (the ring and the dump path must exist
|
||||
/// first) and, like init(), only once.
|
||||
void installCrashHandler();
|
||||
|
||||
/// The function installed via qInstallMessageHandler() forwards here.
|
||||
void handleMessage(QtMsgType type, const QMessageLogContext &context, const QString &msg);
|
||||
|
||||
@@ -81,10 +97,37 @@ class QetLogger
|
||||
/// file names.
|
||||
void pruneOldLogFiles(int days);
|
||||
|
||||
/// Snapshot of the in-memory ring, oldest first. For future use
|
||||
/// (e.g. a diagnostics export action) -- not wired to any UI here.
|
||||
/// Snapshot of the in-memory ring, oldest first.
|
||||
QVector<QByteArray> ringSnapshot() const {return m_ring.snapshot();}
|
||||
|
||||
// --- Step 5: getting the data back out -------------------------
|
||||
|
||||
/// True if a previous run's crash handler left an unretrieved
|
||||
/// dump behind.
|
||||
bool hasPendingCrashDump() const;
|
||||
|
||||
/// Raw contents of the pending crash dump, or an empty array if
|
||||
/// there isn't one. Does not delete it -- call
|
||||
/// clearPendingCrashDump() once it has been offered to the user.
|
||||
QByteArray pendingCrashDumpContents() const;
|
||||
|
||||
/// Deletes the pending crash dump file. Call after the user has
|
||||
/// been offered it (whether they chose to save it or not) so it
|
||||
/// is never offered a second time.
|
||||
void clearPendingCrashDump();
|
||||
|
||||
/// Builds a redacted diagnostics bundle from the *current* session
|
||||
/// (header + this session's log file so far) for the manual
|
||||
/// "Save report" action -- as opposed to pendingCrashDumpContents(),
|
||||
/// which is about a *previous*, already-terminated session.
|
||||
QByteArray buildDiagnosticsReport() const;
|
||||
|
||||
/// Replaces occurrences of the user's home directory with "~".
|
||||
/// Applied to both the crash dump and buildDiagnosticsReport()
|
||||
/// before they are ever shown to the user, since both are
|
||||
/// destined for a public bug tracker.
|
||||
static QByteArray redact(const QByteArray &input);
|
||||
|
||||
private:
|
||||
QetLogger() = default;
|
||||
QetLogger(const QetLogger &) = delete;
|
||||
@@ -93,6 +136,8 @@ class QetLogger
|
||||
void rotateLocked();
|
||||
void writeToFile(const QByteArray &line, QtMsgType type);
|
||||
QString rotatedPath(int index) const;
|
||||
QString crashDumpPath() const;
|
||||
QString currentLogFilePath() const;
|
||||
|
||||
static QByteArray sanitize(const QByteArray &input);
|
||||
static QByteArray truncateMessage(const QByteArray &input, int max_bytes);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "diagnosticsreportdialog.h"
|
||||
|
||||
#include "../../qetmessagebox.h"
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QFontDatabase>
|
||||
#include <QLabel>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
DiagnosticsReportDialog::DiagnosticsReportDialog(
|
||||
const QString &title,
|
||||
const QString &intro,
|
||||
const QByteArray &content,
|
||||
QWidget *parent) :
|
||||
QDialog(parent)
|
||||
{
|
||||
setWindowTitle(title);
|
||||
resize(700, 500);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
|
||||
auto *intro_label = new QLabel(intro, this);
|
||||
intro_label->setWordWrap(true);
|
||||
layout->addWidget(intro_label);
|
||||
|
||||
auto *preview = new QPlainTextEdit(this);
|
||||
preview->setReadOnly(true);
|
||||
preview->setLineWrapMode(QPlainTextEdit::NoWrap);
|
||||
preview->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
|
||||
preview->setPlainText(QString::fromUtf8(content));
|
||||
layout->addWidget(preview);
|
||||
|
||||
auto *buttons = new QDialogButtonBox(this);
|
||||
QPushButton *save_button = buttons->addButton(tr("Enregistrer..."), QDialogButtonBox::ActionRole);
|
||||
buttons->addButton(QDialogButtonBox::Close);
|
||||
connect(save_button, &QPushButton::clicked, this, &DiagnosticsReportDialog::saveToFile);
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
connect(buttons->button(QDialogButtonBox::Close), &QPushButton::clicked, this, &QDialog::accept);
|
||||
layout->addWidget(buttons);
|
||||
|
||||
// Stash the content for saveToFile(); the preview widget already
|
||||
// holds a QString copy but we save the original UTF-8 bytes to avoid
|
||||
// any round-trip surprises.
|
||||
setProperty("qet_report_content", content);
|
||||
}
|
||||
|
||||
void DiagnosticsReportDialog::saveToFile()
|
||||
{
|
||||
const QString path = QFileDialog::getSaveFileName(
|
||||
this,
|
||||
tr("Enregistrer le rapport de diagnostic"),
|
||||
QStringLiteral("qet-diagnostic-report.txt"),
|
||||
tr("Fichiers texte (*.txt);;Tous les fichiers (*)"));
|
||||
|
||||
if (path.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly)) {
|
||||
QET::QetMessageBox::critical(
|
||||
this,
|
||||
tr("Erreur"),
|
||||
tr("Impossible d'écrire dans le fichier « %1 ».").arg(path));
|
||||
return;
|
||||
}
|
||||
|
||||
file.write(property("qet_report_content").toByteArray());
|
||||
file.close();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef DIAGNOSTICSREPORTDIALOG_H
|
||||
#define DIAGNOSTICSREPORTDIALOG_H
|
||||
|
||||
#include <QDialog>
|
||||
|
||||
/**
|
||||
@brief The DiagnosticsReportDialog class
|
||||
Discussion #644, step 5: "Show what's in it before saving -- the user
|
||||
is about to attach this to a public tracker." Used for both the
|
||||
after-a-crash offer (QETApp::checkBackupFiles()) and the manual
|
||||
"Help > Diagnostics > Save report" action -- the only difference
|
||||
between the two is the intro text and where the content comes from
|
||||
(QetLogger::pendingCrashDumpContents() vs. buildDiagnosticsReport()).
|
||||
|
||||
The content passed in is expected to already be redacted
|
||||
(QetLogger::redact()) -- this dialog just displays and optionally
|
||||
saves whatever it's given.
|
||||
*/
|
||||
class DiagnosticsReportDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit DiagnosticsReportDialog(
|
||||
const QString &title,
|
||||
const QString &intro,
|
||||
const QByteArray &content,
|
||||
QWidget *parent = nullptr);
|
||||
|
||||
private slots:
|
||||
void saveToFile();
|
||||
};
|
||||
|
||||
#endif // DIAGNOSTICSREPORTDIALOG_H
|
||||
@@ -152,6 +152,12 @@ QGuiApplication::setHighDpiScaleFactorRoundingPolicy(QetSettings::hdpiScaleFacto
|
||||
// went to stderr, which is invisible in a Windows GUI session.
|
||||
QetLogger::instance().init();
|
||||
qInstallMessageHandler(qetLogMessageHandler);
|
||||
// Step 4 (discussion #644): flush the ring to a crash-dump file if
|
||||
// the process dies from here on. Installed right after the ring
|
||||
// exists (init() just constructed it) and as early as reasonably
|
||||
// possible, so it also covers whatever runs between here and
|
||||
// QETApp's own construction below.
|
||||
QetLogger::instance().installCrashHandler();
|
||||
|
||||
SingleApplication app(argc, argv, true);
|
||||
#ifdef Q_OS_MACOS
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
#include "machine_info.h"
|
||||
#include "TerminalStrip/ui/terminalstripeditorwindow.h"
|
||||
#include "qetversion.h"
|
||||
#include "logging/qetlogger.h"
|
||||
#include "logging/ui/diagnosticsreportdialog.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
@@ -2575,6 +2577,10 @@ void QETApp::checkBackupFiles()
|
||||
}
|
||||
|
||||
if (stale_files.isEmpty()) {
|
||||
// Only offer an unretrieved crash dump when there's no project
|
||||
// to recover this run -- discussion #644 step 5 is explicit
|
||||
// that the two prompts must never both show at once.
|
||||
checkCrashDump();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2628,6 +2634,53 @@ void QETApp::checkBackupFiles()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETApp::checkCrashDump
|
||||
Discussion #644, step 5: if the crash handler (step 4) left an
|
||||
unretrieved dump from a previous run, offer it to the user. Only
|
||||
called from checkBackupFiles() when there was no stale project file
|
||||
to recover this run, so the two prompts never both show at once.
|
||||
*/
|
||||
void QETApp::checkCrashDump()
|
||||
{
|
||||
QetLogger &logger = QetLogger::instance();
|
||||
if (!logger.hasPendingCrashDump()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QByteArray content = logger.pendingCrashDumpContents();
|
||||
|
||||
DiagnosticsReportDialog dialog(
|
||||
tr("Rapport de plantage"),
|
||||
tr("QElectroTech ne s'est pas fermé correctement lors de sa dernière exécution.\n"
|
||||
"Voici les derniers messages enregistrés avant l'arrêt -- vous pouvez les "
|
||||
"enregistrer pour les joindre à un rapport de bug."),
|
||||
content);
|
||||
dialog.exec();
|
||||
|
||||
// Offered once, then marked retrieved -- regardless of whether the
|
||||
// user chose to save it -- so it is never offered a second time.
|
||||
logger.clearPendingCrashDump();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETApp::showDiagnosticsReport
|
||||
Discussion #644, step 5: the manual "Help > Diagnostics > Save
|
||||
report" action. Unlike checkCrashDump(), this is about the *current*,
|
||||
still-running session, not a previous one.
|
||||
*/
|
||||
void QETApp::showDiagnosticsReport()
|
||||
{
|
||||
const QByteArray content = QetLogger::instance().buildDiagnosticsReport();
|
||||
|
||||
DiagnosticsReportDialog dialog(
|
||||
tr("Rapport de diagnostic"),
|
||||
tr("Ceci contient les derniers messages de journalisation de cette session. "
|
||||
"Vérifiez le contenu avant de le joindre à un rapport de bug public."),
|
||||
content);
|
||||
dialog.exec();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETApp::fetchWindowStats
|
||||
Updates the booleans concerning the state of the windows
|
||||
|
||||
@@ -271,6 +271,7 @@ class QETApp : public QObject
|
||||
void openTitleBlockTemplateFiles(const QStringList &);
|
||||
void configureQET();
|
||||
void aboutQET();
|
||||
void showDiagnosticsReport();
|
||||
void receiveMessage(int instanceId, QByteArray message);
|
||||
|
||||
private:
|
||||
@@ -287,6 +288,7 @@ class QETApp : public QObject
|
||||
void initSystemTray();
|
||||
void buildSystemTrayMenu();
|
||||
void checkBackupFiles();
|
||||
void checkCrashDump();
|
||||
void fetchWindowStats(
|
||||
const QList<QETDiagramEditor *> &,
|
||||
const QList<QETElementEditor *> &,
|
||||
|
||||
@@ -136,6 +136,12 @@ void QETMainWindow::initCommonActions()
|
||||
about_qt_ = new QAction(QET::Icons::QtLogo, tr("À propos de &Qt"), this);
|
||||
about_qt_ -> setStatusTip(tr("Affiche des informations sur la bibliothèque Qt", "status bar tip"));
|
||||
connect(about_qt_, SIGNAL(triggered()), qApp, SLOT(aboutQt()));
|
||||
|
||||
diagnostics_action_ = new QAction(QET::Icons::DialogInformation, tr("Enregistrer un rapport de diagnostic..."), this);
|
||||
diagnostics_action_ -> setStatusTip(tr("Génère un rapport avec les derniers messages de journalisation, pour l'inclure dans un rapport de bug", "status bar tip"));
|
||||
connect(diagnostics_action_, &QAction::triggered, this, []() {
|
||||
QETApp::instance()->showDiagnosticsReport();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +164,8 @@ void QETMainWindow::initCommonMenus()
|
||||
help_menu_ -> addAction(donate_);
|
||||
help_menu_ -> addAction(about_qt_);
|
||||
help_menu_ -> addAction(about_qet_);
|
||||
help_menu_ -> addSeparator();
|
||||
help_menu_ -> addAction(diagnostics_action_);
|
||||
|
||||
#ifdef Q_OS_WIN32
|
||||
upgrade_ -> setVisible(true);
|
||||
|
||||
@@ -60,8 +60,9 @@ class QETMainWindow : public QMainWindow {
|
||||
QAction *youtube_; ///< Launch browser on QElectroTech Youtube channel
|
||||
QAction *upgrade_; ///< Launch browser on QElectroTech Windows Nightly builds
|
||||
QAction *upgrade_M; ///< Launch browser on QElectroTech MAC_OS_X builds
|
||||
QAction *donate_; ///< Launch browser to donate link
|
||||
QAction *donate_; ///< Launch browser to donate link
|
||||
QAction *about_qt_; ///< launch the "About Qt" dialog
|
||||
QAction *diagnostics_action_; ///< Open the diagnostics report dialog (discussion #644, step 5)
|
||||
QMenu *settings_menu_; ///< Settings menu
|
||||
QMenu *help_menu_; ///< Help menu
|
||||
QMenu *display_toolbars_; ///< Show/hide toolbars/docks
|
||||
|
||||
Reference in New Issue
Block a user