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
"one frame on the heap, then overwritten on drop". Single-line and
multiline are mutually exclusive with secure (passwords have no line
breaks); the text-input-v3 IME path is also skipped in secure mode so
preedit / commit strings never reach the compositor's IME stack.
breaks). Note: secure does **not** skip the text-input-v3 path — the
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

View File

@@ -82,6 +82,9 @@ pub struct AppData<A: App>
pub current_cursor_shape: Option<crate::types::CursorShape>,
pub text_input_manager: Option<ZwpTextInputManagerV3>,
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
/// global. Used on first configure to honour an incoming
/// `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 is_text_input;
let secure;
{
let ss = self.surface_mut( focus );
@@ -91,6 +92,10 @@ impl<A: App> AppData<A>
.and_then( |i| find_handlers( &ss.widget_rects, i ) )
.map( |h| h.is_text_input() )
.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
.and_then( |i| find_handlers( &ss.widget_rects, i ) )
.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
{
self.deactivate_text_input();
self.app.on_text_input_focused( false );
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.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();
}
}
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,
text_input_manager,
text_input: None,
text_input_secure: false,
activation_state,
activation_token_pending,
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
{
let qh = data.qh.clone();

View File

@@ -13,21 +13,34 @@ use crate::widget::WidgetHandlers;
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();
if let Some( seat ) = seats.into_iter().next()
{
let ti = manager.get_text_input( &seat, qh, () );
ti.enable();
ti.set_content_type(
zwp_text_input_v3::ContentHint::None,
zwp_text_input_v3::ContentPurpose::Normal,
);
ti.set_content_type( hint, purpose );
ti.commit();
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
/// hit-testers for text editing. Returns `None` when the widget
/// 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 ) )
}
}
/// 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
/// `prctl( PR_SET_DUMPABLE, 0 )` on the process.
/// * **Compositor-side records.** Wayland text-input protocols can
/// surface preedit / commit strings to the compositor's IME
/// stack; `secure` skips text-input-v3 registration so this path
/// stays closed. Verify your compositor honours that — most do,
/// but the protocol does not strictly require it.
/// surface preedit / commit strings to the compositor's IME stack.
/// `secure` does *not* suppress text-input-v3 — the field still
/// registers so the on-screen keyboard activates on it — but it is
/// 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
/// (the *Hardening features* section enumerates each guarantee and