mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(tauri): own reset_local_data lifecycle in shell (OPENHUMAN-TAURI-AF) (#1769)
This commit is contained in:
@@ -266,6 +266,224 @@ async fn start_core_process(
|
||||
state.inner().ensure_running().await
|
||||
}
|
||||
|
||||
/// Reset the user's local OpenHuman data and bounce the embedded core.
|
||||
///
|
||||
/// Replaces the prior two-step UI flow that called the core JSON-RPC
|
||||
/// `openhuman.config_reset_local_data` (in-process removal) followed by
|
||||
/// `restart_core_process`. The in-process removal failed on Windows with
|
||||
/// `ERROR_SHARING_VIOLATION` (os error 32) because the running core held
|
||||
/// open handles to SQLite databases, log files, the Sentry session store,
|
||||
/// etc. inside the directory it was being asked to delete — see
|
||||
/// OPENHUMAN-TAURI-AF.
|
||||
///
|
||||
/// New order:
|
||||
///
|
||||
/// 1. Query the core for the **paths** it would remove (`config_get_data_paths`)
|
||||
/// while the core is still up — these are derived from the loaded config
|
||||
/// and the active workspace marker, so the core is authoritative.
|
||||
/// 2. Acquire the restart lock so a concurrent `restart_core_process` cannot
|
||||
/// interleave with the remove.
|
||||
/// 3. Shut down the embedded core. `CoreProcessHandle::shutdown` cancels
|
||||
/// the cancellation token and awaits the tokio task, which drops the
|
||||
/// SQLite pool, log writer, etc. — releasing every Windows file handle.
|
||||
/// 4. Remove the three paths (current data dir, default data dir, active
|
||||
/// workspace marker) from this process. Missing entries are non-fatal.
|
||||
/// 5. Restart the embedded core via `ensure_running`.
|
||||
///
|
||||
/// Returns `Ok(())` only when the core is back up and the directories are
|
||||
/// gone (or were already absent). Any step's `Err` short-circuits and
|
||||
/// surfaces to the UI, which already renders the message as a toast.
|
||||
#[tauri::command]
|
||||
async fn reset_local_data(
|
||||
state: tauri::State<'_, core_process::CoreProcessHandle>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[core] reset_local_data: command invoked from frontend");
|
||||
|
||||
// ── 1. Ask the core for the paths it would remove ────────────────────
|
||||
//
|
||||
// The core is authoritative for path resolution (it owns config
|
||||
// loading, the workspace marker, and the staging-vs-prod default-dir
|
||||
// suffix). Resolve while the core is still up so we don't duplicate
|
||||
// that logic here.
|
||||
let paths = fetch_data_paths().await?;
|
||||
log::info!(
|
||||
"[core] reset_local_data: paths resolved current={} default={} marker={}",
|
||||
paths.current_openhuman_dir.display(),
|
||||
paths.default_openhuman_dir.display(),
|
||||
paths.active_workspace_marker_path.display()
|
||||
);
|
||||
|
||||
// ── 2. Acquire the restart lock ─────────────────────────────────────
|
||||
//
|
||||
// Prevents a concurrent `restart_core_process` from re-spawning the
|
||||
// embedded server in the middle of the remove step.
|
||||
let _guard = state.inner().restart_lock().await;
|
||||
log::debug!("[core] reset_local_data: acquired restart lock");
|
||||
|
||||
// ── 3. Shut down the embedded core ──────────────────────────────────
|
||||
//
|
||||
// Drops the tokio task, which drops the SQLite pool, log writer, and
|
||||
// every other RAII owner of a file handle inside the data directory.
|
||||
// On Windows this is the load-bearing step for OPENHUMAN-TAURI-AF.
|
||||
state.inner().shutdown().await;
|
||||
log::info!("[core] reset_local_data: embedded core stopped");
|
||||
|
||||
// ── 4. Remove the paths ─────────────────────────────────────────────
|
||||
//
|
||||
// Missing entries are non-fatal: the user may already have manually
|
||||
// cleared the dir, or the marker may not exist for fresh installs.
|
||||
//
|
||||
// Capture the first delete error (if any) instead of propagating with
|
||||
// `?` — we must still restart the embedded core in step 5 so the app
|
||||
// doesn't end up with the sidecar dead. The original delete error is
|
||||
// surfaced after the restart attempt.
|
||||
let delete_result: Result<(), String> = async {
|
||||
remove_path_if_exists(
|
||||
&paths.active_workspace_marker_path,
|
||||
"active workspace marker",
|
||||
)
|
||||
.await?;
|
||||
remove_dir_if_exists(&paths.current_openhuman_dir, "current openhuman dir").await?;
|
||||
if paths.default_openhuman_dir != paths.current_openhuman_dir {
|
||||
remove_dir_if_exists(&paths.default_openhuman_dir, "default openhuman dir").await?;
|
||||
} else {
|
||||
log::debug!(
|
||||
"[core] reset_local_data: default dir == current dir; already removed above"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
if let Err(ref e) = delete_result {
|
||||
log::warn!("[core] reset_local_data: delete step failed: {e}; will still restart core");
|
||||
}
|
||||
|
||||
// ── 5. Restart the embedded core ────────────────────────────────────
|
||||
//
|
||||
// Always attempt restart, even if delete failed — otherwise the user
|
||||
// is left with a dead sidecar. If restart itself fails, prefer the
|
||||
// original delete error (more actionable) over the restart error.
|
||||
let restart_result = state.inner().ensure_running().await;
|
||||
match (&delete_result, &restart_result) {
|
||||
(Ok(()), Ok(())) => log::info!("[core] reset_local_data: embedded core back up"),
|
||||
(Err(_), Ok(())) => log::warn!(
|
||||
"[core] reset_local_data: core restarted but delete step failed; surfacing delete error"
|
||||
),
|
||||
(Ok(()), Err(e)) => log::error!("[core] reset_local_data: core restart failed: {e}"),
|
||||
(Err(_), Err(e)) => log::error!(
|
||||
"[core] reset_local_data: both delete and restart failed; restart error: {e}"
|
||||
),
|
||||
}
|
||||
delete_result?;
|
||||
restart_result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolved data paths returned by `config_get_data_paths`.
|
||||
struct ResolvedDataPaths {
|
||||
current_openhuman_dir: std::path::PathBuf,
|
||||
default_openhuman_dir: std::path::PathBuf,
|
||||
active_workspace_marker_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
/// Call the core's `config_get_data_paths` RPC and parse the response.
|
||||
async fn fetch_data_paths() -> Result<ResolvedDataPaths, String> {
|
||||
let url = crate::core_rpc::core_rpc_url_value();
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "openhuman.config_get_data_paths",
|
||||
"params": {}
|
||||
});
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("config_get_data_paths client build failed: {e}"))?;
|
||||
let req = crate::core_rpc::apply_auth(client.post(&url))?;
|
||||
let res = req
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("config_get_data_paths request failed: {e}"))?;
|
||||
if !res.status().is_success() {
|
||||
return Err(format!("config_get_data_paths http {}", res.status()));
|
||||
}
|
||||
let envelope: serde_json::Value = res
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("config_get_data_paths decode failed: {e}"))?;
|
||||
// JSON-RPC envelope wraps the `RpcOutcome` result twice:
|
||||
// `{ "result": { "result": { ...paths... }, "logs": [...] } }`.
|
||||
let inner = envelope
|
||||
.pointer("/result/result")
|
||||
.ok_or_else(|| "config_get_data_paths missing /result/result".to_string())?;
|
||||
let current = inner
|
||||
.get("current_openhuman_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "config_get_data_paths missing current_openhuman_dir".to_string())?;
|
||||
let default = inner
|
||||
.get("default_openhuman_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "config_get_data_paths missing default_openhuman_dir".to_string())?;
|
||||
let marker = inner
|
||||
.get("active_workspace_marker_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "config_get_data_paths missing active_workspace_marker_path".to_string())?;
|
||||
Ok(ResolvedDataPaths {
|
||||
current_openhuman_dir: std::path::PathBuf::from(current),
|
||||
default_openhuman_dir: std::path::PathBuf::from(default),
|
||||
active_workspace_marker_path: std::path::PathBuf::from(marker),
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove a regular file if present. Missing → debug log + Ok.
|
||||
async fn remove_path_if_exists(path: &std::path::Path, label: &str) -> Result<(), String> {
|
||||
match tokio::fs::remove_file(path).await {
|
||||
Ok(()) => {
|
||||
log::info!(
|
||||
"[core] reset_local_data: removed {label} at {}",
|
||||
path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
log::debug!(
|
||||
"[core] reset_local_data: {label} already absent at {}",
|
||||
path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(format!(
|
||||
"Failed to remove {label} at {}: {e}",
|
||||
path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a directory tree if present. Missing → debug log + Ok.
|
||||
async fn remove_dir_if_exists(path: &std::path::Path, label: &str) -> Result<(), String> {
|
||||
match tokio::fs::remove_dir_all(path).await {
|
||||
Ok(()) => {
|
||||
log::info!(
|
||||
"[core] reset_local_data: removed {label} at {}",
|
||||
path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
log::debug!(
|
||||
"[core] reset_local_data: {label} already absent at {}",
|
||||
path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(format!(
|
||||
"Failed to remove {label} at {}: {e}",
|
||||
path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleanly exit the application.
|
||||
///
|
||||
/// Called by the BootCheckGate "Quit" button when the core is unreachable and
|
||||
@@ -2487,6 +2705,7 @@ pub fn run() {
|
||||
install_app_update,
|
||||
restart_core_process,
|
||||
start_core_process,
|
||||
reset_local_data,
|
||||
app_quit,
|
||||
restart_app,
|
||||
get_active_user_id,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { invoke, isTauri } from '@tauri-apps/api/core';
|
||||
import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
|
||||
|
||||
import { callCoreRpc } from '../../services/coreRpcClient';
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() }));
|
||||
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
|
||||
type TauriInternalsHolder = { __TAURI_INTERNALS__?: { invoke: unknown } };
|
||||
|
||||
describe('tauriCommands', () => {
|
||||
const mockIsTauri = isTauri as Mock;
|
||||
const mockInvoke = invoke as Mock;
|
||||
@@ -15,10 +17,20 @@ describe('tauriCommands', () => {
|
||||
let storeSession: typeof import('../tauriCommands').storeSession;
|
||||
let openhumanLocalAiStatus: typeof import('../tauriCommands').openhumanLocalAiStatus;
|
||||
let openhumanServiceStatus: typeof import('../tauriCommands').openhumanServiceStatus;
|
||||
let prevInternals: TauriInternalsHolder['__TAURI_INTERNALS__'];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
// The local `isTauri()` wrapper in `tauriCommands/common.ts` ALSO checks
|
||||
// `window.__TAURI_INTERNALS__.invoke` to detect the CEF bootstrap gap
|
||||
// (see OPENHUMAN-REACT-S). Mocking only the upstream `coreIsTauri`
|
||||
// isn't enough — the wrapper would still return false in tests and
|
||||
// every helper would hit its `if (!isTauri()) return;` early-exit.
|
||||
// Stub a minimal internals shape so the wrapper resolves to true.
|
||||
const holder = window as unknown as TauriInternalsHolder;
|
||||
prevInternals = holder.__TAURI_INTERNALS__;
|
||||
holder.__TAURI_INTERNALS__ = { invoke: () => undefined };
|
||||
const actual = await vi.importActual<typeof import('../tauriCommands')>('../tauriCommands');
|
||||
getAuthState = actual.getAuthState;
|
||||
resetOpenHumanDataAndRestartCore = actual.resetOpenHumanDataAndRestartCore;
|
||||
@@ -27,6 +39,15 @@ describe('tauriCommands', () => {
|
||||
openhumanServiceStatus = actual.openhumanServiceStatus;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const holder = window as unknown as TauriInternalsHolder;
|
||||
if (prevInternals === undefined) {
|
||||
delete holder.__TAURI_INTERNALS__;
|
||||
} else {
|
||||
holder.__TAURI_INTERNALS__ = prevInternals;
|
||||
}
|
||||
});
|
||||
|
||||
test('getAuthState maps result shape from core response', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({
|
||||
result: { isAuthenticated: true, user: { id: 'u1' } },
|
||||
@@ -50,8 +71,30 @@ describe('tauriCommands', () => {
|
||||
test('resetOpenHumanDataAndRestartCore invokes the destructive Tauri command', async () => {
|
||||
await resetOpenHumanDataAndRestartCore();
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.config_reset_local_data' });
|
||||
expect(mockInvoke).toHaveBeenCalledWith('restart_core_process');
|
||||
// The helper used to call `openhuman.config_reset_local_data` over
|
||||
// JSON-RPC followed by `restart_core_process`, but the in-process
|
||||
// remove failed on Windows when the running core held open handles
|
||||
// inside the data directory (OPENHUMAN-TAURI-AF). The Tauri shell
|
||||
// now owns the full sequence (stop core → remove paths → restart
|
||||
// core) behind a single `reset_local_data` command, so no core RPC
|
||||
// call should reach `callCoreRpc` from this helper.
|
||||
expect(mockCallCoreRpc).not.toHaveBeenCalled();
|
||||
expect(mockInvoke).toHaveBeenCalledWith('reset_local_data');
|
||||
});
|
||||
|
||||
test('resetOpenHumanDataAndRestartCore surfaces invoke failures to the caller', async () => {
|
||||
// Callers (e.g. `clearAllAppData`) treat a thrown error as unrecoverable
|
||||
// and abort the flow — so the helper must rethrow instead of swallowing
|
||||
// a `reset_local_data` failure (e.g. Windows `ERROR_SHARING_VIOLATION`
|
||||
// when a handle outside the embedded core still holds a path).
|
||||
const boom = new Error('reset_local_data failed');
|
||||
mockInvoke.mockRejectedValueOnce(boom);
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(resetOpenHumanDataAndRestartCore()).rejects.toBe(boom);
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('openhumanLocalAiStatus returns upgrade hint on unknown method', async () => {
|
||||
|
||||
@@ -261,14 +261,21 @@ export async function resetOpenHumanDataAndRestartCore(): Promise<void> {
|
||||
console.debug('[core] resetOpenHumanDataAndRestartCore: skipped — not running in Tauri');
|
||||
return;
|
||||
}
|
||||
console.debug(
|
||||
'[core] resetOpenHumanDataAndRestartCore: invoking openhuman.config_reset_local_data'
|
||||
);
|
||||
await callCoreRpc({ method: 'openhuman.config_reset_local_data' });
|
||||
console.debug(
|
||||
'[core] resetOpenHumanDataAndRestartCore: local data reset complete, restarting core'
|
||||
);
|
||||
await restartCoreProcess();
|
||||
// Single Tauri command: the shell stops the embedded core (dropping
|
||||
// every open file handle inside the data directory), removes the
|
||||
// resolved data paths, then restarts the core. Previously this was a
|
||||
// two-step `callCoreRpc('config_reset_local_data') + restartCoreProcess()`
|
||||
// dance, but the core RPC ran the remove *inside* the running core's
|
||||
// tokio task — on Windows that hit `ERROR_SHARING_VIOLATION` (os error
|
||||
// 32) because the core still held SQLite / log / Sentry handles open in
|
||||
// the directory it was trying to delete (OPENHUMAN-TAURI-AF).
|
||||
console.debug('[core] resetOpenHumanDataAndRestartCore: invoking reset_local_data');
|
||||
try {
|
||||
await invoke<void>('reset_local_data');
|
||||
} catch (err) {
|
||||
console.error('[core] resetOpenHumanDataAndRestartCore: reset_local_data failed', err);
|
||||
throw err;
|
||||
}
|
||||
console.debug('[core] resetOpenHumanDataAndRestartCore: done');
|
||||
}
|
||||
|
||||
|
||||
@@ -1088,6 +1088,19 @@ pub fn agent_server_status() -> RpcOutcome<serde_json::Value> {
|
||||
}
|
||||
|
||||
/// Deletes all local data directories and workspace markers.
|
||||
///
|
||||
/// Runs **inside the core's tokio task**, which means the running core
|
||||
/// holds open handles to SQLite databases, log files, the Sentry session
|
||||
/// store, etc. On Windows, `remove_dir_all` therefore fails with
|
||||
/// `ERROR_SHARING_VIOLATION` (os error 32) — see OPENHUMAN-TAURI-AF.
|
||||
///
|
||||
/// GUI callers must use the Tauri-side `reset_local_data` command instead:
|
||||
/// it stops the embedded core via `CoreProcessHandle::shutdown` (dropping
|
||||
/// the file handles), removes the directories from the Tauri host process,
|
||||
/// and restarts the core. This JSON-RPC method is kept for headless / CLI
|
||||
/// callers where in-process removal is acceptable (POSIX file semantics
|
||||
/// tolerate unlinking open files; on Windows the CLI invocation runs
|
||||
/// without the core attached, so no handle is in the way).
|
||||
pub async fn reset_local_data() -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
let config = load_config_with_timeout().await?;
|
||||
let current_openhuman_dir = config_openhuman_dir(&config);
|
||||
@@ -1095,6 +1108,35 @@ pub async fn reset_local_data() -> Result<RpcOutcome<serde_json::Value>, String>
|
||||
reset_local_data_for_paths(¤t_openhuman_dir, &default_openhuman_dir).await
|
||||
}
|
||||
|
||||
/// Reports the resolved paths that `reset_local_data` would remove, without
|
||||
/// performing any filesystem changes.
|
||||
///
|
||||
/// Lets the Tauri-side `reset_local_data` command discover the active
|
||||
/// workspace dir, the default `~/.openhuman` dir (which can differ when
|
||||
/// `OPENHUMAN_WORKSPACE` is set or a staging build is in use), and the
|
||||
/// active workspace marker file **before** the core sidecar is shut down —
|
||||
/// after which the Tauri shell removes them while no process holds open
|
||||
/// handles. See OPENHUMAN-TAURI-AF for the Windows file-locking failure
|
||||
/// that motivated the split.
|
||||
pub async fn get_data_paths() -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
let config = load_config_with_timeout().await?;
|
||||
let current_openhuman_dir = config_openhuman_dir(&config);
|
||||
let default_openhuman_dir = default_openhuman_dir();
|
||||
let active_workspace_marker = active_workspace_marker_path(&default_openhuman_dir);
|
||||
Ok(RpcOutcome::new(
|
||||
json!({
|
||||
"current_openhuman_dir": current_openhuman_dir.display().to_string(),
|
||||
"default_openhuman_dir": default_openhuman_dir.display().to_string(),
|
||||
"active_workspace_marker_path": active_workspace_marker.display().to_string(),
|
||||
}),
|
||||
vec![format!(
|
||||
"data paths resolved (current={}, default={})",
|
||||
current_openhuman_dir.display(),
|
||||
default_openhuman_dir.display()
|
||||
)],
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ops_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -196,6 +196,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("get_meet_settings"),
|
||||
schemas("agent_server_status"),
|
||||
schemas("reset_local_data"),
|
||||
schemas("get_data_paths"),
|
||||
schemas("get_onboarding_completed"),
|
||||
schemas("set_onboarding_completed"),
|
||||
schemas("get_dictation_settings"),
|
||||
@@ -285,6 +286,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("reset_local_data"),
|
||||
handler: handle_reset_local_data,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("get_data_paths"),
|
||||
handler: handle_get_data_paths,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("get_onboarding_completed"),
|
||||
handler: handle_get_onboarding_completed,
|
||||
@@ -689,6 +694,17 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
inputs: vec![],
|
||||
outputs: vec![json_output("result", "Reset result with removed paths.")],
|
||||
},
|
||||
"get_data_paths" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "get_data_paths",
|
||||
description:
|
||||
"Resolve the OpenHuman data directories (current workspace, default ~/.openhuman, active workspace marker) that reset_local_data would remove. Read-only — performs no filesystem changes.",
|
||||
inputs: vec![],
|
||||
outputs: vec![json_output(
|
||||
"paths",
|
||||
"Resolved data paths: current_openhuman_dir, default_openhuman_dir, active_workspace_marker_path.",
|
||||
)],
|
||||
},
|
||||
"get_onboarding_completed" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "get_onboarding_completed",
|
||||
@@ -1184,6 +1200,22 @@ fn handle_reset_local_data(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async { to_json(config_rpc::reset_local_data().await?) })
|
||||
}
|
||||
|
||||
fn handle_get_data_paths(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async {
|
||||
log::debug!("[config][rpc] get_data_paths enter");
|
||||
match config_rpc::get_data_paths().await {
|
||||
Ok(outcome) => {
|
||||
log::debug!("[config][rpc] get_data_paths ok");
|
||||
to_json(outcome)
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("[config][rpc] get_data_paths fail: {err}");
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_get_onboarding_completed(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async { to_json(config_rpc::get_onboarding_completed().await?) })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user