Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ jobs:
- name: Installer
shell: pwsh
run: ./scripts/package.ps1 -SkipBuild
- name: Open the main window, with no QML warnings
shell: pwsh
run: ./scripts/ui-check.ps1
- name: Install, run and uninstall it
shell: pwsh
run: ./scripts/smoke-installer.ps1
Expand Down
41 changes: 41 additions & 0 deletions desktop/app/cpp/app_helpers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,37 @@ void register_qml_app_engine(QQmlApplicationEngine &engine)
#include <QtCore/QStandardPaths>
#include <QtCore/QTextStream>

#include <atomic>
#include <cstdio>

namespace {
std::atomic<int> &qmlProblems()
{
static std::atomic<int> count { 0 };
return count;
}

// A warning or error from QML: a component that didn't load, a binding or
// a script that failed.
bool isQmlProblem(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
{
if (type != QtWarningMsg && type != QtCriticalMsg && type != QtFatalMsg)
return false;
return (ctx.file && QByteArray(ctx.file).startsWith("qrc:/")) || msg.contains(QStringLiteral("qrc:/"));
}

// `--ui-check`: messages go to stderr, not the log file, and QML problems
// are counted.
void uiCheckMessageHandler(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
{
if (type == QtDebugMsg)
return;
if (isQmlProblem(type, ctx, msg))
qmlProblems().fetch_add(1);
std::fprintf(stderr, "[%d] %s (%s:%d)\n", int(type), qUtf8Printable(msg), ctx.file ? ctx.file : "", ctx.line);
std::fflush(stderr);
}

void qtLogMessageHandler(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
{
if (type == QtDebugMsg)
Expand All @@ -102,6 +132,17 @@ void prepare_qt()
prepare_qt_without_log();
}

void prepare_qt_for_ui_check()
{
qInstallMessageHandler(uiCheckMessageHandler);
prepare_qt_without_log();
}

int32_t qml_problem_count()
{
return qmlProblems().load();
}

void prepare_qt_without_log()
{
// Windows get an alpha channel so the Mica backdrop can show through, and
Expand Down
2 changes: 2 additions & 0 deletions desktop/app/cpp/app_helpers.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
// Qt setup that must happen before QGuiApplication is created.
void prepare_qt();
void prepare_qt_without_log();
void prepare_qt_for_ui_check();
int32_t qml_problem_count();

// Adds one size of the app icon (straight-alpha RGBA, size x size).
void add_app_icon_image(int32_t size, rust::Slice<const uint8_t> rgba);
Expand Down
6 changes: 6 additions & 0 deletions desktop/app/src/bridge/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ pub mod ffi {
/// Like `prepare_qt`, without the QML log file: for the installer
/// and uninstaller, which mustn't create the app's data folder.
fn prepare_qt_without_log();
/// Like `prepare_qt`, for `--ui-check`: Qt's messages go to stderr
/// instead of the log file, and QML warnings and errors are counted.
fn prepare_qt_for_ui_check();
/// How many QML warnings and errors there were since
/// `prepare_qt_for_ui_check`.
fn qml_problem_count() -> i32;
fn add_app_icon_image(size: i32, rgba: &[u8]);
fn apply_app_icon();
fn keep_running_without_windows();
Expand Down
48 changes: 47 additions & 1 deletion desktop/app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,16 @@ struct Options {
/// The uninstaller's window (`--remove [--installed <folder>]`).
remove: bool,
installed: Option<PathBuf>,
/// `--ui-check` (needs `--data-dir`): open the main window, quit after
/// [`UI_CHECK_SECONDS`], and fail if QML reported a warning or an error.
/// Qt's messages go to stderr. For CI (`scripts/ui-check.ps1`).
ui_check: bool,
}

/// How long `--ui-check` keeps the window open: long enough for the window
/// to load, be restored and settle.
const UI_CHECK_SECONDS: u64 = 8;

fn parse_options() -> Options {
parse_options_from(std::env::args_os().skip(1))
}
Expand Down Expand Up @@ -264,6 +272,10 @@ fn parse_options_from<I: IntoIterator<Item = std::ffi::OsString>>(iter: I) -> Op
in_share = false;
options.uninstall = true;
}
Some("--ui-check") => {
in_share = false;
options.ui_check = true;
}
Some(setup::SETUP_ARG) => {
in_share = false;
options.setup = Some(PathBuf::new());
Expand Down Expand Up @@ -465,6 +477,11 @@ fn main() -> ExitCode {
}
};
}
// The check starts the whole app, so never on the user's own data.
if options.ui_check && options.data_dir.is_none() {
eprintln!("nectarlink: --ui-check needs --data-dir");
return ExitCode::FAILURE;
}
let data_dir = options.data_dir.clone().unwrap_or_else(default_data_dir);
if let Err(e) = std::fs::create_dir_all(&data_dir) {
eprintln!("nectarlink: can't create {}: {e}", data_dir.display());
Expand Down Expand Up @@ -595,7 +612,11 @@ fn main() -> ExitCode {
send_to::handle(request);
}

bridge::native::ffi::prepare_qt();
if options.ui_check {
bridge::native::ffi::prepare_qt_for_ui_check();
} else {
bridge::native::ffi::prepare_qt();
}
let mut app = QGuiApplication::new();
if let Some(mut app) = app.as_mut() {
app.as_mut().set_application_name(&QString::from("Nectarlink"));
Expand Down Expand Up @@ -630,6 +651,12 @@ fn main() -> ExitCode {
bridge::native::ffi::register_qml_app_engine(engine.as_mut());
engine.load(&QUrl::from("qrc:/qt/qml/app/nectarlink/qml/App.qml"));
}
if options.ui_check {
std::thread::spawn(|| {
std::thread::sleep(Duration::from_secs(UI_CHECK_SECONDS));
bridge::app::request_quit();
});
}
let code = app.as_mut().map_or(1, |app| app.exec());

tracing::info!(code, "shutting down");
Expand All @@ -644,6 +671,14 @@ fn main() -> ExitCode {
win::net::stop();
core_host::shutdown();
drop(app);
if options.ui_check {
let problems = bridge::native::ffi::qml_problem_count();
if problems > 0 {
eprintln!("nectarlink: --ui-check: {problems} QML warning(s) or error(s), see above");
return ExitCode::FAILURE;
}
eprintln!("nectarlink: --ui-check: no QML warnings");
}
ExitCode::from(u8::try_from(code).unwrap_or(1))
}

Expand Down Expand Up @@ -672,6 +707,17 @@ mod tests {
);
}

#[test]
fn parse_options_recognizes_ui_check_flag() {
let opts = parse_options_from(
["--ui-check", "--data-dir", r"C:\Temp\nectarlink-test"].into_iter().map(OsString::from),
);
assert!(opts.ui_check);
assert!(!opts.minimized);
assert_eq!(opts.data_dir, Some(PathBuf::from(r"C:\Temp\nectarlink-test")));
assert!(!parse_options_from(["--minimized"].into_iter().map(OsString::from)).ui_check);
}

#[test]
fn parse_options_recognizes_task_flag() {
let find_opts = parse_options_from(
Expand Down
46 changes: 46 additions & 0 deletions scripts/ui-check.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# SPDX-License-Identifier: GPL-3.0-or-later
<#
.SYNOPSIS
Opens the Windows app's main window for a few seconds and fails on any
QML warning or error.

.DESCRIPTION
Runs nectarlink-desktop.exe --ui-check with a new data folder under
target\ui-check, so the user's own data is never touched, and without
USB (NECTARLINK_NO_ADB=1). The app shows the main window, quits after a
few seconds and exits with an error if QML reported a problem, such as a
component that didn't load. The QML tests can't catch that for the app's
windows, which need the Rust singletons and NativeWindow.

-Exe defaults to the staged package (scripts\package.ps1), which has its
Qt libraries next to it; for target\release, pass -Exe and have Qt's bin
folder on PATH.

.EXAMPLE
.\scripts\package.ps1 -StageOnly; .\scripts\ui-check.ps1
#>
param(
[string]$Exe
)

$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
if (-not $Exe) { $Exe = Join-Path $repo 'target\package\Nectarlink\nectarlink-desktop.exe' }
if (-not (Test-Path $Exe)) { throw "$Exe not found; run scripts\package.ps1 -StageOnly first, or pass -Exe" }

$data = Join-Path $repo 'target\ui-check'
if (Test-Path $data) { Remove-Item -Recurse -Force $data }
New-Item -ItemType Directory -Force $data | Out-Null
$stderr = Join-Path $data 'stderr.txt'

$env:NECTARLINK_NO_ADB = '1'
$app = Start-Process $Exe -ArgumentList '--ui-check', '--data-dir', "`"$data`"" `
-RedirectStandardError $stderr -PassThru -NoNewWindow
# Without the handle, ExitCode stays empty after the process exits.
$null = $app.Handle
if (-not $app.WaitForExit(120000)) {
$app.Kill()
throw "the app didn't quit within 2 minutes"
}
Get-Content $stderr | Write-Host
if ($app.ExitCode -ne 0) { throw "UI check failed (exit code $($app.ExitCode))" }
Loading