@@ -128,6 +128,9 @@ enum Commands {
128128 target : String ,
129129 #[ arg( long, value_name = "EXACT_PHRASE" ) ]
130130 confirm : Option < String > ,
131+ /// Emit newline-delimited JSON progress events for trusted clients.
132+ #[ arg( long) ]
133+ json_progress : bool ,
131134 #[ command( flatten) ]
132135 windows : WindowsArgs ,
133136 #[ arg( long, default_value = "off" , value_name = "off|1|2|4" ) ]
@@ -207,13 +210,15 @@ fn main() -> Result<()> {
207210 image,
208211 target,
209212 confirm,
213+ json_progress,
210214 windows,
211215 bad_block_check,
212216 } ) => write_image (
213217 & engine,
214218 image,
215219 & target,
216220 confirm,
221+ json_progress,
217222 write_options ( windows, bad_block_check) ,
218223 ) ,
219224 None if io:: stdout ( ) . is_terminal ( ) => run_tui ( engine, cli. image ) ,
@@ -451,19 +456,30 @@ fn write_image(
451456 image : PathBuf ,
452457 target : & str ,
453458 confirmation : Option < String > ,
459+ json_progress : bool ,
454460 options : WriteOptions ,
455461) -> Result < ( ) > {
456462 let plan = engine. prepare_with_options ( image, target, options) ?;
457463 let Some ( confirmation) = confirmation else {
458464 render_plan_text ( & plan) ;
459465 bail ! (
460- "nothing was written; repeat with --confirm '{}' as root/admin " ,
466+ "nothing was written; repeat with --confirm '{}'" ,
461467 plan. confirmation_phrase
462468 ) ;
463469 } ;
464- let mut reporter = ProgressReporter :: default ( ) ;
465- engine. write ( & plan, & confirmation, |progress| reporter. print ( progress) ) ?;
466- Ok ( ( ) )
470+ let mut reporter = ProgressReporter :: new ( json_progress) ;
471+ let result =
472+ engine. write_with_privilege ( & plan, & confirmation, |progress| reporter. print ( progress) ) ;
473+ match result {
474+ Ok ( ( ) ) => {
475+ reporter. finished ( ) ;
476+ Ok ( ( ) )
477+ }
478+ Err ( error) => {
479+ reporter. failed ( & error. to_string ( ) ) ;
480+ Err ( error. into ( ) )
481+ }
482+ }
467483}
468484
469485fn write_options ( windows : WindowsArgs , bad_block_check : BadBlockCheck ) -> WriteOptions {
@@ -491,9 +507,18 @@ fn write_options(windows: WindowsArgs, bad_block_check: BadBlockCheck) -> WriteO
491507struct ProgressReporter {
492508 phase : Option < ProgressPhase > ,
493509 percentage : Option < u64 > ,
510+ json : bool ,
494511}
495512
496513impl ProgressReporter {
514+ fn new ( json : bool ) -> Self {
515+ Self {
516+ phase : None ,
517+ percentage : None ,
518+ json,
519+ }
520+ }
521+
497522 fn print ( & mut self , progress : Progress ) {
498523 let percentage = progress
499524 . total
@@ -504,13 +529,39 @@ impl ProgressReporter {
504529 if !phase_changed && !percentage_changed {
505530 return ;
506531 }
532+ if self . json {
533+ println ! ( "{}" , progress_event_json( & progress) ) ;
534+ let _ = io:: Write :: flush ( & mut io:: stdout ( ) ) ;
535+ self . phase = Some ( progress. phase ) ;
536+ self . percentage = percentage;
537+ return ;
538+ }
507539 let amount = percentage
508540 . map ( |value| format ! ( "{value:>3}%" ) )
509541 . unwrap_or_else ( || "..." . into ( ) ) ;
510542 eprintln ! ( "{amount} {:?}: {}" , progress. phase, progress. message) ;
511543 self . phase = Some ( progress. phase ) ;
512544 self . percentage = percentage;
513545 }
546+
547+ fn finished ( & self ) {
548+ if self . json {
549+ println ! ( "{{\" event\" :\" finished\" }}" ) ;
550+ }
551+ }
552+
553+ fn failed ( & self , message : & str ) {
554+ if self . json {
555+ println ! (
556+ "{}" ,
557+ serde_json:: json!( { "event" : "failed" , "data" : { "message" : message } } )
558+ ) ;
559+ }
560+ }
561+ }
562+
563+ fn progress_event_json ( progress : & Progress ) -> String {
564+ serde_json:: json!( { "event" : "progress" , "data" : progress } ) . to_string ( )
514565}
515566
516567fn render_plan_text ( plan : & WritePlan ) {
@@ -4911,9 +4962,11 @@ fn device_change_message(added: usize, removed: usize) -> String {
49114962#[ cfg( test) ]
49124963mod layout_tests {
49134964 use super :: {
4914- WorkspaceFocus , advanced_height, application_area, brand_lockup, centered_button_area,
4915- grid_areas, main_shell_layout, windows_option_columns, workspace_height,
4965+ Cli , Commands , Progress , ProgressPhase , WorkspaceFocus , advanced_height, application_area,
4966+ brand_lockup, centered_button_area, grid_areas, main_shell_layout, progress_event_json,
4967+ windows_option_columns, workspace_height,
49164968 } ;
4969+ use clap:: Parser ;
49174970 use ratatui:: layout:: Rect ;
49184971
49194972 #[ test]
@@ -4996,4 +5049,40 @@ mod layout_tests {
49965049 assert ! ( lines[ 0 ] . to_string( ) . contains( "┌┬┬┐ BOOTABLE α" ) ) ;
49975050 assert ! ( lines[ 1 ] . to_string( ) . contains( "╰♨─╯" ) ) ;
49985051 }
5052+
5053+ #[ test]
5054+ fn write_json_progress_is_an_explicit_client_mode ( ) {
5055+ let cli = Cli :: try_parse_from ( [
5056+ "bootable" ,
5057+ "write" ,
5058+ "image.iso" ,
5059+ "/dev/removable" ,
5060+ "--confirm" ,
5061+ "ERASE /dev/removable TEST" ,
5062+ "--json-progress" ,
5063+ ] )
5064+ . expect ( "valid client invocation" ) ;
5065+ assert ! ( matches!(
5066+ cli. command,
5067+ Some ( Commands :: Write {
5068+ json_progress: true ,
5069+ ..
5070+ } )
5071+ ) ) ;
5072+ }
5073+
5074+ #[ test]
5075+ fn progress_events_are_stable_newline_json_payloads ( ) {
5076+ let event = progress_event_json ( & Progress {
5077+ phase : ProgressPhase :: Writing ,
5078+ completed : 25 ,
5079+ total : Some ( 100 ) ,
5080+ message : "Writing and verifying" . into ( ) ,
5081+ } ) ;
5082+ let value: serde_json:: Value = serde_json:: from_str ( & event) . expect ( "valid JSON" ) ;
5083+ assert_eq ! ( value[ "event" ] , "progress" ) ;
5084+ assert_eq ! ( value[ "data" ] [ "phase" ] , "Writing" ) ;
5085+ assert_eq ! ( value[ "data" ] [ "completed" ] , 25 ) ;
5086+ assert_eq ! ( value[ "data" ] [ "total" ] , 100 ) ;
5087+ }
49995088}
0 commit comments