text-input: re-sync on enter and flag secure fields as Password
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

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:
2026-05-27 22:31:36 +02:00
parent 1e2cb836f4
commit 3d8523533c
7 changed files with 89 additions and 19 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 );
}
} }
} }

View File

@@ -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();
}
_ => {} _ => {}
} }
} }

View File

@@ -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();

View File

@@ -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 )
}
}

View File

@@ -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