ltk/print/
portal.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

//! The print portal, for an app in a sandbox that cannot reach CUPS: the desktop shows the dialog and prints. Every call blocks.

use std::collections::HashMap;
use std::fs::File;
use std::io::{ Seek, SeekFrom, Write };
use std::os::fd::{ FromRawFd, OwnedFd };
use std::sync::atomic::{ AtomicU32, Ordering };

use zbus::blocking::{ Connection, Proxy };
use zbus::zvariant::{ Fd, OwnedObjectPath, OwnedValue, Value };

use super::gtk::{ self, PageValue };
use super::PrintOptions;

const DESTINATION: &str = "org.freedesktop.portal.Desktop";
const PATH:        &str = "/org/freedesktop/portal/desktop";
const INTERFACE:   &str = "org.freedesktop.portal.Print";
const REQUEST:     &str = "org.freedesktop.portal.Request";

static NEXT_TOKEN: AtomicU32 = AtomicU32::new( 0 );

pub fn sandboxed() -> bool
{
	std::path::Path::new( "/.flatpak-info" ).exists()
}

/// The options the user chose in the desktop's dialog and the token that prints with them; `None` when they cancelled.
pub fn prepare( title: &str, options: &PrintOptions ) -> Result<Option<( PrintOptions, u32 )>, String>
{
	let connection = Connection::session().map_err( |e| e.to_string() )?;
	let settings: HashMap<&str, Value> = gtk::settings( options, None ).into_iter().map( |( k, v )| ( k, Value::from( v ) ) ).collect();
	let page: HashMap<&str, Value>     = gtk::page_setup( options ).into_iter().map( |( k, v )| ( k, page_value( v ) ) ).collect();
	let ( response, mut results ) = request( &connection, |token|
	{
		let extra = HashMap::from( [ ( "handle_token", Value::from( token ) ), ( "modal", Value::from( true ) ) ] );
		portal( &connection )?.call::<_, _, OwnedObjectPath>( "PreparePrint", &( "", title, settings, page, extra ) )
	} )?;
	match response
	{
		0 => {}
		1 => return Ok( None ),
		_ => return Err( rust_i18n::t!( "print.failed" ).to_string() ),
	}
	let token = results.remove( "token" ).and_then( |v| u32::try_from( v ).ok() ).ok_or( "the portal gave no token" )?;
	let settings = results.remove( "settings" ).map( strings ).unwrap_or_default();
	let page     = results.remove( "page-setup" ).map( page_values ).unwrap_or_default();
	Ok( Some( ( gtk::options( &settings, &page, options ), token ) ) )
}

/// Hand `pdf` to the desktop to print as `token` says.
pub fn print( token: u32, title: &str, pdf: &[ u8 ] ) -> Result<(), String>
{
	let connection = Connection::session().map_err( |e| e.to_string() )?;
	let fd         = memfd( pdf ).map_err( |e| e.to_string() )?;
	let ( response, _ ) = request( &connection, |handle|
	{
		let extra = HashMap::from( [ ( "handle_token", Value::from( handle ) ), ( "token", Value::from( token ) ), ( "modal", Value::from( true ) ) ] );
		portal( &connection )?.call::<_, _, OwnedObjectPath>( "Print", &( "", title, Fd::from( &fd ), extra ) )
	} )?;
	if response == 0 { Ok( () ) } else { Err( rust_i18n::t!( "print.failed" ).to_string() ) }
}

fn portal( connection: &Connection ) -> zbus::Result<Proxy<'static>>
{
	Proxy::new( connection, DESTINATION, PATH, INTERFACE )
}

/// Listens on the request object before `call` makes it, so the answer cannot come first and be missed.
fn request( connection: &Connection, call: impl FnOnce( &str ) -> zbus::Result<OwnedObjectPath> ) -> Result<( u32, HashMap<String, OwnedValue> ), String>
{
	let unique = connection.unique_name().ok_or( "no name on the bus" )?.as_str().trim_start_matches( ':' ).replace( '.', "_" );
	let token  = format!( "ltk{}_{}", std::process::id(), NEXT_TOKEN.fetch_add( 1, Ordering::Relaxed ) );
	let path   = format!( "{PATH}/request/{unique}/{token}" );
	let proxy  = Proxy::new( connection, DESTINATION, path.as_str(), REQUEST ).map_err( |e| e.to_string() )?;
	let mut answers = proxy.receive_signal( "Response" ).map_err( |e| e.to_string() )?;
	call( &token ).map_err( |e| e.to_string() )?;
	let answer = answers.next().ok_or( "the portal did not answer" )?;
	answer.body().deserialize::<( u32, HashMap<String, OwnedValue> )>().map_err( |e| e.to_string() )
}

fn page_value( value: PageValue ) -> Value<'static>
{
	match value
	{
		PageValue::Text( text )     => Value::from( text ),
		PageValue::Number( number ) => Value::from( number ),
	}
}

fn dict( value: OwnedValue ) -> HashMap<String, OwnedValue>
{
	HashMap::<String, OwnedValue>::try_from( value ).unwrap_or_default()
}

fn strings( value: OwnedValue ) -> HashMap<String, String>
{
	dict( value ).into_iter().filter_map( |( k, v )| String::try_from( v ).ok().map( |v| ( k, v ) ) ).collect()
}

fn page_values( value: OwnedValue ) -> HashMap<String, PageValue>
{
	dict( value ).into_iter()
		.filter_map( |( k, v )|
		{
			let value = match &*v
			{
				Value::Str( s ) => PageValue::Text( s.to_string() ),
				Value::F64( n ) => PageValue::Number( *n ),
				_               => return None,
			};
			Some( ( k, value ) )
		} )
		.collect()
}

fn memfd( data: &[ u8 ] ) -> std::io::Result<OwnedFd>
{
	// SAFETY: a new descriptor this function owns from here on.
	let raw = unsafe { libc::memfd_create( c"ltk-print".as_ptr(), libc::MFD_CLOEXEC ) };
	if raw < 0 { return Err( std::io::Error::last_os_error() ); }
	let mut file = unsafe { File::from_raw_fd( raw ) };
	file.write_all( data )?;
	file.seek( SeekFrom::Start( 0 ) )?;
	Ok( OwnedFd::from( file ) )
}