text-input: re-sync on enter and flag secure fields as Password
LTK drives the on-screen keyboard through zwp_text_input_v3: focusing a text widget enables text-input so the compositor's input-method (squeekboard) brings the keyboard up. Two gaps are fixed here. Handle the `enter` event by re-emitting enable + content type + commit. The compositor sends `enter` when it (re)focuses the text input — notably when an input-method connects after the field has already enabled text-input (a startup race). LTK previously ignored `enter`, so that activation was lost and the keyboard never appeared; it now re-declares its state and the OSK comes up. Declare the content type from the field's `secure` flag: secure fields are flagged `ContentPurpose::Password` with `ContentHint::SensitiveData | HiddenText` (so the IME/OSK skips prediction, autocorrect and storing the value), everything else stays `Normal`/`None`. The content type is also refreshed when focus moves between two text fields (e.g. username → password) without re-creating the text-input object, and a new `AppData::text_input_secure` lets the `enter` re-emit path preserve the current field's type. Correct the docs: `TextEdit::secure()` and SECURITY.md claimed secure "skips text-input-v3 registration". It does not — the field still activates text-input so the OSK works on it; the protection is the Password / sensitive flagging, and the value still reaches a trusted compositor/IME.
This commit is contained in:
@@ -71,8 +71,13 @@ message and we will reply with it before sharing any sensitive details.
|
|||||||
replaces the previous frame's snapshot, so the typical lifecycle is
|
replaces the previous frame's snapshot, so the typical lifecycle is
|
||||||
"one frame on the heap, then overwritten on drop". Single-line and
|
"one frame on the heap, then overwritten on drop". Single-line and
|
||||||
multiline are mutually exclusive with secure (passwords have no line
|
multiline are mutually exclusive with secure (passwords have no line
|
||||||
breaks); the text-input-v3 IME path is also skipped in secure mode so
|
breaks). Note: secure does **not** skip the text-input-v3 path — the
|
||||||
preedit / commit strings never reach the compositor's IME stack.
|
field still activates it (so the on-screen keyboard works on password
|
||||||
|
fields) but is flagged `Password` with `SensitiveData | HiddenText`,
|
||||||
|
which asks the IME / OSK not to predict, autocorrect or store the
|
||||||
|
value. The value can still reach a trusted compositor/IME; closing
|
||||||
|
that path would mean not activating text-input there (and losing the
|
||||||
|
OSK on the field).
|
||||||
|
|
||||||
### What `TextEdit::secure` does **not** cover
|
### What `TextEdit::secure` does **not** cover
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ pub struct AppData<A: App>
|
|||||||
pub current_cursor_shape: Option<crate::types::CursorShape>,
|
pub current_cursor_shape: Option<crate::types::CursorShape>,
|
||||||
pub text_input_manager: Option<ZwpTextInputManagerV3>,
|
pub text_input_manager: Option<ZwpTextInputManagerV3>,
|
||||||
pub text_input: Option<ZwpTextInputV3>,
|
pub text_input: Option<ZwpTextInputV3>,
|
||||||
|
/// Whether the currently focused text field is `secure` (password).
|
||||||
|
/// Drives the text-input-v3 content type.
|
||||||
|
pub text_input_secure: bool,
|
||||||
/// `xdg-activation-v1`. Present when the compositor advertises the
|
/// `xdg-activation-v1`. Present when the compositor advertises the
|
||||||
/// global. Used on first configure to honour an incoming
|
/// global. Used on first configure to honour an incoming
|
||||||
/// `XDG_ACTIVATION_TOKEN` (a launcher that spawned us wants the
|
/// `XDG_ACTIVATION_TOKEN` (a launcher that spawned us wants the
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
let was_text_input;
|
let was_text_input;
|
||||||
let is_text_input;
|
let is_text_input;
|
||||||
|
let secure;
|
||||||
{
|
{
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
|
|
||||||
@@ -91,6 +92,10 @@ impl<A: App> AppData<A>
|
|||||||
.and_then( |i| find_handlers( &ss.widget_rects, i ) )
|
.and_then( |i| find_handlers( &ss.widget_rects, i ) )
|
||||||
.map( |h| h.is_text_input() )
|
.map( |h| h.is_text_input() )
|
||||||
.unwrap_or( false );
|
.unwrap_or( false );
|
||||||
|
secure = idx
|
||||||
|
.and_then( |i| find_handlers( &ss.widget_rects, i ) )
|
||||||
|
.map( |h| matches!( h, WidgetHandlers::TextEdit { secure: true, .. } ) )
|
||||||
|
.unwrap_or( false );
|
||||||
was_text_input = ss.focused_idx
|
was_text_input = ss.focused_idx
|
||||||
.and_then( |i| find_handlers( &ss.widget_rects, i ) )
|
.and_then( |i| find_handlers( &ss.widget_rects, i ) )
|
||||||
.map( |h| h.is_text_input() )
|
.map( |h| h.is_text_input() )
|
||||||
@@ -141,17 +146,24 @@ impl<A: App> AppData<A>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eprintln!( "ltk: set_focus idx={:?} is_text_input={} was_text_input={}", idx, is_text_input, was_text_input );
|
||||||
if was_text_input && !is_text_input
|
if was_text_input && !is_text_input
|
||||||
{
|
{
|
||||||
self.deactivate_text_input();
|
self.deactivate_text_input();
|
||||||
self.app.on_text_input_focused( false );
|
self.app.on_text_input_focused( false );
|
||||||
self.dirty_caches();
|
self.dirty_caches();
|
||||||
}
|
}
|
||||||
if is_text_input && !was_text_input
|
else if is_text_input && !was_text_input
|
||||||
{
|
{
|
||||||
self.activate_text_input( qh );
|
self.activate_text_input( qh, secure );
|
||||||
self.app.on_text_input_focused( true );
|
self.app.on_text_input_focused( true );
|
||||||
self.dirty_caches();
|
self.dirty_caches();
|
||||||
}
|
}
|
||||||
|
else if is_text_input
|
||||||
|
{
|
||||||
|
// Focus moved between text fields: refresh the content type so a
|
||||||
|
// password field is flagged even without re-activating.
|
||||||
|
self.activate_text_input( qh, secure );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -674,6 +674,10 @@ impl<A: App> Dispatch<ZwpTextInputV3, ()> for AppData<A>
|
|||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
zwp_text_input_v3::Event::Enter { .. } =>
|
||||||
|
{
|
||||||
|
state.reenable_text_input();
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
current_cursor_shape: None,
|
current_cursor_shape: None,
|
||||||
text_input_manager,
|
text_input_manager,
|
||||||
text_input: None,
|
text_input: None,
|
||||||
|
text_input_secure: false,
|
||||||
activation_state,
|
activation_state,
|
||||||
activation_token_pending,
|
activation_token_pending,
|
||||||
data_device_manager,
|
data_device_manager,
|
||||||
@@ -809,6 +810,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
eprintln!( "ltk: focus_request id={:?} resolved={}", id, hit.is_some() );
|
||||||
if let Some( ( focus, flat_idx ) ) = hit
|
if let Some( ( focus, flat_idx ) ) = hit
|
||||||
{
|
{
|
||||||
let qh = data.qh.clone();
|
let qh = data.qh.clone();
|
||||||
|
|||||||
@@ -13,21 +13,34 @@ use crate::widget::WidgetHandlers;
|
|||||||
|
|
||||||
impl<A: App> AppData<A>
|
impl<A: App> AppData<A>
|
||||||
{
|
{
|
||||||
pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle<Self> )
|
pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle<Self>, secure: bool )
|
||||||
{
|
{
|
||||||
if let ( Some( manager ), None ) = ( &self.text_input_manager, &self.text_input )
|
self.text_input_secure = secure;
|
||||||
|
let ( hint, purpose ) = content_type( secure );
|
||||||
|
match ( &self.text_input_manager, &self.text_input )
|
||||||
|
{
|
||||||
|
( Some( manager ), None ) =>
|
||||||
{
|
{
|
||||||
let seats: Vec<_> = self.seat_state.seats().collect();
|
let seats: Vec<_> = self.seat_state.seats().collect();
|
||||||
if let Some( seat ) = seats.into_iter().next()
|
if let Some( seat ) = seats.into_iter().next()
|
||||||
{
|
{
|
||||||
let ti = manager.get_text_input( &seat, qh, () );
|
let ti = manager.get_text_input( &seat, qh, () );
|
||||||
ti.enable();
|
ti.enable();
|
||||||
ti.set_content_type(
|
ti.set_content_type( hint, purpose );
|
||||||
zwp_text_input_v3::ContentHint::None,
|
|
||||||
zwp_text_input_v3::ContentPurpose::Normal,
|
|
||||||
);
|
|
||||||
ti.commit();
|
ti.commit();
|
||||||
self.text_input = Some( ti );
|
self.text_input = Some( ti );
|
||||||
|
} else {
|
||||||
|
eprintln!( "ltk: activate_text_input: no seat available" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
( None, _ ) =>
|
||||||
|
eprintln!( "ltk: activate_text_input: no text_input_manager (compositor did not advertise zwp_text_input_manager_v3)" ),
|
||||||
|
// Focus moved between text fields: refresh the content type (e.g.
|
||||||
|
// flag a password field) without re-creating the object.
|
||||||
|
( Some( _ ), Some( ti ) ) =>
|
||||||
|
{
|
||||||
|
ti.set_content_type( hint, purpose );
|
||||||
|
ti.commit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,6 +55,17 @@ impl<A: App> AppData<A>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub( crate ) fn reenable_text_input( &self )
|
||||||
|
{
|
||||||
|
if let Some( ti ) = &self.text_input
|
||||||
|
{
|
||||||
|
let ( hint, purpose ) = content_type( self.text_input_secure );
|
||||||
|
ti.enable();
|
||||||
|
ti.set_content_type( hint, purpose );
|
||||||
|
ti.commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Snapshot a focused-widget's geometry needed by the pointer
|
/// Snapshot a focused-widget's geometry needed by the pointer
|
||||||
/// hit-testers for text editing. Returns `None` when the widget
|
/// hit-testers for text editing. Returns `None` when the widget
|
||||||
/// isn't a TextEdit or its rect is missing — the helper above
|
/// isn't a TextEdit or its rect is missing — the helper above
|
||||||
@@ -69,3 +93,19 @@ impl<A: App> AppData<A>
|
|||||||
Some( ( widget.rect, value, multiline, secure, align, font_size ) )
|
Some( ( widget.rect, value, multiline, secure, align, font_size ) )
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a field's `secure` flag to the text-input-v3 content type. Secure
|
||||||
|
/// fields are flagged `Password` with `SensitiveData | HiddenText` so the
|
||||||
|
/// IME/OSK skips prediction, autocorrect and storing the value.
|
||||||
|
fn content_type( secure: bool ) -> ( zwp_text_input_v3::ContentHint, zwp_text_input_v3::ContentPurpose )
|
||||||
|
{
|
||||||
|
if secure
|
||||||
|
{
|
||||||
|
(
|
||||||
|
zwp_text_input_v3::ContentHint::SensitiveData | zwp_text_input_v3::ContentHint::HiddenText,
|
||||||
|
zwp_text_input_v3::ContentPurpose::Password,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
( zwp_text_input_v3::ContentHint::None, zwp_text_input_v3::ContentPurpose::Normal )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -346,10 +346,14 @@ impl<Msg: Clone> TextEdit<Msg>
|
|||||||
/// the credential mount, and restrict core-dump capability with
|
/// the credential mount, and restrict core-dump capability with
|
||||||
/// `prctl( PR_SET_DUMPABLE, 0 )` on the process.
|
/// `prctl( PR_SET_DUMPABLE, 0 )` on the process.
|
||||||
/// * **Compositor-side records.** Wayland text-input protocols can
|
/// * **Compositor-side records.** Wayland text-input protocols can
|
||||||
/// surface preedit / commit strings to the compositor's IME
|
/// surface preedit / commit strings to the compositor's IME stack.
|
||||||
/// stack; `secure` skips text-input-v3 registration so this path
|
/// `secure` does *not* suppress text-input-v3 — the field still
|
||||||
/// stays closed. Verify your compositor honours that — most do,
|
/// registers so the on-screen keyboard activates on it — but it is
|
||||||
/// but the protocol does not strictly require it.
|
/// flagged `Password` with `SensitiveData | HiddenText`, asking the
|
||||||
|
/// IME / OSK to skip prediction, autocorrect and storing the value.
|
||||||
|
/// The value still reaches the (trusted) compositor/IME; for a
|
||||||
|
/// stricter threat model, suppress text-input on secure fields
|
||||||
|
/// instead (at the cost of losing the OSK there).
|
||||||
///
|
///
|
||||||
/// See the in-repo `SECURITY.md` for the full threat-model write-up
|
/// See the in-repo `SECURITY.md` for the full threat-model write-up
|
||||||
/// (the *Hardening features* section enumerates each guarantee and
|
/// (the *Hardening features* section enumerates each guarantee and
|
||||||
|
|||||||
Reference in New Issue
Block a user