diff --git a/.github/workflows/verification-codes.yml b/.github/workflows/verification-codes.yml
new file mode 100644
index 000000000..b28371514
--- /dev/null
+++ b/.github/workflows/verification-codes.yml
@@ -0,0 +1,29 @@
+name: Native verification code offers
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - mseries/phone-setup/*code*.py
+ - mseries/install-verification-codes.sh
+ - .github/workflows/verification-codes.yml
+jobs:
+ codes:
+ name: Private native field insertion and shortcut preservation (arm64)
+ runs-on: ubuntu-26.04-arm
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/checkout@v7
+ with:
+ repository: erikwb/blueferry
+ ref: 60fd7ef1abbd25947981f0cf27a521d82784a75f
+ path: .code-test-blueferry
+ - name: Install native fixture dependencies
+ run: sudo apt-get update && sudo apt-get install -y python3-gi python3-dbus python3-pil gir1.2-gtk-4.0 gir1.2-atspi-2.0 libgranite-7-dev at-spi2-core dbus-x11 xvfb xauth xdotool gsettings-desktop-schemas gnome-settings-daemon-common shellcheck
+ - name: Check models, actual field editing and settings in isolation
+ env:
+ PYTHONPATH: ${{ github.workspace }}/.code-test-blueferry/src
+ run: |
+ bash -n mseries/install-verification-codes.sh
+ shellcheck mseries/install-verification-codes.sh
+ python3 mseries/phone-setup/check_verification_codes.py
diff --git a/docs/features.html b/docs/features.html
index 884a741c2..bfcca244d 100644
--- a/docs/features.html
+++ b/docs/features.html
@@ -38,7 +38,7 @@
Omarchy M-series enhancements
| Function-key mode | Differs | Running with media keys first (fnmode=1); upstream uses the kernel default (auto). Awaiting a deliberate choice. |
| Microphone mapping | Not ported | Needs re-homing to elementary's audio session. |
| iCloud password sync | Options documented | No official Linux client. Unofficial read-only tools exist but sign in with your Apple ID and are new; a one-way export into a cross-platform password manager is the safest path. The owner leans to a Brave extension (icp supports Brave) or Brave Sync. |
-| iMessage / SMS | Working on this desktop | BlueFerry with a nearby iPhone: actual outgoing and incoming texts verified on J314S, iPhone 14 Pro / iOS 27.0.1, scoped BlueZ 5.87. Native GTK/Granite Messages adds conversations, contacts/photos, a composer and bounded recent-inbox refresh. Ordered Apple-pin snapshot import is implemented; actual owner Mac export and automatic pin sync remain unqualified. Full iCloud history, reboot/suspend recovery and factory-image installation are not qualified. No Mac relay is required for texting. Local Codex MCP cache reads and a phone mark-unread patch are also verified; full unread parity and automatic iMessage pin matching remain incomplete. |
+| iMessage / SMS | Working on this desktop | BlueFerry with a nearby iPhone: actual outgoing and incoming texts verified on J314S, iPhone 14 Pro / iOS 27.0.1, scoped BlueZ 5.87. Native GTK/Granite Messages adds conversations, contacts/photos, a composer and bounded recent-inbox refresh. Near-pointer verification-code helper supports native field insertion and explicit browser copy in isolated fixtures; live sign-in qualification remains open. Four-across contact favorites and a separator accompany ordered Apple-pin snapshot import; actual owner Mac export and automatic pin sync remain unqualified. Full iCloud history, reboot/suspend recovery and factory-image installation are not qualified. No Mac relay is required for texting. Local Codex MCP cache reads and a phone mark-unread patch are also verified; full unread parity and automatic iMessage pin matching remain incomplete. |
| Hardware video decode | Missing | Needs AVD kernel support, firmware and a VA-API bridge. |
| Touch ID | Blocked upstream | No public Linux code exists. Omarchy lists it as not in its first release and Asahi lists it as to be announced. A watcher reports when that changes. |
| Windows taskbar CodexBar | Native quota popup beside Wi-Fi with separate grouping, 56% larger text, and bundled Windows Start menu. Canonical build checks preserve the complete taskbar, hover popup and Teams/Outlook running markers. | Local integration loaded; persistence CI |
diff --git a/mseries/OS9-HANDOFF.md b/mseries/OS9-HANDOFF.md
index d776b20f1..68cdb6f59 100644
--- a/mseries/OS9-HANDOFF.md
+++ b/mseries/OS9-HANDOFF.md
@@ -1,5 +1,44 @@
# OS 9 implementation / Claude Code handoff
+## Native favorites and visible Messages launcher — 2026-10-20
+
+Owner requests: four favorite contact icons per row in the contacts sidebar,
+with a horizontal divider before ordinary conversations. Implemented ordered
+GTK grid with photos/initials, names/tooltips, selected state and search reflow;
+thread switching keeps drafts. 15 private Messages GUI tests pass, including
+four-column wrapping, divider visibility, favorite routing and draft retention.
+Light/dark/narrow fixtures reviewed. Native installer passed and staged sources;
+Messages PID 263569 remained running, so grid loads at next launch.
+
+Fixed missing launcher icon: user hicolor theme lacked index.theme/cache.
+Installer preserves an existing index or installs the standard hicolor index,
+updates the scoped cache, and adds TryExec. GIO reports Messages visible;
+GTK resolves our SVG, and launching the desktop entry brings up the existing
+window. No panel/dock restart. Verification Codes is installed/enabled/active;
+shortcut Shift+Super+V. 17 isolated code checks pass including real Return-to-field
+and Brave explicit clipboard copy/no DOM change/no form submit. Live code
+insertion/pointer placement remains unqualified. No real texts sent.
+
+## Apple pins merged; verification-code helper qualification — 2026-10-20
+
+PR #349 merged as `6b1d520c675a227394b5a5a88298083f71e81657`; final head
+`2621ffe` passed every applicable check. Last macOS ARM runner initially failed
+allocation (capacity annotations, no code run), then passed on retry. ARM macOS
+15/26 and Intel 26 fixtures all pass. SIP-enabled owner export/import is still
+open; use the small kit in `phone-setup/macos-visit/README.md`. Installed importer
+loads next Messages launch; the owner's current window/drafts remain protected.
+
+Verification-code helper candidate uses GTK/Granite near-pointer cards, parsed
+shortcut collision checks and AT-SPI field editing. Fixed GTK rejecting
+Component.GrabFocus despite accepting EditableText.SetTextContents. Private
+Brave testing exposed the absent EditableText bridge; browser mode explicitly
+copies/closes instead of promising insertion. Chromium source confirms that
+interface gap. No global runtime key injection, automatic clipboard copy or form
+submission. Optional browser tests use their own profile/debug endpoint and
+synthetic contents only, never the owner's browser. Source/limits and exact
+checks: `phone-setup/VERIFICATION-CODES.md`. Owner-field/pointer/compositor and
+session restart qualification remains open. No real text sent for these tests.
+
## Apple pin cache read/export passes on ARM and Intel macOS — 2026-10-20
PR #349 now reads IMCore's local getter and falls back to Apple's canonical
diff --git a/mseries/ROADMAP.md b/mseries/ROADMAP.md
index d7520c6f2..17c60e2db 100644
--- a/mseries/ROADMAP.md
+++ b/mseries/ROADMAP.md
@@ -1,5 +1,23 @@
# Roadmap, features and follow-ups (MacBook Pro 14" M1 Pro, elementary OS 9 on Asahi)
+## Four-across contact favorites and Applications launcher — 2026-10-20
+
+Favorites now use a four-column photo/initials grid above ordinary conversations,
+with a themed horizontal separator. Search reflows tiles; selection preserves
+drafts. 15 private GTK tests pass; light/dark/narrow fixtures reviewed. Installed
+for next Messages launch. Applications entry is visible and missing icon fixed
+by the scoped hicolor index/cache. No panel/dock restart.
+
+## Verification-code offers — 2026-10-20
+
+Installed/enabled helper adds near-pointer code cards, an available shortcut and explicit
+AT-SPI field insertion. Native keyboard-to-field and private Brave copy-only
+paths are covered by synthetic fixtures; direct Chromium fill needs an extension.
+Offers are memory-only, expire in five minutes and respect SMS domain binding.
+Installer/uninstaller preserves unrelated shortcuts and existing Messages/phone
+services. Owner-field/compositor placement/focus qualification remains open.
+See [implementation and limits](phone-setup/VERIFICATION-CODES.md).
+
## PR #343 source-merge qualification passed — 2026-10-20
Production GTK launcher controls pass light/dark visual inspection and real
@@ -61,12 +79,10 @@ account/FDA/SIP-enabled access and NAS transfer remain unverified. Updated small
kit performs no security changes and retains source modification time. The
earlier process-gate interpretation below is superseded by this result.
-Candidate PR #349 adds the ordered native import/review and encrypted mapping
-store, staged for next Messages launch. The original IMCore reader passes macOS
-runtime fixtures; its real runner probe reports the standalone process unsupported
-and no initialized pin configuration. Resolve that access gate before promising
-an owner export or another successful macOS trip. The source and validation record
-below now distinguishes implemented importer mechanics from unqualified pin access.
+PR #349 merged as `6b1d520c675a227394b5a5a88298083f71e81657` after all checks
+passed. Ordered native import/review and encrypted mapping storage are staged
+for next Messages launch. Owner account export, SIP-enabled access and actual
+transfer/import remain unqualified; no successful owner macOS trip is promised.
Open feature: native Pin/Unpin, a qualified ordered Mac snapshot import, then
direct Linux synchronization of Apple's pin-settings store. Research found
diff --git a/mseries/install-phone-setup.sh b/mseries/install-phone-setup.sh
index 8a5761756..9163440ff 100755
--- a/mseries/install-phone-setup.sh
+++ b/mseries/install-phone-setup.sh
@@ -20,11 +20,20 @@ install -m644 "$here/phone-setup/message_pins.py" "$root/message_pins.py"
icons=${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps
install -d "$icons"
install -m644 "$here/phone-setup/io.elementary.mseries.Messages.svg" "$icons/io.elementary.mseries.Messages.svg"
+icon_theme=${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor
+if [[ ! -f "$icon_theme/index.theme" ]]; then
+ install -m644 /usr/share/icons/hicolor/index.theme "$icon_theme/index.theme"
+fi
+gtk4-update-icon-cache -f "$icon_theme" >/dev/null 2>&1 || true
# Pairing capability checks must use the client matching our scoped BlueZ service.
for pair in 'mseries-phone-setup:phone-setup.py' 'mseries-messages:messages.py'; do
launcher=${pair%%:*}
entry=${pair#*:}
- rm -f "$HOME/.local/bin/$launcher"
+ python3 - "$HOME/.local/bin/$launcher" <<'PY'
+from pathlib import Path
+import sys
+Path(sys.argv[1]).unlink(missing_ok=True)
+PY
cat > "$HOME/.local/bin/$launcher" </dev/null || true
+ python3 "$here/phone-setup/code_shortcut.py" uninstall "$HOME/.local/bin/mseries-verification-codes" "$root"
+ python3 - "$root" "$units" "$apps" "$HOME/.local/bin" <<'PYTHON'
+from pathlib import Path
+import sys
+root,units,apps,bins=map(Path,sys.argv[1:])
+files=[root/name for name in ('verification_codes.py','code_offers.py','code_fields.py','code_shortcut.py')]
+files += [units/'mseries-verification-codes.service', apps/'io.elementary.mseries.VerificationCodes.desktop', bins/'mseries-verification-codes']
+for path in files: path.unlink(missing_ok=True)
+PYTHON
+ systemctl --user daemon-reload
+ update-desktop-database "$apps" 2>/dev/null || true
+ echo 'Verification Codes removed; Messages and Bluetooth remain running.'
+ exit 0
+fi
+[[ $# == 0 ]] || { echo 'Usage: install-verification-codes.sh [--uninstall]' >&2; exit 2; }
+python3 -c 'import gi, blueferry; gi.require_version("Gtk", "4.0"); gi.require_version("Granite", "7.0"); gi.require_version("Atspi", "2.0"); gi.require_version("GdkX11", "4.0")'
+command -v xdotool >/dev/null
+python3 "$here/phone-setup/test_codes.py"
+install -d "$root" "$HOME/.local/bin" "$units" "$apps"
+install -m644 "$here/phone-setup/code_offers.py" "$here/phone-setup/code_fields.py" "$here/phone-setup/code_shortcut.py" "$root/"
+install -m755 "$here/phone-setup/verification_codes.py" "$root/verification_codes.py"
+cat > "$HOME/.local/bin/mseries-verification-codes" < "$units/mseries-verification-codes.service" < "$apps/io.elementary.mseries.VerificationCodes.desktop" </dev/null || true
+echo 'Verification Codes installed. Bluetooth and Messages were not restarted.'
diff --git a/mseries/phone-setup/README.md b/mseries/phone-setup/README.md
index 54ce11bdf..264c737e4 100644
--- a/mseries/phone-setup/README.md
+++ b/mseries/phone-setup/README.md
@@ -79,7 +79,14 @@ window's last snapshot. The app does not download the full iCloud archive or
recover a complete sent history. A real inbox read returned 11 entries on this
phone; a controlled suspend/missed-message/reopen scenario remains unverified.
-### Apple Messages pins (candidate)
+### Verification-code offers
+
+A separate native helper detects recent SMS verification codes and offers them
+near the pointer. Its shortcut activates the card; Enter inserts into a supported
+native field, or explicitly copies for Brave. Browser direct fill remains open.
+Install/undo, qualification and privacy details: [Verification Codes](VERIFICATION-CODES.md).
+
+### Apple Messages pins
Messages → menu → **Import iPhone Pins…** accepts the private JSON generated by
our macOS Apple pin-store helper. The preview preserves Apple's order and suggests only
@@ -87,7 +94,8 @@ unique exact individual-address matches. Choose the current conversation for
ambiguous people or groups; the recipients appear below each choice. Unmatched
pins remain available through **Review Imported Pins…**. Re-importing the same
Apple identifiers retains prior explicit matches even if Apple reorders them.
-Pins appear above ordinary conversations with the export time visible. This is
+Pins appear as four contact tiles per row above ordinary conversations, with a
+horizontal separator, photos/initials and the export time visible. This is
a snapshot, not live phone synchronization, and importing does not send texts
or mark conversations read.
diff --git a/mseries/phone-setup/VERIFICATION-CODES.md b/mseries/phone-setup/VERIFICATION-CODES.md
new file mode 100644
index 000000000..bc17a1412
--- /dev/null
+++ b/mseries/phone-setup/VERIFICATION-CODES.md
@@ -0,0 +1,61 @@
+# Verification codes from iPhone texts
+
+Install `bash mseries/install-verification-codes.sh` after BlueFerry is working.
+This adds **Verification Codes** to Applications and a separate user service.
+It preserves the running Messages window and Bluetooth services.
+
+Focus a verification-code field. A recent matching code can appear near the
+pointer. Press the shortcut printed by the installer (normally **Shift+Super+V**)
+to activate the offer, then **Enter** to use it. **Esc** dismisses. The popup shows
+the selected field and app. Existing shortcuts are compared as parsed accelerators;
+aliases and modifier order count as the same binding. If occupied, the installer
+tries Shift+Super+O, then Ctrl+Alt+O; Applications remains available if all are busy.
+
+Native apps that expose AT-SPI EditableText support **Use Code**. Insertion replaces
+that exact, revalidated code field and does not press Enter or submit the form.
+Passive offers leave partially entered fields alone; the explicit shortcut can
+replace a value in a clearly labelled code field. Generic password and message
+fields are rejected. Split one-character fields require manual paste.
+
+**Brave currently supports offers and Copy Code, not direct insertion.** Its
+Linux accessibility bridge exposes field metadata and Text but not EditableText.
+The popup therefore offers **Copy Code** with Enter as its action, closes, and
+lets the owner paste. No global typing or automatic clipboard change is used.
+Direct browser filling needs a companion extension and is still open work.
+This limitation was reproduced with Brave 155.1.97.59 in a private profile;
+[Chromium's interface registration](https://chromium.googlesource.com/chromium/src/+/c213438d949dc89f3d8d5df3dec27dcc01d7d027/ui/accessibility/platform/ax_platform_node_auralinux.cc)
+also omits EditableText. No Chromium implementation is copied here.
+
+The helper uses upstream BlueFerry's deterministic OTP extraction and its
+existing backend, not a second phone connection. It requests at most 20 recent
+inbox messages at startup or when the backend event count changes. Offers expire
+after five minutes and live only in RAM. This helper writes no message bodies,
+codes or recipient metadata to disk/logs; upstream history retains its existing
+policy. Only an explicit **Copy Code** action writes the clipboard. Consumed
+offers are suppressed for the remaining lifetime of the process. Domain-bound
+SMS codes require the exact HTTPS host; embedded-frame bindings are withheld.
+Unbound SMS codes have no domain assertion and the destination remains visible.
+
+The popup uses GTK 4/Granite 7 and follows the desktop's light/dark preference.
+It runs under XWayland to position near the pointer; Messages remains native
+Wayland. `xdotool` reads pointer/display geometry and moves/activates only the
+helper's own window. Runtime code insertion uses the field's accessibility API.
+No desktop compositor, panel, keyboard driver or browser debug interface is modified.
+
+## Checks and remaining qualification
+
+Run `python3 mseries/phone-setup/check_verification_codes.py` for synthetic-only
+model, keyboard-to-native-field, expiry, password rejection and shortcut
+preservation checks. `--browser` additionally launches an isolated browser and
+checks actual clipboard copy with unchanged DOM input/submit counters. It needs
+Brave/Chromium and Python `websockets`. `--screenshots DIR` renders light/dark
+and copy-only cards. Tests create a private home, settings backend, D-Bus and
+display; they never query the owner's phone or browser profile.
+
+Live owner sign-in insertion, pointer placement/focus on the running compositor,
+session restart and all other apps/sites remain qualification work. Synthetic
+field and private-browser passes do not establish universal autofill support.
+
+Undo with `bash mseries/install-verification-codes.sh --uninstall`. It removes
+only this helper's files/service and its still-owned shortcut, retaining unrelated
+bindings, phone pairing, history and the Messages window.
diff --git a/mseries/phone-setup/check_verification_codes.py b/mseries/phone-setup/check_verification_codes.py
new file mode 100644
index 000000000..9e55059ff
--- /dev/null
+++ b/mseries/phone-setup/check_verification_codes.py
@@ -0,0 +1,36 @@
+#!/usr/bin/python3
+"""Synthetic-only suite with private home, settings, D-Bus and X display."""
+import argparse
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+
+parser=argparse.ArgumentParser(description=__doc__)
+parser.add_argument('--browser',action='store_true',help='Also launch an isolated Chromium/Brave profile (requires Python websockets)')
+parser.add_argument('--screenshots',type=Path)
+args=parser.parse_args()
+here=Path(__file__).resolve().parent
+with tempfile.TemporaryDirectory(prefix='mseries-code-check-') as folder:
+ root=Path(folder)
+ env=dict(os.environ)
+ for key in ('AT_SPI_BUS_ADDRESS','GNOME_KEYRING_CONTROL'): env.pop(key,None)
+ for key,name in [('HOME','home'),('XDG_CONFIG_HOME','config'),('XDG_DATA_HOME','data'),('XDG_CACHE_HOME','cache'),('XDG_RUNTIME_DIR','runtime')]:
+ path=root/name;path.mkdir(mode=0o700);env[key]=str(path)
+ # Expose only accessibility activation, not keyring/document/desktop portals.
+ services=root/'services';services.mkdir()
+ service=Path('/usr/share/dbus-1/services/org.a11y.Bus.service')
+ shutil.copyfile(service, services/service.name)
+ bus=root/'bus.conf'
+ bus.write_text('''
+sessionunix:tmpdir=/tmpEXTERNAL
+'''+str(services)+'''''')
+ env.update(MSERIES_PRIVATE_CODE_TEST='1',MSERIES_TEST_CODE_BROWSER='1' if args.browser else '0',
+ GDK_BACKEND='x11',GSK_RENDERER='cairo',GTK_A11Y='atspi',GIO_USE_VFS='local',
+ GSETTINGS_BACKEND='memory',PYTHONWARNINGS='ignore::DeprecationWarning')
+ if args.screenshots:
+ env['MSERIES_CODE_SCREENSHOTS']=str(args.screenshots.resolve())
+ result=subprocess.run(['dbus-run-session','--config-file='+str(bus),'--','xvfb-run','-a',
+ '/usr/bin/python3','-m','unittest','discover','-s',str(here),'-p','test_code*.py'],env=env,timeout=120)
+raise SystemExit(result.returncode)
diff --git a/mseries/phone-setup/code_fields.py b/mseries/phone-setup/code_fields.py
new file mode 100644
index 000000000..97bd01ec6
--- /dev/null
+++ b/mseries/phone-setup/code_fields.py
@@ -0,0 +1,69 @@
+"""Use accessibility's editable-field API; never inject keys or read field values."""
+import gi
+import time
+from urllib.parse import urlsplit
+gi.require_version('Atspi', '2.0')
+from gi.repository import Atspi
+from code_offers import code_field
+
+
+def describe(source, allow_plain=False):
+ try:
+ deadline = time.monotonic() + .6
+ def call(method, *args):
+ if time.monotonic() > deadline:
+ raise RuntimeError('Field metadata deadline exceeded')
+ return method(*args)
+ call(source.clear_cache)
+ if source.get_role() not in (Atspi.Role.ENTRY, Atspi.Role.TEXT, Atspi.Role.PASSWORD_TEXT):
+ return None
+ states = source.get_state_set()
+ # GTK exposes SENSITIVE for enabled controls; ENABLED is not consistently
+ # exported by GTK or Chromium's AT-SPI bridges.
+ if not all(states.contains(s) for s in (Atspi.StateType.EDITABLE, Atspi.StateType.SENSITIVE, Atspi.StateType.SHOWING)) or states.contains(Atspi.StateType.DEFUNCT):
+ return None
+ name = source.get_name() or ''
+ attributes = {str(k)[:80]:str(v)[:512] for k,v in call(source.get_attributes).items()}
+ if not code_field(name, attributes, allow_plain):
+ return None
+ # Inspect only length, never existing text. Passive suggestions do not
+ # interrupt a code already being entered; the explicit shortcut may replace it.
+ if not allow_plain and call(Atspi.Text.get_character_count, source):
+ return None
+ app = source.get_application()
+ origin = ''
+ parent = source
+ for _ in range(16):
+ if time.monotonic() > deadline:
+ return None
+ if not parent:
+ break
+ if parent.get_role() == Atspi.Role.DOCUMENT_WEB:
+ origin = Atspi.Document.get_document_attribute_value(parent, 'DocURL') or Atspi.Document.get_document_attribute_value(parent, 'URI') or ''
+ parsed = urlsplit(origin)
+ origin = f'{parsed.scheme}://{parsed.hostname}' if parsed.scheme in ('http', 'https') and parsed.hostname else ''
+ break
+ parent = parent.get_parent()
+ return dict(source=source, path=source.path, name=name[:100] or 'Verification code', application=(app.get_name() or 'Application')[:80], pid=app.get_process_id(), origin=origin, explicit_field=allow_plain, max_length=attributes.get('maxlength', ''), can_insert='EditableText' in source.get_interfaces())
+ except Exception:
+ return None
+
+
+def insert(target, code):
+ current = describe(target['source'], target.get('explicit_field', False))
+ if not current or any(current[k] != target[k] for k in ('pid', 'path', 'name', 'origin')):
+ raise RuntimeError('The verification field changed. Focus it again before inserting.')
+ if not current['can_insert']:
+ raise RuntimeError('This app does not expose accessible text editing. Copy the code instead.')
+ if str(current.get('max_length')) == '1' and len(code) > 1:
+ raise RuntimeError('This form uses split code fields. Copy and paste into the first box instead.')
+ # GTK can expose a working EditableText interface while rejecting the
+ # separate Component.GrabFocus request. Insertion addresses this exact
+ # revalidated field, so best-effort focus must not prevent the edit.
+ try:
+ Atspi.Component.grab_focus(target['source'])
+ except Exception:
+ pass
+ if not Atspi.EditableText.set_text_contents(target['source'], code):
+ raise RuntimeError('The app did not accept insertion. Copy the code instead.')
+ return True
diff --git a/mseries/phone-setup/code_offers.py b/mseries/phone-setup/code_offers.py
new file mode 100644
index 000000000..fcaeddd0c
--- /dev/null
+++ b/mseries/phone-setup/code_offers.py
@@ -0,0 +1,87 @@
+"""Short-lived, explicit-use verification-code offers. No disk or clipboard writes."""
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+import re
+import secrets
+from urllib.parse import urlsplit
+from blueferry.otp import extract_otp
+
+MAX_AGE = 300
+_FIELD = re.compile(r'\b(?:otp|2fa|mfa|passcode)\b|(?:verification|security|authentication|confirmation|one[- ]?time|sms|email).{0,28}(?:code|pin)|(?:code|pin).{0,28}(?:verification|authentication|one[- ]?time)', re.I)
+_BOUND = re.compile(r'^@([a-zA-Z0-9.-]+)\s+#([A-Za-z0-9-]+)(?:\s+@([a-zA-Z0-9.-]+))?\s*$', re.M)
+
+
+def code_field(name, attributes, allow_plain=False):
+ values = {str(k).lower(): str(v) for k, v in attributes.items()}
+ if 'one-time-code' in values.get('autocomplete', '').split():
+ return True
+ text = ' '.join([name, *(values.get(k, '') for k in ('placeholder', 'name', 'id', 'description'))])
+ return bool(_FIELD.search(text) or (allow_plain and re.search(r'\b(?:code|pin)\b', text, re.I)))
+
+
+def host_from_url(value):
+ try:
+ parsed = urlsplit(value)
+ return (parsed.hostname or '').lower() if parsed.scheme == 'https' else ''
+ except ValueError:
+ return ''
+
+
+@dataclass(frozen=True)
+class Offer:
+ id: str
+ sender: str
+ timestamp: float
+ code: str = field(repr=False)
+ domain: str = ''
+
+
+class Offers:
+ def __init__(self, now=lambda: datetime.now(timezone.utc).timestamp()):
+ self.now = now
+ self.items = {}
+ self.used = set()
+
+ def update(self, messages):
+ result = {}
+ for raw in messages[:20]:
+ try:
+ stamp = datetime.fromisoformat(raw.get('timestamp', ''))
+ if stamp.tzinfo is None:
+ stamp = stamp.astimezone()
+ seconds = stamp.timestamp()
+ except (TypeError, ValueError):
+ continue
+ if not 0 <= self.now() - seconds <= MAX_AGE:
+ continue
+ code = extract_otp(raw.get('body', ''))
+ if not code:
+ continue
+ key = (str(raw.get('handle', '')), seconds)
+ if not key[0] or key in self.used:
+ continue
+ domain = ''
+ binding = _BOUND.search(raw.get('body', ''))
+ if binding:
+ # Embedded-frame binding cannot safely be proven by a generic
+ # desktop accessibility field, so never fill it through this path.
+ if binding[3] or binding[2].replace('-', '') != code:
+ continue
+ domain = binding[1].lower()
+ old = self.items.get(key)
+ result[key] = old if old and old.code == code and old.domain == domain else Offer(secrets.token_urlsafe(12), str(raw.get('contact_name') or raw.get('sender') or 'iPhone')[:100], seconds, code, domain)
+ self.items = result
+ self.used = {key for key in self.used if 0 <= self.now() - key[1] <= MAX_AGE}
+
+ def available(self, origin=None):
+ host = host_from_url(origin or '')
+ return sorted((o for o in self.items.values() if 0 <= self.now() - o.timestamp <= MAX_AGE and (not o.domain or o.domain == host)), key=lambda o: o.timestamp, reverse=True)[:3]
+
+ def get(self, identity, origin=None):
+ return next((o for o in self.available(origin) if o.id == identity), None)
+
+ def consume(self, identity):
+ for key, offer in list(self.items.items()):
+ if offer.id == identity:
+ self.used.add(key)
+ del self.items[key]
diff --git a/mseries/phone-setup/code_shortcut.py b/mseries/phone-setup/code_shortcut.py
new file mode 100644
index 000000000..2e5c1536b
--- /dev/null
+++ b/mseries/phone-setup/code_shortcut.py
@@ -0,0 +1,80 @@
+"""Own one shortcut; compare parsed accelerators and preserve all other bindings."""
+import json
+from pathlib import Path
+import sys
+import gi
+gi.require_version('Gtk', '4.0')
+from gi.repository import Gio, Gtk
+
+PATH = '/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/mseries-verification-codes/'
+SCHEMA = 'org.gnome.settings-daemon.plugins.media-keys'
+CUSTOM = SCHEMA + '.custom-keybinding'
+CANDIDATES = ('v', 'o', 'o')
+
+
+def accelerator(value):
+ valid, key, modifiers = Gtk.accelerator_parse(value)
+ return (key, int(modifiers)) if valid and key else None
+
+
+def choose(used, preferred=''):
+ occupied = {accelerator(value) for value in used}
+ return next((value for value in (preferred, *CANDIDATES)
+ if accelerator(value) and accelerator(value) not in occupied), '')
+
+
+def settings(schema, path=None):
+ if not Gio.SettingsSchemaSource.get_default().lookup(schema, True):
+ return None
+ return Gio.Settings.new_with_path(schema, path) if path else Gio.Settings.new(schema)
+
+
+def install(launcher, root):
+ keys = settings(SCHEMA)
+ if not keys:
+ print('Keyboard shortcut unavailable; open Verification Codes from Applications.')
+ return
+ paths = keys.get_strv('custom-keybindings')
+ own = settings(CUSTOM, PATH)
+ command = '"' + launcher + '" --show'
+ if own.get_string('command') not in ('', command):
+ print('Reserved shortcut path is already in use; existing binding preserved.')
+ return
+ used = []
+ for path in paths:
+ if path != PATH:
+ used.append(settings(CUSTOM, path).get_string('binding'))
+ for schema in ('org.gnome.desktop.wm.keybindings', SCHEMA, 'io.elementary.desktop.wm.keybindings'):
+ source = settings(schema)
+ if not source: continue
+ for key in source.props.settings_schema.list_keys():
+ value = source.get_value(key)
+ if value.get_type_string() == 'as': used.extend(value.unpack())
+ elif value.get_type_string() == 's': used.append(value.unpack())
+ binding = choose(used, own.get_string('binding') if PATH in paths else '')
+ if not binding:
+ print('No proposed shortcut is free; open Verification Codes from Applications.')
+ return
+ own.set_string('name', 'Use Verification Code')
+ own.set_string('command', command)
+ own.set_string('binding', binding)
+ if PATH not in paths: keys.set_strv('custom-keybindings', paths + [PATH])
+ Gio.Settings.sync()
+ metadata = Path(root) / 'verification-shortcut.json'
+ metadata.write_text(json.dumps(dict(binding=binding, label=Gtk.accelerator_get_label(*accelerator(binding)), path=PATH))+'\n')
+ metadata.chmod(0o600)
+ print('Verification-code shortcut:', Gtk.accelerator_get_label(*accelerator(binding)))
+
+
+def uninstall(launcher, root):
+ keys = settings(SCHEMA)
+ own = settings(CUSTOM, PATH)
+ if keys and own and own.get_string('command') == '"'+launcher+'" --show':
+ keys.set_strv('custom-keybindings', [p for p in keys.get_strv('custom-keybindings') if p != PATH])
+ for key in ('name', 'command', 'binding'): own.reset(key)
+ Gio.Settings.sync()
+ (Path(root)/'verification-shortcut.json').unlink(missing_ok=True)
+
+
+if __name__ == '__main__':
+ {'install':install, 'uninstall':uninstall}[sys.argv[1]](*sys.argv[2:])
diff --git a/mseries/phone-setup/messages.py b/mseries/phone-setup/messages.py
index 2f6d0916b..1dc119d0d 100644
--- a/mseries/phone-setup/messages.py
+++ b/mseries/phone-setup/messages.py
@@ -40,6 +40,9 @@
.ms-section { padding: 12px 12px 4px; font-weight: 600; opacity: .75; }
.ms-pin-note { padding: 4px 14px 10px; }
.ms-pin-row { border-bottom: 1px solid alpha(@theme_fg_color,.08); padding: 12px 0; }
+.ms-favorite { padding: 4px; }
+.ms-favorite:checked { background: alpha(@theme_selected_bg_color,.18); }
+.ms-favorite-avatar { border-radius: 99px; background: alpha(@theme_selected_bg_color,.18); min-width: 42px; min-height: 42px; }
'''
@@ -81,6 +84,8 @@ def __init__(self, fixture=False, dark=False, narrow=False):
self.photo_revision = None
self.photo_pending = False
self.avatars = {}
+ self.favorite_avatars = {}
+ self.favorite_buttons = {}
self.pin_state = dict(version=1, snapshot=None, choices=[])
self.pin_profile = None
self.pin_store = None
@@ -142,8 +147,16 @@ def scheme(*_args):
self.list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE)
self.list.add_css_class('navigation-sidebar')
self.list.connect('row-selected', self.selected)
- self.list.set_header_func(self.section_header)
- list_scroll = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER, child=self.list)
+ contacts = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ self.favorites = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=False)
+ self.favorites.append(label(_('Favorites'), 'ms-section', xalign=0))
+ self.favorite_grid = Gtk.Grid(column_homogeneous=True, row_spacing=8, column_spacing=4, margin_start=10, margin_end=10, margin_bottom=12)
+ self.favorites.append(self.favorite_grid)
+ contacts.append(self.favorites)
+ self.favorite_separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL, margin_start=12, margin_end=12, margin_bottom=8, visible=False)
+ contacts.append(self.favorite_separator)
+ contacts.append(self.list)
+ list_scroll = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER, child=contacts)
self.sidebar.append(list_scroll)
self.connection = label(_('Connecting to iPhone…'), xalign=0, wrap=True)
self.connection.add_css_class('ms-status')
@@ -344,14 +357,15 @@ def loaded(photos, error):
if error or revision != self.photo_revision:
return
self.photo_cache.update(photos)
- for key, (content, old) in self.avatars.items():
+ for key, (content, old) in [*self.avatars.items(), *self.favorite_avatars.items()]:
thread = self.threads.get(key, {})
address = (thread.get('recipients') or [''])[0]
data = photos.get(address)
if data and (photo := self.photo_widget(data)):
content.remove(old)
content.insert_child_after(photo, None)
- self.avatars[key] = (content, photo)
+ registry = self.favorite_avatars if content.has_css_class('ms-favorite-content') else self.avatars
+ registry[key] = (content, photo)
self.load_photos()
self.task(load, loaded)
@@ -366,6 +380,10 @@ def populate(self):
clear(self.list)
self.rows = {}
self.avatars = {}
+ self.favorite_avatars = {}
+ self.favorite_buttons = {}
+ clear(self.favorite_grid)
+ self.grid_filter_signature = None
for key in order:
thread = self.threads[key]
row = Gtk.ListBoxRow()
@@ -395,17 +413,33 @@ def populate(self):
row.set_child(content)
self.rows[key] = row
self.list.append(row)
+ if is_pinned:
+ button = Gtk.ToggleButton(tooltip_text=thread['name'])
+ button.add_css_class('flat');button.add_css_class('ms-favorite')
+ button.update_property([Gtk.AccessibleProperty.LABEL], [thread['name']])
+ tile = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5, halign=Gtk.Align.CENTER)
+ tile.add_css_class('ms-favorite-content')
+ portrait = label((thread['name'] or '?')[0].upper(), 'ms-favorite-avatar')
+ if not thread.get('is_group') and (data := self.photo_cache.get(address)):
+ portrait = self.photo_widget(data) or portrait
+ tile.append(portrait)
+ tile.append(label(thread['name'], xalign=.5, ellipsize=Pango.EllipsizeMode.END, max_width_chars=7))
+ button.set_child(tile)
+ def choose(_button, identity=key):
+ self.list.unselect_all()
+ self.selected(None, self.rows[identity])
+ self.sync_favorite_selection()
+ button.connect('clicked', choose)
+ self.favorite_buttons[key] = button
+ self.favorite_avatars[key] = (tile, portrait)
if self.model.selected in self.rows:
self.list.select_row(self.rows[self.model.selected])
self.filter_rows()
+ self.sync_favorite_selection()
- def section_header(self, row, before):
- if row.is_pinned and (before is None or not before.is_pinned):
- row.set_header(label(_('Pinned'), 'ms-section', xalign=0))
- elif not row.is_pinned and before and before.is_pinned:
- row.set_header(label(_('Messages'), 'ms-section', xalign=0))
- else:
- row.set_header(None)
+ def sync_favorite_selection(self):
+ for key, button in self.favorite_buttons.items():
+ button.set_active(key == self.model.selected)
def pin_notice(self, text):
self.pin_note.set_label(text)
@@ -566,14 +600,29 @@ def saved(result, error):
def filter_rows(self):
query = self.search.get_text().casefold()
+ favorites = []
+ ordinary = False
for key, row in self.rows.items():
thread = self.threads[key]
- row.set_visible(not query or query in thread['name'].casefold() or any(query in m.get('body', '').casefold() for m in thread['messages']))
+ matches = not query or query in thread['name'].casefold() or any(query in m.get('body', '').casefold() for m in thread['messages'])
+ row.set_visible(matches and not row.is_pinned)
+ ordinary = ordinary or row.get_visible()
+ if row.is_pinned and matches: favorites.append(key)
+ if tuple(favorites) != getattr(self, 'grid_filter_signature', None):
+ self.grid_filter_signature = tuple(favorites)
+ clear(self.favorite_grid)
+ for index, key in enumerate(favorites):
+ self.favorite_grid.attach(self.favorite_buttons[key], index % 4, index // 4, 1, 1)
+ if 0 < len(favorites) < 4:
+ self.favorite_grid.attach(Gtk.Box(), 3, 0, 1, 1)
+ self.favorites.set_visible(bool(favorites))
+ self.favorite_separator.set_visible(bool(favorites) and ordinary)
def selected(self, _list, row):
if row is None or row.key == self.model.selected:
return
self.model.selected = row.key
+ self.sync_favorite_selection()
self.rendered = None
self.editor.get_buffer().set_text(self.model.drafts.get(row.key, ''))
self.show_conversation()
diff --git a/mseries/phone-setup/test_code_browser.py b/mseries/phone-setup/test_code_browser.py
new file mode 100644
index 000000000..d71881a80
--- /dev/null
+++ b/mseries/phone-setup/test_code_browser.py
@@ -0,0 +1,115 @@
+"""Optional real Chromium/Brave bridge test on a private bus/display/profile."""
+import json
+import os
+from pathlib import Path
+import shutil
+import signal
+import subprocess
+import tempfile
+import time
+import unittest
+from urllib.request import urlopen
+
+import gi
+gi.require_version('Atspi', '2.0')
+from gi.repository import Atspi
+from test_code_fields_gui import find, settle
+
+
+@unittest.skipUnless(os.environ.get('MSERIES_PRIVATE_CODE_TEST') == '1', 'Requires isolated D-Bus and Xvfb')
+@unittest.skipUnless(os.environ.get('MSERIES_TEST_CODE_BROWSER') == '1', 'Browser check is opt-in')
+class BrowserFieldTests(unittest.TestCase):
+ def test_actual_browser_without_edit_api_offers_copy_without_submit(self):
+ binary = shutil.which('brave-browser') or shutil.which('chromium')
+ if not binary:
+ self.skipTest('Chromium/Brave is not installed')
+ from websockets.sync.client import connect
+ from code_fields import describe, insert
+ with tempfile.TemporaryDirectory(prefix='mseries-code-browser-') as folder:
+ root = Path(folder)
+ page = root / 'code.html'
+ page.write_text('''Private verification fixture
+
+ ''')
+ process = subprocess.Popen([binary, '--user-data-dir='+str(root/'profile'),
+ '--no-first-run', '--no-default-browser-check', '--disable-sync',
+ '--disable-background-networking', '--force-renderer-accessibility',
+ '--ozone-platform=x11', '--remote-debugging-port=0', page.as_uri()],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
+ try:
+ port_file = root/'profile'/'DevToolsActivePort'
+ for _ in range(100):
+ if port_file.exists(): break
+ settle(.1)
+ self.assertTrue(port_file.exists(), 'Private browser did not start')
+ port = int(port_file.read_text().splitlines()[0])
+ with urlopen(f'http://127.0.0.1:{port}/json/list', timeout=3) as response:
+ pages = json.load(response)
+ target_page = next(p for p in pages if p.get('url') == page.as_uri())
+ with connect(target_page['webSocketDebuggerUrl'], open_timeout=3) as socket:
+ counter = 0
+ def evaluate(expression):
+ nonlocal counter
+ counter += 1
+ socket.send(json.dumps(dict(id=counter, method='Runtime.evaluate', params=dict(expression=expression, returnByValue=True))))
+ while True:
+ result = json.loads(socket.recv(timeout=3))
+ if result.get('id') == counter:
+ self.assertNotIn('exceptionDetails', result.get('result', {}))
+ return result['result']['result'].get('value')
+ for _ in range(50):
+ if evaluate('!!document.getElementById("otp")'): break
+ settle(.1)
+ self.assertTrue(evaluate('!!document.getElementById("otp")'), 'Fixture page did not load')
+ evaluate('document.getElementById("otp").focus()')
+ entry = None
+ for _ in range(50):
+ settle(.1)
+ entry = find(Atspi.get_desktop(0), 'Verification code', predicate=lambda node: describe(node) is not None)
+ if entry and describe(entry): break
+ self.assertIsNotNone(entry, 'Browser did not expose code field')
+ target = describe(entry)
+ self.assertIsNotNone(target)
+ # Chromium currently exports Text, not EditableText. Do not
+ # pretend filling works or silently inject global keystrokes.
+ self.assertFalse(target['can_insert'])
+ with self.assertRaises(RuntimeError): insert(target, '246810')
+ from datetime import datetime, timezone
+ from verification_codes import Codes
+ app = Codes(fixture=True)
+ app.register();app.initialize();app.target=target
+ try:
+ app.registry.update([dict(handle='fixture', timestamp=datetime.now(timezone.utc).isoformat(), body='Your verification code is 246810.')])
+ app.render(True)
+ self.assertFalse(app.use.get_visible())
+ self.assertEqual(app.window.get_default_widget(), app.copy)
+ app.copy.emit('clicked')
+ self.assertFalse(app.window.get_visible())
+ copied=[]
+ app.window.get_clipboard().read_text_async(None, lambda clipboard,result: copied.append(clipboard.read_text_finish(result)))
+ for _ in range(20):
+ settle(.05)
+ if copied: break
+ self.assertEqual(copied,['246810'])
+ finally:
+ app.window.destroy();app.pool.shutdown(wait=True, cancel_futures=True);app.quit()
+ self.assertEqual(evaluate('otp.value'), '')
+ self.assertEqual(evaluate('window.inputs'), 0)
+ self.assertEqual(evaluate('window.submits'), 0)
+ evaluate('password.focus()');settle(.1)
+ password = find(Atspi.get_desktop(0), 'Password', predicate=lambda node: node.get_role() == Atspi.Role.PASSWORD_TEXT)
+ self.assertIsNotNone(password)
+ self.assertIsNone(describe(password))
+ finally:
+ os.killpg(process.pid, signal.SIGTERM)
+ try: process.wait(timeout=8)
+ except subprocess.TimeoutExpired:
+ os.killpg(process.pid, signal.SIGKILL);process.wait(timeout=3)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/mseries/phone-setup/test_code_fields_gui.py b/mseries/phone-setup/test_code_fields_gui.py
new file mode 100644
index 000000000..0d0b9d213
--- /dev/null
+++ b/mseries/phone-setup/test_code_fields_gui.py
@@ -0,0 +1,147 @@
+"""Run in dbus-run-session + Xvfb: synthetic entries only, no owner accounts."""
+import os
+import subprocess
+import sys
+import time
+import unittest
+from datetime import datetime, timezone
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Atspi', '2.0')
+from gi.repository import Atspi, Gio, GLib, Gtk
+
+
+def target_app():
+ app = Gtk.Application(application_id='io.elementary.mseries.CodeFieldFixture')
+ def activate(app):
+ window = Gtk.ApplicationWindow(application=app, title='Code field fixture')
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
+ entry = Gtk.Entry()
+ entry.update_property([Gtk.AccessibleProperty.LABEL], ['Verification code'])
+ box.append(entry)
+ password = Gtk.Entry(visibility=False)
+ password.update_property([Gtk.AccessibleProperty.LABEL], ['Password'])
+ box.append(password)
+ submits = Gtk.Label(label='Submitted 0')
+ def submitted(*_): submits.set_label('Submitted 1')
+ entry.connect('activate', submitted)
+ box.append(submits)
+ window.set_child(box);window.present();entry.grab_focus()
+ app.connect('activate', activate);app.run([])
+
+
+def settle(seconds=.2):
+ context = GLib.MainContext.default()
+ end = time.monotonic() + seconds
+ while time.monotonic() < end:
+ while context.pending(): context.iteration(False)
+ time.sleep(.01)
+
+
+def find(node, name, depth=0, predicate=None):
+ if depth > 12:
+ return None
+ try:
+ node.clear_cache()
+ if node.get_name() == name and (predicate is None or predicate(node)): return node
+ for index in range(min(node.get_child_count(), 100)):
+ if result := find(node.get_child_at_index(index), name, depth+1, predicate): return result
+ except Exception:
+ return None
+
+
+@unittest.skipUnless(os.environ.get('MSERIES_PRIVATE_CODE_TEST') == '1', 'Requires isolated D-Bus and Xvfb')
+class FieldTests(unittest.TestCase):
+ def setUp(self):
+ from verification_codes import Codes
+ self.process = subprocess.Popen([sys.executable, __file__, '--target'], stdout=subprocess.DEVNULL)
+ self.app = Codes(fixture=True)
+ self.app.set_application_id('io.elementary.mseries.Codes.Test' + self._testMethodName.replace('_', ''))
+ self.app.register();self.app.initialize()
+ self.desktop = Atspi.get_desktop(0)
+ self.entry = None
+ for _ in range(30):
+ settle(.1)
+ self.entry = find(self.desktop, 'Verification code', predicate=lambda node: node.get_application().get_process_id() == self.process.pid and node.get_role() in (Atspi.Role.ENTRY, Atspi.Role.TEXT))
+ if self.entry: break
+ self.assertIsNotNone(self.entry)
+
+ def tearDown(self):
+ self.app.window.destroy();self.app.pool.shutdown(wait=True, cancel_futures=True)
+ self.app.quit();self.process.terminate();self.process.wait(timeout=5);settle(.05)
+
+ def test_native_field_fill_uses_editable_api_without_submit(self):
+ from code_fields import describe
+ target = describe(self.entry)
+ self.assertIsNotNone(target)
+ self.app.target = target
+ stamp = datetime.now(timezone.utc).isoformat()
+ self.app.registry.update([dict(handle='fixture', timestamp=stamp, sender='Fixture', body='Your verification code is 246810.')])
+ self.app.manual = True;self.app.render(True)
+ # Only this test's isolated bus exposes the synthetic target. Normal
+ # preview mode refuses all insertion, including if run on the real bus.
+ self.app.fixture = False
+ # Exercise Return in the popup, not just the button signal. Key events
+ # are injected only into this isolated fixture display, never at runtime.
+ xid=self.app.window.get_surface().get_xid()
+ subprocess.run(['xdotool','windowfocus',str(xid),'key','Return'],check=True,timeout=3)
+ for _ in range(30):
+ settle(.1)
+ text = Atspi.Text.get_text(self.entry, 0, -1)
+ if text: break
+ self.assertEqual(text, '246810')
+ self.assertIsNotNone(find(self.desktop, 'Submitted 0'))
+ self.assertEqual(self.app.registry.available(), [])
+
+ def test_password_and_changed_field_rejected(self):
+ from code_fields import describe
+ password = find(self.desktop, 'Password')
+ self.assertIsNotNone(password)
+ self.assertIsNone(describe(password))
+ self.app.target = describe(self.entry)
+ event = type('Focus', (), dict(source=password, detail1=1))()
+ self.app.focus_changed(event)
+ self.assertIsNone(self.app.target)
+
+ def test_expired_offer_does_not_touch_field(self):
+ from code_fields import describe
+ self.app.target = describe(self.entry)
+ stamp = datetime.now(timezone.utc)
+ self.app.registry.update([dict(handle='fixture', timestamp=stamp.isoformat(), body='Your verification code is 246810.')])
+ self.app.render(True)
+ self.app.registry.now = lambda: stamp.timestamp() + 301
+ self.app.use_code();settle()
+ self.assertEqual(Atspi.Text.get_text(self.entry, 0, -1), '')
+
+ def test_manual_empty_state_survives_poll_and_keyboard_dismisses(self):
+ from gi.repository import Gdk
+ self.app.manual=True;self.app.render(True);settle(.1)
+ self.assertTrue(self.app.window.get_visible())
+ self.app.render(True);settle(.1)
+ self.assertTrue(self.app.window.get_visible())
+ self.assertFalse(self.app.use.get_sensitive())
+ self.assertTrue(self.app.key_pressed(None, Gdk.KEY_Escape, 0, 0))
+ self.assertFalse(self.app.window.get_visible())
+
+ def test_fixture_theme_screenshots(self):
+ destination = os.environ.get('MSERIES_CODE_SCREENSHOTS')
+ if not destination: self.skipTest('No screenshot output requested')
+ from pathlib import Path
+ from PIL import ImageGrab
+ from code_fields import describe
+ out=Path(destination);out.mkdir(parents=True,exist_ok=True)
+ self.app.target=describe(self.entry)
+ self.app.registry.update([dict(handle='fixture', timestamp=datetime.now(timezone.utc).isoformat(), sender='Example Service', body='Your verification code is 246810.')])
+ self.app.shortcut='Shift+Super+V'
+ for dark in (False,True):
+ Gtk.Settings.get_default().props.gtk_application_prefer_dark_theme=dark
+ self.app.manual=True;self.app.render(True);settle(.3)
+ ImageGrab.grab(bbox=(0,0,self.app.window.get_width(),self.app.window.get_height()),xdisplay=os.environ['DISPLAY']).save(out/('codes-dark.png' if dark else 'codes-light.png'))
+ self.app.target['can_insert']=False
+ self.app.render(True);settle(.3)
+ ImageGrab.grab(bbox=(0,0,self.app.window.get_width(),self.app.window.get_height()),xdisplay=os.environ['DISPLAY']).save(out/'codes-copy.png')
+
+
+if __name__ == '__main__':
+ if '--target' in sys.argv: target_app()
+ else: unittest.main()
diff --git a/mseries/phone-setup/test_code_shortcut.py b/mseries/phone-setup/test_code_shortcut.py
new file mode 100644
index 000000000..2874a0ca4
--- /dev/null
+++ b/mseries/phone-setup/test_code_shortcut.py
@@ -0,0 +1,47 @@
+"""Run with GSETTINGS_BACKEND=memory; never alter owner shortcuts."""
+import os
+from pathlib import Path
+import tempfile
+import unittest
+from code_shortcut import install, uninstall, settings, SCHEMA, CUSTOM, PATH
+
+
+@unittest.skipUnless(os.environ.get('GSETTINGS_BACKEND') == 'memory', 'Requires memory settings backend')
+class ShortcutTests(unittest.TestCase):
+ def setUp(self):
+ self.keys = settings(SCHEMA)
+ self.keys.set_strv('custom-keybindings', [])
+ self.own = settings(CUSTOM, PATH)
+ for key in ('name','command','binding'): self.own.reset(key)
+ self.folder = tempfile.TemporaryDirectory(prefix='mseries-shortcut-test-')
+ self.addCleanup(self.folder.cleanup)
+ self.launcher = '/synthetic/mseries-verification-codes'
+
+ def test_install_upgrade_and_remove_preserve_other_shortcut(self):
+ other_path = '/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/fixture/'
+ other = settings(CUSTOM, other_path)
+ other.set_string('binding','v')
+ other.set_string('command','fixture-command')
+ self.keys.set_strv('custom-keybindings', [other_path])
+ install(self.launcher, self.folder.name)
+ self.assertEqual(self.own.get_string('binding'), 'o')
+ install(self.launcher, self.folder.name)
+ self.assertEqual(self.keys.get_strv('custom-keybindings'), [other_path, PATH])
+ self.assertEqual((Path(self.folder.name)/'verification-shortcut.json').stat().st_mode & 0o777, 0o600)
+ uninstall(self.launcher, self.folder.name)
+ self.assertEqual(self.keys.get_strv('custom-keybindings'), [other_path])
+ self.assertEqual(other.get_string('command'), 'fixture-command')
+ self.assertEqual(self.own.get_string('command'), '')
+
+ def test_collision_is_not_overwritten(self):
+ self.own.set_string('command', 'owner-command')
+ self.own.set_string('binding', 'q')
+ self.keys.set_strv('custom-keybindings', [PATH])
+ install(self.launcher, self.folder.name)
+ uninstall(self.launcher, self.folder.name)
+ self.assertEqual(self.own.get_string('command'), 'owner-command')
+ self.assertEqual(self.keys.get_strv('custom-keybindings'), [PATH])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/mseries/phone-setup/test_codes.py b/mseries/phone-setup/test_codes.py
new file mode 100644
index 000000000..ad3852f30
--- /dev/null
+++ b/mseries/phone-setup/test_codes.py
@@ -0,0 +1,72 @@
+import unittest
+from datetime import datetime, timezone
+from code_offers import Offers, code_field
+
+NOW = datetime(2026, 10, 20, 16, 0, tzinfo=timezone.utc).timestamp()
+
+
+def message(body='Your verification code is 123456.', age=10, handle='one'):
+ return dict(handle=handle, body=body, timestamp=datetime.fromtimestamp(NOW-age, timezone.utc).isoformat(), sender='Test service')
+
+
+class CodesTests(unittest.TestCase):
+ def setUp(self):
+ self.offers = Offers(lambda: NOW)
+
+ def test_code_is_transient_and_not_in_repr(self):
+ self.offers.update([message()])
+ offer = self.offers.available()[0]
+ self.assertEqual(offer.code, '123456')
+ self.assertNotIn('123456', repr(offer))
+
+ def test_old_future_and_missing_time_are_rejected(self):
+ self.offers.update([message(age=301), message(age=-1, handle='future'), dict(handle='bad', body='OTP 123456')])
+ self.assertEqual(self.offers.available(), [])
+
+ def test_refresh_preserves_identity_and_use_consumes_it(self):
+ self.offers.update([message()]);first = self.offers.available()[0]
+ self.offers.update([message()]);self.assertEqual(self.offers.available()[0].id, first.id)
+ self.offers.consume(first.id);self.offers.update([message()])
+ self.assertEqual(self.offers.available(), [])
+
+ def test_origin_bound_message_cannot_fill_wrong_site(self):
+ self.offers.update([message('Your verification code is 123456.\n@example.com #123456')])
+ self.assertEqual(self.offers.available('https://other.example'), [])
+ self.assertEqual(self.offers.available(), [])
+ self.assertEqual(len(self.offers.available('https://example.com/login')), 1)
+ self.assertEqual(self.offers.available('http://example.com'), [])
+
+ def test_embedded_domain_and_mismatched_binding_not_guessed(self):
+ for body in ['Your verification code is 123456.\n@example.com #999999', 'Your verification code is 123456.\n@example.com #123456 @embedded.example']:
+ self.offers.update([message(body)])
+ self.assertEqual(self.offers.available('https://example.com'), [])
+
+ def test_orders_promotions_and_unrelated_numbers_not_offered(self):
+ for body in ['Your order number is 123456.', 'Save 1234 points today.', 'Our door code is 123456.']:
+ self.offers.update([message(body)])
+ self.assertEqual(self.offers.available(), [])
+
+ def test_fields_require_code_context(self):
+ self.assertTrue(code_field('Verification code', {}))
+ self.assertTrue(code_field('', {'autocomplete':'one-time-code'}))
+ self.assertFalse(code_field('Password', {}))
+ self.assertFalse(code_field('Message', {}))
+ self.assertFalse(code_field('Code', {}))
+ self.assertTrue(code_field('Code', {}, allow_plain=True))
+
+ def test_live_expiry_checked_again_at_use(self):
+ now = [NOW]
+ offers = Offers(lambda: now[0]);offers.update([message()]);identity = offers.available()[0].id
+ now[0] += 301
+ self.assertIsNone(offers.get(identity))
+
+ def test_shortcut_aliases_and_modifier_order_do_not_collide(self):
+ from code_shortcut import accelerator, choose
+ self.assertEqual(accelerator('v'), accelerator('v'))
+ self.assertEqual(accelerator('o'), accelerator('o'))
+ self.assertEqual(choose(['v']), 'o')
+ self.assertEqual(choose(['v','o','o']), '')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/mseries/phone-setup/test_messages_gui.py b/mseries/phone-setup/test_messages_gui.py
index 204d1fc80..b39c1a6c7 100644
--- a/mseries/phone-setup/test_messages_gui.py
+++ b/mseries/phone-setup/test_messages_gui.py
@@ -147,7 +147,35 @@ def test_import_pin_order_keeps_draft_selection_and_group_choice(self):
self.assertIn('Pins from Mac', app.pin_note.get_text())
self.assertEqual(app.pin_state['snapshot']['source'], 'macos-pin-cache')
self.assertIn('Apple pin settings last changed', app.pin_note.get_tooltip_text())
- self.assertIsNotNone(app.rows['demo2'].get_header())
+ self.assertIn('demo2', app.favorite_buttons)
+ self.assertTrue(app.favorites.get_visible())
+
+ def test_favorite_grid_four_across_divider_and_thread_selection(self):
+ app=self.app
+ keys=['demo']
+ for index in range(4):
+ key=f'fixture-favorite-{index}'
+ app.threads[key]=dict(app.threads['demo'],key=key,name=f'Favorite {index}',messages=[])
+ keys.append(key)
+ app.pin_state=dict(version=1,snapshot=None,choices=keys)
+ app.populate();settle()
+ self.assertTrue(app.favorite_separator.get_visible())
+ self.assertEqual(tuple(app.favorite_grid.query_child(app.favorite_buttons[keys[3]])), (3,0,1,1))
+ self.assertEqual(tuple(app.favorite_grid.query_child(app.favorite_buttons[keys[4]])), (0,1,1,1))
+ if destination := os.environ.get('MSERIES_MESSAGES_SCREENSHOTS'):
+ from PIL import ImageGrab
+ out=Path(destination);out.mkdir(parents=True,exist_ok=True)
+ for dark in (False,True):
+ Gtk.Settings.get_default().props.gtk_application_prefer_dark_theme=dark;settle(.2)
+ ImageGrab.grab(bbox=(0,0,app.window.get_width(),app.window.get_height()),xdisplay=os.environ['DISPLAY']).save(out/('favorites-dark.png' if dark else 'favorites-light.png'))
+ app.editor.get_buffer().set_text('Preserve favorite draft')
+ app.favorite_buttons[keys[4]].emit('clicked')
+ self.assertEqual(app.model.selected,keys[4])
+ self.assertEqual(app.model.drafts['demo'],'Preserve favorite draft')
+ self.assertTrue(app.favorite_buttons[keys[4]].get_active())
+ app.search.set_text('Favorite 3');app.filter_rows()
+ self.assertFalse(app.favorite_separator.get_visible())
+ self.assertEqual(tuple(app.favorite_grid.query_child(app.favorite_buttons[keys[4]])), (0,0,1,1))
def test_duplicate_pin_match_keeps_existing_pins(self):
dialog = self.app.show_pin_review(self.pin_snapshot());settle()
diff --git a/mseries/phone-setup/verification_codes.py b/mseries/phone-setup/verification_codes.py
new file mode 100644
index 000000000..c52f65800
--- /dev/null
+++ b/mseries/phone-setup/verification_codes.py
@@ -0,0 +1,278 @@
+#!/usr/bin/python3
+"""Near-pointer verification-code suggestions and explicit accessible-field fill."""
+from concurrent.futures import ThreadPoolExecutor
+import gettext
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+# This small pointer-relative popup uses XWayland positioning; Messages remains
+# native Wayland. GTK's Wayland toplevel API cannot choose screen coordinates.
+os.environ.setdefault('GDK_BACKEND', 'x11')
+os.environ.setdefault('GSK_RENDERER', 'cairo')
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+gi.require_version('GdkX11', '4.0')
+gi.require_version('Granite', '7.0')
+gi.require_version('Atspi', '2.0')
+from gi.repository import Atspi, Gdk, GdkX11, Gio, GLib, Granite, Gtk
+from blueferry.client import BackendClient
+from code_offers import Offers
+from code_fields import describe, insert
+
+_ = gettext.gettext
+
+
+class Codes(Gtk.Application):
+ def __init__(self, fixture=False):
+ super().__init__(application_id='io.elementary.mseries.VerificationCodes', flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
+ self.fixture = fixture
+ self.registry = Offers()
+ self.pool = ThreadPoolExecutor(max_workers=1)
+ self.initialized = False
+ self.busy = False
+ self.last_events = None
+ self.target = None
+ self.last_field = None
+ self.shown = None
+ self.selected_offer = None
+ self.manual = False
+ self.filling = False
+ self.shortcut = _('Open Verification Codes from Applications')
+ try:
+ self.shortcut = json.loads((Path(__file__).parent/'verification-shortcut.json').read_text())['label']
+ except (OSError, ValueError, KeyError):
+ pass
+ self.add_main_option('background', 0, GLib.OptionFlags.NONE, GLib.OptionArg.NONE, 'Watch for verification fields', None)
+ self.add_main_option('show', 0, GLib.OptionFlags.NONE, GLib.OptionArg.NONE, 'Show available verification codes', None)
+
+ def do_command_line(self, command):
+ self.initialize()
+ if not command.get_options_dict().contains('background'):
+ self.manual = True
+ if self.last_field:
+ self.target = describe(self.last_field, allow_plain=True)
+ self.render(True, activate=True)
+ self.poll(force=True)
+ return 0
+
+ def initialize(self):
+ if self.initialized:
+ return
+ self.initialized = True
+ self.hold()
+ if hasattr(Granite, 'init'):
+ Granite.init()
+ preferences = Granite.Settings.get_default()
+ def scheme(*_args):
+ Gtk.Settings.get_default().props.gtk_application_prefer_dark_theme = preferences.get_prefers_color_scheme() == Granite.SettingsColorScheme.DARK
+ preferences.connect('notify::prefers-color-scheme', scheme)
+ scheme()
+ self.window = Gtk.ApplicationWindow(application=self, title=_('Verification Code'), decorated=False, resizable=False, default_width=360)
+ self.window.connect('close-request', self.dismiss)
+ card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12, margin_top=18, margin_bottom=18, margin_start=20, margin_end=20)
+ heading = Gtk.Box(spacing=10)
+ heading.append(Gtk.Image.new_from_icon_name('phone-symbolic'))
+ title = Gtk.Label(label=_('Use Code from iPhone'), xalign=0, hexpand=True)
+ title.add_css_class('heading');heading.append(title)
+ close = Gtk.Button(icon_name='window-close-symbolic', tooltip_text=_('Dismiss (Esc)'))
+ close.add_css_class('flat');close.connect('clicked', self.dismiss);heading.append(close)
+ card.append(heading)
+ self.source = Gtk.Label(xalign=0, wrap=True, max_width_chars=42)
+ self.source.add_css_class('dim-label');card.append(self.source)
+ self.code = Gtk.Label(selectable=True, xalign=0)
+ self.code.add_css_class('title-1');card.append(self.code)
+ self.destination = Gtk.Label(xalign=0, wrap=True, max_width_chars=42)
+ card.append(self.destination)
+ self.error = Gtk.Label(xalign=0, wrap=True, visible=False)
+ self.error.add_css_class('error');card.append(self.error)
+ actions = Gtk.Box(spacing=8)
+ self.copy = Gtk.Button(label=_('Copy Code'))
+ self.copy.connect('clicked', self.copy_code);actions.append(self.copy)
+ self.use = Gtk.Button(label=_('Use Code'), hexpand=True)
+ self.use.add_css_class('suggested-action')
+ self.use.connect('clicked', self.use_code);actions.append(self.use)
+ card.append(actions)
+ self.hint = Gtk.Label(label=_('Enter to insert · Esc to dismiss'), xalign=0, wrap=True, max_width_chars=42)
+ self.hint.add_css_class('dim-label');card.append(self.hint)
+ self.window.set_child(card)
+ self.window.set_default_widget(self.use)
+ keys = Gtk.EventControllerKey();keys.connect('key-pressed', self.key_pressed);self.window.add_controller(keys)
+ Atspi.set_timeout(200, 500)
+ if not self.fixture:
+ self.listener = Atspi.EventListener.new(self.focus_changed)
+ self.listener.register('object:state-changed:focused')
+ GLib.timeout_add_seconds(4, self.poll)
+ self.poll(force=True)
+
+ def task(self, work, completed):
+ future = self.pool.submit(work)
+ def done(result):
+ try: value, error = result.result(), None
+ except Exception: value, error = None, True
+ GLib.idle_add(lambda: completed(value, error) or False)
+ future.add_done_callback(done)
+
+ def poll(self, force=False):
+ if self.fixture or self.busy:
+ return True
+ self.busy = True
+ previous = self.last_events
+ def load():
+ client = BackendClient();status = client.status()
+ if not status.map:
+ return None, None
+ return status.events, client.recent('telecom/msg/INBOX', 20) if force or status.events != previous else None
+ def loaded(value, error):
+ self.busy = False
+ if not error:
+ self.last_events, messages = value
+ if messages is not None:
+ self.registry.update(messages)
+ self.render(self.manual)
+ self.task(load, loaded)
+ return True
+
+ def focus_changed(self, event, *_args):
+ if not event.detail1:
+ return
+ try:
+ if event.source.get_application().get_process_id() == os.getpid():
+ return
+ except Exception:
+ self.target = self.last_field = None
+ self.window.set_visible(False)
+ return
+ self.last_field = event.source
+ self.target = describe(event.source)
+ self.manual = False
+ self.render(False)
+
+ def render(self, manual=False, activate=False):
+ origin = self.target['origin'] if self.target else None
+ offers = self.registry.available(origin)
+ self.selected_offer = offers[0] if offers else None
+ if not offers:
+ self.code.set_label('')
+ self.source.set_label(_('No recent verification code.'))
+ self.destination.set_label(_('Request a fresh code on the sign-in screen, then try again.'))
+ self.use.set_sensitive(False);self.copy.set_sensitive(False)
+ self.hint.set_label(_('%s · Esc to dismiss') % self.shortcut)
+ if manual:
+ if activate or not self.window.get_visible(): self.present_popup()
+ else:
+ self.window.set_visible(False)
+ return
+ offer = offers[0]
+ self.source.set_label(_('From %s · recent text') % offer.sender)
+ self.code.set_label(offer.code)
+ can_insert = bool(self.target and self.target.get('can_insert'))
+ if can_insert:
+ destination = _('Insert into “%s” in %s') % (self.target['name'], self.target['application'])
+ elif self.target:
+ destination = _('Copy, then paste into “%s” in %s.') % (self.target['name'], self.target['application'])
+ else:
+ destination = _('Focus a verification-code field and use the shortcut, or copy the code.')
+ self.destination.set_label(destination)
+ self.use.set_visible(can_insert)
+ self.use.set_sensitive(can_insert and not self.filling)
+ self.copy.set_hexpand(not can_insert)
+ if can_insert: self.copy.remove_css_class('suggested-action')
+ else: self.copy.add_css_class('suggested-action')
+ self.copy.set_sensitive(True)
+ self.window.set_default_widget(self.use if can_insert else self.copy)
+ self.hint.set_label((_('%s → Enter to insert · Esc to dismiss') if can_insert else _('%s → Enter to copy · Esc to dismiss')) % self.shortcut)
+ if self.target:
+ identity = (self.target['pid'], self.target['path'], offer.id)
+ if activate or (manual and not self.window.get_visible()) or identity != self.shown:
+ self.shown = identity
+ self.present_popup()
+ elif activate or (manual and not self.window.get_visible()):
+ self.present_popup()
+ elif not manual:
+ self.window.set_visible(False)
+
+ def present_popup(self):
+ self.error.set_visible(False)
+ self.window.realize()
+ surface = self.window.get_surface()
+ if isinstance(surface, GdkX11.X11Surface):
+ surface.set_skip_taskbar_hint(True);surface.set_skip_pager_hint(True)
+ if not self.manual:
+ surface.set_user_time(0)
+ self.window.present()
+ if self.manual:
+ self.window.set_focus(self.use if self.target and self.target.get('can_insert') else self.copy)
+ if isinstance(surface, GdkX11.X11Surface) and not self.fixture:
+ xid = surface.get_xid()
+ def location():
+ result = subprocess.run(['xdotool', 'getmouselocation', '--shell'], check=True, capture_output=True, text=True, timeout=2)
+ values = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
+ geometry = subprocess.run(['xdotool', 'getdisplaygeometry'], check=True, capture_output=True, text=True, timeout=2).stdout.split()
+ width, height = map(int, geometry)
+ return max(0, min(int(values['X']) + 14, width - 390)), max(0, min(int(values['Y']) + 18, height - 290))
+ def move(point, error):
+ if not error:
+ subprocess.Popen(['xdotool', 'windowmove', str(xid), str(point[0]), str(point[1])], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ if self.manual:
+ subprocess.Popen(['xdotool', 'windowactivate', str(xid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ self.task(location, move)
+
+ def use_code(self, *_args):
+ if self.filling or not self.target or not self.target.get('can_insert') or not self.selected_offer:
+ return
+ target, identity = self.target, self.selected_offer.id
+ offer = self.registry.get(identity, target['origin'])
+ if not offer:
+ self.render(self.manual);return
+ self.use.set_sensitive(False)
+ self.filling = True
+ def work():
+ # Revalidate the target immediately before using its editable API.
+ # No keyboard injection, clipboard write or form submission occurs.
+ if self.fixture:
+ raise RuntimeError('Preview cannot fill real fields')
+ if not self.target or self.target['source'] != target['source']:
+ raise RuntimeError('The focused field changed')
+ return insert(target, offer.code)
+ def filled(value, error):
+ self.filling = False
+ if error:
+ self.error.set_label(_('This app did not accept insertion. Copy Code is still available.'))
+ self.error.set_visible(True)
+ self.use.set_sensitive(bool(self.target))
+ else:
+ self.registry.consume(identity)
+ self.window.set_visible(False)
+ self.manual = False
+ self.task(work, filled)
+
+ def copy_code(self, *_args):
+ if not self.selected_offer:
+ return
+ offer = self.registry.get(self.selected_offer.id, self.target['origin'] if self.target else None)
+ if offer:
+ self.window.get_clipboard().set(offer.code)
+ self.dismiss()
+ else:
+ self.render(self.manual)
+
+ def dismiss(self, *_args):
+ self.window.set_visible(False)
+ self.manual = False
+ if self.target:
+ try: self.target['source'].get_component_iface().grab_focus()
+ except Exception: pass
+ return True
+
+ def key_pressed(self, _controller, key, _keycode, _mods):
+ if key == Gdk.KEY_Escape:
+ self.dismiss();return True
+ return False
+
+
+if __name__ == '__main__':
+ Codes().run(sys.argv)