feat: add launch guard and secure multi-user service ownership (#7569)

* feat(service): isolate macOS launch and owner runtime

* ci(windows): run owner security tests

* chore(deps): use kode-bridge v0.4.1 tag

* fix(macos): classify installed service state

* fix(macos): classify marked unavailable service

* fix(macos): gate core startup on service migration

* fix(macos): scope service init to supported platforms

* feat(macos): prompt before migrating installed service

* fix(macos): shorten sidecar IPC path

* build(service): pin helper protocol and artifacts

* fix(core): require confirmed service and sidecar readiness

* fix(macos): harden launch roots and singleton state

* fix(ui): refresh migration state before restart

* fix(macos): linearize service migration lifecycle

* fix(ui): keep service migration state fresh

* feat(service): track authenticated owner sessions

* fix(service): preserve newer owner sessions

* feat(macos): route service proxy changes through helper

* fix(macos): serialize service proxy guard

* fix(macos): close service proxy owner lifecycle

* fix(macos): close remaining proxy stop races

* fix(app): do not cancel controlled service stop

* fix(lifecycle): keep controlled updates cancellation-safe

* fix(macos): stage dev core for service

* test(macos): exercise staged core cleanup

* fix(dev): await controlled macOS exit

* chore: update changelog

* fix(macos): recover sidecar after service install failure

Allow the existing sidecar to continue when service installation leaves the manager in InstallRequired, then settle the session state as SidecarAllowed. Show neutral status text while the migration dialog waits for its final state.

* fix(macos): stage dev service tools before elevation

* fix(macos): localize launch guard prompts

* fix(service): address cross-platform integration issues

* feat: support seamless service migration

* fix: pass Windows service installer path safely

* fix(prebuild): derive service release from Cargo dependency

* fix(dev): preserve service installation state

* fix: resolve clippy warnings in service lifecycle
This commit is contained in:
wonfen
2026-07-24 09:01:49 +08:00
committed by GitHub
parent c079da6c99
commit 23dda47641
90 changed files with 9711 additions and 900 deletions
+127 -26
View File
@@ -36,6 +36,24 @@ struct TaskState {
running: bool,
}
async fn run_timer_profile_update_transition<Started, Update, UpdateFuture, Finished, Completed, Output>(
update_started: Started,
update_profile: Update,
update_finished: Finished,
update_completed: Completed,
) where
Started: FnOnce(),
Update: FnOnce() -> UpdateFuture,
UpdateFuture: std::future::Future<Output = Output>,
Finished: FnOnce(Output),
Completed: FnOnce(),
{
update_started();
let result = update_profile().await;
update_finished(result);
update_completed();
}
impl TaskState {
const fn new(key: Key, interval_minutes: u64) -> Self {
Self {
@@ -386,36 +404,35 @@ impl Timer {
let task_start = std::time::Instant::now();
logging!(debug, Type::Timer, "Running timer task for profile: {}", uid);
match tokio::time::timeout(std::time::Duration::from_secs(40), async {
Self::emit_update_event(uid, true);
let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid);
logging!(
debug,
Type::Timer,
"Profile {} is current active profile: {}",
uid,
is_current
);
feat::update_profile(uid, None, is_current, false, false).await
})
.await
{
Ok(Ok(_)) => {
run_timer_profile_update_transition(
|| Self::emit_update_event(uid, true),
|| async {
let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid);
logging!(
info,
debug,
Type::Timer,
"Timer task completed for uid: {} (took {}ms)",
"Profile {} is current active profile: {}",
uid,
task_start.elapsed().as_millis()
is_current
);
}
Ok(Err(e)) => logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e),
Err(_) => logging_error!(Type::Timer, "Timer task timed out for uid: {}", uid),
}
Self::emit_update_event(uid, false);
feat::update_profile(uid, None, is_current, false, false).await
},
|result| match result {
Ok(_) => {
logging!(
info,
Type::Timer,
"Timer task completed for uid: {} (took {}ms)",
uid,
task_start.elapsed().as_millis()
);
}
Err(e) => logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e),
},
|| Self::emit_update_event(uid, false),
)
.await;
}
async fn wait_until_resolve_done(max_wait: Duration) {
@@ -431,8 +448,31 @@ impl Timer {
#[cfg(test)]
mod tests {
use super::Timer;
use super::{Timer, run_timer_profile_update_transition};
use crate::config::{PrfItem, PrfOption};
use parking_lot::Mutex;
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
task::Poll,
time::Duration,
};
use tokio::sync::Barrier;
struct CancellationProbe {
cancelled: Arc<AtomicBool>,
completed: Arc<AtomicBool>,
}
impl Drop for CancellationProbe {
fn drop(&mut self) {
if !self.completed.load(Ordering::Acquire) {
self.cancelled.store(true, Ordering::Release);
}
}
}
fn remote_profile(uid: &str, allow_auto_update: Option<bool>, update_interval: Option<u64>) -> PrfItem {
PrfItem {
@@ -463,4 +503,65 @@ mod tests {
assert_eq!(map.get("enabled"), Some(&30));
assert_eq!(map.get("missing-flag"), Some(&30));
}
#[tokio::test(start_paused = true)]
async fn timer_profile_update_runs_past_former_deadline_and_pairs_events() -> anyhow::Result<()> {
let calls = Arc::new(Mutex::new(Vec::new()));
let update_started = Arc::new(Barrier::new(2));
let release_update = Arc::new(Barrier::new(2));
let update_cancelled = Arc::new(AtomicBool::new(false));
let update_completed = Arc::new(AtomicBool::new(false));
let mut update = Box::pin(run_timer_profile_update_transition(
{
let calls = Arc::clone(&calls);
move || calls.lock().push("update-started")
},
{
let update_started = Arc::clone(&update_started);
let release_update = Arc::clone(&release_update);
let update_cancelled = Arc::clone(&update_cancelled);
let update_completed = Arc::clone(&update_completed);
move || async move {
let _probe = CancellationProbe {
cancelled: update_cancelled,
completed: Arc::clone(&update_completed),
};
update_started.wait().await;
release_update.wait().await;
update_completed.store(true, Ordering::Release);
Ok::<(), anyhow::Error>(())
}
},
{
let calls = Arc::clone(&calls);
move |result: anyhow::Result<()>| {
assert!(result.is_ok());
calls.lock().push("terminal-result");
}
},
{
let calls = Arc::clone(&calls);
move || calls.lock().push("update-completed")
},
));
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
update_started.wait().await;
tokio::time::advance(Duration::from_secs(41)).await;
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
assert!(!update_cancelled.load(Ordering::Acquire));
assert_eq!(&*calls.lock(), &["update-started"]);
release_update.wait().await;
update.await;
assert!(update_completed.load(Ordering::Acquire));
assert!(!update_cancelled.load(Ordering::Acquire));
assert_eq!(
&*calls.lock(),
&["update-started", "terminal-result", "update-completed"]
);
Ok(())
}
}