feat: add scheduled tasks with locale-aware scheduling and safer desktop quit flow (#920)

* feat: keep desktop app running in background when closing last window

Closing last window hides it instead of quitting — sidecar keeps running
Cmd+Q now shows confirmation dialog warning about stopping background processes
Clicking dock icon reopens hidden window or creates a new one

* docs: add scheduled tasks impl plan

* feat: add scheduled tasks runtime, api, and ui

* feat: conditionally confirm desktop quit on risks

* chore: remove scheduled tasks plan doc

* feat: add scheduled tasks runtime and management UI

Add server-side scheduled task runtime with project-backed config persistence
Add task scheduling UI and API integration for creating and editing schedules
Add tests for runtime scheduling behavior and project config validation

* feat: add locale display preferences for scheduled tasks

Add Appearance settings for time format and week start with settings.json persistence
Apply preferences in scheduled task editor for time display and weekday ordering
Rename Thinking level control and disable it when model variants are unavailable

* feat: improve scheduled tasks editor and sidebar action order

Reorder session sidebar header actions to separate creation and management tools
Polish scheduled tasks dialog layout and controls for clearer editing flow

* feat: polish scheduled task editor usability

Improve scheduled task dialog layout for clearer scheduling controls
Refine time and weekday inputs for more intuitive task configuration
Update editor labels and control states for better model variant guidance

* feat: add prompt autocomplete and command-aware scheduled runs

Add @ and / autocomplete support to task, multi-run, and agent manager prompt fields
Fix agent mention selection so subagents can be inserted from @ suggestions
Run scheduled prompts as commands when they match slash commands, with message fallback
This commit is contained in:
Bohdan Triapitsyn
2026-04-16 15:55:08 +03:00
committed by GitHub
parent ea5c19e934
commit 4f228f768d
28 changed files with 5231 additions and 52 deletions
+173 -1
View File
@@ -90,6 +90,28 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"async-fs",
"async-net",
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.2",
"raw-window-handle",
"serde",
"serde_repr",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -128,6 +150,17 @@ dependencies = [
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
@@ -157,6 +190,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
@@ -886,6 +930,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dlib"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading",
]
[[package]]
name = "dlopen2"
version = "0.8.2"
@@ -924,6 +977,12 @@ dependencies = [
"tendril 0.5.0",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
@@ -2633,6 +2692,7 @@ dependencies = [
"objc2",
"objc2-web-kit",
"reqwest 0.12.28",
"rfd 0.15.4",
"serde",
"serde_json",
"tauri",
@@ -3020,6 +3080,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -3166,6 +3232,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
@@ -3528,6 +3603,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
dependencies = [
"ashpd",
"block2",
"dispatch2",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "rfd"
version = "0.16.0"
@@ -3795,6 +3894,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -4553,7 +4658,7 @@ checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"rfd 0.16.0",
"serde",
"serde_json",
"tauri",
@@ -5283,6 +5388,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
@@ -5514,6 +5625,66 @@ dependencies = [
"semver",
]
[[package]]
name = "wayland-backend"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"scoped-tls",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
dependencies = [
"bitflags 2.11.0",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
dependencies = [
"bitflags 2.11.0",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [
"proc-macro2",
"quick-xml 0.39.2",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"dlib",
"log",
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.92"
@@ -6484,6 +6655,7 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 0.7.15",
"zvariant_derive",
"zvariant_utils",
+1
View File
@@ -39,3 +39,4 @@ strip = true
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-web-kit = "0.3"
rfd = "0.15"
+327 -19
View File
@@ -17,7 +17,7 @@ use std::{
net::TcpListener,
process::Command,
sync::{
atomic::{AtomicU64, Ordering},
atomic::{AtomicU32, AtomicU64, Ordering},
Mutex,
},
time::Duration,
@@ -152,6 +152,8 @@ const MENU_ITEM_REQUEST_FEATURE_ID: &str = "menu_request_feature";
const MENU_ITEM_JOIN_DISCORD_ID: &str = "menu_join_discord";
#[cfg(target_os = "macos")]
const MENU_ITEM_CLEAR_CACHE_ID: &str = "menu_clear_cache";
#[cfg(target_os = "macos")]
const MENU_ITEM_QUIT_ID: &str = "menu_quit";
#[cfg(target_os = "macos")]
const GITHUB_BUG_REPORT_URL: &str =
@@ -162,6 +164,198 @@ const GITHUB_FEATURE_REQUEST_URL: &str =
#[cfg(target_os = "macos")]
const DISCORD_INVITE_URL: &str = "https://discord.gg/ZYRSdnwwKA";
static QUIT_CONFIRMED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static QUIT_CONFIRMATION_PENDING: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
static QUIT_RISK_HAS_ENABLED_SCHEDULED_TASKS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
static QUIT_RISK_HAS_RUNNING_SCHEDULED_TASKS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
static QUIT_RISK_ENABLED_SCHEDULED_TASKS_COUNT: AtomicU32 = AtomicU32::new(0);
static QUIT_RISK_RUNNING_SCHEDULED_TASKS_COUNT: AtomicU32 = AtomicU32::new(0);
static QUIT_RISK_HAS_ACTIVE_TUNNEL: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
static QUIT_RISK_POLLER_STARTED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
#[cfg(target_os = "macos")]
const QUIT_RISK_POLL_INTERVAL: Duration = Duration::from_secs(5);
#[cfg(target_os = "macos")]
fn should_require_quit_confirmation() -> bool {
use std::sync::atomic::Ordering;
QUIT_RISK_HAS_ACTIVE_TUNNEL.load(Ordering::Relaxed)
|| QUIT_RISK_HAS_RUNNING_SCHEDULED_TASKS.load(Ordering::Relaxed)
|| QUIT_RISK_HAS_ENABLED_SCHEDULED_TASKS.load(Ordering::Relaxed)
}
#[cfg(target_os = "macos")]
fn quit_confirmation_message() -> String {
use std::sync::atomic::Ordering;
let has_active_tunnel = QUIT_RISK_HAS_ACTIVE_TUNNEL.load(Ordering::Relaxed);
let running_tasks_count = QUIT_RISK_RUNNING_SCHEDULED_TASKS_COUNT.load(Ordering::Relaxed);
let enabled_tasks_count = QUIT_RISK_ENABLED_SCHEDULED_TASKS_COUNT.load(Ordering::Relaxed);
let mut reasons: Vec<String> = Vec::new();
if has_active_tunnel {
reasons.push("an active tunnel".to_string());
}
if running_tasks_count > 0 {
reasons.push(format!(
"{} running scheduled task{}",
running_tasks_count,
if running_tasks_count == 1 { "" } else { "s" }
));
}
if enabled_tasks_count > 0 {
reasons.push(format!(
"{} enabled scheduled task{}",
enabled_tasks_count,
if enabled_tasks_count == 1 { "" } else { "s" }
));
}
if reasons.is_empty() {
"Background processes (sidecar, SSH sessions) will be stopped.".to_string()
} else {
format!(
"OpenChamber detected {}. Quitting now will stop sidecar/background processes and may interrupt pending work.",
reasons.join(", ")
)
}
}
#[cfg(target_os = "macos")]
const NS_TERMINATE_CANCEL: isize = 0;
#[cfg(target_os = "macos")]
const NS_TERMINATE_NOW: isize = 1;
#[cfg(target_os = "macos")]
unsafe extern "C-unwind" fn application_should_terminate_with_confirmation(
_: &objc2::runtime::AnyObject,
_: objc2::runtime::Sel,
_: *mut std::ffi::c_void,
) -> isize {
use std::sync::atomic::Ordering;
if QUIT_CONFIRMED.load(Ordering::SeqCst) {
return NS_TERMINATE_NOW;
}
if !should_require_quit_confirmation() {
QUIT_CONFIRMED.store(true, Ordering::SeqCst);
return NS_TERMINATE_NOW;
}
if QUIT_CONFIRMATION_PENDING.swap(true, Ordering::SeqCst) {
return NS_TERMINATE_CANCEL;
}
let message = quit_confirmation_message();
let confirmed = matches!(
rfd::MessageDialog::new()
.set_title("Quit OpenChamber?")
.set_description(&message)
.set_level(rfd::MessageLevel::Warning)
.set_buttons(rfd::MessageButtons::OkCancel)
.show(),
rfd::MessageDialogResult::Ok | rfd::MessageDialogResult::Yes
);
QUIT_CONFIRMATION_PENDING.store(false, Ordering::SeqCst);
if confirmed {
QUIT_CONFIRMED.store(true, Ordering::SeqCst);
NS_TERMINATE_NOW
} else {
NS_TERMINATE_CANCEL
}
}
#[cfg(target_os = "macos")]
fn install_macos_quit_confirmation_hook() {
use objc2::ffi;
use objc2::runtime::{AnyClass, AnyObject, Imp, Sel};
use std::ffi::CStr;
unsafe {
let Some(delegate_class) = AnyClass::get(CStr::from_bytes_with_nul_unchecked(
b"TaoAppDelegateParent\0",
)) else {
log::warn!("[desktop] TaoAppDelegateParent class not found; dock Quit confirmation hook skipped");
return;
};
let selector = Sel::register(c"applicationShouldTerminate:");
if !ffi::class_getInstanceMethod(delegate_class, selector).is_null() {
return;
}
let imp: Imp = std::mem::transmute(
application_should_terminate_with_confirmation
as unsafe extern "C-unwind" fn(&AnyObject, Sel, *mut std::ffi::c_void) -> isize,
);
let added = ffi::class_addMethod(
delegate_class as *const _ as *mut _,
selector,
imp,
b"q@:@\0".as_ptr().cast(),
);
if !added.as_bool() {
log::warn!("[desktop] failed to install applicationShouldTerminate hook");
}
}
}
#[cfg(not(target_os = "macos"))]
fn install_macos_quit_confirmation_hook() {}
#[cfg(target_os = "macos")]
fn request_quit_with_confirmation(app: &tauri::AppHandle) {
use std::sync::atomic::Ordering;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
if !should_require_quit_confirmation() {
QUIT_CONFIRMED.store(true, Ordering::SeqCst);
app.exit(0);
return;
}
if QUIT_CONFIRMATION_PENDING.swap(true, Ordering::SeqCst) {
return;
}
// When app has only hidden windows (common after closing last window),
// ensure at least one window is visible so native dialog reliably appears.
let windows = app.webview_windows();
let has_visible = windows.values().any(|w| w.is_visible().unwrap_or(false));
if !has_visible {
if let Some(hidden) = windows.values().find(|w| !w.is_visible().unwrap_or(true)) {
let _ = hidden.show();
let _ = hidden.set_focus();
}
}
let message = quit_confirmation_message();
let handle = app.clone();
app.dialog()
.message(message)
.title("Quit OpenChamber?")
.buttons(MessageDialogButtons::OkCancel)
.kind(tauri_plugin_dialog::MessageDialogKind::Warning)
.show(move |confirmed| {
QUIT_CONFIRMATION_PENDING.store(false, Ordering::SeqCst);
if confirmed {
QUIT_CONFIRMED.store(true, Ordering::SeqCst);
handle.exit(0);
}
});
}
#[cfg(target_os = "macos")]
fn build_macos_menu<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
@@ -394,7 +588,13 @@ fn build_macos_menu<R: tauri::Runtime>(
&PredefinedMenuItem::hide(app, None)?,
&PredefinedMenuItem::hide_others(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::quit(app, None)?,
&MenuItem::with_id(
app,
MENU_ITEM_QUIT_ID,
format!("Quit {}", pkg_info.name),
true,
Some("Cmd+Q"),
)?,
],
)?,
&Submenu::with_items(
@@ -1913,6 +2113,93 @@ async fn wait_for_local_opencode_ready_with(
last_probe
}
#[cfg(target_os = "macos")]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ScheduledTasksQuitRiskResponse {
has_enabled_scheduled_tasks: bool,
has_running_scheduled_tasks: bool,
#[serde(default)]
enabled_scheduled_tasks_count: u32,
#[serde(default)]
running_scheduled_tasks_count: u32,
}
#[cfg(target_os = "macos")]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TunnelStatusResponse {
active: bool,
}
#[cfg(target_os = "macos")]
async fn refresh_quit_risk_flags(local_base_url: &str) {
use std::sync::atomic::Ordering;
let trimmed = local_base_url.trim_end_matches('/');
if trimmed.is_empty() {
return;
}
let client = match reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(2))
.build()
{
Ok(client) => client,
Err(_) => return,
};
let scheduled_url = format!("{trimmed}/api/openchamber/scheduled-tasks/status");
let tunnel_url = format!("{trimmed}/api/openchamber/tunnel/status");
let scheduled_future = client.get(scheduled_url).send();
let tunnel_future = client.get(tunnel_url).send();
let (scheduled_result, tunnel_result) = tokio::join!(scheduled_future, tunnel_future);
if let Ok(response) = scheduled_result {
if response.status().is_success() {
if let Ok(payload) = response.json::<ScheduledTasksQuitRiskResponse>().await {
let enabled_count = payload.enabled_scheduled_tasks_count;
let running_count = payload.running_scheduled_tasks_count;
QUIT_RISK_ENABLED_SCHEDULED_TASKS_COUNT.store(enabled_count, Ordering::Relaxed);
QUIT_RISK_RUNNING_SCHEDULED_TASKS_COUNT.store(running_count, Ordering::Relaxed);
QUIT_RISK_HAS_ENABLED_SCHEDULED_TASKS
.store(payload.has_enabled_scheduled_tasks || enabled_count > 0, Ordering::Relaxed);
QUIT_RISK_HAS_RUNNING_SCHEDULED_TASKS
.store(payload.has_running_scheduled_tasks || running_count > 0, Ordering::Relaxed);
}
}
}
if let Ok(response) = tunnel_result {
if response.status().is_success() {
if let Ok(payload) = response.json::<TunnelStatusResponse>().await {
QUIT_RISK_HAS_ACTIVE_TUNNEL.store(payload.active, Ordering::Relaxed);
}
}
}
}
#[cfg(target_os = "macos")]
fn start_quit_risk_poller(local_base_url: String) {
use std::sync::atomic::Ordering;
if QUIT_RISK_POLLER_STARTED.swap(true, Ordering::SeqCst) {
return;
}
tauri::async_runtime::spawn(async move {
loop {
refresh_quit_risk_flags(&local_base_url).await;
tokio::time::sleep(QUIT_RISK_POLL_INTERVAL).await;
}
});
}
#[cfg(not(target_os = "macos"))]
fn start_quit_risk_poller(_local_base_url: String) {}
/// Uses the same probe_with_retry policy as startup/new-window (soft + hard)
/// so that first-launch/recovery remote connect accepts slow-but-valid hosts.
#[tauri::command]
@@ -3611,6 +3898,10 @@ fn main() {
});
return;
}
if id == MENU_ITEM_QUIT_ID {
request_quit_with_confirmation(app);
return;
}
}
})
.on_window_event(|window, event| {
@@ -3624,12 +3915,10 @@ fn main() {
}
if let tauri::WindowEvent::Destroyed = event {
// Clean up focus tracking for the destroyed window.
if let Some(state) = app.try_state::<WindowFocusState>() {
state.remove_window(&label);
}
// Remove stale per-window init script.
if let Some(state) = app.try_state::<DesktopUiInjectionState>() {
state
.scripts
@@ -3637,24 +3926,25 @@ fn main() {
.expect("desktop ui injection mutex")
.remove(&label);
}
// If this was the last window, kill the sidecar and exit.
let remaining = app.webview_windows().len();
if remaining == 0 {
if let Some(state) = app.try_state::<DesktopSshManagerState>() {
state.shutdown_all(&app);
}
kill_sidecar(app.clone());
app.exit(0);
}
}
if matches!(event, tauri::WindowEvent::Moved(_) | tauri::WindowEvent::Resized(_)) {
schedule_window_state_persist(window.clone(), false);
}
if let tauri::WindowEvent::CloseRequested { .. } = event {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
schedule_window_state_persist(window.clone(), true);
let remaining_visible = app
.webview_windows()
.values()
.filter(|w| w.is_visible().unwrap_or(false))
.count();
if remaining_visible <= 1 {
api.prevent_close();
let _ = window.hide();
}
}
})
.invoke_handler(tauri::generate_handler![
@@ -3754,6 +4044,7 @@ fn main() {
if let Some(state) = handle.try_state::<SidecarState>() {
*state.url.lock().expect("sidecar url mutex") = Some(local_url.clone());
}
start_quit_risk_poller(local_url.clone());
let local_origin = url::Url::parse(&local_ui_url)
.ok()
@@ -3855,10 +4146,18 @@ fn main() {
.build(tauri::generate_context!())
.expect("failed to build Tauri application");
install_macos_quit_confirmation_hook();
app.run(|app_handle, event| {
match event {
tauri::RunEvent::ExitRequested { .. } => {
// Best-effort cleanup; never block shutdown.
tauri::RunEvent::ExitRequested { api, .. } => {
use std::sync::atomic::Ordering;
if !QUIT_CONFIRMED.load(Ordering::SeqCst) {
api.prevent_exit();
#[cfg(target_os = "macos")]
request_quit_with_confirmation(app_handle);
return;
}
if let Some(state) = app_handle.try_state::<DesktopSshManagerState>() {
state.shutdown_all(app_handle);
}
@@ -3875,9 +4174,18 @@ fn main() {
has_visible_windows,
..
} => {
// macOS: clicking dock icon when no windows are open opens a new one.
if !has_visible_windows {
open_new_window(app_handle);
let windows = app_handle.webview_windows();
let hidden = windows
.values()
.find(|w| !w.is_visible().unwrap_or(true));
if let Some(w) = hidden {
let _ = w.show();
let _ = w.set_focus();
} else {
drop(windows);
open_new_window(app_handle);
}
}
}
_ => {}