Integrate the reviewed 2.0 PRs and local fixes from 03136853. Preserve the verified subtitle behavior and model/runtime corrections. Keep the numeric Beta tag compatible with released clients while exposing the date as application build metadata. Co-authored-by: DepengWang <2818245+DepengWang@users.noreply.github.com> Co-authored-by: HKLHaoBin <we3q@qq.com> Co-authored-by: Nahida <1139500183@qq.com> Co-authored-by: TRIP <1933142963@qq.com> Co-authored-by: chyuwei <weichangyuwcy@gmail.com> Co-authored-by: leonoxo <leonoxo@gmail.com> Co-authored-by: tayir <arnoldincredible@126.com>
314 lines
11 KiB
Rust
314 lines
11 KiB
Rust
//! Minimal non-UI host example for the Linux egui team.
|
|
//!
|
|
//! This example deliberately does not create a window or draw a frame. It
|
|
//! demonstrates the lifecycle and event seam that an `eframe::App` can use.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use openless_core::testing::{
|
|
FixtureDictationEngine, FixtureSelectionRuntime, FixtureTextInserter, FixtureTextPolisher,
|
|
RecordingHostActions,
|
|
};
|
|
use openless_core::{
|
|
BackendErrorCode, LocalAsrRuntime, PolishMode, RepolishRequest, SelectionCapture,
|
|
SelectionPolishOutputMode, SelectionPolishRequest, SelectionVoiceApplyOutcome,
|
|
SelectionVoicePhase, SelectionVoicePreviewUpdate, SessionId,
|
|
};
|
|
use openless_linux_egui::{
|
|
drain_events, BackendConfig, BackendDependencies, BackendError, BackendServices,
|
|
EventDrainOutcome, InMemoryCredentialStore, InsertOutcome, LinuxHost, MarketplaceQuery,
|
|
OpenLessBackend, SettingsEffectFailure, SettingsEffectPlan, SettingsEffectReceipt,
|
|
SettingsRuntime, TokioTaskSpawner,
|
|
};
|
|
|
|
struct HeadlessSettingsRuntime;
|
|
|
|
impl SettingsRuntime for HeadlessSettingsRuntime {
|
|
fn prepare(
|
|
&self,
|
|
_plan: &SettingsEffectPlan,
|
|
) -> Result<SettingsEffectReceipt, SettingsEffectFailure> {
|
|
Ok(SettingsEffectReceipt::default())
|
|
}
|
|
|
|
fn commit(
|
|
&self,
|
|
_plan: &SettingsEffectPlan,
|
|
_receipt: &mut SettingsEffectReceipt,
|
|
) -> Result<(), SettingsEffectFailure> {
|
|
Ok(())
|
|
}
|
|
|
|
fn restore(
|
|
&self,
|
|
_plan: &SettingsEffectPlan,
|
|
_receipt: &SettingsEffectReceipt,
|
|
) -> Result<(), BackendError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<(), BackendError> {
|
|
let data_dir = std::env::temp_dir().join(format!(
|
|
"openless-headless-{}-{}",
|
|
std::process::id(),
|
|
uuid::Uuid::new_v4().simple()
|
|
));
|
|
let host = RecordingHostActions::default();
|
|
let selection_runtime = FixtureSelectionRuntime::linux_preview_unsupported(SelectionCapture {
|
|
text: "fixture selection".into(),
|
|
source_app: Some("Headless fixture".into()),
|
|
});
|
|
let backend = Arc::new(OpenLessBackend::new(
|
|
BackendConfig {
|
|
data_dir: data_dir.clone(),
|
|
..BackendConfig::default()
|
|
},
|
|
BackendDependencies {
|
|
host_actions: Arc::new(host),
|
|
text_inserter: Arc::new(FixtureTextInserter::with_outcome(InsertOutcome::Inserted)),
|
|
dictation_engine: Arc::new(FixtureDictationEngine::successful(
|
|
"fixture raw",
|
|
"fixture polished",
|
|
)),
|
|
task_spawner: Arc::new(TokioTaskSpawner),
|
|
credential_store: Arc::new(InMemoryCredentialStore::default()),
|
|
services: BackendServices::unsupported(),
|
|
local_asr_runtime: None,
|
|
marketplace_config: None,
|
|
selection_runtime: Some(Arc::new(selection_runtime)),
|
|
selection_polisher: Some(Arc::new(FixtureTextPolisher::successful(
|
|
"fixture selection polished",
|
|
))),
|
|
qa_runtime: None,
|
|
},
|
|
)?);
|
|
let linux_host =
|
|
LinuxHost::with_settings_runtime(Arc::clone(&backend), Arc::new(HeadlessSettingsRuntime));
|
|
|
|
let mut events = linux_host.subscribe();
|
|
backend.start().await?;
|
|
|
|
// Data-only use-cases are immediately available through the same facade.
|
|
// A real view model can load these before it starts drawing frames.
|
|
let preferences = backend.get_preferences();
|
|
let _history = backend.list_history()?;
|
|
let _style_packs = backend.list_style_packs(&preferences.active_style_pack_id)?;
|
|
|
|
// Unconfigured platform/provider domains fail explicitly. The egui UI
|
|
// should map these errors to unavailable capabilities instead of rendering
|
|
// controls that appear to work. Replace each unsupported adapter with a
|
|
// real Linux adapter or a deterministic fixture without changing callers.
|
|
let services = backend.services();
|
|
let unsupported = [
|
|
services
|
|
.local_asr
|
|
.runtime_status(LocalAsrRuntime::Generic)
|
|
.await
|
|
.expect_err("headless Local ASR must be unsupported"),
|
|
services
|
|
.marketplace
|
|
.list(MarketplaceQuery {
|
|
query: None,
|
|
sort: None,
|
|
limit: None,
|
|
})
|
|
.await
|
|
.expect_err("headless Marketplace must be unsupported"),
|
|
services
|
|
.qa
|
|
.snapshot()
|
|
.await
|
|
.expect_err("headless QA must be unsupported"),
|
|
services
|
|
.remote_input
|
|
.status()
|
|
.expect_err("headless Remote Input must be unsupported"),
|
|
];
|
|
assert!(unsupported
|
|
.iter()
|
|
.all(|error| error.code == BackendErrorCode::Unsupported));
|
|
let auxiliary_error = services
|
|
.auxiliary
|
|
.repolish(RepolishRequest {
|
|
raw_text: "fixture raw".into(),
|
|
style_pack_id: None,
|
|
front_app: None,
|
|
})
|
|
.await
|
|
.expect_err("headless auxiliary processing must be unsupported");
|
|
assert_eq!(auxiliary_error.code, BackendErrorCode::Unsupported);
|
|
let retranscription_error = services
|
|
.auxiliary
|
|
.retranscribe_pcm(vec![0, 0])
|
|
.await
|
|
.expect_err("headless retranscription must be unsupported");
|
|
assert_eq!(
|
|
retranscription_error.error.code,
|
|
BackendErrorCode::Unsupported
|
|
);
|
|
|
|
// Less Computer voice hosts reserve a Core capture lease before touching
|
|
// recorder/native ASR resources and release it explicitly on cancellation
|
|
// or startup failure. The headless example exercises that seam without
|
|
// requiring a coding-agent process.
|
|
let mut less_computer_preferences = backend.get_preferences();
|
|
less_computer_preferences.coding_agent_enabled = true;
|
|
linux_host.update_settings_strict(
|
|
less_computer_preferences,
|
|
linux_host.snapshot().preferences_revision,
|
|
)?;
|
|
let less_computer_session = SessionId::new();
|
|
backend.begin_less_computer_capture(less_computer_session)?;
|
|
assert_eq!(
|
|
backend.less_computer_active_session(),
|
|
Some(less_computer_session)
|
|
);
|
|
backend
|
|
.cancel_less_computer(Some(less_computer_session))
|
|
.await?;
|
|
// Core 2.0 cancellation is terminal and releases the capture lease. A host
|
|
// that observes the cancellation after the await must not expect the old
|
|
// lease's flag to remain queryable.
|
|
assert_eq!(backend.less_computer_active_session(), None);
|
|
backend.abort_less_computer_capture(less_computer_session)?;
|
|
assert_eq!(backend.less_computer_active_session(), None);
|
|
|
|
// The deterministic Linux selection fixture supports direct replacement,
|
|
// but retained preview targets and platform revert are intentionally
|
|
// unsupported. A view model can therefore exercise the exact capability
|
|
// branches without fcitx5 or a window.
|
|
let direct_session = services
|
|
.selection
|
|
.begin_polish(SelectionPolishRequest {
|
|
selected_text: Some("fixture selection".into()),
|
|
mode: PolishMode::Raw,
|
|
instruction: None,
|
|
})
|
|
.await?;
|
|
let revert_error = services
|
|
.selection
|
|
.revert(direct_session)
|
|
.await
|
|
.expect_err("headless Linux revert must be unsupported");
|
|
assert_eq!(revert_error.code, BackendErrorCode::Unsupported);
|
|
|
|
let mut preview_preferences = backend.get_preferences();
|
|
preview_preferences.selection_polish_output_mode = SelectionPolishOutputMode::PreviewConfirm;
|
|
linux_host.update_settings_strict(
|
|
preview_preferences,
|
|
linux_host.snapshot().preferences_revision,
|
|
)?;
|
|
let preview_error = services
|
|
.selection
|
|
.begin_polish(SelectionPolishRequest {
|
|
selected_text: Some("fixture selection".into()),
|
|
mode: PolishMode::Raw,
|
|
instruction: None,
|
|
})
|
|
.await
|
|
.expect_err("headless Linux preview must be unsupported");
|
|
assert_eq!(preview_error.code, BackendErrorCode::Unsupported);
|
|
|
|
exercise_selection_voice(services).await?;
|
|
|
|
let _session = backend.start_dictation().await?;
|
|
let result = backend.stop_dictation().await?;
|
|
println!("{}", result.polished_text);
|
|
backend.shutdown().await?;
|
|
|
|
// A real egui host drains this subscription without blocking its frame and
|
|
// requests a repaint after each event. The example only proves that the
|
|
// subscription can be created and consumed by a host runtime.
|
|
match drain_events(&mut events, |event| println!("event #{}", event.sequence)) {
|
|
EventDrainOutcome::Idle { .. } => {}
|
|
EventDrainOutcome::Lagged { dropped, .. } => {
|
|
eprintln!("event subscription lagged by {dropped}; resync from snapshot");
|
|
let _snapshot = linux_host.snapshot();
|
|
}
|
|
EventDrainOutcome::Closed { .. } => eprintln!("event subscription closed"),
|
|
}
|
|
let _ = std::fs::remove_dir_all(data_dir);
|
|
Ok(())
|
|
}
|
|
|
|
async fn exercise_selection_voice(services: &BackendServices) -> Result<(), BackendError> {
|
|
let voice = &services.selection_voice;
|
|
|
|
let confirmed = voice
|
|
.begin(SelectionCapture {
|
|
text: "source".into(),
|
|
source_app: None,
|
|
})
|
|
.await?;
|
|
voice.mark_processing(confirmed).await?;
|
|
voice
|
|
.set_preview(SelectionVoicePreviewUpdate {
|
|
session_id: confirmed,
|
|
owner_session_id: Some(confirmed),
|
|
text: "preview".into(),
|
|
summary: Some("fixture summary".into()),
|
|
})
|
|
.await?;
|
|
assert!(voice.preview(Some(confirmed)).await?.is_some());
|
|
let ticket = voice.begin_preview_apply(Some(confirmed), "confirmed preview".into())?;
|
|
voice
|
|
.finish_preview_apply(ticket.ticket_id, SelectionVoiceApplyOutcome::Inserted)
|
|
.await?;
|
|
assert_eq!(
|
|
voice.snapshot().await?.phase,
|
|
SelectionVoicePhase::Completed
|
|
);
|
|
|
|
let unknown = voice
|
|
.begin(SelectionCapture {
|
|
text: "unknown source".into(),
|
|
source_app: None,
|
|
})
|
|
.await?;
|
|
voice.mark_processing(unknown).await?;
|
|
voice
|
|
.set_preview(SelectionVoicePreviewUpdate {
|
|
session_id: unknown,
|
|
owner_session_id: Some(unknown),
|
|
text: "unknown preview".into(),
|
|
summary: None,
|
|
})
|
|
.await?;
|
|
let ticket = voice.begin_preview_apply(Some(unknown), "unknown preview".into())?;
|
|
voice
|
|
.finish_preview_apply(ticket.ticket_id, SelectionVoiceApplyOutcome::CopiedFallback)
|
|
.await?;
|
|
assert_eq!(
|
|
voice.snapshot().await?.apply_outcome,
|
|
Some(SelectionVoiceApplyOutcome::CopiedFallback)
|
|
);
|
|
|
|
let cancelled = voice
|
|
.begin(SelectionCapture {
|
|
text: "cancel source".into(),
|
|
source_app: None,
|
|
})
|
|
.await?;
|
|
voice.cancel(Some(cancelled)).await?;
|
|
assert_eq!(
|
|
voice.snapshot().await?.phase,
|
|
SelectionVoicePhase::Cancelled
|
|
);
|
|
|
|
let current = voice
|
|
.begin(SelectionCapture {
|
|
text: "current source".into(),
|
|
source_app: None,
|
|
})
|
|
.await?;
|
|
let stale = voice
|
|
.cancel(Some(cancelled))
|
|
.await
|
|
.expect_err("a stale selection voice session must not cancel the current one");
|
|
assert_eq!(stale.code, BackendErrorCode::Cancelled);
|
|
voice.cancel(Some(current)).await?;
|
|
Ok(())
|
|
}
|