Evan's Blog
EvanZhu0721 / 公开仓库

SBMS

镜像已同步

SBMS complete the calculation rules for the default logical desktop topology relationship and the physical dimensions of the displays in the Windows multi-screen collaboration system.

上游 GitHub
READ-ONLY MIRROR

README、源码与 Release 已保存到本站;源码视图固定到 commit a37d6c4b。

main/ root / src
a37d6c4b· 固定源码快照
src/main.rs24.4 KB
1use std::error::Error;
2use std::fmt::{Display, Formatter};
3use std::fs;
4use std::io;
5use std::path::Path;
6use std::sync::Arc;
7use std::time::Duration;
8
9use sbms::config::{ConfigError, ConfigProfileStore, ConfigStore, GroupRouteConfig};
10use sbms::diagnostics::{self, Level, MappingSessionId};
11use sbms::display::{active_displays, unique_physical_display};
12use sbms::geometry::Rotation;
13use sbms::launch_broker::{self, LaunchDisposition};
14use sbms::mapping::{
15 MappingError, MappingEvent, MappingGroupRequest, MappingPlan, MappingRoute, MappingSession,
16};
17use sbms::renderer::RendererEvent;
18use sbms::session_gate::VirtualMode;
19use windows::Win32::UI::HiDpi::{
20 DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, SetProcessDpiAwarenessContext,
21};
22
23#[derive(Debug)]
24struct MapPreflightError {
25 stage: &'static str,
26 message: String,
27}
28
29impl MapPreflightError {
30 fn new(stage: &'static str, error: impl Display) -> Self {
31 Self {
32 stage,
33 message: error.to_string(),
34 }
35 }
36}
37
38impl Display for MapPreflightError {
39 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
40 write!(formatter, "{}: {}", self.stage, self.message)
41 }
42}
43
44impl Error for MapPreflightError {}
45
46fn main() -> Result<(), Box<dyn Error>> {
47 if let Err(error) = diagnostics::init() {
48 eprintln!("warning: diagnostics initialization failed: {error}");
49 }
50 diagnostics::log(Level::Info, "cli", "startup", None, "SBMS CLI started");
51 let result = run();
52 if let Err(error) = &result {
53 let message = error.to_string();
54 if !diagnostics::latest_error_matches(&message) {
55 diagnostics::log(Level::Error, "cli", "command", None, message);
56 }
57 }
58 result
59}
60
61fn run() -> Result<(), Box<dyn Error>> {
62 unsafe { SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) }?;
63 let mut arguments = std::env::args().skip(1);
64 match arguments.next().as_deref() {
65 Some("--version") => version(arguments),
66 Some("list") => list(arguments),
67 Some("map") => map(arguments),
68 Some("plan") => plan(arguments),
69 Some("config") => config(arguments),
70 Some("shutdown") => shutdown(arguments),
71 Some("ui") if arguments.next().is_none() => match launch_broker::route_tray(true)? {
72 LaunchDisposition::Exit => Ok(()),
73 LaunchDisposition::RunHere(instance) => sbms::ui::run_host(instance, true),
74 },
75 _ => usage(),
76 }
77}
78
79fn plan(mut arguments: impl Iterator<Item = String>) -> Result<(), Box<dyn Error>> {
80 match arguments.next().as_deref() {
81 Some("validate") => {
82 let path = arguments
83 .next()
84 .ok_or("plan validate requires a JSON file")?;
85 if arguments.next().is_some() {
86 usage();
87 }
88 let plan = load_plan(&path)?;
89 plan.validate()?;
90 println!("valid_groups={}", plan.groups.len());
91 Ok(())
92 }
93 Some("run") => {
94 let path = arguments.next().ok_or("plan run requires a JSON file")?;
95 let mut hold = None;
96 while let Some(argument) = arguments.next() {
97 match argument.as_str() {
98 "--hold-ms" if hold.is_none() => {
99 let milliseconds = arguments
100 .next()
101 .ok_or("--hold-ms requires a value")?
102 .parse::<u64>()?;
103 hold = Some(Duration::from_millis(milliseconds));
104 }
105 _ => usage(),
106 }
107 }
108 let plan = load_plan(&path)?;
109 let session_id = diagnostics::new_mapping_session_id();
110 diagnostics::log(
111 Level::Info,
112 "mapping",
113 "start",
114 Some(session_id),
115 format!("starting mapping plan with {} group(s)", plan.groups.len()),
116 );
117 let reporter = Arc::new(move |event| match event {
118 MappingEvent::GroupReady(group) => println!(
119 "group_ready={} route={} device={} sunshine={} source={}",
120 group.id,
121 route_name(&group.route),
122 group.source_device_name,
123 group.sunshine_id.as_deref().unwrap_or("-"),
124 group.source_id
125 ),
126 MappingEvent::Renderer {
127 id,
128 event: RendererEvent::Fps(fps),
129 } => println!("group={id} fps={fps}"),
130 MappingEvent::Renderer {
131 id,
132 event: RendererEvent::Failed(error),
133 } => {
134 diagnostics::log(
135 Level::Error,
136 "renderer",
137 "renderer",
138 Some(session_id),
139 format!("group={id}: {error}"),
140 );
141 eprintln!("group={id} renderer_error={error}");
142 }
143 MappingEvent::Renderer {
144 id,
145 event: RendererEvent::TopologyLost,
146 } => {
147 diagnostics::log(
148 Level::Warn,
149 "renderer",
150 "topology-lost",
151 Some(session_id),
152 format!("group={id}: desktop duplication topology was lost"),
153 );
154 eprintln!("group={id} renderer_topology_lost");
155 }
156 MappingEvent::Topology { message } => {
157 diagnostics::log(
158 Level::Debug,
159 "mapping",
160 "topology",
161 Some(session_id),
162 message,
163 );
164 }
165 });
166 let mut session = MappingSession::start_plan_with_reporter(plan, reporter)
167 .map_err(|error| log_mapping_error(session_id, error))?;
168 diagnostics::log(
169 Level::Info,
170 "mapping",
171 "running",
172 Some(session_id),
173 format!("mapping active with {} group(s)", session.groups().len()),
174 );
175 println!("running_groups={}", session.groups().len());
176 wait(hold)?;
177 session
178 .stop()
179 .map_err(|error| log_mapping_error(session_id, error))?;
180 diagnostics::log(
181 Level::Info,
182 "mapping",
183 "stop",
184 Some(session_id),
185 "mapping stopped",
186 );
187 println!("stopped");
188 Ok(())
189 }
190 _ => usage(),
191 }
192}
193
194fn load_plan(path: &str) -> Result<MappingPlan, Box<dyn Error>> {
195 let bytes = std::fs::read(path)?;
196 Ok(serde_json::from_slice(&bytes)?)
197}
198
199fn route_name(route: &MappingRoute) -> &'static str {
200 match route {
201 MappingRoute::Mirror { .. } => "mirror",
202 MappingRoute::StreamOnly => "stream_only",
203 }
204}
205
206fn shutdown(mut arguments: impl Iterator<Item = String>) -> Result<(), Box<dyn Error>> {
207 if arguments.next().is_some() {
208 usage();
209 }
210 println!(
211 "{}",
212 if sbms::control::signal_shutdown()? {
213 "shutdown=signaled"
214 } else {
215 "shutdown=not-running"
216 }
217 );
218 Ok(())
219}
220
221fn version(mut arguments: impl Iterator<Item = String>) -> Result<(), Box<dyn Error>> {
222 if arguments.next().is_some() {
223 usage();
224 }
225 println!("sbms {}", env!("CARGO_PKG_VERSION"));
226 Ok(())
227}
228
229fn list(mut arguments: impl Iterator<Item = String>) -> Result<(), Box<dyn Error>> {
230 if arguments.next().is_some() {
231 usage();
232 }
233 for display in active_displays()? {
234 println!(
235 "id={}\tname={}\tdevice={}\tconnector={}\tsunshine={}\trect={},{},{},{}\tnative={}x{}\trefresh={}/{}\t{}{}",
236 display.id,
237 display.name,
238 display.device_name,
239 display.connector_index,
240 display.sunshine_id.as_deref().unwrap_or("-"),
241 display.rect.left,
242 display.rect.top,
243 display.rect.right - display.rect.left,
244 display.rect.bottom - display.rect.top,
245 display.native_width,
246 display.native_height,
247 display.refresh_numerator,
248 display.refresh_denominator,
249 if display.primary { "primary " } else { "" },
250 if display.virtual_display {
251 "virtual"
252 } else {
253 "physical"
254 }
255 );
256 }
257 Ok(())
258}
259
260fn map(mut arguments: impl Iterator<Item = String>) -> Result<(), Box<dyn Error>> {
261 let mut target = None;
262 let mut hold = None;
263 while let Some(argument) = arguments.next() {
264 match argument.as_str() {
265 "--target" if target.is_none() => {
266 target = Some(arguments.next().ok_or("--target requires a display id")?);
267 }
268 "--hold-ms" if hold.is_none() => {
269 let milliseconds = arguments
270 .next()
271 .ok_or("--hold-ms requires a value")?
272 .parse::<u64>()?;
273 hold = Some(Duration::from_millis(milliseconds));
274 }
275 _ => usage(),
276 }
277 }
278 let session_id = diagnostics::new_mapping_session_id();
279 diagnostics::log(
280 Level::Info,
281 "mapping",
282 "start",
283 Some(session_id),
284 "starting mapping",
285 );
286 let plan =
287 configured_map_plan(target).map_err(|error| log_map_preflight_error(session_id, error))?;
288 let reporter = Arc::new(move |event| match event {
289 MappingEvent::Renderer {
290 id: 0,
291 event: RendererEvent::Fps(fps),
292 } => println!("fps={fps}"),
293 MappingEvent::Renderer {
294 id: 0,
295 event: RendererEvent::Failed(error),
296 } => {
297 diagnostics::log(
298 Level::Error,
299 "renderer",
300 "renderer",
301 Some(session_id),
302 &error,
303 );
304 eprintln!("renderer_error={error}");
305 }
306 MappingEvent::Renderer {
307 id: 0,
308 event: RendererEvent::TopologyLost,
309 } => {
310 diagnostics::log(
311 Level::Warn,
312 "renderer",
313 "topology-lost",
314 Some(session_id),
315 "desktop duplication topology was lost",
316 );
317 eprintln!("renderer_topology_lost");
318 }
319 _ => {}
320 });
321 let mut session = MappingSession::start_plan_with_reporter(plan, reporter)
322 .map_err(|error| log_mapping_error(session_id, error))?;
323 let source_id = session
324 .groups()
325 .next()
326 .expect("a running mapping session has at least one group")
327 .source_id
328 .clone();
329 diagnostics::log(
330 Level::Info,
331 "mapping",
332 "running",
333 Some(session_id),
334 format!("mapping active on {source_id}"),
335 );
336 println!("running={source_id}");
337 wait(hold)?;
338 session
339 .stop()
340 .map_err(|error| log_mapping_error(session_id, error))?;
341 diagnostics::log(
342 Level::Info,
343 "mapping",
344 "stop",
345 Some(session_id),
346 "mapping stopped",
347 );
348 println!("stopped");
349 Ok(())
350}
351
352fn configured_map_plan(target: Option<String>) -> Result<MappingPlan, MapPreflightError> {
353 let snapshot = ConfigProfileStore::default_store()
354 .map_err(|error| MapPreflightError::new("config", error))?
355 .load_active()
356 .map_err(|error| MapPreflightError::new("config", error))?;
357 let target = match target {
358 Some(target) => target,
359 None => {
360 let output_one = snapshot.config.group(0).ok_or_else(|| {
361 MapPreflightError::new(
362 "config",
363 "Output 1 is not present in the saved configuration",
364 )
365 })?;
366 match &output_one.route {
367 GroupRouteConfig::Mirror {
368 target_id: Some(target),
369 } => target.clone(),
370 GroupRouteConfig::Mirror { target_id: None } => {
371 return Err(MapPreflightError::new(
372 "config",
373 "Output 1 has no saved target display",
374 ));
375 }
376 GroupRouteConfig::StreamOnly { .. } => {
377 return Err(MapPreflightError::new(
378 "config",
379 "Output 1 is stream-only; use `sbms plan run` or provide --target",
380 ));
381 }
382 }
383 }
384 };
385 let sizing = snapshot.config.groups.iter().find_map(|group| {
386 matches!(
387 &group.route,
388 GroupRouteConfig::Mirror {
389 target_id: Some(configured_target)
390 } if configured_target.eq_ignore_ascii_case(&target)
391 )
392 .then_some(group.sizing)
393 .flatten()
394 });
395 let mode = match sizing {
396 Some(sizing) => {
397 let result = sizing
398 .calculate()
399 .map_err(|error| MapPreflightError::new("geometry", error))?;
400 let refresh_millihz = match result.preferred_refresh_millihz {
401 Some(refresh) => refresh,
402 None => target_refresh_millihz(&target)?,
403 };
404 VirtualMode::from_millihz(
405 result.virtual_mode.width,
406 result.virtual_mode.height,
407 refresh_millihz,
408 )
409 .map_err(|error| MapPreflightError::new("mode", error))?
410 }
411 None => VirtualMode::default(),
412 };
413 MappingPlan::new(vec![MappingGroupRequest {
414 id: 0,
415 mode,
416 rotation: Rotation::Deg0,
417 route: MappingRoute::Mirror { target },
418 }])
419 .map_err(|error| MapPreflightError::new(error.stage(), error.message()))
420}
421
422fn target_refresh_millihz(target_id: &str) -> Result<u32, MapPreflightError> {
423 let displays = active_displays().map_err(|error| MapPreflightError::new("target", error))?;
424 let target = unique_physical_display(&displays, target_id)
425 .map_err(|error| MapPreflightError::new("target", error))?;
426 if target.refresh_numerator == 0 || target.refresh_denominator == 0 {
427 return Err(MapPreflightError::new(
428 "mode",
429 "target display reported an invalid refresh rate",
430 ));
431 }
432 u64::from(target.refresh_numerator)
433 .checked_mul(1_000)
434 .and_then(|value| value.checked_div(u64::from(target.refresh_denominator)))
435 .and_then(|value| u32::try_from(value).ok())
436 .filter(|value| *value > 0)
437 .ok_or_else(|| {
438 MapPreflightError::new("mode", "target refresh rate is outside the supported range")
439 })
440}
441
442fn log_map_preflight_error(
443 session_id: MappingSessionId,
444 error: MapPreflightError,
445) -> MapPreflightError {
446 diagnostics::log(
447 Level::Error,
448 "mapping",
449 error.stage,
450 Some(session_id),
451 error.to_string(),
452 );
453 error
454}
455
456fn log_mapping_error(session_id: MappingSessionId, error: MappingError) -> MappingError {
457 diagnostics::log(
458 Level::Error,
459 "mapping",
460 error.stage(),
461 Some(session_id),
462 error.to_string(),
463 );
464 error
465}
466
467fn config(mut arguments: impl Iterator<Item = String>) -> Result<(), Box<dyn Error>> {
468 let store = ConfigProfileStore::default_store()?;
469 match arguments.next().as_deref() {
470 Some("path") if arguments.next().is_none() => {
471 println!("{}", store.path().display());
472 }
473 Some("list") if arguments.next().is_none() => {
474 let profiles = store.list()?;
475 let summaries = profiles
476 .profiles
477 .iter()
478 .map(|profile| {
479 serde_json::json!({
480 "id": profile.id,
481 "revision": profile.revision,
482 "active": profile.id.eq_ignore_ascii_case(&profiles.active_profile),
483 })
484 })
485 .collect::<Vec<_>>();
486 println!("{}", serde_json::to_string_pretty(&summaries)?);
487 }
488 Some("show") => {
489 let profile = arguments.next();
490 if arguments.next().is_some() {
491 usage();
492 }
493 let snapshot = match profile {
494 Some(profile) => store.load_profile(&profile)?,
495 None => store.load_active()?,
496 };
497 println!("{}", serde_json::to_string_pretty(&snapshot.config)?);
498 }
499 Some("save") => {
500 let profile = arguments
501 .next()
502 .ok_or("config save requires a profile id")?;
503 if arguments.next().is_some() {
504 usage();
505 }
506 let snapshot = store.save_active_as(&profile)?;
507 println!(
508 "saved={} revision={}",
509 snapshot.profile.id, snapshot.profile.revision
510 );
511 }
512 Some("import") => {
513 let profile = arguments
514 .next()
515 .ok_or("config import requires a profile id")?;
516 let path = arguments
517 .next()
518 .ok_or("config import requires a JSON file path")?;
519 let mut replace = false;
520 let mut activate = false;
521 for argument in arguments {
522 match argument.as_str() {
523 "--replace" => replace = true,
524 "--activate" => activate = true,
525 _ => usage(),
526 }
527 }
528 let imported = ConfigStore::new(path.into()).load_strict()?;
529 let (snapshot, is_active) =
530 store.save_profile(&profile, &imported, replace, activate)?;
531 let reload = is_active.then(signal_config_reload).transpose()?;
532 println!(
533 "imported={} revision={}{}",
534 snapshot.profile.id,
535 snapshot.profile.revision,
536 reload
537 .map(|signaled| format!(" reload={}", reload_status(signaled)))
538 .unwrap_or_default()
539 );
540 }
541 Some("export") => {
542 let profile = arguments
543 .next()
544 .ok_or("config export requires a profile id")?;
545 let path = arguments
546 .next()
547 .ok_or("config export requires a JSON file path")?;
548 let force = match arguments.next().as_deref() {
549 None => false,
550 Some("--force") if arguments.next().is_none() => true,
551 _ => usage(),
552 };
553 let snapshot = store.load_profile(&profile)?;
554 let destination = Path::new(&path);
555 if destination.exists() {
556 if same_existing_path(destination, store.path())? {
557 return Err("refusing to export over the config profile store".into());
558 }
559 if !force {
560 return Err(format!(
561 "refusing to overwrite {path}; pass --force to replace it"
562 )
563 .into());
564 }
565 }
566 let export_store = ConfigStore::new(path.clone().into());
567 if force {
568 export_store.save(&snapshot.config)?;
569 } else {
570 export_store.save_new(&snapshot.config)?;
571 }
572 println!("exported={} path={path}", snapshot.profile.id);
573 }
574 Some("activate") => {
575 let profile = arguments
576 .next()
577 .ok_or("config activate requires a profile id")?;
578 if arguments.next().is_some() {
579 usage();
580 }
581 let snapshot = store.activate(&profile)?;
582 let signaled = signal_config_reload()?;
583 println!(
584 "active={} revision={} reload={}",
585 snapshot.profile.id,
586 snapshot.profile.revision,
587 reload_status(signaled)
588 );
589 }
590 Some("delete") => {
591 let profile = arguments
592 .next()
593 .ok_or("config delete requires a profile id")?;
594 if arguments.next().is_some() {
595 usage();
596 }
597 store.delete(&profile)?;
598 println!("deleted={profile}");
599 }
600 Some("reload") if arguments.next().is_none() => {
601 println!("reload={}", reload_status(signal_config_reload()?));
602 }
603 Some("set-target") => {
604 let target = arguments
605 .next()
606 .ok_or("config set-target requires a display id")?;
607 if arguments.next().is_some() {
608 usage();
609 }
610 validate_physical_target(&target)?;
611 let snapshot = store.update_active(|config| {
612 let output_one = config.group_mut(0).ok_or_else(|| {
613 ConfigError::new("Output 1 is not present in the saved configuration")
614 })?;
615 output_one.route = GroupRouteConfig::Mirror {
616 target_id: Some(target),
617 };
618 Ok(())
619 })?;
620 println!(
621 "saved={} revision={} reload={}",
622 snapshot.profile.id,
623 snapshot.profile.revision,
624 reload_status(signal_config_reload()?)
625 );
626 }
627 Some("clear-target") if arguments.next().is_none() => {
628 let snapshot = store.update_active(|config| {
629 let output_one = config.group_mut(0).ok_or_else(|| {
630 ConfigError::new("Output 1 is not present in the saved configuration")
631 })?;
632 output_one.route = GroupRouteConfig::Mirror { target_id: None };
633 Ok(())
634 })?;
635 println!(
636 "saved={} revision={} reload={}",
637 snapshot.profile.id,
638 snapshot.profile.revision,
639 reload_status(signal_config_reload()?)
640 );
641 }
642 Some("reset") if arguments.next().is_none() => {
643 let snapshot = store.reset_active()?;
644 println!(
645 "reset={} revision={} reload={}",
646 snapshot.profile.id,
647 snapshot.profile.revision,
648 reload_status(signal_config_reload()?)
649 );
650 }
651 _ => usage(),
652 }
653 Ok(())
654}
655
656fn signal_config_reload() -> Result<bool, Box<dyn Error>> {
657 sbms::control::signal_config_reload().map_err(Into::into)
658}
659
660fn reload_status(signaled: bool) -> &'static str {
661 if signaled { "signaled" } else { "not-running" }
662}
663
664fn same_existing_path(left: &Path, right: &Path) -> io::Result<bool> {
665 let left = fs::canonicalize(left)?;
666 let right = fs::canonicalize(right)?;
667 Ok(left
668 .to_string_lossy()
669 .eq_ignore_ascii_case(&right.to_string_lossy()))
670}
671
672fn validate_physical_target(target_id: &str) -> Result<(), Box<dyn Error>> {
673 let matches: Vec<_> = active_displays()?
674 .into_iter()
675 .filter(|display| display.id.eq_ignore_ascii_case(target_id))
676 .collect();
677 match matches.as_slice() {
678 [target] if !target.virtual_display => Ok(()),
679 [_] => Err("the saved target cannot be the SBMS virtual display".into()),
680 [] => Err(format!("active physical display id not found: {target_id}").into()),
681 _ => Err(format!("display id is ambiguous: {target_id}").into()),
682 }
683}
684
685fn wait(hold: Option<Duration>) -> io::Result<()> {
686 if let Some(duration) = hold {
687 std::thread::sleep(duration);
688 } else {
689 println!("Press Enter to stop.");
690 let mut line = String::new();
691 io::stdin().read_line(&mut line)?;
692 }
693 Ok(())
694}
695
696fn usage() -> ! {
697 eprintln!(
698 "usage:
699 sbms --version
700 sbms list
701 sbms map [--target <monitor-device-path>] [--hold-ms <milliseconds>]
702 sbms plan validate <plan.json>
703 sbms plan run <plan.json> [--hold-ms <milliseconds>]
704 sbms config path|list|show [<profile>]
705 sbms config save <profile>
706 sbms config import <profile> <file.json> [--replace] [--activate]
707 sbms config export <profile> <file.json> [--force]
708 sbms config activate|delete <profile>
709 sbms config reload
710 sbms config set-target <monitor-device-path>|clear-target|reset
711 sbms shutdown
712 sbms ui"
713 );
714 std::process::exit(2);
715}
716