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 modeDiffersRunning with media keys first (fnmode=1); upstream uses the kernel default (auto). Awaiting a deliberate choice. Microphone mappingNot portedNeeds re-homing to elementary's audio session. iCloud password syncOptions documentedNo 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 / SMSWorking on this desktopBlueFerry 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 / SMSWorking on this desktopBlueFerry 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 decodeMissingNeeds AVD kernel support, firmware and a VA-API bridge. Touch IDBlocked upstreamNo 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 CodexBarNative 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)