Skip to main content
Version: 0.9.0 (next)

Local App Onboarding

What you build​

The smallest source app that joins QAROS on the Hub machine. It loads the installed runtime, asks the Hub to let it in, and exits with a code that says what happened. The first time, QAROS asks the user to allow the app; later starts of the same executable join silently.

This is how a source app on the Hub machine joins QAROS.1 There is nothing to type and no onboarding id to store.

What you will learn:

  • how OnboardMethod::LocalApp joins, and what the user sees during the approval
  • why a local app always loads the installed runtime
  • how to follow the approval wait through progress reports
  • which status codes a refusal comes with, and what to tell the user for each

Before you start​

  • Windows. Local app onboarding is available on Windows; elsewhere the call fails with NOT_IMPLEMENTED.
  • QAROS installed on the machine the app runs on. When the Hub is not running, joining as a local app starts it (qaros-hub-service from the installation's bin/, else from QarRuntimeInit::runtime_binaries_folder_path, which is RuntimeConfig::runtime_binaries_directory in Rust); a running Hub is left as it is, and the Hub keeps running after the app exits. The Hub refuses a runtime of another version or flavor than its own, so the app must load the runtime of the QAROS that runs.
  • Someone at the Hub machine to click Allow on the first run. The prompt waits up to 120 seconds by default.
  • macOS or Linux: the example runs against qaros-stub, which lets every app in at once, with no prompt.
  • The latest stable Rust. Run the commands from qar-streaming-rust/.

Run it​

cargo run -p qaros-examples --bin local_app_onboarding

# Wait up to 90 s for the approval instead of the Hub's default.
cargo run -p qaros-examples --bin local_app_onboarding -- 90000

The only argument is the approval timeout in milliseconds. Absent or 0 means the Hub's default of 120 s; the Hub caps longer waits at 10 minutes. The example takes no library path: like a shipped app, it always uses the default lookup.

On the first run, while the approval prompt is open and after the user clicks Allow, the example prints this (the runtime may add log lines of its own):

Connecting to the local QAROS hub.
Waiting for approval in QAROS hub
Certificate signed.
Onboarding complete.
Joined QAROS.

It exits with 0. Later starts of the same executable print the same lines without Waiting for approval in QAROS hub.

Without Windows, the stub must be found by the default lookup, under the runtime's file name. Put a copy in target/stub-runtime, inside the build folder, which git ignores. On macOS:

cargo build -p qaros-stub
mkdir -p target/stub-runtime
cp target/debug/libqaros_stub.dylib target/stub-runtime/libqar-streaming-c.dylib
QAROS_RUNTIME_DIR="$PWD/target/stub-runtime" cargo run -p qaros-examples --bin local_app_onboarding
stub onboarding complete
Joined QAROS.

Walkthrough​

1. Read the approval timeout​

/// The optional first argument in milliseconds; absent or 0 is the hub's default.
fn parse_approval_timeout(argument: Option<&str>) -> Result<Option<Duration>, String> {
let Some(text) = argument else {
return Ok(None);
};
let milliseconds: u32 = text.parse().map_err(|error| {
format!("approval timeout '{text}' is not a number of milliseconds: {error}")
})?;
Ok((milliseconds > 0).then(|| Duration::from_millis(u64::from(milliseconds))))
}

The wrapper takes Option<Duration>: None is the Hub's default. It refuses Some(Duration::ZERO) with Error::InvalidArgument, because the C API reads a zero timeout as "the default" and a zero you meant literally would silently become 120 s. So the example turns 0 into None itself. Text that is not a number is refused with exit code 1, as in the C sample.

2. Load the installed runtime​

// SAFETY: the runtime found is the installed QAROS (or one QAROS_RUNTIME_DIR names), which
// matches these bindings.
let loaded = unsafe {
Library::load_default(&LibraryConfig {
console_logging: false,
..LibraryConfig::default()
})
};
let library = match loaded {
Ok(library) => library,
Err(qaros::Error::Load(load_error)) => {
eprintln!("QAROS is not installed on this PC.\n{load_error}");
return ExitCode::from(2);
}
Err(error) => {
eprintln!("The QAROS runtime failed to initialize: {error}");
return ExitCode::from(1);
}
};

A local app always uses Library::load_default, never a path of its own (Load the installed runtime). The Hub refuses a runtime of another version or flavor than its own, such as a private copy shipped with the app, with ONBOARDING_FAILED and the reason in the message. Dynamic Loading explains the lookup.

Console logging is off: this app talks to a user, not a developer. Any Error::Load becomes exit code 2, "QAROS is not installed on this PC", followed by the load error itself; read the second line, since a runtime that lacks functions ends here too. A failing qar_library_init becomes exit code 1.

3. Create a runtime​

let runtime = match library.create_runtime(&RuntimeConfig::default()) {
Ok(runtime) => runtime,
Err(error) => {
println!("qar_runtime_create: ERROR {error}");
return ExitCode::from(1);
}
};

RuntimeConfig::default() is all a local app needs. It stores no identity, so it needs no storage folder of its own. The runtime binaries folder stays empty, so the runtime uses the folder it was loaded from.

4. Join, and follow the approval​

let (progress, progress_reports) = std::sync::mpsc::channel::<Progress>();
let printer = std::thread::spawn(move || {
for report in progress_reports {
if !report.message.is_empty() {
println!("{}", report.message);
}
}
});
let presentation = PeerPresentation {
display_name: Some("Local App Onboarding Example".to_string()),
..PeerPresentation::default()
};
let joined = runtime.onboard(
&OnboardMethod::LocalApp { approval_timeout },
&presentation,
&BlockingOptions {
progress: Some(progress),
cancel: None,
},
);
if printer.join().is_err() {
eprintln!("the progress printer panicked");
}

OnboardMethod::LocalApp asks the Hub on the same machine to let the process in (Local app). The Hub identifies the process from Windows: its path, file hash, signature and user. Your display_name is shown to the user separately, labelled "App says:".

  • Approved before and unchanged: the session comes back at once, with no prompt.
  • Otherwise QAROS shows "Allow local_app_onboarding.exe to join QAROS?" in the Visualizer and as a tray window, in front of every other window. Deny is the default button, and closing the prompt counts as Deny.
  • Allow saves a trust record on the Hub. For an unsigned executable it holds the exact path and file hash, so every cargo build that changes the executable asks again. That is expected during development; sign release builds and users approve them once (The trust record).

The call blocks until the Hub answers, which can take minutes. The progress reports show what it is waiting for. They arrive on a library thread, so the example passes the sending half of a channel in BlockingOptions::progress and prints from a thread of its own. When the call returns, the options and with them the sender are dropped, the channel closes, and the printer thread ends; join waits for its last line.

The example passes no cancel token, so it can only wait. An application passes a CancelToken in BlockingOptions::cancel and cancels it when the user quits; CancelToken::cancel_handle gives a handle that cancels from another thread, such as a Ctrl+C handler.

A local app is stateless. Onboarded::onboarding_id is the zero id, and there is nothing to save: every start calls onboard again, and the Hub remembers the approval. Runtime::rejoin and Runtime::forget refuse the zero id with Error::InvalidArgument.

5. Tell the user what happened​

/// Maps the onboarding result to the documented exit code and explains it.
fn explain_result(joined: Result<(), &qaros::Error>) -> u8 {
let error = match joined {
Ok(()) => {
println!("Joined QAROS.");
return 0;
}
Err(error) => error,
};
let explained = [
(
StatusCode::ONBOARDING_HUB_UNREACHABLE,
3,
"No QAROS hub is running on this PC. Start QAROS (tray icon) and try again.",
),
(
StatusCode::ONBOARDING_APPROVAL_DENIED,
4,
"QAROS did not allow this app: the approval was denied or revoked.",
),
(
StatusCode::ONBOARDING_APPROVAL_TIMEOUT,
5,
"Nobody answered the approval prompt in QAROS in time. Start the app again.",
),
(
StatusCode::ONBOARDING_HUB_NOT_AUTHENTIC,
6,
"The program answering on this PC is not the installed QAROS. Reinstall QAROS.",
),
(
StatusCode::ONBOARDING_APP_IDENTITY_MISMATCH,
7,
"This app no longer matches what QAROS approved. Revoke it in QAROS and approve it again.",
),
];
for (code, exit_code, explanation) in explained {
if error.has_code(code) {
eprintln!("{explanation}");
return exit_code;
}
}
println!("qar_runtime_onboard: ERROR {error}");
1
}

Each refusal comes with its own status code, and each needs a different answer from the user. The example maps them to the exit codes of the C sample, so scripts can rely on them:

ExitStatusMeaning
0joined
1any otherother error, printed as qar_runtime_onboard: ERROR ...
2QAROS is not installed: no runtime loaded
3ONBOARDING_HUB_UNREACHABLE (1802)no QAROS Hub answers on this machine, and none could be started
4ONBOARDING_APPROVAL_DENIED (1805)Deny, a closed prompt, or a revoked approval
5ONBOARDING_APPROVAL_TIMEOUT (1806)nobody answered in time
6ONBOARDING_HUB_NOT_AUTHENTIC (1807)the program answering on this machine is not the installed QAROS
7ONBOARDING_APP_IDENTITY_MISMATCH (1808)the executable no longer matches its approval

None of these is worth retrying silently. A timeout may be offered again; a denial is retried only when the user asks; a Hub that is not authentic is never retried (Status codes and what to tell the user). When you print the error itself, its text already ends with what the user has to do for codes 1805 to 1808 (StatusCode::user_action).

6. Leave​

let exit_code = explain_result(joined.as_ref().map(|_| ()));
// The session, the runtime and the library are destroyed in that order as they drop.
drop(joined);
drop(runtime);
drop(library);
ExitCode::from(exit_code)

The exit code is computed while the result is still alive, then the session, the runtime and the library go in that order. Nothing is written to disk by the app.

Ownership and teardown​

  • The result owns the session. joined is a Result<Onboarded>; on success its session is the only handle. Dropping it calls qar_session_handle_destroy. The Hub keeps the approval, not the session.
  • Each handle keeps its parent alive. The explicit drops document the order, but they are not what makes it safe: the session keeps the runtime alive and the runtime the library, so any drop order ends in session, runtime, library.
  • The progress sender lives as long as the call. The C callback reaches the Sender through a pointer that is valid only during the blocking call. The library promises no progress callback after the call returns, and the borrow of BlockingOptions ensures the sender is still there until then.
  • The library stays loaded. As in every Rust example, dropping the Library destroys it and leaves qar-streaming-c mapped until the process exits (Dynamic Loading).
  • What the wrapper does not do for you: cancel a pending approval when your app is asked to stop. Pass a CancelToken for that.

Troubleshooting​

What you seeCauseWhat to do
exit 2, QAROS is not installed on this PC. and no loadable QAROS runtime ...; looked at:no runtime in any place of the lookupinstall QAROS (Installation)
exit 2, QAROS is not installed on this PC. and ... lacks N of M required symbolsthe runtime is older than the QAROS version the bindings were generated frominstall a QAROS of the same version as the repository's qar-streaming-c/include/qar_streaming.h
exit 3QAROS is installed but its Hub does not answer, and the runtime did not start itstart QAROS from the Start menu, then the app again
exit 4someone clicked Deny, closed the prompt, or an operator revoked the appstart the app again and click Allow
exit 5nobody answered within the timeoutanswer the prompt in the Visualizer or the tray window; pass a longer timeout if users need it
exit 6the program serving the local enrollment is not the installed, signed QAROSdo not retry; reinstall QAROS from the official installer
exit 7the executable was moved, copied, re-signed or changed since its approvalrevoke the old approval in the Visualizer (Approved local apps) and start again
exit 1, ONBOARDING_FAILED with a runtime version or flavor in the messagethe app loaded a runtime other than the one the Hub installed, for example through QAROS_RUNTIME_DIR or a copy beside the executableload the installed runtime: unset the variable, remove the copy
exit 1, NOT_IMPLEMENTEDnot Windowsrun the app on Windows, on the Hub machine
exit 1, approval timeout '...' is not a number of millisecondsthe argument is not a numberpass milliseconds, or nothing
the prompt comes back after every buildthe executable is unsigned, so its approval is for one exact fileexpected during development

More in Troubleshooting: Joining QAROS as a local app.

From C​

The C tutorial is Local App Onboarding. The Rust port keeps its exit codes and what its messages tell the user; the wording differs in places, the 1802 message among them.

CRust
qar_library_load(NULL), then qar_library_init with enable_console_logging = falseLibrary::load_default(&LibraryConfig { console_logging: false, .. })
qar_runtime_create with qar_runtime_init_default()Library::create_runtime(&RuntimeConfig::default())
QarOnboardLocalAppExt chained into QarOnboardInit, approval_timeout_msOnboardMethod::LocalApp { approval_timeout }, an Option<Duration>
init.presentation.display_namePeerPresentation { display_name, .. }
a progress callback and its state pointerBlockingOptions { progress: Some(sender), .. }, reports as Progress { severity, percent, message }
qar_result_has_code(result, QAR_STATUS_ONBOARDING_...)error.has_code(StatusCode::ONBOARDING_...)
qar_session_handle_destroy, qar_runtime_handle_destroy, qar_library_destroy and qar_library_unloaddrop the values; the library is destroyed, not unloaded
strtoull on the argument, refused when it is not a whole number of millisecondsparsed and refused when it is not a number

Next​

Footnotes​

  1. cpu_rendering_visualizer, d3d11_render_sender, gui_panel_operations and app_volume_management also accept a pairing code as their first argument, as their C originals do, and onboarding_and_rejoin always pairs with one (Onboarding and Rejoin). This example and vulkan_cube join as a local app only. ↩