Release Vulpes OS 2.7-preview.22

This commit is contained in:
OverSu 2026-10-22 20:42:26 +02:00
commit a138f97877
294 changed files with 515786 additions and 187 deletions

View file

@ -4,6 +4,266 @@
Updates by date and platform. Within each group: Add, Change, Fix, Note, Test. Entries describe the state at their date; a fix in development requires an updated installed version.
<a id="2.7-preview.22"></a>
## 22/10/2026 : 2.7-preview.22
![Published](https://img.shields.io/badge/Published-334155?style=flat-square)
### ![All Platforms](https://img.shields.io/badge/All%20platforms-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Sharing](https://img.shields.io/badge/Sharing-475569?style=flat-square) : Browser Share offers Messages and Email; the keyboard hides so the chooser remains accessible.
### ![Desktop](https://img.shields.io/badge/Desktop-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Web input](https://img.shields.io/badge/Web%20input-475569?style=flat-square) : Focus is restored for fields and dropdowns in the desktop browser.
- ![Test](https://img.shields.io/badge/Test-6B7280?style=flat-square) ![Browser](https://img.shields.io/badge/Browser-475569?style=flat-square) : First-click input, typing, selectors and sharing to a Messages draft checked on desktop.
### ![Android / APK](https://img.shields.io/badge/Android%20%2F%20APK-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-487768?style=flat-square) ![Update](https://img.shields.io/badge/Update-475569?style=flat-square) : Preview 22 APK built with shared Gaia fixes and an incremented Android version.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Startup](https://img.shields.io/badge/Startup-475569?style=flat-square) : GeckoView logs a session-restore message on the emulator first launch; Gaia still reaches Home without crashing.
- ![Test](https://img.shields.io/badge/Test-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Preview 21 installation and upgrade to Preview 22 succeeded in Android 11 emulation; Gaia reached Home. Reported phones still need qualification, including Pixel 4.
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-487768?style=flat-square) ![Keyboard](https://img.shields.io/badge/Keyboard-475569?style=flat-square) : Initial offline T9 input using Gaia dictionaries. Physical keyboard qualification is still needed.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Network](https://img.shields.io/badge/Network-475569?style=flat-square) : Wi-Fi, reconnection and airplane-mode cycle checked in a RAM session; Gaia controls follow hardware state.
### ![Pixel 3a / Sargo](https://img.shields.io/badge/Pixel%203a%20%2F%20Sargo-2563EB?style=flat-square)
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Native image](https://img.shields.io/badge/Native%20image-475569?style=flat-square) : This release does not include a new Pixel image. Preview 21 remains the latest published image; this Preview's changes are not installed on the phone.
<a id="development-2026-10-20-web-nokia"></a>
## 20/10/2026 : Fixes being prepared for Preview 22
### ![All Platforms](https://img.shields.io/badge/All%20platforms-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Browser](https://img.shields.io/badge/Browser-475569?style=flat-square) : Share opens the Messages or Email chooser. The keyboard no longer covers this menu.
### ![Desktop / Tundra](https://img.shields.io/badge/Desktop%20%2F%20Tundra-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Browser](https://img.shields.io/badge/Browser-475569?style=flat-square) : Web dropdowns regain the focus needed to open.
- ![Test](https://img.shields.io/badge/Test-6B7280?style=flat-square) ![Input](https://img.shields.io/badge/Input-475569?style=flat-square) : First-click input, web fields, selectors and sharing to a Messages draft checked on desktop. Pixel retesting remains pending.
### ![Android / APK](https://img.shields.io/badge/APK-2563EB?style=flat-square)
- ![Test](https://img.shields.io/badge/Test-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Preview 20 to Preview 21 upgrade succeeded in Android 11 emulation. The reported Pixel 4 installation failure still needs diagnosis.
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-13A37D?style=flat-square) ![T9 keyboard](https://img.shields.io/badge/T9%20keyboard-475569?style=flat-square) : Initial offline Nokia implementation using all 42 Gaia dictionaries. Physical qualification remains pending with the flip open.
- ![Change](https://img.shields.io/badge/Change-52748B?style=flat-square) ![Development](https://img.shields.io/badge/Development-475569?style=flat-square) : The launcher now synchronizes the complete shared Gaia payload while keeping phone data.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Airplane mode](https://img.shields.io/badge/Airplane%20mode-475569?style=flat-square) : Gaia now turns the Nokia modem off and back on. Stale network states no longer cancel airplane mode.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Wi-Fi](https://img.shields.io/badge/Wi--Fi-475569?style=flat-square) : Nokia phone-mode startup now exposes the Wi-Fi broker to Gaia. Network scanning, radio toggle, saved-network reconnection and HTTPS access passed in a RAM session.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) : Web dropdown selection with the OK key still needs correction and qualification.
- ![Test](https://img.shields.io/badge/Test-6B7280?style=flat-square) ![Hardware](https://img.shields.io/badge/Hardware-475569?style=flat-square) : Wi-Fi and reconnection, volume with Gaia indicator, brightness and vibration stop checked on Nokia. SIM recognized and LTE registration observed.
- ![Test](https://img.shields.io/badge/Test-6B7280?style=flat-square) ![Network](https://img.shields.io/badge/Network-475569?style=flat-square) : Nokia airplane-mode cycle verified: radios and Wi-Fi off, then back on with LTE registration restored.
- ![Test](https://img.shields.io/badge/Test-6B7280?style=flat-square) ![Media](https://img.shields.io/badge/Media-475569?style=flat-square) : Each RAM launch copies a test Ogg track and WebM video into Music and Video; both files are readable by the Gaia apps.
<a id="development-2026-10-18-nokia-radio-timeout"></a>
## 18/10/2026 : Nokia network timeouts
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Phone](https://img.shields.io/badge/Phone-475569?style=flat-square) : Shorter call-status wait when the modem stops responding; identical unanswered requests no longer accumulate. Clearer error message.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Network](https://img.shields.io/badge/Network-475569?style=flat-square) : The cause of modem stalls and a new real call still need verification.
<a id="development-2026-10-18-forgejo-fixes"></a>
## 18/10/2026 : Forgejo fixes — in development
### ![All Platforms](https://img.shields.io/badge/All%20platforms-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Gallery](https://img.shields.io/badge/Gallery-475569?style=flat-square) : Corrected photo orientation, sharing to Messages or Email, and the empty gallery background.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Languages](https://img.shields.io/badge/Languages-475569?style=flat-square) : Language changes no longer revert to an earlier selection. Added missing characters in language names.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Marketplace](https://img.shields.io/badge/Marketplace-475569?style=flat-square) : Connected the FFAA Install button for packaged apps, with confirmation before installation.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Customization](https://img.shields.io/badge/Customization-475569?style=flat-square) : Gaia wallpapers now use their higher-resolution originals.
### ![Desktop / Tundra](https://img.shields.io/badge/Desktop%20%2F%20Tundra-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Keyboard](https://img.shields.io/badge/Keyboard-475569?style=flat-square) : Gaia keyboard input in Marketplace search, including after web navigation.
### ![Tundra / Pixel 3a](https://img.shields.io/badge/Tundra%20%2F%20Pixel%203a-2563EB?style=flat-square)
- ![Change](https://img.shields.io/badge/Change-64748B?style=flat-square) ![Battery](https://img.shields.io/badge/Battery-475569?style=flat-square) : Reduced hardware communication overhead while idle; day-long battery testing is still needed.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Camera](https://img.shields.io/badge/Camera-475569?style=flat-square) : Video recording is accessible and playable; flash controls follow the sensor’s actual capabilities.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Date and time](https://img.shields.io/badge/Date%20and%20time-475569?style=flat-square) : Time survives a reboot even when the hardware clock is incorrect.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Vibration](https://img.shields.io/badge/Vibration-475569?style=flat-square) : Corrected vibration start and stop.
- ![Test](https://img.shields.io/badge/Test-8B5CF6?style=flat-square) ![Camera](https://img.shields.io/badge/Camera-475569?style=flat-square) : Verified a 12 MP photo, front/rear switching and playback of a short video on Pixel.
### ![All Platforms — Tests](https://img.shields.io/badge/Test%20status-2563EB?style=flat-square)
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Validation](https://img.shields.io/badge/Validation-475569?style=flat-square) : Unreleased fixes. Motorola-specific issues, long-term battery use, video audio and wake behavior still need qualification. Full OTA updates and hosted FFAA apps remain unfinished.
<a id="development-2026-10-18-nokia-settings-keypad"></a>
## 18/10/2026 : Nokia Settings navigation
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Settings](https://img.shields.io/badge/Settings-475569?style=flat-square) : Airplane mode and Geolocation are reachable with Up/Down. OK activates Gaia switches and checkboxes; disabled controls remain skipped.
<a id="development-2026-10-18-nokia-fluidity"></a>
## 18/10/2026 : Nokia 2720 Flip responsiveness
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Change](https://img.shields.io/badge/Change-64748B?style=flat-square) ![Performance](https://img.shields.io/badge/Performance-475569?style=flat-square) : Lighter hardware communication reduces CPU load, leaving more room for the interface.
<a id="development-2026-10-18-nokia-wifi"></a>
## 18/10/2026 : Nokia 2720 Flip Wi-Fi
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-238636?style=flat-square) ![Wi-Fi](https://img.shields.io/badge/Wi--Fi-475569?style=flat-square) : Find networks, switch Wi-Fi on or off and connect from Gaia Settings. Saved networks can be reused without entering their password again.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Wi-Fi](https://img.shields.io/badge/Wi--Fi-475569?style=flat-square) : Browser Internet access and reconnection after switching Wi-Fi off. The displayed status follows reconnections.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Wi-Fi](https://img.shields.io/badge/Wi--Fi-475569?style=flat-square) : Open and WPA/WPA2 Personal networks are supported. Enterprise networks, WPS and hotspot sharing still need integration on the Nokia.
### ![Shared fixes](https://img.shields.io/badge/PC%20%2F%20APK%20%2F%20Tundra-475569?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Wi-Fi](https://img.shields.io/badge/Wi--Fi-475569?style=flat-square) : Wi-Fi results remain visible when a signal update arrives before the list. Saved networks that are not detected are marked out of range.
<a id="development-2026-10-18-nokia-browser-keypad"></a>
## 18/10/2026 : Nokia 2720 Flip keypad browsing
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Browser](https://img.shields.io/badge/Browser-475569?style=flat-square) : Press Up then OK to access the address bar, type with the keypad and submit. Back closes address entry; Down returns to page content. The bar remains reachable on web pages and network error pages.
<a id="development-2026-10-18-nokia-appearance-privacy"></a>
## 18/10/2026 : Nokia customization and privacy
### ![All Platforms](https://img.shields.io/badge/All%20platforms-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Browser](https://img.shields.io/badge/Browser-475569?style=flat-square) : Tracking protection enabled by default; introductory popup no longer shown on launch.
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Cover screen](https://img.shields.io/badge/Cover%20screen-475569?style=flat-square) : Added red, orange, purple, cyan, turquoise and grey.
- ![Change](https://img.shields.io/badge/Change-64748B?style=flat-square) ![Interface](https://img.shields.io/badge/Interface-475569?style=flat-square) : Decorative transitions are nearly instant on Nokia; loading animations remain active.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Cover screen](https://img.shields.io/badge/Cover%20screen-475569?style=flat-square) : Custom text saves after typing without leaving the field.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Home](https://img.shields.io/badge/Home-475569?style=flat-square) : The status bar uses the same dimming as the home background.
<a id="development-2026-10-18-nokia-closed-start"></a>
## 18/10/2026 : Nokia 2720 Flip lid and keys
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Notifications](https://img.shields.io/badge/Notifications-475569?style=flat-square) : The dedicated key opens and closes the Gaia notification tray.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Display](https://img.shields.io/badge/Display-475569?style=flat-square) : The cover display is activated when starting with the phone closed, without opening the lid first.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Volume](https://img.shields.io/badge/Volume-475569?style=flat-square) : Volume keys remain usable with the lid closed.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Keypad](https://img.shields.io/badge/Keypad-475569?style=flat-square) : The assistance and headset keys, and the long-press power menu, still need integration.
<a id="development-2026-10-18-nokia-hardware"></a>
## 18/10/2026 : Nokia 2720 Flip hardware controls
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Volume](https://img.shields.io/badge/Volume-475569?style=flat-square) : Side keys connected to the Gaia volume panel and native phone volume.
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Display](https://img.shields.io/badge/Display-475569?style=flat-square) : Brightness control now adjusts the screen backlight.
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Vibration](https://img.shields.io/badge/Vibration-475569?style=flat-square) : Short vibration patterns with automatic stop and cancellation.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Volume](https://img.shields.io/badge/Volume-475569?style=flat-square) : Gaia starts with the phone’s actual volume level.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Camera](https://img.shields.io/badge/Camera-475569?style=flat-square) : A fixed-focus 2 MP rear camera has been identified. Connecting it to Vulpes remains in progress.
- ![Test](https://img.shields.io/badge/Test-75658A?style=flat-square) ![Hardware](https://img.shields.io/badge/Hardware-475569?style=flat-square) : Volume, Gaia panel, brightness and vibration stop checked on the Nokia. No new flash or APK published.
<a id="development-2026-10-18-nokia-build"></a>
## 18/10/2026 : Experimental Nokia 2720 Flip build
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Build](https://img.shields.io/badge/Build-475569?style=flat-square) : First private boot package built with Gecko 156.0.1 and shared Gaia.
- ![Change](https://img.shields.io/badge/Change-64748B?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Prototype files now survive a Nokia reboot.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Validation](https://img.shields.io/badge/Validation-475569?style=flat-square) : Unsigned image, not yet boot-tested or qualified for flashing. The phone still relies on its installed hardware services; no standalone ROM published.
- ![Test](https://img.shields.io/badge/Test-75658A?style=flat-square) ![Build](https://img.shields.io/badge/Build-475569?style=flat-square) : Image format, archives and fallback logic checked locally. Gaia and network verified from the new persistent location.
<a id="development-2026-10-18-nokia-phone"></a>
## 18/10/2026 : In development — Nokia 2720 Flip
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Cover screen](https://img.shields.io/badge/Cover%20screen-475569?style=flat-square) : Custom text and title/battery colours in Display settings, with a preview.
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Phone](https://img.shields.io/badge/Phone-475569?style=flat-square) : Calls and simple SMS connected to the Gaia applications.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Flip](https://img.shields.io/badge/Flip-475569?style=flat-square) : Displays and keypad backlight corrected when opening and closing; operation confirmed on the phone.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Network](https://img.shields.io/badge/Network-475569?style=flat-square) : Radio reconnections are spaced out after errors to avoid a retry loop.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Keys](https://img.shields.io/badge/Keys-475569?style=flat-square) : Red key connected to Gaia’s Hang up button and return to Home.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Prototype](https://img.shields.io/badge/Prototype-475569?style=flat-square) : Temporary session on the existing KaiOS hardware stack; no standalone image or new release. Long-term reliability still needs testing.
- ![Test](https://img.shields.io/badge/Test-75658A?style=flat-square) ![Communications](https://img.shields.io/badge/Communications-475569?style=flat-square) : Call, two-way voice audio and SMS confirmed by the Nokia owner.
### ![All Platforms](https://img.shields.io/badge/All%20Platforms-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Calls](https://img.shields.io/badge/Calls-475569?style=flat-square) : Repeated presses of Call no longer send multiple requests while connecting. An audio error no longer blocks the call screen.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Compact mode](https://img.shields.io/badge/Compact%20mode-475569?style=flat-square) : Phone keypad, Call button and tabs fit small screens without overlap.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Shared resources synchronized; no new APK compiled.
<a id="development-2026-10-17-nokia-cover"></a>
## 17/10/2026 : In development — Nokia 2720 Flip
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Flip](https://img.shields.io/badge/Flip-475569?style=flat-square) : Inner display power management when closing and reopening the phone, under hardware validation.
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Cover display](https://img.shields.io/badge/Cover%20display-475569?style=flat-square) : Clock, date, operator and battery; turns off after 15 seconds.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Prototype](https://img.shields.io/badge/Prototype-475569?style=flat-square) : Temporary trial only. Physical flip behavior and battery life still need validation; no new image published.
<a id="2.7-preview.21-sargo.1"></a>
## 15/10/2026 : 2.7-preview.21-sargo.1
![Experimental image published](https://img.shields.io/badge/Experimental%20image%20published-75658A?style=flat-square)
### ![Sargo / Pixel 3a](https://img.shields.io/badge/Sargo%20%2F%20Pixel%203a-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Full Pixel 3a package available with installer, FR/EN instructions and checksums. Unlocked bootloader required; installation erases data.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Browser](https://img.shields.io/badge/Browser-475569?style=flat-square) : Includes fixes for DuckDuckGo search, keyboard submission and web page rendering.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Experimental](https://img.shields.io/badge/Experimental-475569?style=flat-square) : The exact public binary still needs a hardware boot test. Calls, audio, suspend and battery life need validation; provenance limitations are detailed in the release.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Sources](https://img.shields.io/badge/Sources-475569?style=flat-square) : Vulpes sources and collected upstream sources attached. Complete correspondence for some components remains to be established.
[Download and instructions](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.21-sargo.1)
<a id="development-2026-10-14-browser"></a>
## 14/10/2026 : Browser and video
![In development](https://img.shields.io/badge/In%20development-334155?style=flat-square)
### ![All Platforms](https://img.shields.io/badge/All%20Platforms-2563EB?style=flat-square)
- ![Change](https://img.shields.io/badge/Change-64748B?style=flat-square) ![Search](https://img.shields.io/badge/Search-475569?style=flat-square) : DuckDuckGo becomes the default search engine. Explicit engine choices are preserved.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Keyboard](https://img.shields.io/badge/Keyboard-475569?style=flat-square) : The address bar reopens the keyboard on the first tap, even after dismissal.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Keyboard](https://img.shields.io/badge/Keyboard-475569?style=flat-square) : Submitting from the keyboard now closes it before displaying the page, without freezing the interface.
### ![Desktop](https://img.shields.io/badge/Desktop-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Camera](https://img.shields.io/badge/Camera-475569?style=flat-square) : Microphone permission uses the Gaia panel instead of a separate Firefox window.
- ![Test](https://img.shields.io/badge/Test-75658A?style=flat-square) ![Browser](https://img.shields.io/badge/Browser-475569?style=flat-square) : DuckDuckGo navigation and return to Home checked in the test profile.
### ![Android / APK](https://img.shields.io/badge/Android%20%2F%20APK-2563EB?style=flat-square)
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Shared resources updated; no new APK distributed for these fixes.
### ![Sargo / Pixel 3a](https://img.shields.io/badge/Sargo%20%2F%20Pixel%203a-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Browser](https://img.shields.io/badge/Browser-475569?style=flat-square) : Gaia edge gesture zones no longer hide web pages on the Pixel’s narrow screen.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Camera](https://img.shields.io/badge/Camera-475569?style=flat-square) : Microphone permission appears inside Gaia to start video recording.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Validation](https://img.shields.io/badge/Validation-475569?style=flat-square) : Flicker cleared after a cold restart; search results and a direct address visually checked on the Pixel. Video audio quality still needs listening tests; no new release published.
- ![Test](https://img.shields.io/badge/Test-75658A?style=flat-square) ![Navigation and video](https://img.shields.io/badge/Navigation%20and%20video-475569?style=flat-square) : Search, keyboard, return to Home and a short playable video with thumbnail checked on the Pixel in RAM without the debugger. Permission buttons remain accessible above Home.
### ![Web](https://img.shields.io/badge/Web-2563EB?style=flat-square)
- ![Change](https://img.shields.io/badge/Change-64748B?style=flat-square) ![Pixel 3a](https://img.shields.io/badge/Pixel%203a-475569?style=flat-square) : FR/EN compatibility page updated: standalone installation, Bluetooth, video, browser and remaining feature limitations. Public image still in preparation.
<a id="2.7-preview.21"></a>
## 14/10/2026 : 2.7-preview.21
@ -26,8 +286,10 @@ Updates by date and platform. Within each group: Add, Change, Fix, Note, Test. E
### ![Sargo / Pixel 3a](https://img.shields.io/badge/Sargo%20%2F%20Pixel%203a-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Boot](https://img.shields.io/badge/Boot-475569?style=flat-square) : Smaller boot image; fixed the black screen at startup on Pixel 3a.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Shared Gaia fixes are included in full installation images and boot updates.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : New installation image built; Pixel boot and flashing must be tested before publication.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Testing](https://img.shields.io/badge/Testing-475569?style=flat-square) : Calls, audio, SMS/MMS, camera, automatic time and battery life still need testing on this installation.
- ![Test](https://img.shields.io/badge/Test-75658A?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Full installation and reboots verified on Pixel 3a; personal data restored. Image not yet published.
<a id="2.7-preview.20"></a>

View file

@ -4,6 +4,266 @@
Les nouveautés, par date et par plateforme. Dans chaque groupe : Add, Change, Fix, Note, Test. Les entrées décrivent la situation à leur date ; une correction en développement nécessite une nouvelle version installée.
<a id="2.7-preview.22"></a>
## 22/10/2026 : 2.7-preview.22
![Publié](https://img.shields.io/badge/Publi%C3%A9-334155?style=flat-square)
### ![Toutes les plateformes](https://img.shields.io/badge/Toutes%20les%20plateformes-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Partage](https://img.shields.io/badge/Partage-475569?style=flat-square) : Le bouton Partager du navigateur propose Messages et Courrier ; le clavier se masque pour laisser le choix accessible.
### ![PC](https://img.shields.io/badge/PC-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Saisie web](https://img.shields.io/badge/Saisie%20web-475569?style=flat-square) : Le focus des champs et listes web est rétabli dans le navigateur PC.
- ![Test](https://img.shields.io/badge/Test-6B7280?style=flat-square) ![Navigateur](https://img.shields.io/badge/Navigateur-475569?style=flat-square) : Premier appui, saisie, sélecteurs et partage vers un brouillon Messages contrôlés sur PC.
### ![Android / APK](https://img.shields.io/badge/Android%20%2F%20APK-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-487768?style=flat-square) ![Mise à jour](https://img.shields.io/badge/Mise%20%C3%A0%20jour-475569?style=flat-square) : APK Preview 22 construit avec les corrections Gaia communes et une version Android incrémentée.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Démarrage](https://img.shields.io/badge/D%C3%A9marrage-475569?style=flat-square) : Un message GeckoView de restauration de session apparaît au premier lancement de l’émulateur ; Gaia atteint néanmoins l’accueil sans plantage.
- ![Test](https://img.shields.io/badge/Test-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Installation de la Preview 21 puis mise à jour vers la 22 réussies sur Android 11 en émulation ; Gaia démarre sur l’accueil. Les téléphones signalés restent à qualifier, dont le Pixel 4.
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-487768?style=flat-square) ![Clavier](https://img.shields.io/badge/Clavier-475569?style=flat-square) : Première saisie T9 hors ligne avec les lexiques Gaia. Qualification physique du clavier encore nécessaire.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Réseau](https://img.shields.io/badge/R%C3%A9seau-475569?style=flat-square) : Wi-Fi, reconnexion et cycle du mode avion vérifiés en session RAM ; les contrôles Gaia suivent l’état matériel.
### ![Pixel 3a / Sargo](https://img.shields.io/badge/Pixel%203a%20%2F%20Sargo-2563EB?style=flat-square)
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Image native](https://img.shields.io/badge/Image%20native-475569?style=flat-square) : Cette release ne contient pas de nouvelle image Pixel. La Preview 21 reste la dernière image publiée ; les changements de cette Preview ne sont pas installés sur le téléphone.
<a id="development-2026-10-20-web-nokia"></a>
## 20/10/2026 : Correctifs en préparation pour Preview 22
### ![Toutes les plateformes](https://img.shields.io/badge/Toutes%20les%20plateformes-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Navigateur](https://img.shields.io/badge/Navigateur-475569?style=flat-square) : Le bouton Partager ouvre le choix Messages ou Courrier. Le clavier ne recouvre plus ce choix.
### ![PC / Tundra](https://img.shields.io/badge/PC%20%2F%20Tundra-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Navigateur](https://img.shields.io/badge/Navigateur-475569?style=flat-square) : Les listes déroulantes web retrouvent le focus nécessaire à leur ouverture.
- ![Test](https://img.shields.io/badge/Test-6B7280?style=flat-square) ![Saisie](https://img.shields.io/badge/Saisie-475569?style=flat-square) : Premier appui, champs web, sélecteurs et partage vers un brouillon Messages vérifiés sur PC. Retest Pixel à faire.
### ![Android / APK](https://img.shields.io/badge/APK-2563EB?style=flat-square)
- ![Test](https://img.shields.io/badge/Test-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Mise à jour de Preview 20 vers Preview 21 réussie sur Android 11 en émulation. Le refus signalé sur Pixel 4 reste à diagnostiquer.
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-13A37D?style=flat-square) ![Clavier T9](https://img.shields.io/badge/Clavier%20T9-475569?style=flat-square) : Première implémentation Nokia hors ligne, avec les 42 lexiques Gaia. Qualification physique à faire clapet ouvert.
- ![Change](https://img.shields.io/badge/Change-52748B?style=flat-square) ![Développement](https://img.shields.io/badge/D%C3%A9veloppement-475569?style=flat-square) : Le lanceur synchronise désormais l’ensemble des fichiers Gaia communs, en conservant les données du téléphone.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Mode avion](https://img.shields.io/badge/Mode%20avion-475569?style=flat-square) : Gaia commande maintenant la coupure et la réactivation du modem Nokia. Les anciens états réseau ne font plus annuler le mode avion.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Wi-Fi](https://img.shields.io/badge/Wi--Fi-475569?style=flat-square) : Le lancement Nokia en mode téléphone expose bien le broker Wi-Fi à Gaia. Recherche de réseaux, coupure/réactivation, reconnexion au réseau enregistré et accès HTTPS vérifiés en session RAM.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) : La sélection des listes web avec la touche OK reste à corriger et qualifier.
- ![Test](https://img.shields.io/badge/Test-6B7280?style=flat-square) ![Matériel](https://img.shields.io/badge/Mat%C3%A9riel-475569?style=flat-square) : Wi-Fi et reconnexion, volume avec indicateur Gaia, luminosité et arrêt du vibreur vérifiés sur le Nokia. SIM reconnue et LTE observée.
- ![Test](https://img.shields.io/badge/Test-6B7280?style=flat-square) ![Réseau](https://img.shields.io/badge/R%C3%A9seau-475569?style=flat-square) : Cycle mode avion vérifié sur Nokia : radios et Wi-Fi coupés, puis réactivés avec retour sur le réseau LTE.
- ![Test](https://img.shields.io/badge/Test-6B7280?style=flat-square) ![Médias](https://img.shields.io/badge/M%C3%A9dias-475569?style=flat-square) : Le lanceur copie un morceau Ogg et une vidéo WebM de test dans Musique et Vidéo à chaque démarrage RAM ; les deux fichiers sont lisibles par les applications Gaia.
<a id="development-2026-10-18-nokia-radio-timeout"></a>
## 18/10/2026 : Attente réseau sur Nokia
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Téléphone](https://img.shields.io/badge/T%C3%A9l%C3%A9phone-475569?style=flat-square) : Attente de l’état des appels raccourcie lorsque le modem ne répond plus ; les requêtes identiques ne s’accumulent plus. Message d’erreur clarifié.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Réseau](https://img.shields.io/badge/R%C3%A9seau-475569?style=flat-square) : La cause des silences du modem et le passage d’un nouvel appel réel restent à vérifier.
<a id="development-2026-10-18-forgejo-fixes"></a>
## 18/10/2026 : Correctifs Forgejo — en développement
### ![Toutes les plateformes](https://img.shields.io/badge/Toutes%20les%20plateformes-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Galerie](https://img.shields.io/badge/Galerie-475569?style=flat-square) : Orientation des photos corrigée ; partage vers Messages ou Courrier et fond du panneau vide rétablis.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Langues](https://img.shields.io/badge/Langues-475569?style=flat-square) : Un changement de langue ne revient plus à une ancienne sélection. Affichage des noms de langues complété.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Marketplace](https://img.shields.io/badge/Marketplace-475569?style=flat-square) : Bouton Installer du catalogue FFAA raccordé pour les applications empaquetées, avec confirmation avant installation.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Personnalisation](https://img.shields.io/badge/Personnalisation-475569?style=flat-square) : Fonds d’écran Gaia remplacés par leurs originaux de meilleure résolution.
### ![PC / Tundra](https://img.shields.io/badge/PC%20%2F%20Tundra-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Clavier](https://img.shields.io/badge/Clavier-475569?style=flat-square) : Saisie avec le clavier Gaia dans la recherche du Marketplace, y compris après une navigation web.
### ![Tundra / Pixel 3a](https://img.shields.io/badge/Tundra%20%2F%20Pixel%203a-2563EB?style=flat-square)
- ![Change](https://img.shields.io/badge/Change-64748B?style=flat-square) ![Autonomie](https://img.shields.io/badge/Autonomie-475569?style=flat-square) : Échanges avec le matériel allégés au repos ; mesure d’autonomie sur une journée encore nécessaire.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Caméra](https://img.shields.io/badge/Cam%C3%A9ra-475569?style=flat-square) : Mode vidéo accessible et lisible ; flash raccordé aux capacités réelles du capteur.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Date et heure](https://img.shields.io/badge/Date%20et%20heure-475569?style=flat-square) : Heure conservée après redémarrage même quand l’horloge matérielle est incorrecte.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Vibration](https://img.shields.io/badge/Vibration-475569?style=flat-square) : Déclenchement et arrêt des impulsions corrigés.
- ![Test](https://img.shields.io/badge/Test-8B5CF6?style=flat-square) ![Caméra](https://img.shields.io/badge/Cam%C3%A9ra-475569?style=flat-square) : Photo de 12 Mpx, changement avant/arrière et lecture d’une courte vidéo vérifiés sur le Pixel.
### ![Toutes les plateformes — Tests](https://img.shields.io/badge/%C3%89tat%20des%20tests-2563EB?style=flat-square)
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Validation](https://img.shields.io/badge/Validation-475569?style=flat-square) : Correctifs non publiés. Les cas Motorola, l’autonomie prolongée, le son des vidéos et le réveil restent à qualifier. L’OTA complet et les applications FFAA hébergées restent à développer.
<a id="development-2026-10-18-nokia-settings-keypad"></a>
## 18/10/2026 : Navigation dans les Paramètres du Nokia
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Paramètres](https://img.shields.io/badge/Param%C3%A8tres-475569?style=flat-square) : Mode avion et Géolocalisation accessibles avec Haut/Bas. La touche OK actionne les interrupteurs et cases à cocher Gaia ; les commandes désactivées restent ignorées.
<a id="development-2026-10-18-nokia-fluidity"></a>
## 18/10/2026 : Fluidité du Nokia 2720 Flip
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Change](https://img.shields.io/badge/Change-64748B?style=flat-square) ![Fluidité](https://img.shields.io/badge/Fluidit%C3%A9-475569?style=flat-square) : Échanges avec le matériel allégés pour réduire la charge du processeur et laisser plus de place à l’interface.
<a id="development-2026-10-18-nokia-wifi"></a>
## 18/10/2026 : Wi-Fi sur Nokia 2720 Flip
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-238636?style=flat-square) ![Wi-Fi](https://img.shields.io/badge/Wi--Fi-475569?style=flat-square) : Recherche des réseaux, activation et connexion depuis les Paramètres Gaia. Les réseaux enregistrés peuvent être réutilisés sans ressaisir le mot de passe.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Wi-Fi](https://img.shields.io/badge/Wi--Fi-475569?style=flat-square) : Accès Internet du navigateur et remise en place de la connexion après une coupure du Wi-Fi. L’état affiché suit les reconnexions.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Wi-Fi](https://img.shields.io/badge/Wi--Fi-475569?style=flat-square) : Réseaux ouverts et WPA/WPA2 personnels pris en charge. Réseaux d’entreprise, WPS et partage de connexion restent à intégrer sur le Nokia.
### ![Toutes les plateformes](https://img.shields.io/badge/PC%20%2F%20APK%20%2F%20Tundra-475569?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Wi-Fi](https://img.shields.io/badge/Wi--Fi-475569?style=flat-square) : Le panneau Wi-Fi affiche les résultats même si le signal arrive avant la liste. Les réseaux enregistrés non détectés sont indiqués hors de portée.
<a id="development-2026-10-18-nokia-browser-keypad"></a>
## 18/10/2026 : Navigation au clavier sur Nokia 2720 Flip
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Navigateur](https://img.shields.io/badge/Navigateur-475569?style=flat-square) : Accès à la barre d’adresse avec Haut puis OK, saisie au pavé numérique et validation. Retour ferme la saisie ; Bas revient au contenu. La barre reste accessible sur les pages web et les erreurs réseau.
<a id="development-2026-10-18-nokia-appearance-privacy"></a>
## 18/10/2026 : Personnalisation du Nokia et confidentialité
### ![Toutes les plateformes](https://img.shields.io/badge/Toutes%20les%20plateformes-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Navigateur](https://img.shields.io/badge/Navigateur-475569?style=flat-square) : Protection contre le pistage activée par défaut ; suppression du rappel de présentation au lancement.
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Écran extérieur](https://img.shields.io/badge/%C3%89cran%20ext%C3%A9rieur-475569?style=flat-square) : Ajout du rouge, orange, violet, cyan, turquoise et gris.
- ![Change](https://img.shields.io/badge/Change-64748B?style=flat-square) ![Interface](https://img.shields.io/badge/Interface-475569?style=flat-square) : Transitions décoratives quasi instantanées sur le Nokia ; animations de chargement conservées.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Accueil](https://img.shields.io/badge/Accueil-475569?style=flat-square) : La barre d’état utilise le même assombrissement que le fond de l’accueil.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Écran extérieur](https://img.shields.io/badge/%C3%89cran%20ext%C3%A9rieur-475569?style=flat-square) : Le texte personnalisé se sauvegarde après la saisie, sans devoir quitter le champ.
<a id="development-2026-10-18-nokia-closed-start"></a>
## 18/10/2026 : Clapet et touches du Nokia 2720 Flip
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Notifications](https://img.shields.io/badge/Notifications-475569?style=flat-square) : La touche dédiée ouvre et ferme le volet de notifications Gaia.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Affichage](https://img.shields.io/badge/Affichage-475569?style=flat-square) : L’écran externe est activé au lancement téléphone fermé, sans devoir ouvrir le clapet auparavant.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Volume](https://img.shields.io/badge/Volume-475569?style=flat-square) : Les touches de volume restent utilisables clapet fermé.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Clavier](https://img.shields.io/badge/Clavier-475569?style=flat-square) : Les touches d’assistance et du casque, ainsi que le menu d’arrêt par appui long, restent à raccorder.
<a id="development-2026-10-18-nokia-hardware"></a>
## 18/10/2026 : Commandes matérielles du Nokia 2720 Flip
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Volume](https://img.shields.io/badge/Volume-475569?style=flat-square) : Touches latérales raccordées au panneau Gaia et au volume du téléphone.
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Affichage](https://img.shields.io/badge/Affichage-475569?style=flat-square) : Réglage de la luminosité relié au rétroéclairage de l’écran.
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Vibration](https://img.shields.io/badge/Vibration-475569?style=flat-square) : Vibrations courtes avec arrêt automatique et annulation.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Volume](https://img.shields.io/badge/Volume-475569?style=flat-square) : Au démarrage, Gaia reprend le volume réel du téléphone.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Caméra](https://img.shields.io/badge/Cam%C3%A9ra-475569?style=flat-square) : Caméra arrière de 2 Mpx identifiée, à mise au point fixe. Son raccordement à Vulpes reste en chantier.
- ![Test](https://img.shields.io/badge/Test-75658A?style=flat-square) ![Matériel](https://img.shields.io/badge/Mat%C3%A9riel-475569?style=flat-square) : Volume, panneau Gaia, luminosité et arrêt du vibreur contrôlés sur le Nokia. Aucun nouveau flash ni APK publié.
<a id="development-2026-10-18-nokia-build"></a>
## 18/10/2026 : Build expérimental Nokia 2720 Flip
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Build](https://img.shields.io/badge/Build-475569?style=flat-square) : Premier paquet de démarrage privé construit avec Gecko 156.0.1 et Gaia commun.
- ![Change](https://img.shields.io/badge/Change-64748B?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Les fichiers du prototype sont conservés après redémarrage du Nokia.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Validation](https://img.shields.io/badge/Validation-475569?style=flat-square) : Image non signée, pas encore démarrée ni qualifiée pour le flash. Le téléphone reste dépendant de ses services matériels installés ; aucune ROM autonome publiée.
- ![Test](https://img.shields.io/badge/Test-75658A?style=flat-square) ![Build](https://img.shields.io/badge/Build-475569?style=flat-square) : Format de l’image, archives et retour de secours contrôlés localement. Gaia et réseau vérifiés depuis le nouvel emplacement persistant.
<a id="development-2026-10-18-nokia-phone"></a>
## 18/10/2026 : En développement — Nokia 2720 Flip
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Écran extérieur](https://img.shields.io/badge/%C3%89cran%20ext%C3%A9rieur-475569?style=flat-square) : Texte personnalisé et couleurs du titre et de la batterie dans Affichage, avec aperçu.
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Téléphone](https://img.shields.io/badge/T%C3%A9l%C3%A9phone-475569?style=flat-square) : Appels et SMS simples raccordés aux applications Gaia.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Clapet](https://img.shields.io/badge/Clapet-475569?style=flat-square) : Écrans et éclairage des touches corrigés à l’ouverture et à la fermeture ; fonctionnement confirmé sur le téléphone.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Réseau](https://img.shields.io/badge/R%C3%A9seau-475569?style=flat-square) : Reconnexions espacées après une erreur radio pour éviter une boucle de tentatives.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Touches](https://img.shields.io/badge/Touches-475569?style=flat-square) : Touche rouge raccordée au bouton Raccrocher de Gaia et au retour Accueil.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Prototype](https://img.shields.io/badge/Prototype-475569?style=flat-square) : Session temporaire sur le matériel KaiOS existant ; aucune image autonome ni nouvelle publication. Stabilité prolongée à vérifier.
- ![Test](https://img.shields.io/badge/Test-75658A?style=flat-square) ![Communications](https://img.shields.io/badge/Communications-475569?style=flat-square) : Appel, son dans les deux sens et SMS confirmés par le propriétaire du Nokia.
### ![Toutes les plateformes](https://img.shields.io/badge/Toutes%20les%20plateformes-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Appels](https://img.shields.io/badge/Appels-475569?style=flat-square) : Les appuis répétés sur Appeler ne lancent plus plusieurs requêtes pendant la connexion. Une erreur audio ne bloque plus l’écran d’appel.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Mode compact](https://img.shields.io/badge/Mode%20compact-475569?style=flat-square) : Pavé, bouton Appeler et onglets Téléphone ajustés aux petits écrans, sans chevauchement.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Ressources communes synchronisées ; aucun nouvel APK compilé.
<a id="development-2026-10-17-nokia-cover"></a>
## 17/10/2026 : En développement — Nokia 2720 Flip
### ![Tundra / Nokia 2720 Flip](https://img.shields.io/badge/Tundra%20%2F%20Nokia%202720%20Flip-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Clapet](https://img.shields.io/badge/Clapet-475569?style=flat-square) : Gestion de l'écran intérieur à la fermeture et à la réouverture, en cours de validation sur le téléphone.
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Écran extérieur](https://img.shields.io/badge/%C3%89cran%20ext%C3%A9rieur-475569?style=flat-square) : Horloge, date, opérateur et batterie ; extinction après 15 secondes.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Prototype](https://img.shields.io/badge/Prototype-475569?style=flat-square) : Essai temporaire uniquement. La fermeture physique du clapet et l'autonomie restent à valider ; aucune nouvelle image publiée.
<a id="2.7-preview.21-sargo.1"></a>
## 15/10/2026 : 2.7-preview.21-sargo.1
![Image expérimentale publiée](https://img.shields.io/badge/Image%20exp%C3%A9rimentale%20publi%C3%A9e-75658A?style=flat-square)
### ![Sargo / Pixel 3a](https://img.shields.io/badge/Sargo%20%2F%20Pixel%203a-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Paquet complet Pixel 3a disponible avec installateur, instructions FR/EN et sommes de contrôle. Bootloader déverrouillé requis ; installation avec effacement des données.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Navigateur](https://img.shields.io/badge/Navigateur-475569?style=flat-square) : Correctifs de recherche DuckDuckGo, validation au clavier et affichage des pages inclus.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Expérimental](https://img.shields.io/badge/Exp%C3%A9rimental-475569?style=flat-square) : Le binaire public exact reste à tester sur le téléphone. Appels, audio, veille et autonomie restent à valider ; limites de provenance détaillées dans la release.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Sources](https://img.shields.io/badge/Sources-475569?style=flat-square) : Sources Vulpes et collection des sources amont jointes. La correspondance complète de certains composants reste à établir.
[Téléchargement et instructions](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.21-sargo.1)
<a id="development-2026-10-14-browser"></a>
## 14/10/2026 : Navigateur et vidéo
![En développement](https://img.shields.io/badge/En%20d%C3%A9veloppement-334155?style=flat-square)
### ![Toutes les plateformes](https://img.shields.io/badge/Toutes%20les%20plateformes-2563EB?style=flat-square)
- ![Change](https://img.shields.io/badge/Change-64748B?style=flat-square) ![Recherche](https://img.shields.io/badge/Recherche-475569?style=flat-square) : DuckDuckGo devient le moteur par défaut. Les choix explicites de moteur restent conservés.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Clavier](https://img.shields.io/badge/Clavier-475569?style=flat-square) : La barre d’adresse rouvre le clavier dès le premier appui, même après sa fermeture.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Clavier](https://img.shields.io/badge/Clavier-475569?style=flat-square) : La validation au clavier ferme correctement celui-ci avant d’afficher la page, sans bloquer l’interface.
### ![Desktop](https://img.shields.io/badge/Desktop-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Photo](https://img.shields.io/badge/Photo-475569?style=flat-square) : L’autorisation du micro utilise le panneau Gaia, au lieu d’une fenêtre Firefox séparée.
- ![Test](https://img.shields.io/badge/Test-75658A?style=flat-square) ![Navigateur](https://img.shields.io/badge/Navigateur-475569?style=flat-square) : Recherche DuckDuckGo et retour à l’accueil vérifiés dans le profil de test.
### ![Android / APK](https://img.shields.io/badge/Android%20%2F%20APK-2563EB?style=flat-square)
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Ressources communes actualisées ; aucun nouvel APK distribué pour ces corrections.
### ![Sargo / Pixel 3a](https://img.shields.io/badge/Sargo%20%2F%20Pixel%203a-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Navigateur](https://img.shields.io/badge/Navigateur-475569?style=flat-square) : Les zones de geste latérales de Gaia ne masquent plus la page web sur le petit écran du Pixel.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Photo](https://img.shields.io/badge/Photo-475569?style=flat-square) : L’autorisation du micro s’affiche dans Gaia pour démarrer la vidéo.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Validation](https://img.shields.io/badge/Validation-475569?style=flat-square) : Clignotement disparu après redémarrage à froid ; recherche et adresse directe vérifiées visuellement sur le Pixel. Qualité audio des vidéos à écouter ; aucune nouvelle release publiée.
- ![Test](https://img.shields.io/badge/Test-75658A?style=flat-square) ![Navigation et vidéo](https://img.shields.io/badge/Navigation%20et%20vid%C3%A9o-475569?style=flat-square) : Recherche, clavier, retour Accueil et courte vidéo lisible avec miniature vérifiés sur le Pixel en RAM, sans débogueur. Boutons d’autorisation accessibles au-dessus de Home.
### ![Web](https://img.shields.io/badge/Web-2563EB?style=flat-square)
- ![Change](https://img.shields.io/badge/Change-64748B?style=flat-square) ![Pixel 3a](https://img.shields.io/badge/Pixel%203a-475569?style=flat-square) : Fiche de compatibilité FR/EN actualisée : installation autonome, Bluetooth, vidéo, navigateur et limites des fonctions encore en cours. Image publique toujours en préparation.
<a id="2.7-preview.21"></a>
## 14/10/2026 : 2.7-preview.21
@ -26,8 +286,10 @@ Les nouveautés, par date et par plateforme. Dans chaque groupe : Add, Change, F
### ![Sargo / Pixel 3a](https://img.shields.io/badge/Sargo%20%2F%20Pixel%203a-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Démarrage](https://img.shields.io/badge/D%C3%A9marrage-475569?style=flat-square) : Image de démarrage allégée ; écran noir au lancement corrigé sur Pixel 3a.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Les corrections communes de Gaia sont incluses dans les images complètes et les mises à jour de démarrage.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Nouvelle image d’installation construite ; démarrage et flash sur Pixel à vérifier avant publication.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Vérification](https://img.shields.io/badge/V%C3%A9rification-475569?style=flat-square) : Appels, audio, SMS/MMS, caméra, heure automatique et autonomie restent à valider sur cette installation.
- ![Test](https://img.shields.io/badge/Test-75658A?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Installation complète et redémarrages vérifiés sur Pixel 3a ; données personnelles restaurées. Image pas encore publiée.
<a id="2.7-preview.20"></a>

View file

@ -38,12 +38,13 @@ Je développe Vulpes principalement seul, sous le nom d’OverSu, avec l’aide
Sur Pixel 3a, **Tundra démarre directement Gecko et Gaia** et assure leur accès au matériel. Droidian a servi d’environnement de test au début du projet : il n’est pas inclus dans Vulpes OS et Tundra ne repose pas dessus.
### Preview 20 · 13 octobre 2026
### Preview 22 · 22 octobre 2026
[Télécharger l’APK](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.20). Les corrections communes concernent les paramètres Gaia, l’enregistrement vidéo, les contacts et les médias. Les adaptations réseau natives sont incluses dans les sources. Le [changelog](CHANGELOG.md) précise les vérifications et les limites restantes.
[Télécharger l’APK Preview 22](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.22). Les corrections Gaia concernent le partage du navigateur et la saisie web ; les contrôles PC ont réussi. La release comprend aussi les sources Tundra/Nokia actuelles. L’installation et la mise à jour ont réussi sur émulateur Android 11 ; les téléphones Android réels restent à qualifier. Le clavier T9 du Nokia demande aussi un essai physique.
PC/Sargo utilisent Gecko **156.0.1** ; Android utilise GeckoView **156.0**. Android 8 / API 26 minimum déclaré, sans validation de tous les appareils. Appels et SMS ouvrent les applications Android. L’APK est un build de développement signé, avec débogage via ADB autorisé.
L’image native du Pixel 3a reste la Preview 21. Cette release ne contient pas de nouvelle image Sargo et ses changements n’ont pas été flashés sur le Pixel. Le [changelog](CHANGELOG.md) détaille les plateformes et les points restant à vérifier.
PC/Sargo utilisent Gecko **156.0.1** ; Android utilise GeckoView **156.0**. Android 8 / API 26 minimum est déclaré. L’APK est un build de développement signé, avec débogage via ADB autorisé.
### Linux
Prérequis : Linux x86_64, Git, Python 3 et les bibliothèques système nécessaires à Firefox.

View file

@ -38,12 +38,13 @@ I’m OverSu, and I develop Vulpes mostly on my own, with help from people who t
On the Pixel 3a, **Tundra boots Gecko and Gaia directly** and provides access to the hardware. Droidian was used as a testing environment early in development. It is not included in Vulpes OS, and Tundra is not based on it.
### Preview 20 · 13 October 2026
### Preview 22 · 22 October 2026
[Download the APK](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.20). Shared updates cover Gaia settings, camera recording, contacts and media. Native network changes are included in the sources. See the [changelog](CHANGELOG.en.md) for checks and remaining limitations.
[Download the Preview 22 APK](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.22). Shared Gaia fixes cover browser sharing and web input; PC checks passed. The release also includes the current Tundra/Nokia sources. Android 11 emulator installation and upgrade passed; physical Android devices still need qualification. The Nokia T9 keyboard also needs physical qualification.
Desktop/Sargo use Gecko **156.0.1**; Android uses GeckoView **156.0**. Android 8 / API 26 minimum is declared; not every device is tested. Calls and SMS open Android applications. The APK is a signed development build, with debugging through authorized ADB.
The Pixel 3a native image remains Preview 21. This release contains no new Sargo image, and its changes have not been flashed to the Pixel. See the [changelog](CHANGELOG.en.md) for platform details and remaining issues.
Desktop/Sargo use Gecko **156.0.1**; Android uses GeckoView **156.0**. Android 8 / API 26 minimum is declared. The APK is a signed development build with debugging available through authorized ADB.
### Linux
You’ll need Linux x86_64, Git, Python 3 and the system libraries required by Firefox.

View file

@ -15,6 +15,11 @@ import org.mozilla.geckoview.*;
public final class MainActivity extends Activity {
void updateTrackingProtection(boolean enabled) {
if (gaia != null) gaia.getSettings().setUseTrackingProtection(enabled);
if (web != null) web.getSettings().setUseTrackingProtection(enabled);
}
private VulpesApplication app;
private GeckoSession gaia, web;
private GeckoView gaiaView, webView;
@ -130,6 +135,7 @@ public final class MainActivity extends Activity {
? app.gaiaSession
: new GeckoSession(
new GeckoSessionSettings.Builder()
.useTrackingProtection(app.trackingProtection)
.displayMode(GeckoSessionSettings.DISPLAY_MODE_STANDALONE)
.build()
);
@ -216,7 +222,8 @@ public final class MainActivity extends Activity {
return;
}
if (web == null) {
web = new GeckoSession();
web = new GeckoSession(new GeckoSessionSettings.Builder()
.useTrackingProtection(app.trackingProtection).build());
web.setContentDelegate(content);
web.setPromptDelegate(prompts);
web.setPermissionDelegate(permissions);

View file

@ -20,6 +20,7 @@ public final class VulpesApplication extends Application {
boolean pendingScreenLock;
boolean homeVisible;
boolean homeAtBottom;
boolean trackingProtection = true;
@Override
public void onCreate() {
@ -66,6 +67,8 @@ public final class VulpesApplication extends Application {
.locales(new String[] { "fr", "en-US" })
.automaticFontSizeAdjustment(false)
.fontInflation(false)
.contentBlocking(new ContentBlocking.Settings.Builder()
.antiTracking(ContentBlocking.AntiTracking.DEFAULT).build())
.build()
);
services = new AndroidServices(this);
@ -104,6 +107,12 @@ public final class VulpesApplication extends Application {
return GeckoResult.fromException(new SecurityException("EXTENSION_REQUIRED"));
}
JSONObject json = (JSONObject) message;
if (json.optString("type").equals("privacy.configure")) {
trackingProtection = json.optBoolean("enabled", true);
if (gaiaSession != null) gaiaSession.getSettings().setUseTrackingProtection(trackingProtection);
if (activity != null) activity.updateTrackingProtection(trackingProtection);
return GeckoResult.fromValue(null);
}
if (json.optString("type").equals("marketplace.return") && activity!=null) {activity.runOnUiThread(()->activity.closeBrowser());return GeckoResult.fromValue(null);}
if (json.optString("type").startsWith("packages.")) {
GeckoResult<Object> result=new GeckoResult<>();

View file

@ -3,7 +3,7 @@ import {Input} from './services/input.mjs';
import { Packages, packageURL } from './services/packages.mjs';
import { Activities, isGaiaActivity } from './services/activities.mjs';
import { authorizePlatform } from './services/platform.mjs';
import { SettingsService } from './services/settings.mjs';
import { SettingsService, migrateSearchDefaults, migratePrivacyDefaults } from './services/settings.mjs';
import { DataStoreService } from './services/datastores.mjs';
import { DataStoresIndexedDB } from './adapters/datastores-indexeddb.mjs';
import { Contacts } from './Contacts.mjs';
@ -35,7 +35,7 @@ function grants(app) {
function broadcast(type, data, target) {
for (const peer of peers) {
if (target && peer.app.id !== target) continue;
if (type === 'settingchange' && !['language.current', 'vulpes.theme'].includes(data.key) && !grants(peer.app).includes('settings.read')) continue;
if (type === 'settingchange' && !['language.current', 'vulpes.theme', 'vulpes.layout'].includes(data.key) && !grants(peer.app).includes('settings.read')) continue;
if (
type === 'contactchange' &&
!peer.app.manifest.permissions?.contacts?.access?.includes('read')
@ -80,6 +80,12 @@ const ready = (async () => {
const defaults = await (await fetch('defaults.json')).json();
const saved = await browser.storage.local.get('settings');
values = { ...defaults, ...saved.settings };
const privacyChanged = migratePrivacyDefaults(values);
if (migrateSearchDefaults(values) || privacyChanged) {
await browser.storage.local.set({settings:values});
}
await browser.runtime.sendNativeMessage('vulpes', {type:'privacy.configure',
enabled:values['privacy.trackingprotection.enabled'] === true});
if (!values['vulpes.gaia-restoration.v1']) {
values['lockscreen.enabled'] = true;
values['vulpes.gaia-restoration.v1'] = true;
@ -106,6 +112,8 @@ const ready = (async () => {
await browser.storage.local.set({ settings: next });
values = next;
for (const [key, value] of entries) {
if (key === 'privacy.trackingprotection.enabled')
await browser.runtime.sendNativeMessage('vulpes', {type:'privacy.configure',enabled:value === true});
broadcast('settingchange', { key, value });
if(key==='keyboard.gaia.enabled') {
input.hide();broadcast('input-mode',{enabled:value===true});
@ -333,6 +341,7 @@ async function handle(peer, request) {
}
if (method === 'input.config') return values['keyboard.gaia.enabled'] === true;
if (method === 'input.request') return input.request(peer,params);
if (method === 'layout.current') return {mode:values['vulpes.layout'] || 'auto', compactDefault:false};
if (method === 'appearance.current') return values['vulpes.theme'] || 'light';
if (method === 'locale.current') return values['language.current'] || 'en-US';
if (method === 'settings.snapshot') {
@ -541,7 +550,8 @@ let marketplaceDownload=null;
browser.runtime.onMessage.addListener(async (message,sender)=>{
if(!message.marketplaceDownload)return;
const origin=new URL(sender.url);
if(origin.origin!=='https://vulpes-os.org' || !origin.pathname.startsWith('/marketplace/'))throw Error('PERMISSION_DENIED');
if(!((origin.origin==='https://vulpes-os.org' && origin.pathname.startsWith('/marketplace/')) ||
(origin.origin==='https://ffapps.danielherr.software' && origin.pathname.startsWith('/app/'))))throw Error('PERMISSION_DENIED');
await ready;marketplaceDownload=packageURL(message.marketplaceDownload);
const app=apps.find(a=>a.id==='marketplace');launch(app);
await browser.runtime.sendNativeMessage('vulpes',{type:'marketplace.return'});

View file

@ -13,7 +13,8 @@
"nativeMessaging",
"geckoViewAddons",
"http://*.localhost/*",
"https://vulpes-os.org/*"
"https://vulpes-os.org/*",
"https://ffapps.danielherr.software/*"
],
"background": {
"page": "background.html",
@ -33,7 +34,8 @@
},
{
"matches": [
"https://vulpes-os.org/marketplace/*"
"https://vulpes-os.org/marketplace/*",
"https://ffapps.danielherr.software/app/*"
],
"js": [
"marketplace-page.js",

View file

@ -45,7 +45,7 @@ try:
const values={};window.nativeCalls=[];window.events=[];
window.browser={storage:{local:{get:async key=>({[key]:structuredClone(values[key])}),set:async data=>Object.assign(values,structuredClone(data))}},runtime:{
connectNative:()=>({onMessage:{addListener(fn){window.nativeEvent=fn;}},onDisconnect:{addListener(){}},postMessage(){}}),
sendNativeMessage:async(app,message)=>{nativeCalls.push(message);return message.type==='android.snapshot'?{}:null;},onConnect:{addListener(fn){window.connectPort=fn;}}
sendNativeMessage:async(app,message)=>{nativeCalls.push(message);if(message.type==='packages.list')return '[]';return message.type==='android.snapshot'?{}:null;},onConnect:{addListener(fn){window.connectPort=fn;}},onMessage:{addListener(){}}
}};
window.makePort=url=>{
let receive,seq=0;const waiting=new Map();

View file

@ -174,8 +174,8 @@
"phone.dtmf.type": "long",
"powersave.enabled": false,
"powersave.threshold": -1,
"privacy.donottrackheader.value": "-1",
"privacy.donottrackheader.enabled": false,
"privacy.donottrackheader.value": 1,
"privacy.donottrackheader.enabled": true,
"ril.telephony.defaultServiceId": 0,
"ril.voicemail.defaultServiceId": 0,
"ril.sms.defaultServiceId": 0,
@ -336,5 +336,7 @@
},
"notification.ringtone.default.id": "builtin:alerttone/notifier_firefox",
"notification.ringtone.id": "builtin:alerttone/notifier_firefox",
"homescreen.manifestURL": "app://homescreen.gaiamobile.org/manifest.webapp"
"homescreen.manifestURL": "app://homescreen.gaiamobile.org/manifest.webapp",
"privacy.trackingprotection.enabled": true,
"privacy.trackingprotection.shown": true
}

View file

@ -396,7 +396,7 @@ function onUssdReceived(evt){var lock=null;var safetyId;function releaseWakeLock
if(document.hidden){lock=navigator.requestWakeLock('high-priority');safetyId=setTimeout(releaseWakeLock,30000);document.addEventListener('visibilitychange',releaseWakeLock);}
if(!document.hidden||evt.session){MmiManager.handleMMIReceived(evt.message,evt.session,evt.serviceId);}else if(evt.message){MmiManager.sendNotification(evt.message,evt.serviceId).then(releaseWakeLock);}else{releaseWakeLock();}}
function init(){LazyLoader.load(['/shared/js/mobile_operator.js','/dialer/js/mmi.js','/dialer/js/mmi_ui.js','/shared/style/progress_activity.css','/dialer/style/mmi.css'],function(){if(window.navigator.mozSetMessageHandler){window.navigator.mozSetMessageHandler('telephony-call-ended',callEnded);window.navigator.mozSetMessageHandler('activity',handleActivity);window.navigator.mozSetMessageHandler('notification',handleNotification);window.navigator.mozSetMessageHandler('bluetooth-dialer-command',btCommandHandler);window.navigator.mozSetMessageHandler('ussd-received',onUssdReceived);}});LazyLoader.load('/shared/js/settings_listener.js',function(){SettingsListener.observe('engineering-mode.key',null,function(value){engineeringModeKey=value||null;});});}
return{init:init,call:call};})();window.onresize=function(e){if(window.innerHeight<440){NavbarManager.hide();}else{NavbarManager.show();}};;'use strict';var CallLog={_initialized:false,_headersInterval:null,_empty:true,_contactCache:false,init:function cl_init(){if(this._initialized){this.becameVisible();return;}
return{init:init,call:call};})();window.onresize=function(e){if(window.innerHeight<440&&document.documentElement.dataset.vulpesLayout!=='compact'){NavbarManager.hide();}else{NavbarManager.show();}};;'use strict';var CallLog={_initialized:false,_headersInterval:null,_empty:true,_contactCache:false,init:function cl_init(){if(this._initialized){this.becameVisible();return;}
window.performance.mark('callLogStart');this._initialized=true;var lazyFiles=['/shared/style/confirm.css','/shared/style/switches.css','/shared/style/lists.css','/shared/js/confirm.js','/shared/js/dialer/utils.js','/shared/js/sticky_header.js','/shared/js/sim_settings_helper.js','/shared/js/date_time_helper.js'];var self=this;var validContactsCachePromise=this._validateContactsCache();LazyLoader.load(lazyFiles,function resourcesLoaded(){var mainNodes=['all-filter','call-log-container','call-log-edit-mode','call-log-filter','call-log-icon-edit','call-log-view','deselect-all-threads','delete-button','edit-mode-header','header-edit-mode-text','missed-filter','select-all-threads','edit-mode'];mainNodes.forEach(function(id){this[Utils.toCamelCase(id)]=document.getElementById(id);},self);var dualSim=!!navigator.mozIccManager&&navigator.mozIccManager.iccIds.length>1;self.callLogContainer.classList.toggle('dual-sim',dualSim);validContactsCachePromise.then(function(){self.render();window.addEventListener('timeformatchange',self._updateCallTimes.bind(self));self.callLogIconEdit.addEventListener('click',self.showEditMode.bind(self));self.editModeHeader.addEventListener('action',self.hideEditMode.bind(self));self.missedFilter.addEventListener('click',self.filter.bind(self));self.allFilter.addEventListener('click',self.unfilter.bind(self));self.callLogContainer.addEventListener('click',self);self.callLogContainer.addEventListener('contextmenu',self);self.selectAllThreads.addEventListener('click',self.selectAll.bind(self));self.deselectAllThreads.addEventListener('click',self.deselectAll.bind(self));self.deleteButton.addEventListener('click',self.deleteLogGroups.bind(self));document.addEventListener('visibilitychange',function(){if(document.hidden){self.pauseHeaders();}else{self.updateHeadersContinuously();if(Navigation.currentView==='recents'){self.becameVisible();}}});self.sticky=new StickyHeader(self.callLogContainer,document.getElementById('sticky'));self.becameVisible();});});},_validateContactsCache:function cl_validateContactsCache(){var self=this;return new Promise(function(resolve,reject){window.asyncStorage.getItem('contactCacheRevision',function onItem(cacheRevision){Contacts.getRevision(function(contactsRevision){if(cacheRevision>contactsRevision){window.asyncStorage.setItem('contactCacheRevision',contactsRevision);self._contactCache=true;resolve();return;}
self._contactCache=(cacheRevision>=contactsRevision);if(self._contactCache){resolve();return;}
CallLogDBManager.invalidateContactsCache(function(error){if(!error){self._contactCache=true;resolve();}});});});});},_updateCallTimes:function cl_updateCallTimes(){var logItemElts=this.callLogContainer.querySelectorAll('.log-item');for(var i=0;i<logItemElts.length;i++){var logItemElt=logItemElts[i];var timestamp=logItemElt.getAttribute('data-timestamp');var formattedTime=Utils.prettyDate(parseInt(timestamp,10));var callTimeElt=logItemElt.querySelector('.call-time');callTimeElt.textContent=formattedTime;}},becameVisible:function cl_becameVisible(){this.updateHeaders();this.cleanNotifications();},updateHeaders:function cl_updateHeaders(){var headers=this.callLogContainer.getElementsByTagName('header');for(var i=0,l=headers.length;i<l;i++){var current=headers[i];Utils.setHeaderDate(current,parseInt(current.dataset.timestamp));}},updateHeadersContinuously:function cl_updateHeaders(){var updater=this.updateHeaders.bind(this);this._headersInterval=setInterval(updater,60000);},pauseHeaders:function cl_pauseHeaders(){clearInterval(this._headersInterval);},render:function cl_render(){var self=this;var daysToRender=[];var chunk=[];var prevDate;var screenRendered=false;var MAX_GROUPS_FOR_FIRST_RENDER=8;var MAX_GROUPS_TO_BATCH_RENDER=100;var batchGroupCounter=0;CallLogDBManager.getGroupList(function logGroupsRetrieved(cursor){if(!cursor.value){if(chunk.length===0){self.renderEmptyCallLog();self.disableEditModeButton();}else{daysToRender.push(chunk);self.renderSeveralDays(daysToRender);if(!screenRendered){window.performance.mark('firstChunkReady');}

View file

@ -859,7 +859,7 @@ icon.icon.then((blob)=>{var retryIndex=this.iconsToRetry.indexOf(id);if(retryInd
this.metadata.set([{id:id,icon:blob}]).then(()=>{},(e)=>{console.error('Error saving icon',e);});});}.bind(this,icon,id));icon.size=this.iconSize?this.iconSize:icon.size;if(entry!==-1){icon.icon=this.startupMetadata[entry].icon;this.startupMetadata.splice(entry,1);icon.updateName();if(window.VulpesCompat&&icon.app)icon.refresh();}else{icon.refresh();}
icon.addEventListener('activated',e=>{e.preventDefault();this.handleEvent({type:'activate',detail:{target:e.target.parentNode},preventDefault:()=>{}});});},refreshIcon:function(icon){icon.size=this.iconSize?this.iconSize:icon.size;if(icon.bookmark){IconsHelper.setElementIcon(icon,this.iconSize).then(()=>{},e=>{console.error('Error refreshing bookmark icon',e);if(!icon.isUserSet){icon.refresh();}});}else{icon.refresh();}},storeAppOrder:function(){var storedOrders=[];var children=this.icons.children;for(var i=0,iLen=children.length;i<iLen;i++){var appIcon=children[i].firstElementChild;var id=this.getIconId(appIcon.app?appIcon.app:appIcon.bookmark,appIcon.entryPoint);storedOrders.push({id:id,order:i});}
this.metadata.set(storedOrders).then(()=>{},(e)=>{console.error('Error storing app order',e);});},iconAdded:function(container){if(container.style.display==='none'){return;}
this.refreshGridSize();},refreshGridSize:function(){var children=this.icons.children;var cols=this.settings.small?4:3;var visibleChildren=0;var firstVisibleChild=-1;for(var i=0,iLen=children.length;i<iLen;i++){if(children[i].style.display!=='none'){visibleChildren++;if(firstVisibleChild===-1){firstVisibleChild=i;this.iconsLeft=this.icons.getChildOffsetRect(children[i]).left;}else if(visibleChildren===cols){this.iconsRight=this.icons.getChildOffsetRect(children[i]).right;}}}
this.refreshGridSize();},refreshGridSize:function(){var children=this.icons.children;var cols=document.documentElement.dataset.vulpesLayout==='compact'?3:(this.settings.small?4:3);var visibleChildren=0;var firstVisibleChild=-1;for(var i=0,iLen=children.length;i<iLen;i++){if(children[i].style.display!=='none'){visibleChildren++;if(firstVisibleChild===-1){firstVisibleChild=i;this.iconsLeft=this.icons.getChildOffsetRect(children[i]).left;}else if(visibleChildren===cols){this.iconsRight=this.icons.getChildOffsetRect(children[i]).right;}}}
if(visibleChildren<1){this.pendingGridHeight=this.gridHeight=0;this.pageHeight=this.scrollable.clientHeight;}else{var iconHeight=Math.round(children[firstVisibleChild].offsetHeight);var scrollHeight=this.scrollable.clientHeight;var rowsPerPage=Math.floor(scrollHeight/iconHeight);var pageHeight=rowsPerPage*iconHeight;var gridHeight;if(this.settings.scrollSnapping){gridHeight=(Math.ceil((iconHeight*Math.ceil(visibleChildren/cols))/pageHeight)*pageHeight)+(scrollHeight-pageHeight);}else{gridHeight=(Math.ceil(visibleChildren/cols)+1)*iconHeight;}
this.pageHeight=pageHeight;this.pendingGridHeight=gridHeight;this.iconsPerPage=rowsPerPage*cols;if(!this.visualLoadComplete&&Math.floor(visibleChildren/cols)*iconHeight>=scrollHeight){this.onVisualLoad();}}
this.scrollable.style.scrollSnapPointsY=`repeat(${this.pageHeight}px)`;this.icons.style.backgroundSize='100% '+(this.pageHeight*2)+'px';if(this.resizeTimeout!==null){clearTimeout(this.resizeTimeout);}

View file

@ -52,3 +52,18 @@ gaia-app-icon {
visibility: hidden;
height: 0;
}
/* Compact layouts can widen labels independently of the image. */
#subtitle {
width: var(--vulpes-label-width, auto);
margin-inline-start: var(--vulpes-label-offset, 0px);
}
#image-container > img {
display: var(--vulpes-icon-display, inline);
}
#title-container {
/* Visually hide compact labels without removing their accessible names. */
position: var(--vulpes-icon-title-position, static);
clip-path: var(--vulpes-icon-title-clip, none);
}

View file

@ -1,12 +1,351 @@
/**
* WifiUtils is a utils-box that keeps wifi-operations needed utils.
*
* @module WifiUtils
*/
define(function(require) {
'use strict';
define(['require','shared/wifi_helper'],function(require){'use strict';var WifiHelper=require('shared/wifi_helper');var wifiManager=WifiHelper.getWifiManager();var WifiUtils={newExplanationItem:function(message){var li=document.createElement('li');li.className='explanation';li.setAttribute('data-l10n-id',message);return li;},newListItem:function(options){var network=options.network;var showNotInRange=options.showNotInRange||false;var onClick=options.onClick||function(){};var icon=document.createElement('aside');icon.classList.add('pack-end');icon.classList.add('wifi-icon');var level=Math.min(Math.floor(network.relSignalStrength/20),4);icon.classList.add('level-'+level);var ssid=document.createElement('span');ssid.dir='auto';ssid.textContent=network.ssid;var small=document.createElement('small');var keys=WifiHelper.getSecurity(network);var networkNotInRange=(network.known&&level===0);var hasSecurity=(keys&&keys.length);if(hasSecurity){if(showNotInRange&&networkNotInRange){small.setAttribute('data-l10n-id','notInRange');}else{document.l10n.setAttributes(small,'securedBy',{capabilities:keys.join(', ')});}
icon.classList.add('secured');}else{if(showNotInRange&&networkNotInRange){small.setAttribute('data-l10n-id','notInRange');}else{small.setAttribute('data-l10n-id','securityOpen');}}
var a=document.createElement('a');a.appendChild(ssid);a.appendChild(small);var li=document.createElement('li');li.dataset.ssid=network.ssid;li.appendChild(icon);li.appendChild(a);icon.classList.add('wifi-signal');var networkStatus=WifiHelper.getNetworkStatus(network);var kActiveStatuses=['associated','connecting','connected'];if(-1!==kActiveStatuses.indexOf(networkStatus)){li.classList.add('active');if('connected'===networkStatus){icon.classList.add('connected');}else{icon.classList.add('connecting');}
small.setAttribute('data-l10n-id','short-status-'+networkStatus);}
li.onclick=function(){onClick(network);};return li;},changeDisplay:function(panel,security){var eap=panel.querySelector('li.eap select');var identity=panel.querySelector('input[name=identity]');var password=panel.querySelector('input[name=password]');var authPhase2=panel.querySelector('li.auth-phase2 select');var certificate=panel.querySelector('li.server-certificate select');var description=panel.querySelector('li.server-certificate-description');if(security==='WEP'||security==='WPA-PSK'){identity.parentNode.style.display='none';password.parentNode.style.display='block';authPhase2.parentNode.parentNode.style.display='none';certificate.parentNode.parentNode.style.display='none';description.style.display='none';}else if(security==='WPA-EAP'){if(eap){switch(eap.value){case'SIM':identity.parentNode.style.display='none';password.parentNode.style.display='none';authPhase2.parentNode.parentNode.style.display='none';certificate.parentNode.parentNode.style.display='none';description.style.display='none';break;case'PEAP':case'TLS':case'TTLS':identity.parentNode.style.display='block';password.parentNode.style.display='block';authPhase2.parentNode.parentNode.style.display='block';certificate.parentNode.parentNode.style.display='block';description.style.display='block';break;default:break;}}}else{identity.parentNode.style.display='none';password.parentNode.style.display='none';}},initializeAuthFields:function(panel,network){var ssid=panel.querySelector('input[name=ssid]');var identity=panel.querySelector('input[name=identity]');var password=panel.querySelector('input[name=password]');var showPassword=panel.querySelector('gaia-checkbox[name=show-pwd]');var eap=panel.querySelector('li.eap select');var certificate=panel.querySelector('li.server-certificate select');var submitButton=panel.querySelector('button[type=submit]');this.loadImportedCertificateOptions(certificate);identity.value=network.identity||'';password.type='password';password.value=network.password||'';showPassword.checked=false;showPassword.onchange=function(){password.type=this.checked?'text':'password';};var checkPassword=function(){var key=WifiHelper.getKeyManagement(network);var isDisabled=!WifiHelper.isValidInput(key,password.value,identity.value,eap.value);if(ssid){isDisabled=isDisabled||!WifiHelper.isSSIDValid(ssid.value);}
submitButton.disabled=isDisabled;};eap.onchange=function(){var key=WifiHelper.getKeyManagement(network);checkPassword();WifiUtils.changeDisplay(panel,key);};if(ssid){ssid.oninput=checkPassword;}
password.oninput=checkPassword;identity.oninput=checkPassword;checkPassword();},loadImportedCertificateOptions:function(select){if(!wifiManager.getImportedCerts){return;}
var certRequest=wifiManager.getImportedCerts();certRequest.onsuccess=function(){var i;var certList=certRequest.result;var certificateList=certList.ServerCert;var originLengthOfOptions=select.options.length;for(i=0;i<originLengthOfOptions-1;i++){select.remove(1);}
for(i=0;i<certificateList.length;i++){var option=document.createElement('option');option.text=certificateList[i];option.value=certificateList[i];select.add(option,null);}};certRequest.onerror=function(){console.warn('getImportedCerts failed');};},updateNetworkSignal:function(network,networkSignal){var li=document.querySelector('li[data-ssid="'+network.ssid+'"]');var icon=li.querySelector('aside');icon.className=icon.className.replace(/level-\w*/,'');var level=Math.min(Math.floor(networkSignal/20),4);icon.classList.add('level-'+level);},getNetworkKey:function(network){if(!network){return'';}else{var key=network.ssid+'+'+WifiHelper.getSecurity(network).join('+');return key;}},updateListItemStatus:function(options){options=options||{};var listItems=options.listItems;var activeItemDOM=options.activeItemDOM;var network=options.network;var networkStatus=options.networkStatus;if(!network||!networkStatus||!listItems){console.log('Please check passing options for updateListItemStatus');return;}
var key=this.getNetworkKey(network);var listItemDOM=listItems[key];if(activeItemDOM&&activeItemDOM!=listItemDOM){activeItemDOM.classList.remove('active');activeItemDOM.querySelector('small').setAttribute('data-l10n-id','short-status-disconnected');activeItemDOM.querySelector('aside').classList.remove('connecting');activeItemDOM.querySelector('aside').classList.remove('connected');}
if(listItemDOM){listItemDOM.classList.add('active');listItemDOM.querySelector('small').setAttribute('data-l10n-id','short-status-'+networkStatus);if(networkStatus==='connecting'||networkStatus==='associated'){listItemDOM.querySelector('aside').classList.remove('connected');listItemDOM.querySelector('aside').classList.add('connecting');}else if(networkStatus==='connected'){listItemDOM.querySelector('aside').classList.remove('connecting');listItemDOM.querySelector('aside').classList.add('connected');}}}};return WifiUtils;});
var WifiHelper = require('shared/wifi_helper');
var wifiManager = WifiHelper.getWifiManager();
var WifiUtils = {
/**
* Create an explanatory list item
*
* @memberOf WifiUtils
* @param {String} message
* @returns {HTMLLIElement}
*/
newExplanationItem: function(message) {
var li = document.createElement('li');
li.className = 'explanation';
li.setAttribute('data-l10n-id', message);
return li;
},
/**
* Create a network list item
*
* @memberOf WifiUtils
* @param {Object} options
* @returns {HTMLLIElement}
*/
newListItem: function(options) {
/**
* A Wi-Fi list item has the following HTML structure:
* <li>
* <aside class="pack-end wifi-icon level-[?] [secured]"></aside>
* <a>
* <span> Network SSID </span>
* <small> Network Security </small>
* </a>
* </li>
*/
var network = options.network;
var showNotInRange = options.showNotInRange || false;
var onClick = options.onClick || function() {};
// icon
var icon = document.createElement('aside');
icon.classList.add('pack-end');
icon.classList.add('wifi-icon');
var level = Math.min(Math.floor(network.relSignalStrength / 20), 4);
icon.classList.add('level-' + level);
// ssid
var ssid = document.createElement('span');
ssid.dir = 'auto';
ssid.textContent = network.ssid;
// supported authentication methods
var small = document.createElement('small');
var keys = WifiHelper.getSecurity(network);
var networkNotInRange = (network.known && level === 0);
var hasSecurity = (keys && keys.length);
if (hasSecurity) {
if (showNotInRange && networkNotInRange) {
small.setAttribute('data-l10n-id', 'notInRange');
} else {
document.l10n.setAttributes(small, 'securedBy',
{ capabilities: keys.join(', ') });
}
icon.classList.add('secured');
} else {
if (showNotInRange && networkNotInRange) {
small.setAttribute('data-l10n-id', 'notInRange');
} else {
small.setAttribute('data-l10n-id', 'securityOpen');
}
}
var a = document.createElement('a');
a.appendChild(ssid);
a.appendChild(small);
// create list item
var li = document.createElement('li');
li.dataset.ssid = network.ssid;
li.appendChild(icon);
li.appendChild(a);
// Show connection status
icon.classList.add('wifi-signal');
var networkStatus = WifiHelper.getNetworkStatus(network);
var kActiveStatuses = ['associated', 'connecting', 'connected'];
if (-1 !== kActiveStatuses.indexOf(networkStatus)) {
li.classList.add('active');
if ('connected' === networkStatus) {
icon.classList.add('connected');
} else {
icon.classList.add('connecting');
}
small.setAttribute('data-l10n-id', 'short-status-' + networkStatus);
}
// bind connection callback
li.onclick = function() {
onClick(network);
};
return li;
},
/**
* Change dialog layout based on dialogId and security
*
* @memberOf WifiUtils
* @param {HTMLElement} panel
* @param {String} security
*/
changeDisplay: function(panel, security) {
var eap = panel.querySelector('li.eap select');
var identity = panel.querySelector('input[name=identity]');
var password = panel.querySelector('input[name=password]');
var authPhase2 = panel.querySelector('li.auth-phase2 select');
var certificate = panel.querySelector('li.server-certificate select');
var description =
panel.querySelector('li.server-certificate-description');
if (security === 'WEP' || security === 'WPA-PSK') {
identity.parentNode.style.display = 'none';
password.parentNode.style.display = 'block';
authPhase2.parentNode.parentNode.style.display = 'none';
certificate.parentNode.parentNode.style.display = 'none';
description.style.display = 'none';
} else if (security === 'WPA-EAP') {
if (eap) {
switch (eap.value) {
case 'SIM':
identity.parentNode.style.display = 'none';
password.parentNode.style.display = 'none';
authPhase2.parentNode.parentNode.style.display = 'none';
certificate.parentNode.parentNode.style.display = 'none';
description.style.display = 'none';
break;
case 'PEAP':
case 'TLS':
case 'TTLS':
identity.parentNode.style.display = 'block';
password.parentNode.style.display = 'block';
authPhase2.parentNode.parentNode.style.display = 'block';
certificate.parentNode.parentNode.style.display = 'block';
description.style.display = 'block';
break;
default:
break;
}
}
} else {
identity.parentNode.style.display = 'none';
password.parentNode.style.display = 'none';
}
},
/**
* This is used to help us do some initialization works if the panel
* is auth related.
*
* @memberOf WifiUtils
* @param {String} panel
* @param {Object} network
*/
initializeAuthFields: function(panel, network) {
var ssid = panel.querySelector('input[name=ssid]');
var identity = panel.querySelector('input[name=identity]');
var password = panel.querySelector('input[name=password]');
var showPassword = panel.querySelector('gaia-checkbox[name=show-pwd]');
var eap = panel.querySelector('li.eap select');
var certificate = panel.querySelector('li.server-certificate select');
var submitButton = panel.querySelector('button[type=submit]');
// load needed certificates first
this.loadImportedCertificateOptions(certificate);
identity.value = network.identity || '';
password.type = 'password';
password.value = network.password || '';
showPassword.checked = false;
showPassword.onchange = function() {
password.type = this.checked ? 'text' : 'password';
};
var checkPassword = function() {
var key = WifiHelper.getKeyManagement(network);
var isDisabled = !WifiHelper.isValidInput(
key, password.value, identity.value, eap.value);
if (ssid) {
isDisabled = isDisabled || !WifiHelper.isSSIDValid(ssid.value);
}
submitButton.disabled = isDisabled;
};
eap.onchange = function() {
var key = WifiHelper.getKeyManagement(network);
checkPassword();
WifiUtils.changeDisplay(panel, key);
};
if (ssid) {
ssid.oninput = checkPassword;
}
password.oninput = checkPassword;
identity.oninput = checkPassword;
checkPassword();
},
/**
* This is an inner function that used to inject certificates options
* into select element.
*
* @memberOf WifiUtils
* @param {HTMLSelectElement} select
*/
loadImportedCertificateOptions: function(select) {
if (!wifiManager.getImportedCerts) {
return;
}
var certRequest = wifiManager.getImportedCerts();
certRequest.onsuccess = function() {
var i;
var certList = certRequest.result;
// save the imported server certificates
var certificateList = certList.ServerCert;
// reset the option to be <option value="none">--</option> only
var originLengthOfOptions = select.options.length;
for (i = 0; i < originLengthOfOptions - 1; i++) {
select.remove(1);
}
for (i = 0; i < certificateList.length; i++) {
var option = document.createElement('option');
option.text = certificateList[i];
option.value = certificateList[i];
select.add(option, null);
}
};
certRequest.onerror = function() {
console.warn('getImportedCerts failed');
};
},
/**
* Updates the icon of the given network
*
* @memberOf WifiUtils
* @param {Object} network
* @param {Integer} networkSignal
*/
updateNetworkSignal: function(network, networkSignal) {
if (!network) { return; }
var items = document.querySelectorAll('li[data-ssid]');
var li = Array.prototype.find.call(items, function(item) {
return item.dataset.ssid === network.ssid;
});
// Signal updates can arrive before the scan has populated the list.
var icon = li && li.querySelector('aside');
if (!icon) { return; }
// Clean previous state
icon.className = icon.className.replace(/level-\w*/, '');
var level = Math.min(Math.floor(networkSignal / 20), 4);
icon.classList.add('level-' + level);
},
/**
* Get concated networkKey which can be used as identifier
*
* @memberOf WifiUtils
* @param {Object} network
* @return {String} concated network identifier
*/
getNetworkKey: function(network) {
if (!network) {
return '';
} else {
var key =
network.ssid + '+' + WifiHelper.getSecurity(network).join('+');
return key;
}
},
/**
* Reflect incoming network status on related listItem (show different UI)
*
* @memberOf WifiUtils
* @param {Object} options
* @param {Object} options.listItems - listItems with DOM elements
* @param {Object} options.activeItemDOM - DOM element for active item
* @param {Object} options.network - network object
* @param {Object} options.networkStatus - current status for network
*/
updateListItemStatus: function(options) {
options = options || {};
var listItems = options.listItems;
var activeItemDOM = options.activeItemDOM;
var network = options.network;
var networkStatus = options.networkStatus;
if (!network || !networkStatus || !listItems) {
console.log('Please check passing options for updateListItemStatus');
return;
}
var key = this.getNetworkKey(network);
var listItemDOM = listItems[key];
if (activeItemDOM && activeItemDOM != listItemDOM) {
activeItemDOM.classList.remove('active');
activeItemDOM.querySelector('small').
setAttribute('data-l10n-id', 'short-status-disconnected');
activeItemDOM.querySelector('aside').classList.remove('connecting');
activeItemDOM.querySelector('aside').classList.remove('connected');
}
if (listItemDOM) {
listItemDOM.classList.add('active');
listItemDOM.querySelector('small').
setAttribute('data-l10n-id', 'short-status-' + networkStatus);
if (networkStatus === 'connecting' || networkStatus === 'associated') {
listItemDOM.querySelector('aside').classList.remove('connected');
listItemDOM.querySelector('aside').classList.add('connecting');
} else if (networkStatus === 'connected') {
listItemDOM.querySelector('aside').classList.remove('connecting');
listItemDOM.querySelector('aside').classList.add('connected');
}
}
}
};
return WifiUtils;
});

View file

@ -1191,6 +1191,18 @@
ModalDialog.selectOne(title, options, selected);
};
AppChrome.prototype.onShare = function ac_onShare() {
this.hideOverflowMenu();
var url = this._currentURL;
if (!url || !/^https?:\/\//i.test(url)) {
return;
}
return new MozActivity({
name: 'share',
data: { type: 'url', url: url }
});
};
AppChrome.prototype.showWindows = function ac_showWindows() {
window.dispatchEvent(
new CustomEvent('taskmanagershow',

File diff suppressed because one or more lines are too long

View file

@ -429,7 +429,8 @@ var CallHandler = (function callHandler() {
// Listening to the keyboard being shown
// Waiting for issue 787444 being fixed
window.onresize = function(e) {
if (window.innerHeight < 440) {
if (window.innerHeight < 440 &&
document.documentElement.dataset.vulpesLayout !== 'compact') {
NavbarManager.hide();
} else {
NavbarManager.show();

View file

@ -600,7 +600,8 @@
refreshGridSize: function() {
var children = this.icons.children;
var cols = this.settings.small ? 4 : 3;
var cols = document.documentElement.dataset.vulpesLayout === 'compact' ?
3 : (this.settings.small ? 4 : 3);
var visibleChildren = 0;
var firstVisibleChild = -1;

View file

@ -268,8 +268,14 @@ define(function(require) {
* @param {Integer} networkSignal
*/
updateNetworkSignal: function(network, networkSignal) {
var li = document.querySelector('li[data-ssid="' + network.ssid + '"]');
var icon = li.querySelector('aside');
if (!network) { return; }
var items = document.querySelectorAll('li[data-ssid]');
var li = Array.prototype.find.call(items, function(item) {
return item.dataset.ssid === network.ssid;
});
// Signal updates can arrive before the scan has populated the list.
var icon = li && li.querySelector('aside');
if (!icon) { return; }
// Clean previous state
icon.className = icon.className.replace(/level-\w*/, '');
var level = Math.min(Math.floor(networkSignal / 20), 4);

View file

@ -1191,6 +1191,18 @@
ModalDialog.selectOne(title, options, selected);
};
AppChrome.prototype.onShare = function ac_onShare() {
this.hideOverflowMenu();
var url = this._currentURL;
if (!url || !/^https?:\/\//i.test(url)) {
return;
}
return new MozActivity({
name: 'share',
data: { type: 'url', url: url }
});
};
AppChrome.prototype.showWindows = function ac_showWindows() {
window.dispatchEvent(
new CustomEvent('taskmanagershow',

View file

@ -441,6 +441,11 @@
*/
focus: function() {
if (this.active) {
// The keyboard may have been dismissed while this input kept focus.
// Refocusing must also notify the host input-method adapter.
if (window.VulpesNativeInput && document.activeElement === this.input) {
this.input.blur();
}
this.input.focus();
}
},

View file

@ -52,3 +52,18 @@ gaia-app-icon {
visibility: hidden;
height: 0;
}
/* Compact layouts can widen labels independently of the image. */
#subtitle {
width: var(--vulpes-label-width, auto);
margin-inline-start: var(--vulpes-label-offset, 0px);
}
#image-container > img {
display: var(--vulpes-icon-display, inline);
}
#title-container {
/* Visually hide compact labels without removing their accessible names. */
position: var(--vulpes-icon-title-position, static);
clip-path: var(--vulpes-icon-title-clip, none);
}

View file

@ -61,3 +61,59 @@
font-style: italic;
font-display: swap;
}
/* Native language names missing from Fira Sans. Subsets retain shaping tables. */
@font-face {
font-family: FiraSans;
src: url("languages/VulpesLanguageArabic-Regular.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0628,U+0631,U+0639,U+064A;
}
@font-face {
font-family: FiraSans;
src: url("languages/VulpesLanguageBengali-Regular.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0982,U+09A6,U+09AC,U+09B2,U+09B6,U+09BE,U+09C7;
}
@font-face {
font-family: FiraSans;
src: url("languages/VulpesLanguageDevanagari-Regular.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0928,U+092A,U+0932,U+093E,U+0940,U+0947;
}
@font-face {
font-family: FiraSans;
src: url("languages/VulpesLanguageGurmukhi-Regular.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0A1C,U+0A2A,U+0A2C,U+0A3E,U+0A40,U+0A70;
}
@font-face {
font-family: FiraSans;
src: url("languages/VulpesLanguageKhmer-Regular.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+1781,U+1798,U+179A,U+17C2,U+17D2;
}
@font-face {
font-family: FiraSans;
src: url("languages/VulpesLanguageCJK-Regular.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+4E2D,U+4F53,U+6587,U+65E5,U+672C,U+6B63,U+7B80,U+7E41,U+8A9E,U+9AD4,U+AD6D,U+C5B4,U+D55C;
}

View file

@ -0,0 +1,92 @@
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,121 @@
[
{
"file": "VulpesLanguageArabic-Regular.woff2",
"source": "NotoSansArabic-Regular.ttf",
"sourceSha256": "bdff3e5659d67e67def05b33f749683b9376ae819d65d3dd62ac4640b3aaef48",
"attribution": {
"0": "Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic)",
"5": "Version 2.013; ttfautohint (v1.8.4.16-eb64)",
"13": "This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is available with a FAQ at: https://openfontlicense.org",
"14": "https://openfontlicense.org"
},
"unicode": [
1576,
1585,
1593,
1610
]
},
{
"file": "VulpesLanguageBengali-Regular.woff2",
"source": "NotoSansBengali-Regular.ttf",
"sourceSha256": "b55c62ee531e3214da6c0701daecea89a52ba42db7d8206b92e6b51f397a3193",
"attribution": {
"0": "Copyright 2025 The Noto Project Authors (https://github.com/notofonts/bengali)",
"5": "Version 3.011; ttfautohint (v1.8.4.16-eb64)",
"13": "This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is available with a FAQ at: https://openfontlicense.org",
"14": "https://openfontlicense.org/"
},
"unicode": [
2434,
2470,
2476,
2482,
2486,
2494,
2503
]
},
{
"file": "VulpesLanguageDevanagari-Regular.woff2",
"source": "NotoSansDevanagari-Regular.ttf",
"sourceSha256": "4e3c66638958c3e2ab5d37f47a8deb89fffeb7be9985c665a519bbc7ba762313",
"attribution": {
"0": "Copyright 2022 The Noto Project Authors (https://github.com/notofonts/devanagari)",
"5": "Version 2.007; ttfautohint (v1.8.4.16-eb64)",
"13": "This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is available with a FAQ at: https://scripts.sil.org/OFL",
"14": "https://scripts.sil.org/OFL"
},
"unicode": [
2344,
2346,
2354,
2366,
2368,
2375
]
},
{
"file": "VulpesLanguageGurmukhi-Regular.woff2",
"source": "NotoSansGurmukhi-Regular.ttf",
"sourceSha256": "658d0207da305a1411c539a8b0bbeda64d4146e54fb4827facddb890b6b90d74",
"attribution": {
"0": "Copyright 2022 The Noto Project Authors (https://github.com/notofonts/gurmukhi)",
"5": "Version 2.004; ttfautohint (v1.8.4.7-5d5b)",
"13": "This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is available with a FAQ at: https://scripts.sil.org/OFL",
"14": "https://scripts.sil.org/OFL"
},
"unicode": [
2588,
2602,
2604,
2622,
2624,
2672
]
},
{
"file": "VulpesLanguageKhmer-Regular.woff2",
"source": "NotoSansKhmer-Regular.ttf",
"sourceSha256": "e66675f2082788f0511a714bef5a1748928294b38c8e286a96ea73a864b5e605",
"attribution": {
"0": "Copyright 2022 The Noto Project Authors (https://github.com/notofonts/khmer)",
"5": "Version 2.004; ttfautohint (v1.8.4.7-5d5b)",
"13": "This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is available with a FAQ at: https://scripts.sil.org/OFL",
"14": "https://scripts.sil.org/OFL"
},
"unicode": [
6017,
6040,
6042,
6082,
6098
]
},
{
"file": "VulpesLanguageCJK-Regular.woff2",
"source": "NotoSansCJK-Regular.ttc",
"sourceSha256": "b76b0433203017ca80401b2ee0dd69350349871c4b19d504c34dbdd80541690a",
"attribution": {
"0": "© 2014-2021 Adobe (http://www.adobe.com/).",
"5": "Version 2.004;hotconv 1.0.118;makeotfexe 2.5.65603",
"13": "This Font Software is licensed under the SIL Open Font License, Version 1.1. This Font Software is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the SIL Open Font License for the specific language, permissions and limitations governing your use of this Font Software.",
"14": "http://scripts.sil.org/OFL"
},
"unicode": [
20013,
20307,
25991,
26085,
26412,
27491,
31616,
32321,
35486,
39636,
44397,
50612,
54620
]
}
]

View file

@ -94,6 +94,20 @@ function getImageSize(blob, callback, error) {
else {
metadata.type = 'jpeg';
}
// Modern Gecko decodes EXIF orientation itself, including
// canvas.drawImage(). Gaia must not rotate those pixels twice.
if (metadata.rotation || metadata.mirrored) {
if (metadata.rotation === 90 || metadata.rotation === 270) {
var width = metadata.width;
metadata.width = metadata.height;
metadata.height = width;
}
metadata.rotation = 0;
metadata.mirrored = false;
// Embedded thumbnails need not carry the parent's EXIF tag.
// Generate a matching preview from the oriented full image.
delete metadata.preview;
}
callback(metadata);
},
error);

View file

@ -1,69 +1,73 @@
{
"search_providers": {
"yahoo": {
"title": "Yahoo",
"search": {
"url": "https://search.yahoo.com/yhs/search",
"params": {"p": "{searchTerms}"}
"params": {
"p": "{searchTerms}"
}
},
"suggest": {
"url": "https://search.yahoo.com/sugg/ff",
"params": {"command": "{searchTerms}", "output": "fxjson"}
"params": {
"command": "{searchTerms}",
"output": "fxjson"
}
}
},
"ddg": {
"title": "DuckDuckGo",
"search": {
"url": "https://duckduckgo.com/",
"params": {"t": "ffos", "q": "{searchTerms}"}
"params": {
"q": "{searchTerms}"
}
},
"suggest": {
"url": "https://ac.duckduckgo.com/ac/",
"params": {"q": "{searchTerms}", "type": "list"}
"params": {
"q": "{searchTerms}",
"type": "list"
}
}
},
"google": {
"title": "Google",
"search": {
"url": "https://www.google.com/search",
"params": {"q": "{searchTerms}"}
"params": {
"q": "{searchTerms}"
}
},
"suggest": {
"url": "https://www.google.com/complete/search",
"params": {"q": "{searchTerms}", "client": "firefox"}
"params": {
"q": "{searchTerms}",
"client": "firefox"
}
}
},
"bing": {
"title": "Bing",
"search": {
"url": "https://www.bing.com/search",
"params": {"q": "{searchTerms}"}
"params": {
"q": "{searchTerms}"
}
},
"suggest": {
"url": "https://www.bing.com/osjson.aspx",
"params": {"query": "{searchTerms}"}
"params": {
"query": "{searchTerms}"
}
}
}
},
"simConfigs": {
"310-000": {"en-US": {"defaultEngine": "yahoo"}},
"311-000": {"en-US": {"defaultEngine": "yahoo"}},
"312-000": {"en-US": {"defaultEngine": "yahoo"}},
"313-000": {"en-US": {"defaultEngine": "yahoo"}},
"316-000": {"en-US": {"defaultEngine": "yahoo"}}
},
"partnerConfig": {
},
"simConfigs": {},
"partnerConfig": {},
"defaultEngines": {
"defaultEngine": "google",
"defaultEngine": "ddg",
"providers": {
"yahoo": {},
"google": {},
@ -71,7 +75,5 @@
"ddg": {}
}
},
"locales": {
}
"locales": {}
}

View file

@ -0,0 +1,49 @@
// Explicit diagnostic: inspect real navigation and its rendered surfaces.
export async function auditBrowserVisual({win,evaluate,sleep,root}) {
const {Views}=ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs');
const result={at:Date.now(),samples:[]};
const capture=async(context,name,rect=null)=>{
const image=await context.currentWindowGlobal.drawSnapshot(rect,1,'white');
const c=win.document.createElementNS('http://www.w3.org/1999/xhtml','canvas');
c.width=image.width;c.height=image.height;c.getContext('2d').drawImage(image,0,0);image.close();
const blob=await new Promise(r=>c.toBlob(r,'image/png'));
await IOUtils.write(`${root}/logs/${name}.png`,new Uint8Array(await blob.arrayBuffer()));
};
try {
win.focus();win.document.getElementById('system').focus();
for(let n=0;n<40;n++) {
if(await evaluate('system','return !!window.core?.appCore?.rocketbar;')) break;
await sleep(500);
}
await evaluate('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
await sleep(1200);
await evaluate('system','const r=core.appCore.rocketbar;await r.activate();r.focus();return true;');
let keyboard=false;
for(let n=0;n<20;n++) {
keyboard=await evaluate('system','return !!document.getElementById("vulpes-gaia-keyboard")?.offsetHeight;');
if(keyboard) break;
await sleep(250);
}
if(!keyboard) throw Error('Keyboard did not open');
await sleep(1800);
await evaluate('system','const r=core.appCore.rocketbar;r.setInput("Vulpes OS");r.handleInput();return true;');
await sleep(700);
await evaluate('keyboard','navigator.mozInputMethod.inputcontext.sendKey(13,0).catch(console.error);return true;');
await sleep(8000);
for(let i=0;i<3;i++) {
await sleep(3000);
const sample={system:await evaluate('system','const r=core.appCore.rocketbar;return {active:Service.query("getActiveApp")?.origin,bar:r.active,input:r.input.value,port:!!r._port,keyboardHidden:document.getElementById("vulpes-gaia-keyboard")?.hidden,search:r.searchWindow?.iframe?.src};'),views:[]};
for(const [id,e] of Views.entries) {
const b=e.browser;
const v={id,url:b.currentURI.spec,loading:b.webProgress.isLoadingDocument,style:b.style.cssText,rect:b.getBoundingClientRect().toJSON(),contextId:b.browsingContext.id,geometry:e.geometry,active:b.docShellIsActive,renderLayers:b.renderLayers};
try {v.page=await b.browsingContext.currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate','return {url:location.href,ready:document.readyState,text:document.body?.innerText.slice(0,1200),width:window.innerWidth,height:window.innerHeight,visibility:document.visibilityState};');await capture(b.browsingContext,`browser-page-${i}`);}catch(error){v.error=String(error);}
sample.views.push(v);
}
await capture(win.browsingContext,`browser-screen-${i}`,new win.DOMRect(0,0,win.innerWidth,win.innerHeight));
result.samples.push(sample);
await IOUtils.writeJSON(`${root}/logs/browser-visual.json`,result);
}
const last=result.samples.at(-1);result.passed=!!last.system.keyboardHidden && last.views.some(v=>v.style.includes('visibility: visible') && v.page?.text?.length>0 && v.page.width>0 && v.url.startsWith('https://duckduckgo.com/'));
}catch(error){result.error=String(error);}
await IOUtils.writeJSON(`${root}/logs/browser-visual.json`,result);
}

View file

@ -0,0 +1,69 @@
// Opt-in hardware regression checks; never run during a normal boot.
export async function auditMediaFeedback({win, evaluate, wait, tap, sleep, save}) {
const {Views}=ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs');
const report={passed:false,checks:{},errors:{}};
const assert=(value,message)=>{if(!value)throw Error(message);};
const system=code=>evaluate('system','const rocketbar=core.appCore.rocketbar;'+code);
const principal=Services.scriptSecurityManager.createContentPrincipal(Services.io.newURI('http://camera.localhost:8765'),{});
const previous=Services.perms.testExactPermissionFromPrincipal(principal,'microphone');
try {
win.focus();win.document.getElementById("system").focus();
await system('ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
await system('await rocketbar.activate();rocketbar.focus();return true;');
await wait('system','return document.querySelector("#vulpes-gaia-keyboard")?.getBoundingClientRect().height>0;');
await system('await VulpesCompat.call("input.request",{operation:"hide"});return true;');
await wait('system','return document.querySelector("#vulpes-gaia-keyboard").hidden;');
await system('rocketbar.focus();return true;');
report.checks.refocusKeyboard=!!await wait('system','return !document.querySelector("#vulpes-gaia-keyboard").hidden;');
await system('rocketbar.handleCancel();await VulpesCompat.call("input.request",{operation:"hide"});return true;');
await wait('system','return !core.appCore.rocketbar.active && !core.appCore.rocketbar.isClosing;');
await sleep(500);
await system('dispatchEvent(new CustomEvent("activity-view",{detail:{source:{data:{type:"url",url:"https://duckduckgo.com/?q=vulpes"}}}}));return true;');
await sleep(10000);
report.views=[...Views.entries.values()].map(e=>({url:e.browser.currentURI.spec,visible:e.browser.style.visibility,rect:e.browser.getBoundingClientRect().toJSON()}));
assert(report.views.some(v=>v.visible==='visible' && v.rect.height>0 && v.url.startsWith('https://duckduckgo.com/')),'DuckDuckGo view hidden');
report.checks.searchVisible=true;
const point=await system('const r=Service.query("getActiveApp").appChrome.title.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};');
await evaluate('system',point,'Tap');
report.checks.addressFirstTap=!!await wait('system','return !document.querySelector("#vulpes-gaia-keyboard").hidden && document.activeElement===core.appCore.rocketbar.input;');
win.document.getElementById('home').click();
await wait('system','return !core.appCore.rocketbar.active && !core.appCore.rocketbar.isClosing;');
win.document.getElementById('home').click();
await wait('system','return ["http://homescreen.localhost:8765","http://verticalhome.localhost:8765"].includes(Service.query("getActiveApp")?.origin);');
await sleep(1000);
assert([...Views.entries.values()].every(e=>e.browser.style.visibility==='hidden'),'External view covers Home');
report.checks.homeReturns=true;
} catch(error){report.errors.browser=String(error);}
await save(report);
try {
Services.perms.removeFromPrincipal(principal,'microphone');
await system('await VulpesCompat.call("apps.launch",{manifestURL:"http://camera.localhost:8765/manifest.webapp"});return true;');
await wait('camera','return !!window.app?.camera?.mozCamera && !app.camera.isBusy;');
await evaluate('camera','window.__lastVideo=null;app.once("newmedia",v=>{window.__lastVideo=v;});app.settings.mode.select("video");return true;');
await wait('camera','return app.camera.mode==="video" && !app.camera.isBusy;');
await sleep(500);
await tap('camera','.js-capture');
await wait('system','return document.getElementById("permission-screen").classList.contains("visible");',20000);
report.permission=await system('const p=document.getElementById("permission-screen"),r=document.getElementById("permission-yes").getBoundingClientRect();return {type:p.dataset.type,button:r.toJSON(),height:innerHeight};');
const bounds=win.document.getElementById('system').getBoundingClientRect();
assert(report.permission.button.bottom<=bounds.height+1,'Permission button extends under the Home bar');
await tap('system','#permission-yes');
report.checks.gaiaPermission=true;
await wait('camera','return app.camera.get("recording")==="started";',20000);
await sleep(3000);
await tap('camera','.js-capture');
await wait('camera','return !!window.__lastVideo?.blob;',30000);
report.video=await evaluate('camera',`const v=__lastVideo,el=document.createElement('video');el.muted=true;el.src=URL.createObjectURL(v.blob);try{await Promise.race([new Promise((ok,no)=>{el.onloadeddata=ok;el.onerror=()=>no(Error('Decode failed'));}),new Promise((_,no)=>setTimeout(()=>no(Error('Decode timeout')),10000))]);return {native:!!app.camera.mozCamera.vulpesNative,bytes:v.blob.size,type:v.blob.type,width:el.videoWidth,height:el.videoHeight,poster:v.poster.blob.size};}finally{URL.revokeObjectURL(el.src);el.remove();}`);
assert(report.video.bytes>0 && report.video.width>0 && report.video.poster>0,'Invalid recording');
report.checks.videoDecoded=true;
} catch(error){report.errors.video=String(error);}
finally {
await system('if(document.getElementById("permission-screen").classList.contains("visible"))document.getElementById("permission-no").click();return true;').catch(()=>{});
await evaluate('camera','app.camera.stopRecording();await app.camera.mozCamera?.vulpesFinishRecording?.();if(window.__lastVideo){await navigator.getDeviceStorage("videos").delete(__lastVideo.filepath);await navigator.getDeviceStorage("pictures").delete(__lastVideo.poster.filepath);}app.settings.mode.select("picture");return true;').catch(()=>{});
if(previous===Ci.nsIPermissionManager.UNKNOWN_ACTION)Services.perms.removeFromPrincipal(principal,'microphone');
else Services.perms.addFromPrincipal(principal,'microphone',previous);
win.document.getElementById('home').click();
}
report.passed=Object.keys(report.errors).length===0;
await save(report);return report;
}

View file

@ -53,7 +53,7 @@ export class NativeCamera {
}
if (!this.session || this.session.actor !== actor || this.session.id !== params.session)
throw Error('CAMERA_NOT_OPEN');
if (!['frame','focus','zoom','picture'].includes(params.action)) throw Error('CAMERA_INVALID_COMMAND');
if (!['frame','focus','zoom','picture','flash'].includes(params.action)) throw Error('CAMERA_INVALID_COMMAND');
let controlRejected = false;
try {
if (params.action === 'frame') {

View file

@ -85,7 +85,7 @@ export class Platform {
if (Services.env.get('VULPES_TUNDRA_RADIO') === '1') {
const poll = async () => {
try { await this.radio('calls'); } catch (_) {}
setTimeout(poll, 500);
setTimeout(poll, Services.env.get('VULPES_DEVICE') === 'beatles' ? 1000 : 500);
};
poll();
const inbox = async()=>{
@ -103,7 +103,7 @@ export class Platform {
}
} catch(error) {console.error('SMS inbox:',error.message);}
try {
const rows=await tundraMms('incoming');
const rows=Services.env.get('VULPES_DEVICE') === 'beatles' ? [] : await tundraMms('incoming');
const app=this.runtime.apps.find(a=>a.id==='sms');
for(const row of rows) {
const message=await this.runtime.messages.receive(receivedMms(row));
@ -124,7 +124,13 @@ export class Platform {
if (operation === 'ackCallHistory') this.journal.acknowledge(params.ids);
else {
const result = await tundraRadio(operation, params);
if (operation === 'dial') this.journal.start({path:result.path,number:params.number,state:'dialing'}, Date.now(), 'outgoing');
if (operation === 'dial') {
const call = {path:result.path,number:params.number,state:'dialing',direction:'outgoing'};
this.journal.start(call, Date.now(), 'outgoing');
// The modem has confirmed this call; do not serve the pre-dial cache.
this.lastCalls = [...(this.lastCalls || []).filter(c=>c.path!==result.path),call];
this.lastCallsAt = Date.now();
}
if (operation === 'calls') {
const previous = new Set(this.journal.completed.map(c=>c.id));
this.journal.update(result); this.lastCalls = result; this.lastCallsAt = Date.now();
@ -175,16 +181,21 @@ export class Platform {
}
const principal = global.documentPrincipal;
const video = request.videoInputDevices?.[0], audio = request.audioInputDevices?.[0];
for (const [device, kind, message] of [
[video, 'camera', 'Autoriser Photo à utiliser la caméra ? / Allow Camera to use your camera?'],
[audio, 'microphone', 'Autoriser Photo à enregistrer le son ? / Allow Camera to record audio?']
for (const [device, kind] of [
[video, 'camera'],
[audio, 'microphone']
]) {
if (!device) continue;
let permission = Services.perms.testExactPermissionFromPrincipal(principal, kind);
if (permission === Ci.nsIPermissionManager.UNKNOWN_ACTION) {
permission = Services.prompt.confirm(browser.ownerDocument.defaultView, 'Vulpes — Photo', message)
? Ci.nsIPermissionManager.ALLOW_ACTION : Ci.nsIPermissionManager.DENY_ACTION;
Services.perms.addFromPrincipal(principal, kind, permission);
let decision;
try {
decision = await browser.browsingContext.currentWindowGlobal.getActor('Vulpes').sendQuery('Vulpes:MediaPermission', {
id:Services.uuid.generateUUID().toString(), origin:app.origin, manifestURL:app.manifestURL, kind,
});
} catch (_) { deny(); return; }
permission = decision?.allowed ? Ci.nsIPermissionManager.ALLOW_ACTION : Ci.nsIPermissionManager.DENY_ACTION;
if (decision?.remember) Services.perms.addFromPrincipal(principal, kind, permission);
}
if (permission !== Ci.nsIPermissionManager.ALLOW_ACTION) { deny(); return; }
}
@ -230,6 +241,24 @@ export class Platform {
if (['settings', 'capture', 'alarms', 'notification'].includes(operation))
throw Error('NOT_SUPPORTED');
}
if (Services.env.get('VULPES_TUNDRA_STATUS') === '1') {
if (operation === 'wifi') return tundraWifi(params);
const radio = Services.env.get('VULPES_TUNDRA_RADIO') === '1';
if (radio && operation === 'radioPower') return tundraRadio('radioPower',params);
if (radio && operation === 'calls' && Date.now()-this.lastCallsAt<1500) return this.lastCalls;
if (radio && ['calls','dial','callHistory','ackCallHistory'].includes(operation)) return this.radio(operation,params);
if (radio && ['hangup','answer','sms'].includes(operation)) return tundraRadio(operation,params);
if (radio && operation === 'callAudio') return tundraCallAudio(params);
if (operation === 'snapshot') return tundraSnapshot();
if (operation === 'capabilities') return {
platform:'tundra', platformName:'Tundra', model:'Nokia 2720 Flip',
adapter:{version:'0.1.0-dev',device:'beatles'}, tundra:'0.1.0-dev',
version:this.runtime.release.version, gaia:this.runtime.release.gaia,
gecko:Services.appinfo.platformVersion, hardwareStatus:true,
radioStatus:true, wifiControl:Services.env.get('VULPES_TUNDRA_WIFI') === '1', camera:false, media:true, contacts:true,
notifications:true, dial:radio, callControl:radio, sms:radio, mms:false, callAudio:radio, alarms:false,
};
}
if (operation === 'capabilities')
return {
platform: 'desktop',

View file

@ -4,7 +4,7 @@ import {Input} from 'resource://vulpes/host/Input.sys.mjs';
import { ScreenPower } from 'resource://vulpes/services/screen-power.mjs';
import { Packages } from 'resource://vulpes/services/packages.mjs';
import { Activities, isGaiaActivity } from 'resource://vulpes/services/activities.mjs';
import { SettingsService } from 'resource://vulpes/services/settings.mjs';
import { SettingsService, migrateSearchDefaults, migratePrivacyDefaults } from 'resource://vulpes/services/settings.mjs';
import { DataStoreService } from 'resource://vulpes/services/datastores.mjs';
import { DataStoresIndexedDB } from 'resource://vulpes/adapters/datastores-indexeddb.mjs';
import { Views } from 'resource://vulpes/host/Views.sys.mjs';
@ -16,6 +16,7 @@ import { Platform } from 'resource://vulpes/host/Platform.sys.mjs';
import { Media } from 'resource://vulpes/host/Media.sys.mjs';
import { tundraCallAudio, tundraHardware, tundraRadio, tundraMms } from 'resource://vulpes/host/Tundra.sys.mjs';
Cu.importGlobalProperties(['indexedDB']);
const beatlesHardware = () => Services.env.get('VULPES_DEVICE') === 'beatles' && Services.env.get('VULPES_TUNDRA_RADIO') === '1';
// This is the only privileged owner of application identity and settings data.
// Application messages are never allowed to supply their own permissions.
@ -46,6 +47,7 @@ export const Runtime = {
this.idleListeners.delete(actor);
},
async init() {
this.hardwareKeyboard = Services.env.get('VULPES_KEYPAD') === '1';
this.input = new Input(this);
const root = Services.env.get('VULPES_WORKSPACE_ROOT');
this.apps = await IOUtils.readJSON(PathUtils.join(root, 'assets', 'registry.json'));
@ -106,6 +108,12 @@ export const Runtime = {
'deviceinfo.platform_version': Services.appinfo.platformVersion,
'deviceinfo.platform_build_id': Services.appinfo.platformBuildID,
};
const privacyChanged = migratePrivacyDefaults(this.values);
if (migrateSearchDefaults(this.values) || privacyChanged) {
await IOUtils.writeJSON(path, this.values, {tmpPath:path+'.tmp'});
}
for (const pref of ['privacy.trackingprotection.enabled', 'privacy.donottrackheader.enabled'])
Services.prefs.setBoolPref(pref, this.values[pref] === true);
if (!saved['vulpes.gaia-restoration.v1']) {
this.values['lockscreen.enabled'] = true;
this.values['vulpes.gaia-restoration.v1'] = true;
@ -117,6 +125,15 @@ export const Runtime = {
notify:(actor,type,data)=>actor.sendAsyncMessage('Vulpes:Event',{type,data}),
launch:(target,path,{reload=false,entryPoint}={})=>this.broadcast(reload?'activity-launch':'launch',{manifestURL:target.manifestURL,origin:target.origin,
entryPoint,url:new URL(path || target.manifest.launch_path || '/index.html',target.origin).href,timestamp:Date.now()},'system')});
if (beatlesHardware()) {
try {
const volumes = await tundraRadio('volumeState');
for (const [stream,level] of Object.entries(volumes)) {
if (!['content','notification','alarm','telephony','bt_sco'].includes(stream) || !Number.isFinite(level) || level<0 || level>1) continue;
this.values['audio.volume.'+stream]=Math.round(level*(['telephony','bt_sco'].includes(stream)?5:15));
}
} catch (error) { console.warn('Nokia volume state:',error.message); }
}
this.writeQueue = Promise.resolve();
this.settings = new SettingsService({
get: async (key) => this.values[key],
@ -130,6 +147,7 @@ export const Runtime = {
'accessibility.monoaudio.enable':'accessibility.monoaudio.enable',
'geolocation.enabled':'geo.enabled',
'privacy.donottrackheader.enabled':'privacy.donottrackheader.enabled',
'privacy.trackingprotection.enabled':'privacy.trackingprotection.enabled',
'debug.paint-flashing.enabled':'nglayout.debug.paint_flashing',
'apz.overscroll.enabled':'apz.overscroll.enabled'
};
@ -140,12 +158,14 @@ export const Runtime = {
}
if (entries.some(([key]) => key === 'language.current'))
Services.prefs.setStringPref('intl.accept_languages', values['language.current']);
if (Services.env.get('VULPES_TUNDRA') === '1') {
if (Services.env.get('VULPES_TUNDRA') === '1' || beatlesHardware()) {
const volume = entries.find(([key, value]) => /^audio\.volume\.(content|notification|alarm|telephony|bt_sco)$/.test(key) && Number.isFinite(value));
if (volume) {
const maximum = /\.(telephony|bt_sco)$/.test(volume[0]) ? 5 : 15;
const level = Math.max(0,Math.min(1,volume[1]/maximum));
const control = maximum === 5 ? tundraCallAudio({operation:'set',volume:level}) : tundraHardware({operation:'volume',level});
const control = beatlesHardware()
? tundraRadio('volume',{stream:volume[0].split('.').pop(),level})
: maximum === 5 ? tundraCallAudio({operation:'set',volume:level}) : tundraHardware({operation:'volume',level});
await control.catch(error => console.error(error.message));
}
}
@ -171,7 +191,7 @@ export const Runtime = {
: null,
(type, message) => this.broadcast('sms-' + type, message, 'sms'),
path => tundraRadio('messageState', {path}),
Services.env.get('VULPES_TUNDRA_RADIO') === '1' ? {validate:mmsPayload,
Services.env.get('VULPES_TUNDRA_RADIO') === '1' && Services.env.get('VULPES_DEVICE') !== 'beatles' ? {validate:mmsPayload,
send:message=>tundraMms('send',mmsPayload(message)), status:path=>tundraMms('state',{path})} : null,
);
this.media = new Media((data) => this.broadcast('mediachange', data));
@ -185,6 +205,14 @@ export const Runtime = {
'deviceinfo.hardware': 'Tundra / Sargo',
'deviceinfo.firmware_revision': 'Android 9 / API 28 (vendor)',
});
} else if (Services.env.get('VULPES_DEVICE') === 'beatles') {
Object.assign(this.values, {
'deviceinfo.product_model': 'Nokia 2720 Flip',
'deviceinfo.product_device': 'beatles',
'deviceinfo.product_manufacturer': 'Nokia',
'deviceinfo.hardware': 'ARMv7 — prototype framebuffer',
'deviceinfo.firmware_revision': 'KaiOS 2.5.2.2 — session temporaire',
});
}
const alarmPath = PathUtils.join(PathUtils.profileDir, 'vulpes-alarms-v1.json');
this.alarms = new Alarms(
@ -227,6 +255,16 @@ export const Runtime = {
return null;
return this.apps.find((app) => app.origin === origin) || null;
},
inputIdentity(actor) {
const app = this.identity(actor);
if (app) return app;
const context = actor.browsingContext.top;
for (const entry of Views.entries.values()) {
if (entry.browser.browsingContext === context && entry.browser.isConnected &&
entry.browser.style.visibility === 'visible') return this.identity(entry.actor);
}
return null;
},
grants(app) {
const access = app.manifest.permissions?.settings?.access || '';
return [
@ -254,11 +292,15 @@ export const Runtime = {
return {error:{code:'METHOD_NOT_SUPPORTED'}};
}
if(method==='hardware.vibrate') {
if(app.id!=='system' || Services.env.get('VULPES_TUNDRA')!=='1') return {error:{code:'NOT_SUPPORTED'}};
if(app.id!=='system' || (Services.env.get('VULPES_TUNDRA')!=='1' && !beatlesHardware())) return {error:{code:'NOT_SUPPORTED'}};
const pattern=params?.pattern;
if(!Array.isArray(pattern) || pattern.length>32 || pattern.some(n=>!Number.isInteger(n) || n<0 || n>10000) || pattern.reduce((a,b)=>a+b,0)>10000)
return {error:{code:'INVALID_VALUE'}};
try {await tundraHardware({operation:'vibrate',pattern});return {result:true};}
try {
if (beatlesHardware()) await tundraRadio('hardware',{settings:{operation:'vibrate',pattern}});
else await tundraHardware({operation:'vibrate',pattern});
return {result:true};
}
catch(error) {return {error:{code:error.message}};}
}
if(method==='input.request') {
@ -388,6 +430,12 @@ export const Runtime = {
return {result:await this.downloads.request(app,params)};
} catch(error) {return {error:{code:error.message || 'DOWNLOAD_ERROR'}};}
}
if (method === 'layout.current') return { result: {
mode: this.values['vulpes.layout'] || 'auto',
compactDefault: Services.env.get('VULPES_KEYPAD') === '1',
coverScreen: Services.env.get('VULPES_DEVICE') === 'beatles',
reducedMotion: Services.env.get('VULPES_DEVICE') === 'beatles',
} };
if (method === 'appearance.current') return { result: this.values['vulpes.theme'] || 'light' };
if (method === 'locale.current') return { result: this.values['language.current'] || 'en-US' };
if (method === 'settings.snapshot')
@ -466,13 +514,15 @@ export const Runtime = {
)
return { error: { code: 'INVALID_VALUE' } };
this.screenPower ||= new ScreenPower(this.power, async (next, changed) => {
if (Services.env.get('VULPES_TUNDRA') === '1' && ['screenEnabled','screenBrightness'].includes(changed))
if (beatlesHardware() && ['screenEnabled','screenBrightness'].includes(changed))
await tundraRadio('hardware',{settings:{operation:'screen',enabled:next.screenEnabled,level:next.screenBrightness}});
else if (Services.env.get('VULPES_TUNDRA') === '1' && ['screenEnabled','screenBrightness'].includes(changed))
await tundraHardware({operation:'screen', enabled:next.screenEnabled, level:next.screenBrightness});
});
await this.screenPower.set(name, value);
const screen =
actor.browsingContext.top.embedderElement.ownerDocument.getElementById('screen');
screen.style.filter = Services.env.get('VULPES_TUNDRA') === '1' ? '' : `brightness(${this.power.screenEnabled ? this.power.screenBrightness : 0})`;
screen.style.filter = (Services.env.get('VULPES_TUNDRA') === '1' || beatlesHardware()) ? '' : `brightness(${this.power.screenEnabled ? this.power.screenBrightness : 0})`;
screen.inert = !this.power.screenEnabled;
return { result: { ...this.power } };
}
@ -552,7 +602,7 @@ export const Runtime = {
try {
const app = this.identity(actor);
if (!app || (target && app.id !== target)) continue;
if (type === 'settingchange' && !['language.current', 'vulpes.theme'].includes(data.key) && !this.grants(app).includes('settings.read')) continue;
if (type === 'settingchange' && !['language.current', 'vulpes.theme', 'vulpes.layout'].includes(data.key) && !this.grants(app).includes('settings.read')) continue;
if (
type === 'mediachange' &&
!app.manifest.permissions?.['device-storage:' + data.type]?.access?.includes('read')

View file

@ -0,0 +1,75 @@
// Explicit, private regression marker only. Captures stay in memory and are discarded.
export async function auditTickets({win,evaluate,wait,tap,sleep,root}) {
const report={recordedAt:new Date().toISOString(),results:{}};
const step=async(name,run)=>{
try{report.results[name]={value:await run()};}
catch(error){report.results[name]={error:String(error),stack:error.stack};}
await IOUtils.writeJSON(root+'/logs/ticket-regressions.json',report);
};
const ev=(app,code)=>evaluate(app,code);
const launch=async app=>{
await ev('system',`await VulpesCompat.call('apps.launch',{manifestURL:'http://${app}.localhost:8765/manifest.webapp'});return true;`);
await wait(app,"return document.readyState==='complete';",15000);
await sleep(600);
};
win.focus();win.document.getElementById('system').focus();
await step('unlock',()=>ev('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;'));
await step('cameraOpen',async()=>{
await launch('camera');
await wait('camera','return !!app.camera.mozCamera && !app.camera.isBusy;',15000);
return ev('camera','const c=app.camera.mozCamera;return {mode:app.camera.mode,flash:c.capabilities.flashModes,photo:c.getPictureSize(),frames:c.vulpesTiming};');
});
await step('cameraFlash',()=>ev('camera',`const c=app.camera.mozCamera;
if(!c.capabilities.flashModes.includes('torch'))return {supported:false};
try{return await c.vulpesCameraControl({action:'flash',mode:'torch'});}
finally{await new Promise(r=>setTimeout(r,200));await c.vulpesCameraControl({action:'flash',mode:'off'});}`));
await step('cameraPicture',()=>ev('camera',`const b=await app.camera.mozCamera.takePicture(),i=await createImageBitmap(b);
const r={width:i.width,height:i.height,bytes:b.size};i.close();return r;`));
for(const lens of ['front','back']) await step('cameraSwitch-'+lens,async()=>{
await ev('camera',`window.__ticketOldCamera=app.camera.mozCamera;if(app.settings.cameras.selected('key')!==${JSON.stringify(lens)})app.settings.cameras.next();else window.__ticketOldCamera=null;return true;`);
await wait('camera','return !!app.camera.mozCamera && app.camera.mozCamera!==window.__ticketOldCamera && !app.camera.isBusy;',15000);
return ev('camera',`const c=app.camera.mozCamera, before=c.vulpesTiming.frames;
await new Promise(r=>setTimeout(r,1000));return {selected:app.settings.cameras.selected('key'),frames:c.vulpesTiming.frames-before,flash:c.capabilities.flashModes};`);
});
const principal=Services.scriptSecurityManager.createContentPrincipal(Services.io.newURI('http://camera.localhost:8765'),{});
const previous=Services.perms.testExactPermissionFromPrincipal(principal,'microphone');
await step('cameraVideo',async()=>{
try{
Services.perms.addFromPrincipal(principal,'microphone',Ci.nsIPermissionManager.ALLOW_ACTION,Ci.nsIPermissionManager.EXPIRE_SESSION);
await ev('camera','window.__ticketVideo=null;app.once("newmedia",v=>{window.__ticketVideo=v;});app.settings.mode.select("video");return true;');
await wait('camera','return app.camera.mode==="video" && !!app.camera.mozCamera && !app.camera.isBusy;',15000);
await tap('camera','.js-capture',10000);
await wait('camera','return app.camera.get("recording")==="started";',15000);
await sleep(2200);await tap('camera','.js-capture',10000);
await wait('camera','return !!window.__ticketVideo?.blob;',20000);
return await ev('camera',`const v=__ticketVideo,el=document.createElement('video');el.muted=true;el.src=URL.createObjectURL(v.blob);
try{await Promise.race([new Promise((ok,no)=>{el.onloadeddata=ok;el.onerror=()=>no(Error('Decode failed'));}),new Promise((_,no)=>setTimeout(()=>no(Error('Decode timeout')),7000))]);
return {bytes:v.blob.size,type:v.blob.type,width:el.videoWidth,height:el.videoHeight,poster:v.poster.blob.size};}
catch(error){return {decodeError:String(error),mediaError:el.error?.code,bytes:v.blob.size,type:v.blob.type,header:Array.from(new Uint8Array(await v.blob.slice(0,32).arrayBuffer())),poster:v.poster?.blob?.size};}
finally{URL.revokeObjectURL(el.src);el.remove();}`);
}finally{
await ev('camera','app.camera.stopRecording();await app.camera.mozCamera?.vulpesFinishRecording?.();if(window.__ticketVideo){await navigator.getDeviceStorage("videos").delete(__ticketVideo.filepath);await navigator.getDeviceStorage("pictures").delete(__ticketVideo.poster.filepath);}app.settings.mode.select("picture");return true;').catch(()=>{});
if(previous===Ci.nsIPermissionManager.UNKNOWN_ACTION)Services.perms.removeFromPrincipal(principal,'microphone');
else Services.perms.addFromPrincipal(principal,'microphone',previous);
}
});
await step('emailCaret',async()=>{
await launch('email');
await tap('email','input:not([type=hidden])',10000);
return ev('email',`const e=document.activeElement;return {field:e.localName,focus:document.hasFocus(),caret:getComputedStyle(e).caretColor};`);
});
await step('galleryShare',async()=>{
await launch('gallery');
await wait('gallery','return typeof share==="function";',10000);
try {
await ev('gallery',`const c=document.createElement('canvas');c.width=80;c.height=80;
c.getContext('2d').fillRect(0,0,80,80);
share([await new Promise(r=>c.toBlob(r,'image/png'))],'vulpes-share-test.png');return true;`);
await wait('system','return document.querySelector("#action-menu-form")?.classList.contains("visible");',10000);
return await ev('system',`return [...document.querySelectorAll('#action-menu button[data-manifest]')].map(e=>e.dataset.manifest);`);
} finally {
await ev('system','document.querySelector("#action-menu button[data-action=cancel]")?.click();return true;');
}
});
await step('home',()=>ev('system','dispatchEvent(new CustomEvent("home"));return true;'));
}

View file

@ -1,3 +1,15 @@
import {radioState} from '../services/radio-state.mjs';
const RADIO_ERRORS = ['RADIO_NOT_REGISTERED', 'RADIO_BUSY', 'RADIO_NOT_READY', 'RADIO_TIMEOUT',
'RADIO_REQUEST_FAILED', 'RADIO_READBACK_FAILED', 'INVALID_REQUEST', 'APN_IN_USE', 'APN_NOT_FOUND', 'APN_WRITE_FAILED', 'APN_ROLLBACK_FAILED', 'CLOCK_REQUEST_FAILED', 'CLOCK_UNAVAILABLE', 'INVALID_NUMBER', 'INVALID_SMS', 'SMS_TOO_LONG', 'INVALID_CALL', 'NOT_SUPPORTED', 'CALL_AUDIO_UNAVAILABLE', 'HARDWARE_READBACK_FAILED'];
const socketControl = () => Services.env?.get('VULPES_SOCKET_CONTROL') === '1';
async function localControl(kind, params) {
const {unixControl}=ChromeUtils.importESModule('resource://vulpes/host/UnixControl.sys.mjs');
const reply=await unixControl(kind,params);
if(!reply || reply.error || reply.ok===false || !Object.hasOwn(reply,'result') ||
(['radio','hardware','mms'].includes(kind) && reply.ok!==true))
throw Error(kind==='radio' ? (RADIO_ERRORS.includes(reply?.error) ? reply.error : 'RADIO_REQUEST_FAILED') : reply?.error || 'HARDWARE_REQUEST_FAILED');
return reply.result;
}
// Native state is published by Tundra, never supplied by an application.
export async function tundraSnapshot() {
try {
@ -16,11 +28,12 @@ export async function tundraSnapshot() {
? battery
: null,
wifi: value.wifi,
radio: radioState(value.radio),
recordedAt: value.recordedAt,
available: true,
};
} catch (_) {
return { battery: null, wifi: { available: false, state: 'unavailable' }, available: false };
return { battery: null, radio: [], wifi: { available: false, state: 'unavailable' }, available: false };
}
}
@ -51,14 +64,15 @@ export function tundraCapabilities(release) {
export async function tundraRadio(operation, params = {}) {
if (Services.env.get('VULPES_TUNDRA_RADIO') !== '1') throw Error('RADIO_UNAVAILABLE');
if (!['deviceSettings', 'mobileSettings', 'status', 'calls', 'dial', 'hangup', 'answer', 'sms', 'incomingMessages', 'ackMessages', 'messageState'].includes(operation)) throw Error('RADIO_INVALID_REQUEST');
if (!['radioPower', 'hardware', 'volumeState', 'volume', 'deviceSettings', 'mobileSettings', 'status', 'calls', 'dial', 'hangup', 'answer', 'sms', 'incomingMessages', 'ackMessages', 'messageState', 'callAudio'].includes(operation)) throw Error('RADIO_INVALID_REQUEST');
if(socketControl())return localControl('radio',{...params,version:1,operation});
const { Subprocess } = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process = await Subprocess.call({
command: '/usr/bin/python3',
arguments: ['/etc/tundra/radio-control.py', '--client-envelope'],
command: Services.env.get('VULPES_DEVICE') === 'beatles' ? '/work/control-client' : '/usr/bin/python3',
arguments: Services.env.get('VULPES_DEVICE') === 'beatles' ? ['--radio'] : ['/etc/tundra/radio-control.py', '--client-envelope'],
stderr: 'ignore',
});
await process.stdin.write(JSON.stringify({ version: 1, operation, ...params }));
await process.stdin.write(JSON.stringify({ ...params, version: 1, operation }));
await process.stdin.close();
let output = '';
for (;;) {
@ -79,9 +93,8 @@ export async function tundraRadio(operation, params = {}) {
throw Error('RADIO_INVALID_REPLY');
}
if (reply?.ok === false) {
const errors = ['RADIO_NOT_REGISTERED', 'RADIO_BUSY', 'RADIO_NOT_READY', 'RADIO_TIMEOUT',
'RADIO_REQUEST_FAILED', 'RADIO_READBACK_FAILED', 'INVALID_REQUEST', 'APN_IN_USE', 'APN_NOT_FOUND', 'APN_WRITE_FAILED', 'APN_ROLLBACK_FAILED', 'CLOCK_REQUEST_FAILED', 'CLOCK_UNAVAILABLE', 'INVALID_NUMBER', 'INVALID_SMS', 'INVALID_CALL'];
throw Error(errors.includes(reply.error) ? reply.error : 'RADIO_REQUEST_FAILED');
throw Error(RADIO_ERRORS.includes(reply.error) ? reply.error : 'RADIO_REQUEST_FAILED');
}
if (reply?.ok !== true) throw Error('RADIO_INVALID_REPLY');
return reply.result;
@ -117,10 +130,11 @@ export async function tundraWifi(params) {
}
async function wifiCommand(params) {
if(socketControl())return localControl('wifi',params);
const { Subprocess } = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process = await Subprocess.call({
command: '/usr/bin/python3',
arguments: ['/etc/tundra/wifi-control.py', '--client'],
command: Services.env.get('VULPES_DEVICE') === 'beatles' ? '/work/control-client' : '/usr/bin/python3',
arguments: Services.env.get('VULPES_DEVICE') === 'beatles' ? ['--wifi'] : ['/etc/tundra/wifi-control.py', '--client'],
stderr: 'ignore',
});
// Passwords are never command-line arguments, preferences or log messages.
@ -144,6 +158,7 @@ async function wifiCommand(params) {
}
export async function tundraHardware(params) {
if(socketControl())return localControl('hardware',params);
if (Services.env.get('VULPES_TUNDRA') !== '1') return;
const { Subprocess } = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process = await Subprocess.call({command:'/usr/bin/python3',
@ -156,6 +171,8 @@ export async function tundraHardware(params) {
}
export async function tundraCallAudio(params) {
if(socketControl())return localControl('audio',params);
if (Services.env?.get('VULPES_DEVICE') === 'beatles') return tundraRadio('callAudio',{settings:params});
const {Subprocess} = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process = await Subprocess.call({command:'/usr/bin/python3',
arguments:['/etc/tundra/call-audio.py','--client'],stderr:'ignore'});
@ -183,6 +200,7 @@ export async function tundraCallAudio(params) {
export async function tundraMms(operation, params={}) {
if(Services.env.get('VULPES_TUNDRA_RADIO')!=='1') throw Error('NotSupportedError');
if(!['send','state','incoming','ack'].includes(operation)) throw Error('INVALID_REQUEST');
if(socketControl())return localControl('mms',{...params,version:1,operation});
const {Subprocess}=ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process=await Subprocess.call({command:'/usr/bin/python3',arguments:['/etc/tundra/mms-control.py','--client'],stderr:'ignore'});
await process.stdin.write(JSON.stringify({version:1,operation,...params}));await process.stdin.close();
@ -194,6 +212,7 @@ export async function tundraMms(operation, params={}) {
// A persistent daemon owns BlueZ discovery and pending pairing requests.
export async function tundraBluetooth(params) {
if (Services.env.get('VULPES_TUNDRA') !== '1') throw Error('NotSupportedError');
if(socketControl())return localControl('bluetooth',{...params,version:1});
const {Subprocess}=ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process=await Subprocess.call({command:'/usr/bin/python3',
arguments:['/etc/tundra/bluetooth-control.py','--client'],stderr:'ignore'});

64
host/UnixControl.sys.mjs Normal file
View file

@ -0,0 +1,64 @@
// Fixed local brokers keep device permissions in Tundra. Gecko needs no child
// Python interpreter for each idle status poll.
import {setTimeout, clearTimeout} from 'resource://gre/modules/Timer.sys.mjs';
const routes = {
radio:['/run/tundra-radio/radio.sock',65536,120000],
wifi:['/run/tundra-wifi/control.sock',65536,120000],
bluetooth:['/run/tundra-bluetooth/control.sock',65536,120000],
hardware:['/run/tundra-buttons/control.sock',4096,5000],
audio:['/run/tundra-call-audio/control.sock',4096,10000],
mms:['/run/tundra-mms/mms.sock',1228800,120000],
};
export function unixControl(kind, params) {
if (!Object.hasOwn(routes,kind)) throw Error('INVALID_BROKER');
const [path,limit,timeout]=routes[kind];
return exchange(path,params,limit,timeout);
}
export function exchange(path, params, limit=65536, timeout=20000) {
const bytes=new TextEncoder().encode(JSON.stringify(params)+'\n');
if (bytes.length>1228800) return Promise.reject(Error('REQUEST_TOO_LARGE'));
return new Promise((resolve,reject)=>{
let transport,input,output,reader,timer,finished=false,received='',offset=0;
const finish=(error,value)=>{
if(finished)return;finished=true;clearTimeout(timer);
try{input?.close();}catch(_){}try{output?.close();}catch(_){}
try{transport?.close(Cr.NS_BINDING_ABORTED);}catch(_){}
if(error)reject(error);else resolve(value);
};
timer=setTimeout(()=>finish(Error('BROKER_TIMEOUT')),timeout);
try {
const file=Cc['@mozilla.org/file/local;1'].createInstance(Ci.nsIFile);file.initWithPath(path);
transport=Cc['@mozilla.org/network/socket-transport-service;1']
.getService(Ci.nsISocketTransportService).createUnixDomainTransport(file);
input=transport.openInputStream(0,0,0).QueryInterface(Ci.nsIAsyncInputStream);
output=transport.openOutputStream(0,0,0).QueryInterface(Ci.nsIAsyncOutputStream);
reader=Cc['@mozilla.org/scriptableinputstream;1'].createInstance(Ci.nsIScriptableInputStream);
reader.init(input);
const read={onInputStreamReady(){
if(finished)return;
try{
const count=input.available();
if(!count)throw Error('BROKER_CLOSED');
if(received.length+count>limit)throw Error('BROKER_REPLY_TOO_LARGE');
received+=reader.readBytes(count);
const end=received.indexOf('\n');
if(end>=0){
const data=Uint8Array.from(received.slice(0,end),c=>c.charCodeAt(0));
finish(null,JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(data)));
}else input.asyncWait(read,0,0,Services.tm.currentThread);
}catch(error){finish(error);}
}};
const write={onOutputStreamReady(){
if(finished)return;
try{
const chunk=String.fromCharCode(...bytes.subarray(offset,offset+8192));
offset+=output.write(chunk,chunk.length);
if(offset<bytes.length)output.asyncWait(write,0,0,Services.tm.currentThread);
}catch(error){finish(error);}
}};
input.asyncWait(read,0,0,Services.tm.currentThread);
output.asyncWait(write,0,0,Services.tm.currentThread);
}catch(error){finish(error);}
});
}

View file

@ -2,6 +2,21 @@
// real top-level browser, never by removing CSP or frame-ancestors protection.
export const Views = {
entries: new Map(),
syncContext(entry) {
const context=entry.browser.browsingContext;
if (!context || context.id===entry.topContextId) return;
// Site isolation may replace the top-level context on navigation.
const contexts=Services.ppmm.sharedData.get('vulpes:host-contexts') || [];
Services.ppmm.sharedData.set('vulpes:host-contexts',
[...new Set(contexts.filter(id=>id!==entry.topContextId).concat(context.id))]);
Services.ppmm.sharedData.flush();
entry.topContextId=context.id;
if (!Services.ppmm.sharedData.get('vulpes:native-touch')) {
context.inRDMPane=true;
context.setRDMPaneMaxTouchPoints(1);
context.touchEventsOverride='enabled';
}
},
remove(id, entry) {
const contextId = entry.topContextId;
try {
@ -44,9 +59,13 @@ export const Views = {
Object.assign(entry.browser.style,{left:(left-x)+'px',top:(top-y)+'px',
width:Math.max(0,right-left)+'px',height:Math.max(0,bottom-top)+'px',
visibility:visible && right>left && bottom>top?'visible':'hidden'});
// Remote Wayland surfaces need the same layout flush as the Gaia browser.
entry.browser.getBoundingClientRect();
entry.browser.frameLoader?.requestUpdatePosition?.();
},
prune() {
for (const [id, entry] of this.entries) {
this.syncContext(entry);
if (!BrowsingContext.get(id)?.parent) {
this.remove(id, entry);
continue;
@ -149,6 +168,7 @@ export const Views = {
]),
onLocationChange(progress, request, location) {
if (!progress.isTopLevel) return;
if (entry) Views.syncContext(entry);
if (location.spec === 'about:blank') return; // Initial, uncommitted context.
emit('locationchange', {
url: location.spec,

View file

@ -0,0 +1,294 @@
import {nextKeypadTarget} from 'resource://vulpes/services/keypad-navigation.mjs';
import {KeypadText} from 'resource://vulpes/services/keypad-text.mjs';
import {InputEditor} from 'resource://vulpes/services/input-editor.mjs';
export class KeypadChild extends JSWindowActorChild {
receiveMessage(message) {
if (message.name === 'Vulpes:FocusBrowserToolbar') {
if (this.document.location.hostname !== 'system.localhost') return;
const bar = [...this.document.querySelectorAll('.chrome .urlbar')].find(element => {
if (!element.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return false;
const rect = element.getBoundingClientRect();
return rect.width > 0 && element.contains(this.document.elementFromPoint(
rect.x + rect.width / 2, rect.y + rect.height / 2));
});
if (bar) this.select(bar);
return;
}
if (message.name !== 'Vulpes:Navigate') return;
const {key, code = '', keyCode = 0, repeat = false} = message.data;
if (typeof key !== 'string' || key.length > 32) return;
const document = this.document, win = this.contentWindow;
if (key === 'F2') {
if (!repeat) this.sendAsyncMessage('Vulpes:KeypadNotifications');
return;
}
const exposed = element => {
if (!element.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return false;
const rect = element.getBoundingClientRect();
const x = rect.x + rect.width / 2, y = rect.y + rect.height / 2;
return x >= 0 && x < win.innerWidth && y >= 0 && y < win.innerHeight &&
element.contains(document.elementFromPoint(x, y));
};
this.composer ||= new KeypadText();
if (key==='#' && !repeat) {
this.hashHoldHandled=false;
this.hashStartMode=this.composer.mode;
this.hashStartReturnMode=this.composer.returnMode;
}
const target = document.activeElement;
const editing = !target?.readOnly && (target?.isContentEditable || target?.localName === 'textarea' ||
(target?.localName === 'input' && ['text','search','email','url','tel','password','number'].includes(target.type)));
const modal = [...document.querySelectorAll('[aria-modal="true"],[data-type="confirm"],[data-type="action"]')]
.reverse().find(exposed);
if (!editing || modal) { this.composer.commit(); this.hint?.remove(); }
if (modal && !this.modalAnchor && target?.localName === 'select') this.modalAnchor = target;
if (!modal) this.modalAnchor = null;
const browserControls = target?.closest?.('.chrome .controls');
const inBrowserToolbar = browserControls && document.location.hostname === 'system.localhost';
// Keep toolbar keys in Gaia; Down returns to the browser's content frame.
if (!editing && !modal && (!inBrowserToolbar || key === 'ArrowDown')) {
for (const frame of document.querySelectorAll('iframe')) {
if (!frame.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) continue;
const rect = frame.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
if (document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2) !== frame) continue;
if (frame.browsingContext) {
if (inBrowserToolbar) frame.focus();
const browserContent = message.data.browserContent ||
document.location.hostname === 'system.localhost' &&
!!frame.closest('.appWindow')?.querySelector('.chrome .urlbar');
this.sendAsyncMessage('Vulpes:KeypadRoute', {
contextId:frame.browsingContext.id, key, code, keyCode, repeat, browserContent,
});
return;
}
}
}
// The Nokia's red key maps to F4; keep Gaia's own call controls.
if (key === 'F4' || key === 'EndCall') {
if (repeat) return;
this.composer.commit(); this.hint?.remove();
const hangup = [...document.querySelectorAll('#callbar-hang-up,#incoming-reject')].find(exposed);
if (hangup && !hangup.disabled) hangup.click();
else this.sendAsyncMessage('Vulpes:KeypadHome');
return;
}
// Gaia's dial pad is touch-driven, including its own number model.
if (!modal && document.location.hostname === 'communications.localhost' && !editing) {
const digit = /^[0-9*#]$/.test(key) ? key : key === 'Backspace' ? 'delete' : null;
const button = digit && document.querySelector('[role="key"][data-value="'+digit+'"]');
if (button && exposed(button)) { this.activate(button); return; }
}
if (!modal && key === 'F1') {
const button = [...document.querySelectorAll('#callbar-answer,#incoming-answer,#keypad-callbar-call-action')].find(exposed);
if (button && !button.disabled) { button.click(); return; }
}
if (editing && !modal && !['Escape', 'BrowserBack', 'GoBack', 'ContextMenu', 'ArrowUp', 'ArrowDown'].includes(key)) {
if (key==='#' && repeat && !this.hashHoldHandled &&
Services.env.get('VULPES_DEVICE')==='beatles' && !['tel','number'].includes(target.type)) {
this.hashHoldHandled=true;
this.composer.setT9(this.hashStartMode!=='t9',
this.hashStartMode==='t9' ? this.hashStartReturnMode : this.hashStartMode);
if (this.composer.mode==='t9') this.loadT9Dictionary();
this.showMode(target);
return;
}
if (!repeat || !/^[0-9*#]$/.test(key)) this.editKey(target, key);
return;
}
if (['Escape', 'BrowserBack', 'GoBack', 'Backspace'].includes(key)) {
this.composer.commit(); this.hint?.remove();
const addressCancel = document.querySelector('#rocketbar.active #rocketbar-cancel');
if (addressCancel && exposed(addressCancel)) { addressCancel.click(); return; }
const cancel = modal?.querySelector('[data-action="cancel"],button.cancel,button[data-l10n-id="cancel"]');
if (cancel) { cancel.click(); this.modalAnchor?.focus({preventScroll:true}); return; }
const header = [...document.querySelectorAll('gaia-header[action="back"],gaia-header[action="close"]')]
.find(exposed);
const back = header?.shadowRoot?.querySelector('.action-button') || header?.querySelector('.action-button') ||
[...document.querySelectorAll('[data-action="back"],button.back,[role="button"].back')]
.find(exposed);
if (back) back.click();
else this.sendAsyncMessage('Vulpes:KeypadHome');
return;
}
if (key === 'ContextMenu') {
this.sendAsyncMessage('Vulpes:KeypadCards');
return;
}
if (!modal && target?.localName === 'select' && ['Enter','Accept','Select'].includes(key)) {
target.click();
return;
}
if (!modal && target?.type === 'range' && ['ArrowLeft','ArrowRight'].includes(key)) {
key === 'ArrowLeft' ? target.stepDown() : target.stepUp();
target.dispatchEvent(new win.Event('input', {bubbles:true}));
target.dispatchEvent(new win.Event('change', {bubbles:true}));
return;
}
this.composer.commit(); this.hint?.remove();
const arrows = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'];
if (!arrows.includes(key) && !['Enter', 'Accept', 'Select'].includes(key)) {
return;
}
const root = modal || (inBrowserToolbar ? browserControls : document);
const candidates = [...root.querySelectorAll('a[href],button,input,select,textarea,[contenteditable="true"],[role="button"],[role="key"],[role="tab"],[tabindex]:not([tabindex="-1"]),[data-vulpes-keypad-focus],gaia-app-icon,gaia-pin-card,gaia-switch,gaia-checkbox')];
if (inBrowserToolbar) candidates.push(...browserControls.querySelectorAll('.urlbar'));
for (const header of root.querySelectorAll('gaia-header'))
candidates.push(...(header.shadowRoot?.querySelectorAll('button') || []));
const elements = [...new Set(candidates)].filter(element => {
const rect = element.getBoundingClientRect();
return !element.disabled && !element.hasAttribute('disabled') && !element.readOnly && element.getAttribute('aria-disabled') !== 'true' &&
element.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}) &&
rect.width > 0 && rect.height > 0 && rect.right > 0 && rect.left < win.innerWidth;
});
if (!elements.length) {
if (key === 'ArrowUp' && message.data.browserContent && win.scrollY <= 0)
this.sendAsyncMessage('Vulpes:KeypadToolbar');
else win.scrollBy({top:key === 'ArrowUp' ? -80 : key === 'ArrowDown' ? 80 : 0});
return;
}
const focused = elements.includes(document.activeElement) ? document.activeElement : null;
const compactSelection = document.documentElement.dataset.vulpesLayout === 'compact' ?
document.querySelector('[data-vulpes-compact-selected]') : null;
const current = elements.indexOf(focused || compactSelection || (this.selected?.isConnected && this.selected));
const rects = elements.map(element => {
const rect = element.getBoundingClientRect();
return {x:rect.x + rect.width / 2, y:rect.y + rect.height / 2};
});
if (!arrows.includes(key)) {
this.activate(elements[current < 0 ? 0 : current]);
if (modal && !modal.isConnected) this.modalAnchor?.focus({preventScroll:true});
return;
}
const index = nextKeypadTarget(rects, current, key);
if (key === 'ArrowUp' && message.data.browserContent && win.scrollY <= 0 && (current < 0 || index < 0)) {
this.sendAsyncMessage('Vulpes:KeypadToolbar');
return;
}
if (index < 0) {
win.scrollBy({top:key === 'ArrowUp' ? -80 : key === 'ArrowDown' ? 80 : 0});
return;
}
this.select(elements[index]);
}
select(element) {
const document = this.document;
if (!this.style) {
this.style = document.createElement('style');
this.style.textContent = '[data-vulpes-keypad-focus]{outline:2px solid #00aaff !important;outline-offset:-2px !important;}';
document.documentElement.append(this.style);
}
this.composer?.commit(); this.hint?.remove();
this.selected?.removeAttribute('data-vulpes-keypad-focus');
this.selected = element;
this.selected.setAttribute('data-vulpes-keypad-focus', '');
if (!this.selected.hasAttribute('tabindex')) this.selected.setAttribute('tabindex', '-1');
this.selected.focus({preventScroll:true});
this.selected.scrollIntoView({block:'nearest',inline:'nearest'});
}
activate(element) {
if (element.disabled || element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true') return;
if (['gaia-switch', 'gaia-checkbox'].includes(element.localName)) {
// The component owns toggling and change events inside its shadow tree.
// Calling the host's native click from this actor bypasses that handler.
element.shadowRoot?.querySelector(element.localName === 'gaia-switch' ? '#switch' : '#checkbox')?.click();
return;
}
if (element.getAttribute('role') === 'key' && element.hasAttribute('data-value')) {
for (const type of ['touchstart','touchend'])
element.dispatchEvent(new this.contentWindow.Event(type,{bubbles:true,cancelable:true}));
} else element.click();
}
editKey(element, key) {
const editor = new InputEditor(this.contentWindow);
editor.focus(element);
if (element.classList.contains('recipient') && ['Enter','Backspace'].includes(key)) {
const keyCode = key === 'Enter' ? 13 : 8;
// Gaia's recipients consume keypress, but a synthetic KeyboardEvent also
// enters unsupported native key-binding IPC in the standalone host.
const event = new this.contentWindow.Event('keypress', {bubbles:true,cancelable:true});
for (const [name,value] of Object.entries({key,keyCode,which:keyCode,
DOM_VK_BACK_SPACE:8,DOM_VK_ENTER:14,DOM_VK_TAB:9}))
Object.defineProperty(event.wrappedJSObject,name,{value});
if (!element.dispatchEvent(event)) { this.composer.commit(); return; }
}
const state = editor.state();
const numeric = ['tel','number'].includes(state.inputType) ||
['tel','numeric','decimal'].includes(state.inputMode);
if (/^[0-9*#]$/.test(key)) {
if (this.composer.mode==='t9' && this.t9Loading && /^[2-9]$/.test(key)) {
this.t9Queue ||= [];
if (this.t9Queue.length<16) this.t9Queue.push(key);
return;
}
const operation = this.composer.press(key, {field:element,
text:element.isContentEditable ? element.textContent : element.value,
start:state.selectionStart, end:state.selectionEnd, numeric});
if (operation?.text !== undefined) {
if (operation.replace) editor.edit({operation:'replace',offset:-operation.replace,
length:operation.replace,text:operation.text});
else editor.edit({operation:'key',key:operation.text});
}
if (operation?.mode === 't9') this.loadT9Dictionary();
if (!numeric) this.showMode(element);
} else {
this.composer.commit();
if (key === 'ArrowLeft' || key === 'ArrowRight') {
const start = key === 'ArrowLeft' ? Math.max(0, state.selectionStart - 1) :
Math.min(state.text.length, state.selectionEnd + 1);
editor.edit({operation:'selection', start, length:0});
} else if (key.length === 1 || ['Backspace', 'Enter'].includes(key)) {
editor.edit({operation:'key', key});
}
}
}
showMode(element) {
if (!this.hint) {
this.hint = this.document.createElement('span');
this.hint.id = 'vulpes-keypad-mode';
this.hint.style.cssText = 'position:fixed;right:4px;bottom:4px;z-index:2147483647;pointer-events:none;padding:2px 5px;border-radius:3px;background:#102337;color:white;font:12px sans-serif';
this.contentWindow.addEventListener('blur', () => {
this.composer.commit(); this.hint.remove();
}, true);
}
if (this.composer.mode==='t9') {
const french=(this.document.documentElement.lang || this.contentWindow.navigator.language || '').toLowerCase().startsWith('fr');
this.hint.textContent=(french ? 'T9 · maintien # · * ' : 'T9 · hold # · * ')+
(this.t9Loading ? '…' : this.composer.suggestion || '');
} else this.hint.textContent=this.composer.mode+' · #';
this.document.documentElement.append(this.hint);
}
loadT9Dictionary() {
const win=this.contentWindow;
const locale=(this.document.documentElement.lang || win.navigator.language || 'en').toLowerCase();
if (this.t9Loading || this.composer.dictionary.size && this.t9Locale===locale) return;
this.t9Loading=true;
this.composer.dictionary=new Map();
const choices=[locale,locale.split('-')[0]];
if (locale==='en-us') choices.unshift('en_us');
if (locale==='en-gb') choices.unshift('en_gb');
if (locale==='pt-br') choices.unshift('pt_br');
if (locale==='pt-pt') choices.unshift('pt_pt');
const root=Services.env.get('VULPES_WORKSPACE_ROOT');
if (!root) { this.t9Loading=false; return; }
this.t9Field=this.document.activeElement;
const {IOUtils}=ChromeUtils.importESModule('resource://gre/modules/IOUtils.sys.mjs');
(async()=>{
for (const language of choices) {
if (!/^(?:[a-z]{2,3}(?:_[a-z]{2}|-[a-z]{2}|-latn|-cyrl)?)$/.test(language)) continue;
try {
const bytes=await IOUtils.read(root+'/tundra/devices/beatles/t9/'+language+'.tsv');
this.composer.setDictionary(new TextDecoder().decode(bytes));
if (this.composer.dictionary.size) { this.t9Locale=locale; break; }
} catch (_) {}
}
this.t9Loading=false;
if (this.hint?.isConnected) this.showMode(this.document.activeElement);
if (this.document.activeElement===this.t9Field) {
for (const key of this.t9Queue || []) this.editKey(this.t9Field,key);
}
this.t9Queue=[];
})();
}
}

View file

@ -0,0 +1,44 @@
import {Runtime} from 'resource://vulpes/host/Runtime.sys.mjs';
import {Views} from 'resource://vulpes/host/Views.sys.mjs';
export class KeypadParent extends JSWindowActorParent {
receiveMessage(message) {
if (Services.env.get('VULPES_KEYPAD') !== '1') return;
if (message.name === 'Vulpes:KeypadNotifications') {
Runtime.broadcast('keypad-notifications', {}, 'system');
return;
}
if (message.name === 'Vulpes:KeypadToolbar') {
const shell = this.browsingContext.top.embedderElement?.ownerDocument;
const system = shell?.getElementById('system')?.browsingContext;
const belongsToHost = this.browsingContext.top === system ||
[...Views.entries.values()].some(entry => entry.browser.browsingContext === this.browsingContext.top);
if (belongsToHost) {
system?.currentWindowGlobal?.getActor('Keypad')
.sendAsyncMessage('Vulpes:FocusBrowserToolbar', {});
}
return;
}
if (message.name === 'Vulpes:KeypadRoute') {
const child = BrowsingContext.get(message.data.contextId);
let ancestor = child?.parent;
while (ancestor && ancestor !== this.browsingContext) ancestor = ancestor.parent;
if (ancestor !== this.browsingContext) return;
const view = Views.entries.get(child.id);
const destination = view && view.browser.style.visibility === 'visible' ? view.browser.browsingContext : child;
const page = destination.currentWindowGlobal;
// Gecko error pages can autofocus a retry button far below the viewport.
// Up must still offer an immediate way to edit the failed address.
if (message.data.key === 'ArrowUp' &&
/^about:(neterror|certerror|httpsonlyerror)\?/.test(page?.documentURI.spec || '')) {
this.receiveMessage({name:'Vulpes:KeypadToolbar'});
return;
}
page?.getActor('Keypad').sendAsyncMessage('Vulpes:Navigate', message.data);
return;
}
if (!['Vulpes:KeypadHome', 'Vulpes:KeypadCards'].includes(message.name)) return;
Views.hideAll();
Runtime.broadcast(message.name === 'Vulpes:KeypadCards' ? 'holdhome' : 'home', {}, 'system');
}
}

View file

@ -3,9 +3,13 @@ import { Views } from 'resource://vulpes/host/Views.sys.mjs';
import { packageURL } from 'resource://vulpes/services/packages.mjs';
export class VulpesMarketplaceParent extends JSWindowActorParent {
receiveMessage(message) {
if(message.name!=='Marketplace:Download' || this.manager.documentPrincipal.originNoSuffix!=='https://vulpes-os.org')return;
if(message.name!=='Marketplace:Download')return;
const page=this.manager.documentURI;
if(!((this.manager.documentPrincipal.originNoSuffix==='https://vulpes-os.org' && page.pathQueryRef.startsWith('/marketplace/')) ||
(this.manager.documentPrincipal.originNoSuffix==='https://ffapps.danielherr.software' && page.pathQueryRef.startsWith('/app/'))))return;
const view=[...Views.entries.values()].find(v=>v.browser.browsingContext.top.id===this.browsingContext.top.id);
if(!view || !Runtime.identity(view.actor))return;
if(view.browser.style.visibility!=='visible')return;
Runtime.marketplaceDownload=packageURL(message.data.url);
const app=Runtime.apps.find(a=>a.id==='marketplace');
Runtime.broadcast('launch',{manifestURL:app.manifestURL,origin:app.origin,url:app.origin+'/index.html',timestamp:Date.now()},'system');

View file

@ -1,4 +1,6 @@
import {InputEditor} from 'resource://vulpes/services/input-editor.mjs';
import {frameUnobscured} from 'resource://vulpes/services/view-visibility.mjs';
import {requestMediaPermission} from 'resource://vulpes/services/media-permission.mjs';
export class VulpesChild extends JSWindowActorChild {
handleEvent(event) {
if(['focusin','pointerdown','click'].includes(event.type)) {
@ -41,20 +43,7 @@ export class VulpesChild extends JSWindowActorChild {
params.contextId = native.browsingContext.id;
if (params.operation === 'geometry') {
const rect = native.getBoundingClientRect();
const samples = [
[0.5, 0.5],
[0.05, 0.05],
[0.95, 0.05],
[0.05, 0.95],
[0.95, 0.95],
];
const unobscured = samples.every(
([x, y]) =>
this.document.elementFromPoint(
rect.left + rect.width * x,
rect.top + rect.height * y,
) === native,
);
const unobscured = frameUnobscured(this.document, native, rect);
params.geometry = {
left: rect.left + win.mozInnerScreenX,
top: rect.top + win.mozInnerScreenY,
@ -84,6 +73,11 @@ export class VulpesChild extends JSWindowActorChild {
inputState() { return this.editor.state(); }
edit(params) { return this.editor.edit(params); }
receiveMessage(message) {
if (message.name === 'Vulpes:MediaPermission') {
if (this.document.location.hostname !== 'system.localhost' || this.browsingContext.parent)
throw new Error('Invalid permission UI');
return requestMediaPermission(this.contentWindow, message.data, value => Cu.cloneInto(value, this.contentWindow));
}
if(message.name==='Vulpes:Edit') return this.edit(message.data);
if (message.name === 'Vulpes:ViewState') {
const frame = Array.from(this.document.querySelectorAll('iframe')).find(

View file

@ -0,0 +1,29 @@
import {InputEditor} from 'resource://vulpes/services/input-editor.mjs';
// Web pages may edit their focused field, but receive no Gaia service bridge.
export class WebInputChild extends JSWindowActorChild {
handleEvent(event) {
if (!Services.cpmm.sharedData.get('vulpes:native-input') || this.editor?.editing) return;
const contexts = Services.cpmm.sharedData.get('vulpes:host-contexts') || [];
if (!contexts.includes(this.browsingContext.top.id)) return;
// Standalone web views have no Firefox tabbrowser to focus their embedder.
// Keep Gecko's picker focus checks intact by activating only a touched view.
if (event.isTrusted && ['pointerdown','mousedown','click'].includes(event.type))
this.sendAsyncMessage('Vulpes:WebActivate', null);
let element = event.composedPath()[0];
if (element?.isContentEditable) element = element.closest('[contenteditable=true]') || element;
const editable = element?.isContentEditable || element?.localName === 'textarea' ||
(element?.localName === 'input' && ['text','search','email','url','tel','password','number'].includes(element.type));
if (!editable || element.disabled || element.readOnly) {
if (event.type === 'click') this.sendAsyncMessage('Vulpes:InputFocus', null);
return;
}
this.editor ||= new InputEditor(this.contentWindow);
this.editor.focus(element);
this.sendAsyncMessage('Vulpes:InputFocus', this.editor.state());
}
receiveMessage(message) {
if (message.name !== 'Vulpes:Edit' || !this.editor) throw Error('INPUT_CONTEXT_EXPIRED');
return this.editor.edit(message.data);
}
}

View file

@ -0,0 +1,22 @@
import {Views} from 'resource://vulpes/host/Views.sys.mjs';
import {Runtime} from 'resource://vulpes/host/Runtime.sys.mjs';
export class WebInputParent extends JSWindowActorParent {
receiveMessage(message) {
if (message.name === 'Vulpes:WebActivate') {
if (Runtime.identity(this) || !Runtime.inputIdentity(this)) return;
for (const entry of Views.entries.values()) {
if (entry.browser.browsingContext === this.browsingContext.top &&
entry.browser.ownerDocument.hasFocus()) {
entry.browser.focus();
break;
}
}
return;
}
if (message.name !== 'Vulpes:InputFocus') throw Error('METHOD_NOT_SUPPORTED');
if (Runtime.identity(this)) return; // Bundled apps already use VulpesChild.
Runtime.input.changed(this, message.data);
}
didDestroy() { Runtime.input.cleanup(this); }
}

View file

@ -175,6 +175,12 @@ html[data-vulpes-app='video'] #in-use-overlay-content {
min-width: 0;
overflow-y: auto;
}
/* The old one-pixel tile produces moiré at fractional phone scale factors. */
html[data-vulpes-app='gallery'] #overlay-content {
background-image: url('/style/images/ui/gradient.png');
background-size: 100% 100%;
background-repeat: no-repeat;
}
html[data-vulpes-app='video'] #overlay-text,
html[data-vulpes-app='video'] #in-use-overlay-text {
width: auto;

View file

@ -328,8 +328,8 @@
return request;
}
async function openCamera(name, config, ticket) {
if (config.mode && config.mode !== 'picture')
throw new DOMException('Video capture is not ported yet', 'NotSupportedError');
if (config.mode && !['picture', ...(videoMime ? ['video'] : [])].includes(config.mode))
throw new DOMException('Unsupported capture mode', 'NotSupportedError');
if (current) await releaseStream(current);
await closing;
if (document.hidden || ticket !== generation)
@ -406,7 +406,8 @@
const pictureSizes = native?.pictureSizes || [size];
let pictureSize = pictureSizes.reduce((a,b)=>a.width*a.height>b.width*b.height?a:b);
let focusArea = null, zoom = 1, mode = 'picture', recording, startingRecording = false;
let flashMode = 'off', flashUpdate = Promise.resolve();
let focusArea = null, zoom = 1, mode = config.mode || 'picture', recording, startingRecording = false;
const profile = {containerFormat:'webm',video:{width:size.width,height:size.height,codec:'vp8',bitrate:1500000,framerate:30},audio:{codec:'opus',samplerate:48000,channels:1,bitrate:64000}};
const configuration = () => ({
mode,
@ -420,7 +421,7 @@
previewSizes: [size],
thumbnailSizes: [],
recorderProfiles: videoMime ? {webm:profile} : {},
flashModes: ['off'],
flashModes: native?.flashModes?.length ? native.flashModes : ['off'],
focusModes: native ? (native.focusModes.includes('auto') ? ['auto','infinity'] : ['fixed']) : ['fixed'],
sceneModes: ['auto'],
whiteBalanceModes: ['auto'],
@ -472,6 +473,7 @@
},
setThumbnailSize() {},
async takePicture() {
await flashUpdate;
if (native) {
const result=await stream.vulpesCameraControl({action:'picture',size:pictureSize});
emit(stream,'shutter');
@ -555,6 +557,16 @@
const done=recording.done;stream.stopRecording();await done;
},
});
if (native) Object.defineProperty(stream,'flashMode',{
configurable:true, get:()=>flashMode,
set(value) {
if (!stream.capabilities.flashModes.includes(value) || value===flashMode) return;
const previous=flashMode;
flashMode=value;
flashUpdate=flashUpdate.catch(()=>{}).then(()=>stream.vulpesCameraControl({action:'flash',mode:value}));
flashUpdate.catch(error=>{if(flashMode===value)flashMode=previous;recordError(error);});
}
});
if (native) Object.defineProperty(stream,'zoom',{
configurable:true, get:()=>zoom,
set(value) {
@ -568,7 +580,7 @@
});
dispatchEvent(
new CustomEvent('vulpes-camera-capabilities', {
detail: { cameras: [...cameras], flash: false, video: !!videoMime },
detail: { cameras: [...cameras], flash: stream.capabilities.flashModes.length > 1, video: !!videoMime },
}),
);
return { camera: stream, configuration: configuration() };
@ -602,7 +614,7 @@
const controls = [
[
'.js-flash',
true,
!current || current.capabilities?.flashModes?.length < 2,
'Flash indisponible dans cette Preview',
'Flash unavailable in this Preview',
'flash-off',

View file

@ -102,7 +102,10 @@
async function refreshCalls() {
try {
const current = await call('platform.calls',{});
if (location.hostname === 'callscreen.localhost') Object.assign(audioState,await call('platform.callAudio',{operation:'status'}));
if (location.hostname === 'callscreen.localhost' && current.some(c=>c.state!=='disconnected')) {
// Audio routing may be unavailable while modem call control still works.
try { Object.assign(audioState,await call('platform.callAudio',{operation:'status'})); } catch (_) {}
}
const live = current.filter(c=>c.state!=='disconnected');
const old = telephony.calls;
let changed = false;
@ -188,6 +191,7 @@
return r;
}
let language = navigator.language;
let languageRevision = 0;
function setLanguage(value) {
if (typeof value !== 'string' || !/^[a-z]{2,3}(?:-[a-zA-Z0-9]{2,8})*$/.test(value)) return;
const changed = language !== value;
@ -196,7 +200,10 @@
}
Object.defineProperty(navigator, 'language', {get:() => language});
Object.defineProperty(navigator, 'languages', {get:() => Object.freeze([language])});
call('locale.current').then(setLanguage).catch(() => {});
const initialLanguageRevision = languageRevision;
call('locale.current').then(value => {
if (languageRevision === initialLanguageRevision) setLanguage(value);
}).catch(() => {});
const observers = new Map();
const settings = new EventTarget();
settings.createLock = () => ({
@ -228,7 +235,10 @@
addEventListener('vulpes-service-event', (event) => {
const { type, data } = event.detail;
if (type === 'settingchange') {
if (data.key === 'language.current') setLanguage(data.value);
if (data.key === 'language.current') {
languageRevision++;
setLanguage(data.value);
}
const change = {
settingName: data.key,
settingValue: settingsCodec ? settingsCodec.decode(data.value) : data.value,
@ -238,7 +248,17 @@
}
if (type === 'callscreen-closed') document.querySelector('iframe[name="call_screen"]')?.dispatchEvent(new CustomEvent('mozbrowserclose',{bubbles:true}));
if (type === 'hardware-key') dispatchEvent(new CustomEvent('softwareButtonEvent', {detail:data}));
if (type === 'activity-choice') dispatchEvent(new CustomEvent('mozChromeEvent', {detail:{...data,type:'activity-choice'}}));
if (type === 'keypad-notifications' && location.hostname === 'system.localhost') {
const tray = window.UtilityTray;
if (tray?.motion) {
if (['open', 'opening'].includes(tray.motion.state)) tray.hide(true);
else if (!window.Service?.query('locked')) tray.show(true);
}
}
if (type === 'activity-choice') {
dispatchEvent(new CustomEvent('activityrequesting'));
dispatchEvent(new CustomEvent('mozChromeEvent', {detail:{...data,type:'activity-choice'}}));
}
if (type === 'activity-launch') dispatchEvent(new CustomEvent('open-app', {detail:{...data,showApp:true,onlyShowApp:false}}));
if (type === 'launch') dispatchEvent(new CustomEvent('webapps-launch', { detail: data }));
if (type === 'screen-off') {

View file

@ -76,16 +76,37 @@
if(event.source===frame?.contentWindow && event.origin==='http://keyboard.localhost:8765' &&
event.data?.type==='vulpes-keyboard-ready') sendViewport();
});
let resizeSequence=0;
const resize=next=>{
if(next===height) return;
height=next;
const sequence=++resizeSequence, pending=[];
if(frame) {frame.style.height=height+'px';frame.hidden=!height;}
dispatchEvent(new CustomEvent(height?'keyboardchange':'keyboardhide',{detail:{height,waitUntil:()=>{}}}));
dispatchEvent(new CustomEvent(height?'keyboardchange':'keyboardhide',{
detail:{height,waitUntil:promise=>pending.push(Promise.resolve(promise))}
}));
// AppWindowManager waits for this completion before opening the next app.
// A refocus while layout settles must not complete an obsolete hide.
if(!height) Promise.allSettled(pending).then(()=>{
if(sequence===resizeSequence && !height)
dispatchEvent(new CustomEvent('keyboardhidden'));
});
};
const attach=setInterval(()=>{
if(!window.Service) return;
clearInterval(attach);
Service.registerState('getHeight',{name:'InputWindowManager',getHeight:()=>height});
const hide=()=>call('input.request',{operation:'hide'});
const provider=window.inputWindowManager || {name:'InputWindowManager'};
provider.getHeight=()=>height;
provider.hideInputWindow=hide;
provider.hideInputWindowImmediately=hide;
Service.registerState('getHeight',provider);
// KeyboardManager is disabled with the host input API, so its original
// InputWindowManager may never exist. Register the replacement explicitly.
if(!window.inputWindowManager) {
Service.register('hideInputWindow',provider);
Service.register('hideInputWindowImmediately',provider);
}
},100);
addEventListener('vulpes-service-event',({detail})=>{
if(detail.type==='input-height') {if(frame && !frame.hidden) resize(detail.data.height);return;}
@ -102,7 +123,7 @@
frame.hidden=false;
resize(height || 260);
});
for(const type of ['home','lockscreen-appopened','appwillclose'])
for(const type of ['home','lockscreen-appopened','appwillclose','activityrequesting'])
addEventListener(type,()=>call('input.request',{operation:'hide'}).catch(console.error));
}
}

View file

@ -1,7 +1,107 @@
// Native status and radio power use the original Gaia SIM and signal components.
// Other control APIs stay unavailable until the device adapter implements them.
(() => {
if (!window.VulpesNativeRadioStatus || !['system.localhost','settings.localhost'].includes(location.hostname)) return;
const requestFrom = task => {
// Gaia uses DOMRequest callbacks; newer consumers can await the same request.
const request=Object.assign(new EventTarget(),{readyState:'pending',result:null,error:null});
const promise=Promise.resolve().then(task);
request.then=(resolve,reject)=>promise.then(resolve,reject);
promise.then(result=>{
request.result=result;request.readyState='done';
const event=new Event('success');request.dispatchEvent(event);request.onsuccess?.(event);
},error=>{
request.error=error;request.readyState='done';
const event=new Event('error');request.dispatchEvent(event);request.onerror?.(event);
});
return request;
};
const unsupported = () => requestFrom(()=>{
throw new DOMException('Not supported by this adapter','RequestNotSupported');
});
const emit = (object,type,detail={}) => {
const event=Object.assign(new Event(type),detail);
object.dispatchEvent(event);object['on'+type]?.(event);
};
const emptyInfo=()=>({connected:false,state:'unknown',type:null,network:null,relSignalStrength:null,roaming:false,emergencyCallsOnly:false});
const powerChangedAt=[0,0];
const connections=Array.from({length:2},(_,index)=>Object.assign(new EventTarget(),{
serviceId:index,iccId:null,radioState:'unknown',voice:emptyInfo(),data:emptyInfo(),
supportedNetworkTypes:[],networkSelectionMode:'automatic',
getNetworks:unsupported,selectNetwork:unsupported,selectNetworkAutomatically:unsupported,
setRadioEnabled(enabled) {
return requestFrom(async()=>{
const state=await VulpesCompat.call('platform.radioPower',{slot:index,enabled});
powerChangedAt[index]=Date.now()/1000;
const changed=this.radioState!==state.radioState;
this.radioState=state.radioState;
if(changed)emit(this,'radiostatechange');
return null;
});
},getCallForwardingOption:unsupported,setCallForwardingOption:unsupported,
getCallWaitingOption:unsupported,setCallWaitingOption:unsupported,
getCallingLineIdRestriction:unsupported,setCallingLineIdRestriction:unsupported,
getVoicePrivacyMode:unsupported,setVoicePrivacyMode:unsupported,
getRoamingPreference:unsupported,setRoamingPreference:unsupported,
getPreferredNetworkType:unsupported,setPreferredNetworkType:unsupported,
exitEmergencyCbMode:unsupported,
}));
const cards=new Map();
const manager=Object.assign(new EventTarget(),{iccIds:[],getIccById:id=>cards.get(id)||null});
Object.defineProperty(navigator,'mozMobileConnections',{value:connections});
Object.defineProperty(navigator,'mozIccManager',{value:manager});
async function refresh() {
let slots=[],recordedAt=0;
try { const snapshot=await VulpesCompat.call('platform.snapshot',{});slots=snapshot.radio || [];recordedAt=snapshot.recordedAt || 0; } catch (_) {}
document.documentElement.toggleAttribute('data-vulpes-radio-unavailable',
!connections.every((_,index)=>slots[index]?.available && typeof slots[index]?.sim?.present==='boolean'));
connections.forEach((conn,index)=>{
const slot=slots[index];
const previous=conn.iccId;
// An opaque slot handle is sufficient here; no ICCID/IMSI is requested.
conn.iccId=slot?.available && slot.sim?.present ? 'tundra-slot-'+index : null;
const previousRadio=conn.radioState;
// A cached snapshot must not undo a just-confirmed power command.
if(recordedAt>=powerChangedAt[index]) conn.radioState=slot?.available ? slot.radioState : 'unknown';
for(const kind of ['voice','data']) {
conn[kind]=Object.assign(emptyInfo(),slot?.available ? slot[kind] : {},{
network:slot?.operator || null,relSignalStrength:slot?.signal ?? null,
});
}
if(previous!==conn.iccId && previous) {
cards.delete(previous);manager.iccIds=[...cards.keys()];
emit(manager,'iccundetected',{iccId:previous});
}
if(conn.iccId) {
if(!cards.has(conn.iccId)) {
const card=Object.assign(new EventTarget(),{cardState:'unknown',iccInfo:null});
for(const name of ['getCardLock','unlockCardLock','setCardLock','getCardLockRetryCount','readContacts','updateContact',
'iccOpenChannel','iccExchangeAPDU','iccCloseChannel','sendStkResponse','sendStkMenuSelection','sendStkTimerExpiration','sendStkEventDownload'])card[name]=unsupported;
card.getServiceState=unsupported;cards.set(conn.iccId,card);
}
const card=cards.get(conn.iccId);
card.cardState=slot.sim.cardState;
card.iccInfo={iccid:conn.iccId,spn:slot.operator?.longName || '',iccType:'sim'};
manager.iccIds=[...cards.keys()];
if(previous!==conn.iccId)emit(manager,'iccdetected',{iccId:conn.iccId});
emit(card,'cardstatechange');emit(card,'iccinfochange');
}
manager.iccIds=[...cards.keys()];
if(previousRadio!==conn.radioState)emit(conn,'radiostatechange');
for(const type of ['voicechange','datachange','iccchange'])emit(conn,type);
});
}
document.documentElement.setAttribute('data-vulpes-radio-unavailable','');
const style=document.createElement('style');
style.textContent='[data-vulpes-radio-unavailable] #statusbar-mobile-connection{display:none!important}';
document.documentElement.append(style);
addEventListener('DOMContentLoaded',()=>{refresh();setInterval(refresh,5000);},{once:true});
})();
// Expose the single native SIM to the original Gaia Settings controllers.
// Other applications retain their existing telephony/message adapters.
(() => {
if (location.hostname !== 'settings.localhost' || !window.VulpesNativeTelephony) return;
if (location.hostname !== 'settings.localhost' || !window.VulpesNativeTelephony || window.VulpesNativeRadioStatus) return;
const {call} = VulpesCompat;
const read = action => call('platform.mobileSettings', {action});
const unavailable = () => Promise.reject(new DOMException('Not supported by this adapter', 'NotSupportedError'));

View file

@ -125,3 +125,177 @@
background-color: var(--vulpes-surface) !important;
color: var(--vulpes-text) !important;
}
/* Shared compact home: three columns, original icons, reversible wallpaper veil. */
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] {
--compact-cell: round(down, calc((100vw - 12px) / 3), 1px);
}
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] body {
background-color: rgba(5, 15, 31, .72);
}
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] #apps .icon-container {
width: var(--compact-cell);
height: 74px;
}
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] #apps gaia-app-icon {
width: 52px;
left: round(down, calc((var(--compact-cell) - 52px) / 2), 1px);
border: 2px solid transparent;
--vulpes-label-width: calc(var(--compact-cell) - 4px);
--vulpes-label-offset: round(down, calc((48px - var(--vulpes-label-width)) / 2), 1px);
--vulpes-icon-display: block;
--vulpes-icon-title-position: absolute;
--vulpes-icon-title-clip: inset(50%);
font-size: 12px;
line-height: 16px;
transform: none;
}
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] #apps-panel > .scrollable {
padding-top: 2px;
padding-bottom: 0;
}
/* A single applications panel; saved pinned pages remain available in classic mode. */
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] :is(#page-indicator-header, #pages-panel, #shadow) {
display: none;
}
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] #panels {
height: calc(100vh - 52px);
overflow-x: hidden;
scroll-snap-type: none;
}
html[data-vulpes-layout="compact"][data-vulpes-app="system"] #homescreen {
--rocketbar-urlbar-height: 0px;
}
html[data-vulpes-layout="compact"][data-vulpes-app="system"] #homescreen > .chrome {
display: none;
}
/* Gaia finishes opening/closing on animationend: retain that event, skip the effect. */
html[data-vulpes-motion="reduced"][data-vulpes-app="system"] .appWindow,
html[data-vulpes-motion="reduced"][data-vulpes-app="homescreen"] :is(gaia-app-icon, .gaia-container-child, .icon-container, .scrollable, #panels) {
animation-duration: .001s !important;
animation-delay: 0s !important;
transition-duration: .001s !important;
transition-delay: 0s !important;
scroll-behavior: auto !important;
}
/* Enabled by the Nokia adapter, independent of the shared compact layout.
* Keep a nonzero duration so Gaia's transitionend navigation still completes. */
html[data-vulpes-motion="reduced"],
html[data-vulpes-motion="reduced"] *,
html[data-vulpes-motion="reduced"] *::before,
html[data-vulpes-motion="reduced"] *::after {
transition-duration: .001s !important;
transition-delay: 0s !important;
scroll-behavior: auto !important;
}
html[data-vulpes-motion="reduced"][data-vulpes-layout="compact"][data-vulpes-app="system"]
#screen:has(#homescreen.active):not(.rocketbar-focused):not(.utility-tray):not(.locked) #statusbar {
background: rgba(5, 15, 31, .72) !important;
}
#vulpes-compact-app-name {
display: none;
}
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] #vulpes-compact-app-name {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 52px;
padding: 2px 10px;
color: white;
font: 500 20px/24px FiraSans, sans-serif;
text-align: center;
overflow-wrap: anywhere;
overflow: hidden;
text-shadow: 0 1px 2px #000;
}
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] gaia-app-icon[data-vulpes-compact-selected] {
outline: 2px solid #00aaff !important;
outline-offset: -2px !important;
border-radius: 8px;
}
/* Keep Gaia's dial pad and controls inside a feature-phone viewport. */
@media (max-width: 320px) and (max-height: 400px) {
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] #phone-number-view-container {
height: 48px;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] #phone-number-view-container .phone-number-view-wrapper-input {
margin-bottom: 0;
padding: 0 8px;
min-width: 0;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] #phone-number-view {
font-size: 24px !important;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] #keypad-delete {
margin-bottom: 0;
padding: 0 8px 0 0;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] #keyboard-container {
height: calc(100% - 48px);
display: flex;
flex-direction: column;
padding: 0 6px;
box-sizing: border-box;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] #keypad {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-rows: repeat(4, minmax(0, 1fr));
flex: 1;
min-height: 0;
height: auto;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] .keypad-row { display: contents; }
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] .keypad-key {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0;
height: auto;
font-size: 30px;
line-height: 1;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] .keypad-key :is(span,em) {
width: auto;
margin: 0;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] .keypad-key em { font-size: 10px; }
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] .keypad-key .sharp { font-size: 30px; line-height: 1; }
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] #keypad-callbar {
flex: 0 0 40px;
height: 40px;
}
html[data-vulpes-layout="compact"][data-vulpes-app="communications"] :is(.keypad-callbar-button,#keypad-callbar-call-action) {
height: 32px;
background-size: 32px;
}
}
#keypad-callbar-call-action[aria-busy="true"] { opacity: .5; }
#vulpes-cover-settings {
display: block !important; height: auto !important; padding: 0 0 1rem;
}
#vulpes-cover-settings h2 { margin: 1rem 0; height: auto; overflow: visible; }
#vulpes-cover-settings label {
display: block; height: auto !important; padding: .4rem 0; overflow: visible;
}
#vulpes-cover-settings label > span {
display: block; margin: 0; width: auto; line-height: 1.5;
white-space: normal; font-size: 1.6rem; overflow: visible;
}
#vulpes-cover-settings :is(input,select) {
position: static; width: 100%; box-sizing: border-box; margin: 0;
}
#vulpes-cover-settings .vulpes-cover-preview {
background: #04060b; padding: 1rem; text-align: center; overflow-wrap: anywhere;
height: auto; font-size: 1.4rem; line-height: 1.5;
}

View file

@ -56,3 +56,213 @@
{childList:true, subtree:true, attributes:true, attributeFilter:['lang']});
insert();
})();
// Compact is a user preference, with a keypad-device default supplied by the host.
(() => {
const key = 'vulpes.layout';
let mode = 'auto', compactDefault = false, select;
function apply(value) {
mode = ['auto', 'compact', 'standard'].includes(value) ? value : 'auto';
const compact = mode === 'compact' || (mode === 'auto' && compactDefault);
document.documentElement.dataset.vulpesLayout = compact ? 'compact' : 'standard';
if (select) select.value = mode;
// Gaia caches grid coordinates; switching layout also invalidates that cache.
requestAnimationFrame(() => {
const apps = window.appWindow?.apps;
if (!apps?.icons) return;
if (compact) {
window.appWindow.panels.scrollLeft = 0;
window.appWindow.updatePanelIndicator();
}
apps._iconSize = 0;
apps.icons.synchronise();
apps.refreshGridSize();
});
}
addEventListener('vulpes-service-event', ({detail}) => {
if (detail.type === 'settingchange' && detail.data.key === key) apply(detail.data.value);
});
VulpesCompat.call('layout.current', {}).then(value => {
compactDefault = value.compactDefault;
document.documentElement.dataset.vulpesMotion = value.reducedMotion ? 'reduced' : 'standard';
if (value.reducedMotion) {
// Preserve animationend callbacks used by Gaia, and leave busy indicators running.
const shorten = animations => {
for (const animation of animations) {
const timing = animation.effect?.getTiming();
if (timing && Number.isFinite(timing.iterations) && animation.playState !== 'finished')
animation.effect.updateTiming({duration:1, delay:0, endDelay:0});
}
};
document.addEventListener('animationstart', event => shorten(event.target.getAnimations()), true);
shorten(document.getAnimations());
}
apply(value.mode);
}).catch(console.error);
if (location.hostname !== 'settings.localhost') return;
function label() {
const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
const labels = fr ? ['Mode compact', 'Automatique', 'Activé', 'Désactivé'] :
['Compact mode', 'Automatic', 'On', 'Off'];
const title = document.getElementById('vulpes-layout-label');
if (title && title.textContent !== labels[0]) title.textContent = labels[0];
if (select) [...select.options].forEach((option, i) => {
if (option.textContent !== labels[i + 1]) option.textContent = labels[i + 1];
});
}
function insert() {
const list = document.querySelector('#display > div > ul');
if (!list || select?.isConnected) return;
const row = document.createElement('li'), title = document.createElement('p');
row.id = 'vulpes-layout-row'; title.id = 'vulpes-layout-label';
const wrapper = document.createElement('span');
wrapper.className = 'button icon icon-dialog';
select = document.createElement('select'); select.id = 'vulpes-layout';
select.setAttribute('aria-labelledby', title.id);
for (const value of ['auto', 'compact', 'standard']) {
const option = document.createElement('option'); option.value = value; select.append(option);
}
select.value = mode;
select.onchange = async () => {
select.disabled = true;
try { await navigator.mozSettings.createLock().set({[key]:select.value}); }
catch (error) { select.value = mode; console.error(error); }
finally { select.disabled = false; }
};
wrapper.append(select); row.append(title, wrapper); list.append(row); label();
}
addEventListener('localized', label);
new MutationObserver(() => { insert(); label(); }).observe(document.documentElement,
{childList:true, subtree:true, attributes:true, attributeFilter:['lang']});
insert();
})();
// One readable application name above the compact grid, including keypad focus.
(() => {
if (location.hostname !== 'homescreen.localhost') return;
let heading, selected, grid;
const nameObserver = new MutationObserver(updateName);
function updateName() {
if (!heading || !selected) return;
const name = selected._subtitle?.textContent || selected.getAttribute('aria-label') || '';
if (heading.textContent !== name) heading.textContent = name;
}
function choose(icon) {
if (!icon || !grid?.contains(icon)) return;
if (selected !== icon) {
selected?.removeAttribute('data-vulpes-compact-selected');
selected?.removeAttribute('data-vulpes-keypad-focus');
selected = icon;
selected.setAttribute('data-vulpes-compact-selected', '');
nameObserver.disconnect();
if (selected.shadowRoot) nameObserver.observe(selected.shadowRoot,
{childList:true, subtree:true, characterData:true});
}
updateName();
}
function refresh() {
const focus = grid?.querySelector('gaia-app-icon[data-vulpes-keypad-focus]');
choose(focus || (selected?.isConnected && selected) || grid?.querySelector('gaia-app-icon'));
}
function init() {
grid = document.getElementById('apps');
if (!grid) return;
heading = document.createElement('div');
heading.id = 'vulpes-compact-app-name';
heading.setAttribute('aria-live', 'polite');
heading.setAttribute('aria-atomic', 'true');
document.body.insertBefore(heading, document.getElementById('panels'));
new MutationObserver(refresh).observe(grid,
{childList:true, subtree:true, attributes:true, attributeFilter:['data-vulpes-keypad-focus']});
for (const type of ['focus', 'pointerdown', 'pointerover']) grid.addEventListener(type, event => {
choose(event.target.closest('gaia-app-icon'));
}, true);
addEventListener('localized', updateName);
refresh();
}
if (document.readyState === 'loading') addEventListener('DOMContentLoaded', init, {once:true});
else init();
})();
// Cover-screen preferences live in the shared settings store, like appearance.
(() => {
if (location.hostname !== 'settings.localhost') return;
VulpesCompat.call('layout.current', {}).then(async capabilities => {
if (!capabilities.coverScreen) return;
const defaults = {'vulpes.cover.title':'VULPES', 'vulpes.cover.titleColor':'#33a6ff',
'vulpes.cover.batteryColor':'#33a6ff'};
const values = {...defaults}, controls = {};
const colors = ['#33a6ff', '#ffffff', '#4ade80', '#fbbf24', '#f472b6',
'#ef4444', '#f97316', '#a855f7', '#22d3ee', '#14b8a6', '#9ca3af'];
const result = await navigator.mozSettings.createLock().get('*');
for (const key of Object.keys(defaults)) if (typeof result[key] === 'string') values[key] = result[key];
let row, title, previewTitle, previewBattery;
function labels() {
const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
if (!row) return;
title.textContent = fr ? 'Écran extérieur' : 'Cover screen';
const texts = fr ? ['Texte', 'Couleur du texte', 'Couleur de la batterie'] :
['Text', 'Text colour', 'Battery colour'];
const names = fr ? ['Bleu', 'Blanc', 'Vert', 'Jaune', 'Rose', 'Rouge', 'Orange', 'Violet', 'Cyan', 'Turquoise', 'Gris'] :
['Blue', 'White', 'Green', 'Yellow', 'Pink', 'Red', 'Orange', 'Purple', 'Cyan', 'Turquoise', 'Grey'];
Object.keys(defaults).forEach((key, index) => {
const control = controls[key];
control.previousElementSibling.textContent = texts[index];
if (control.options) [...control.options].forEach((option, i) => option.textContent = names[i]);
});
previewBattery.textContent = fr ? 'Batterie · aperçu' : 'Battery · preview';
}
function preview() {
previewTitle.textContent = controls['vulpes.cover.title'].value.trim() || 'VULPES';
previewTitle.style.color = controls['vulpes.cover.titleColor'].value;
previewBattery.style.color = controls['vulpes.cover.batteryColor'].value;
}
function insert() {
const list = document.querySelector('#display > div > ul');
if (!list || row?.isConnected) return;
row = document.createElement('li'); row.id = 'vulpes-cover-settings';
title = document.createElement('h2'); row.append(title);
for (const key of Object.keys(defaults)) {
const label = document.createElement('label'), caption = document.createElement('span');
const control = document.createElement(key.endsWith('.title') ? 'input' : 'select');
control.id = key.replaceAll('.', '-'); label.htmlFor = control.id;
if (control.localName === 'input') {control.type = 'text'; control.maxLength = 24;}
else for (const color of colors) {
const option = document.createElement('option'); option.value = color; control.append(option);
}
control.value = values[key];
if (control.localName === 'select' && !colors.includes(values[key])) control.value = defaults[key];
controls[key] = control;
let pending, writes = Promise.resolve();
const save = () => {
clearTimeout(pending);
preview();
const value = control.value.trim() || defaults[key];
writes = writes.then(async () => {
await navigator.mozSettings.createLock().set({[key]:value});
values[key] = value;
}).catch(console.error);
return writes;
};
control.oninput = () => {
preview();
// Keypad edits emit input; closing the lid need not blur the field.
if (control.localName === 'input') {
clearTimeout(pending); pending = setTimeout(save, 250);
}
};
control.onchange = save;
control.onblur = save;
label.append(caption, control); row.append(label);
}
const sample = document.createElement('div'); sample.className = 'vulpes-cover-preview';
previewTitle = document.createElement('div'); previewBattery = document.createElement('div');
sample.append(previewTitle, previewBattery); row.append(sample); list.append(row); labels(); preview();
}
new MutationObserver(insert).observe(document.documentElement, {childList:true, subtree:true});
addEventListener('localized', labels); insert();
}).catch(console.error);
})();

View file

@ -1,4 +1,4 @@
// Tundra's NetworkManager broker, exposed through Gaia's legacy Wi-Fi interface.
// Tundra's native Wi-Fi broker, exposed through Gaia's legacy Wi-Fi interface.
(() => {
if (!window.VulpesNativeWifi || !['system','settings'].includes(location.hostname.split('.')[0])) return;
const {call, request} = VulpesCompat;
@ -7,6 +7,8 @@
connectionInformation:null, macAddress:'',
});
let networks = [], connectionInfo = {};
const securityTypes = value => value === 'open' ? [] :
[['wpa-psk','sae'].includes(value) ? 'WPA-PSK' : 'WPA-EAP'];
function emit(type, values = {}) {
const event = Object.assign(new Event(type), values);
manager.dispatchEvent(event); manager['on' + type]?.(event);
@ -27,6 +29,19 @@
// disconnected must not announce connection information for a missing AP.
if (network) emit('connectioninfoupdate', {network, ...manager.connectionInformation});
}
function applyState(state) {
power(state.enabled === true);
manager.macAddress=state.macAddress || manager.macAddress;
if (state.state) {
connectionInfo=state.connectionInformation || {};
const network=state.state==='connected' && state.network;
update(network ? {
...networks.find(n=>n.ssid===network.ssid),ssid:network.ssid,
security:securityTypes(network.security),
relSignalStrength:network.signal,savedId:network.savedId,connected:true
} : null);
}
}
let scanPending;
function scan() {
if (scanPending) return scanPending;
@ -36,9 +51,10 @@
async function scanNetworks() {
const result = await call('platform.wifi',{operation:'scan'});
power(result.enabled !== false);
manager.macAddress=result.macAddress || manager.macAddress;
connectionInfo=result.connectionInformation || {};
networks = result.networks.map(n => ({ssid:n.ssid, relSignalStrength:n.signal,
security:n.security === 'open' ? [] : [['wpa-psk','sae'].includes(n.security) ? 'WPA-PSK' : 'WPA-EAP'],
security:securityTypes(n.security),
known:n.known === true, connected:n.connected, vulpesId:n.id, savedId:n.savedId}));
update(networks.find(n => n.connected) || null);
return networks;
@ -46,7 +62,7 @@
manager.getNetworks = () => request(scan());
let knownPending;
manager.getKnownNetworks = () => {
if (!knownPending) knownPending = call('platform.wifi',{operation:'known'}).then(r=>r.networks.map(n=>({ssid:n.ssid,security:n.security==='open'?[]:['WPA-PSK'],known:true,savedId:n.id}))).finally(()=>{knownPending=null;});
if (!knownPending) knownPending = call('platform.wifi',{operation:'known'}).then(r=>r.networks.map(n=>({ssid:n.ssid,security:securityTypes(n.security),relSignalStrength:0,known:true,savedId:n.id}))).finally(()=>{knownPending=null;});
return request(knownPending);
};
manager.associate = network => request((async () => {
@ -148,7 +164,7 @@
addEventListener('DOMContentLoaded', async () => {
try {
const state = await call('platform.wifi',{operation:'status'});
power(state.enabled);
applyState(state);
if (location.hostname.startsWith('system.')) {
let queue=Promise.resolve();
navigator.mozSettings.addObserver('wifi.enabled', event => {
@ -158,6 +174,8 @@
try {
const result=await call('platform.wifi',{operation:'setEnabled',enabled});
power(result.enabled,true);
applyState(result);
if(result.enabled) await scan();
if(!result.enabled)await call('settings.set',{values:{'tethering.wifi.enabled':false}});
} catch(error) {
await call('settings.set',{values:{'wifi.enabled':manager.enabled}});
@ -168,20 +186,20 @@
await call('settings.set',{values:{'wifi.enabled':state.enabled}});
} else {
navigator.mozSettings.addObserver('wifi.enabled', () => {
call('platform.wifi',{operation:'status'}).then(s=>power(s.enabled)).catch(console.warn);
call('platform.wifi',{operation:'status'}).then(applyState).catch(console.warn);
});
}
// Settings may receive the preference before the radio operation completes.
// Refresh while visible so its panel also follows hardware power changes.
if(location.hostname.startsWith('settings.')) {
// Settings follows delayed power changes. Adapters with live association
// state also refresh System, so reconnects update the status-bar icon.
if(location.hostname.startsWith('settings.') || state.state) {
let pending=false;
const refresh=setInterval(async()=>{
if(document.hidden || pending) return;
if((location.hostname.startsWith('settings.') && document.hidden) || pending) return;
pending=true;
try {power((await call('platform.wifi',{operation:'status'})).enabled);}
try {applyState(await call('platform.wifi',{operation:'status'}));}
catch(error) {console.warn('Wi-Fi status refresh failed',error);}
finally {pending=false;}
},2000);
},location.hostname.startsWith('settings.') ? 2000 : 5000);
addEventListener('pagehide',()=>clearInterval(refresh),{once:true});
}
if(manager.enabled) await scan();

View file

@ -1,5 +1,20 @@
// Runs only on our remote catalogue. It exposes no privileged API to the page.
// Catalogue controls enter the same local confirmation; no privileged page API.
function installMarketplacePage(win, send) {
if(win.location.origin==='https://ffapps.danielherr.software' && win.location.pathname.startsWith('/app/')) {
const doc=win.document,button=doc.getElementById('install_button');
const request=(element,url)=>element.addEventListener('click',event=>{
if(!event.isTrusted || event.button!==0 || event.ctrlKey || event.metaKey || event.shiftKey)return;
event.preventDefault();event.stopImmediatePropagation();send(url.href);
},true);
if(button?.dataset.manifest==='manifest.webapp') {
button.disabled=false;request(button,new URL('manifest.webapp',win.location.href));
}
for(const link of doc.querySelectorAll('a[download][href]')) {
const url=new URL(link.href);
if(url.origin===win.location.origin && url.pathname.endsWith('.zip'))request(link,url);
}
return;
}
if(win.location.origin!=='https://vulpes-os.org' || !win.location.pathname.startsWith('/marketplace/'))return;
const doc=win.document;
for(const link of doc.querySelectorAll('a[href]')) {

View file

@ -68,6 +68,8 @@
'The modem is not ready yet. Try again shortly.'],
RADIO_BUSY: ['Une opération téléphonique est déjà en cours.',
'A phone operation is already in progress.'],
RADIO_TIMEOUT: ['Le modem ne répond pas. Attendez le retour du réseau avant de réessayer.',
'The modem is not responding. Wait for the network to recover before trying again.'],
RADIO_REQUEST_FAILED: ['Le modem a refusé la demande. Vérifiez la SIM et le réseau mobile.',
'The modem rejected the request. Check the SIM and mobile network.'],
INVALID_NUMBER: ['Numéro invalide.', 'Invalid phone number.'],
@ -88,14 +90,25 @@
};
if (app === 'communications') {
// All Gaia call entry points use the same authorized host operation.
window.VulpesPlatformDial = async number => {
let dialing = null;
window.VulpesPlatformDial = number => {
if (dialing) return dialing;
const button = document.getElementById('keypad-callbar-call-action');
const disabled = button?.disabled;
if (button) { button.disabled = true; button.setAttribute('aria-busy', 'true'); }
dialing = (async () => {
try {
return navigator.mozTelephony ? await navigator.mozTelephony.dial(number) :
await call('platform.dial', {number});
} catch (error) {
failure(error);
throw error;
} finally {
dialing = null;
if (button) { button.disabled = disabled; button.removeAttribute('aria-busy'); }
}
})();
return dialing;
};
}
function button(id, french, english, action) {

View file

@ -1,4 +1,5 @@
/* Navigation matches the Android preview; Gaia owns the application theme. */
[keypad] #home { display: none; }
#home {
appearance: none;
box-sizing: border-box;

View file

@ -2,6 +2,8 @@
const { Runtime } = ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');
const { Views } = ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs');
const fullscreenHost = Services.env.get('VULPES_FULLSCREEN') === '1';
const keypadHost = Services.env.get('VULPES_KEYPAD') === '1';
if (keypadHost) document.documentElement.setAttribute('keypad', '');
if (fullscreenHost) {
// Set native window decoration before the Wayland surface is mapped.
document.documentElement.setAttribute('hidechrome', 'true');
@ -9,6 +11,7 @@ if (fullscreenHost) {
}
window.addEventListener('load', async () => {
try {
if (keypadHost) window.resizeTo(240, 320);
if (fullscreenHost) {
window.fullScreen = true;
let last = '', stableSince = performance.now();
@ -22,6 +25,12 @@ window.addEventListener('load', async () => {
}
window.vulpesStartup = {width:innerWidth,height:innerHeight,dpr:devicePixelRatio,fullscreen:window.fullScreen};
await Runtime.init();
if (keypadHost) ChromeUtils.registerWindowActor('Keypad', {
parent: {esModuleURI:'resource://vulpes/host/actors/KeypadParent.sys.mjs'},
child: {esModuleURI:'resource://vulpes/host/actors/KeypadChild.sys.mjs'},
allFrames:true, safeForUntrustedWebProcess:true,
matches:['http://*/*', 'https://*/*', 'about:blank', 'about:neterror?*', 'about:certerror?*', 'about:httpsonlyerror?*'],
});
Services.ppmm.sharedData.set('vulpes:native-input',Services.env.get('VULPES_TUNDRA')==='1' || Services.env.get('VULPES_TEST_INPUT')==='1');
Services.ppmm.sharedData.flush();
if (Services.env.get('VULPES_TUNDRA') === '1') {
@ -60,10 +69,16 @@ window.addEventListener('load', async () => {
safeForUntrustedWebProcess: true,
matches: Runtime.apps.filter(app=>!app.installed).map((app) => 'http://' + Services.io.newURI(app.origin).host + '/*'),
});
ChromeUtils.registerWindowActor('WebInput', {
parent:{esModuleURI:'resource://vulpes/host/actors/WebInputParent.sys.mjs'},
child:{esModuleURI:'resource://vulpes/host/actors/WebInputChild.sys.mjs',
events:{focusin:{capture:true},pointerdown:{capture:true},mousedown:{capture:true},click:{capture:true}}},
allFrames:true,safeForUntrustedWebProcess:true,matches:['http://*/*','https://*/*'],
});
ChromeUtils.registerWindowActor('VulpesMarketplace', {
parent:{esModuleURI:'resource://vulpes/host/actors/MarketplaceParent.sys.mjs'},
child:{esModuleURI:'resource://vulpes/host/actors/MarketplaceChild.sys.mjs',events:{DOMContentLoaded:{capture:true}}},
allFrames:true,safeForUntrustedWebProcess:true,matches:['https://vulpes-os.org/marketplace/*']
allFrames:true,safeForUntrustedWebProcess:true,matches:['https://vulpes-os.org/marketplace/*','https://ffapps.danielherr.software/app/*']
});
ChromeUtils.registerWindowActor('VulpesTouch', {
child: {
@ -82,6 +97,20 @@ window.addEventListener('load', async () => {
browser.setAttribute('maychangeremoteness', 'true');
browser.style.cssText = 'display:block;width:480px;height:800px';
document.getElementById('screen').append(browser);
if (keypadHost) for (const type of ['keydown','keyup']) window.addEventListener(type, event => {
const direction = event.key === 'AudioVolumeUp' ? 'up' : event.key === 'AudioVolumeDown' ? 'down' : null;
if (!event.isTrusted || !direction) return;
event.preventDefault(); event.stopImmediatePropagation();
if (!event.repeat) Runtime.broadcast('hardware-key', {
type:'volume-'+direction+'-button-'+(type === 'keydown' ? 'press' : 'release')
}, 'system');
}, true);
if (keypadHost) window.addEventListener('keydown', event => {
if (!event.isTrusted || event.ctrlKey || event.altKey || event.metaKey || event.key === 'Shift') return;
event.preventDefault(); event.stopPropagation();
browser.browsingContext.currentWindowGlobal?.getActor('Keypad').sendAsyncMessage('Vulpes:Navigate',
{key:event.key,code:event.code,keyCode:event.keyCode,repeat:event.repeat});
}, true);
// A standalone compositor owns the whole display; desktop windows retain
// their existing phone dimensions unless the launcher requests fullscreen.
{
@ -89,7 +118,7 @@ window.addEventListener('load', async () => {
const homeButton = document.getElementById('home');
const resize = () => {
const landscape = window.innerWidth > window.innerHeight;
const overlay = document.documentElement.hasAttribute('home-active');
const overlay = keypadHost || document.documentElement.hasAttribute('home-active');
const width = Math.max(1, window.innerWidth - (landscape && !overlay ? 44 : 0));
const height = Math.max(1, window.innerHeight - (!landscape && !overlay ? 44 : 0));
screen.style.width = width + 'px';

View file

@ -1052,6 +1052,20 @@ function getImageSize(blob, callback, error) {
else {
metadata.type = 'jpeg';
}
// Modern Gecko decodes EXIF orientation itself, including
// canvas.drawImage(). Gaia must not rotate those pixels twice.
if (metadata.rotation || metadata.mirrored) {
if (metadata.rotation === 90 || metadata.rotation === 270) {
var width = metadata.width;
metadata.width = metadata.height;
metadata.height = width;
}
metadata.rotation = 0;
metadata.mirrored = false;
// Embedded thumbnails need not carry the parent's EXIF tag.
// Generate a matching preview from the oriented full image.
delete metadata.preview;
}
callback(metadata);
},
error);

View file

@ -0,0 +1,351 @@
/**
* WifiUtils is a utils-box that keeps wifi-operations needed utils.
*
* @module WifiUtils
*/
define(function(require) {
'use strict';
var WifiHelper = require('shared/wifi_helper');
var wifiManager = WifiHelper.getWifiManager();
var WifiUtils = {
/**
* Create an explanatory list item
*
* @memberOf WifiUtils
* @param {String} message
* @returns {HTMLLIElement}
*/
newExplanationItem: function(message) {
var li = document.createElement('li');
li.className = 'explanation';
li.setAttribute('data-l10n-id', message);
return li;
},
/**
* Create a network list item
*
* @memberOf WifiUtils
* @param {Object} options
* @returns {HTMLLIElement}
*/
newListItem: function(options) {
/**
* A Wi-Fi list item has the following HTML structure:
* <li>
* <aside class="pack-end wifi-icon level-[?] [secured]"></aside>
* <a>
* <span> Network SSID </span>
* <small> Network Security </small>
* </a>
* </li>
*/
var network = options.network;
var showNotInRange = options.showNotInRange || false;
var onClick = options.onClick || function() {};
// icon
var icon = document.createElement('aside');
icon.classList.add('pack-end');
icon.classList.add('wifi-icon');
var level = Math.min(Math.floor(network.relSignalStrength / 20), 4);
icon.classList.add('level-' + level);
// ssid
var ssid = document.createElement('span');
ssid.dir = 'auto';
ssid.textContent = network.ssid;
// supported authentication methods
var small = document.createElement('small');
var keys = WifiHelper.getSecurity(network);
var networkNotInRange = (network.known && level === 0);
var hasSecurity = (keys && keys.length);
if (hasSecurity) {
if (showNotInRange && networkNotInRange) {
small.setAttribute('data-l10n-id', 'notInRange');
} else {
document.l10n.setAttributes(small, 'securedBy',
{ capabilities: keys.join(', ') });
}
icon.classList.add('secured');
} else {
if (showNotInRange && networkNotInRange) {
small.setAttribute('data-l10n-id', 'notInRange');
} else {
small.setAttribute('data-l10n-id', 'securityOpen');
}
}
var a = document.createElement('a');
a.appendChild(ssid);
a.appendChild(small);
// create list item
var li = document.createElement('li');
li.dataset.ssid = network.ssid;
li.appendChild(icon);
li.appendChild(a);
// Show connection status
icon.classList.add('wifi-signal');
var networkStatus = WifiHelper.getNetworkStatus(network);
var kActiveStatuses = ['associated', 'connecting', 'connected'];
if (-1 !== kActiveStatuses.indexOf(networkStatus)) {
li.classList.add('active');
if ('connected' === networkStatus) {
icon.classList.add('connected');
} else {
icon.classList.add('connecting');
}
small.setAttribute('data-l10n-id', 'short-status-' + networkStatus);
}
// bind connection callback
li.onclick = function() {
onClick(network);
};
return li;
},
/**
* Change dialog layout based on dialogId and security
*
* @memberOf WifiUtils
* @param {HTMLElement} panel
* @param {String} security
*/
changeDisplay: function(panel, security) {
var eap = panel.querySelector('li.eap select');
var identity = panel.querySelector('input[name=identity]');
var password = panel.querySelector('input[name=password]');
var authPhase2 = panel.querySelector('li.auth-phase2 select');
var certificate = panel.querySelector('li.server-certificate select');
var description =
panel.querySelector('li.server-certificate-description');
if (security === 'WEP' || security === 'WPA-PSK') {
identity.parentNode.style.display = 'none';
password.parentNode.style.display = 'block';
authPhase2.parentNode.parentNode.style.display = 'none';
certificate.parentNode.parentNode.style.display = 'none';
description.style.display = 'none';
} else if (security === 'WPA-EAP') {
if (eap) {
switch (eap.value) {
case 'SIM':
identity.parentNode.style.display = 'none';
password.parentNode.style.display = 'none';
authPhase2.parentNode.parentNode.style.display = 'none';
certificate.parentNode.parentNode.style.display = 'none';
description.style.display = 'none';
break;
case 'PEAP':
case 'TLS':
case 'TTLS':
identity.parentNode.style.display = 'block';
password.parentNode.style.display = 'block';
authPhase2.parentNode.parentNode.style.display = 'block';
certificate.parentNode.parentNode.style.display = 'block';
description.style.display = 'block';
break;
default:
break;
}
}
} else {
identity.parentNode.style.display = 'none';
password.parentNode.style.display = 'none';
}
},
/**
* This is used to help us do some initialization works if the panel
* is auth related.
*
* @memberOf WifiUtils
* @param {String} panel
* @param {Object} network
*/
initializeAuthFields: function(panel, network) {
var ssid = panel.querySelector('input[name=ssid]');
var identity = panel.querySelector('input[name=identity]');
var password = panel.querySelector('input[name=password]');
var showPassword = panel.querySelector('gaia-checkbox[name=show-pwd]');
var eap = panel.querySelector('li.eap select');
var certificate = panel.querySelector('li.server-certificate select');
var submitButton = panel.querySelector('button[type=submit]');
// load needed certificates first
this.loadImportedCertificateOptions(certificate);
identity.value = network.identity || '';
password.type = 'password';
password.value = network.password || '';
showPassword.checked = false;
showPassword.onchange = function() {
password.type = this.checked ? 'text' : 'password';
};
var checkPassword = function() {
var key = WifiHelper.getKeyManagement(network);
var isDisabled = !WifiHelper.isValidInput(
key, password.value, identity.value, eap.value);
if (ssid) {
isDisabled = isDisabled || !WifiHelper.isSSIDValid(ssid.value);
}
submitButton.disabled = isDisabled;
};
eap.onchange = function() {
var key = WifiHelper.getKeyManagement(network);
checkPassword();
WifiUtils.changeDisplay(panel, key);
};
if (ssid) {
ssid.oninput = checkPassword;
}
password.oninput = checkPassword;
identity.oninput = checkPassword;
checkPassword();
},
/**
* This is an inner function that used to inject certificates options
* into select element.
*
* @memberOf WifiUtils
* @param {HTMLSelectElement} select
*/
loadImportedCertificateOptions: function(select) {
if (!wifiManager.getImportedCerts) {
return;
}
var certRequest = wifiManager.getImportedCerts();
certRequest.onsuccess = function() {
var i;
var certList = certRequest.result;
// save the imported server certificates
var certificateList = certList.ServerCert;
// reset the option to be <option value="none">--</option> only
var originLengthOfOptions = select.options.length;
for (i = 0; i < originLengthOfOptions - 1; i++) {
select.remove(1);
}
for (i = 0; i < certificateList.length; i++) {
var option = document.createElement('option');
option.text = certificateList[i];
option.value = certificateList[i];
select.add(option, null);
}
};
certRequest.onerror = function() {
console.warn('getImportedCerts failed');
};
},
/**
* Updates the icon of the given network
*
* @memberOf WifiUtils
* @param {Object} network
* @param {Integer} networkSignal
*/
updateNetworkSignal: function(network, networkSignal) {
if (!network) { return; }
var items = document.querySelectorAll('li[data-ssid]');
var li = Array.prototype.find.call(items, function(item) {
return item.dataset.ssid === network.ssid;
});
// Signal updates can arrive before the scan has populated the list.
var icon = li && li.querySelector('aside');
if (!icon) { return; }
// Clean previous state
icon.className = icon.className.replace(/level-\w*/, '');
var level = Math.min(Math.floor(networkSignal / 20), 4);
icon.classList.add('level-' + level);
},
/**
* Get concated networkKey which can be used as identifier
*
* @memberOf WifiUtils
* @param {Object} network
* @return {String} concated network identifier
*/
getNetworkKey: function(network) {
if (!network) {
return '';
} else {
var key =
network.ssid + '+' + WifiHelper.getSecurity(network).join('+');
return key;
}
},
/**
* Reflect incoming network status on related listItem (show different UI)
*
* @memberOf WifiUtils
* @param {Object} options
* @param {Object} options.listItems - listItems with DOM elements
* @param {Object} options.activeItemDOM - DOM element for active item
* @param {Object} options.network - network object
* @param {Object} options.networkStatus - current status for network
*/
updateListItemStatus: function(options) {
options = options || {};
var listItems = options.listItems;
var activeItemDOM = options.activeItemDOM;
var network = options.network;
var networkStatus = options.networkStatus;
if (!network || !networkStatus || !listItems) {
console.log('Please check passing options for updateListItemStatus');
return;
}
var key = this.getNetworkKey(network);
var listItemDOM = listItems[key];
if (activeItemDOM && activeItemDOM != listItemDOM) {
activeItemDOM.classList.remove('active');
activeItemDOM.querySelector('small').
setAttribute('data-l10n-id', 'short-status-disconnected');
activeItemDOM.querySelector('aside').classList.remove('connecting');
activeItemDOM.querySelector('aside').classList.remove('connected');
}
if (listItemDOM) {
listItemDOM.classList.add('active');
listItemDOM.querySelector('small').
setAttribute('data-l10n-id', 'short-status-' + networkStatus);
if (networkStatus === 'connecting' || networkStatus === 'associated') {
listItemDOM.querySelector('aside').classList.remove('connected');
listItemDOM.querySelector('aside').classList.add('connecting');
} else if (networkStatus === 'connected') {
listItemDOM.querySelector('aside').classList.remove('connecting');
listItemDOM.querySelector('aside').classList.add('connected');
}
}
}
};
return WifiUtils;
});

View file

@ -0,0 +1,119 @@
/* exported getImageSize */
/* global BlobView */
/* global parseJPEGMetadata */
/*
* Determine the pixel dimensions of an image without actually
* decoding the image. Passes an object of metadata to the callback
* function on success or an error message to the error function on
* failure. The metadata object will include type, width and height
* properties. Supported image types are GIF, PNG and JPEG. JPEG
* metadata may also include information about an EXIF preview image.
*
* Because of shortcomings in the way Gecko handles images, the
* Gallery app will crash with an OOM error if it attempts to decode
* and display an image that is too big. Images require 4 bytes per
* pixel, so a 10 megapixel photograph requires 40 megabytes of image
* memory. This function gives the gallery app a way to reject images
* that are too large.
*
* Requires the BlobView class from shared/js/blobview.js and the
* parseJPEGMetadata() function from shared/js/media/jpeg_metadata_parser.js
*/
function getImageSize(blob, callback, error) {
'use strict';
BlobView.get(blob, 0, Math.min(1024, blob.size), function(data) {
// Make sure we are at least 8 bytes long before reading the first 8 bytes
if (data.byteLength <= 8) {
error('corrupt image file');
return;
}
var magic = data.getBinaryText(0, 8);
if (magic.substring(0, 4) === 'GIF8') {
try {
callback({
type: 'gif',
width: data.getUint16(6, true),
height: data.getUint16(8, true)
});
}
catch (e) {
error(e.toString());
}
}
else if (magic.substring(0, 8) === '\x89PNG\r\n\x1A\n') {
try {
callback({
type: 'png',
width: data.getUint32(16, false),
height: data.getUint32(20, false)
});
}
catch (e) {
error(e.toString());
}
}
else if (magic.substring(0, 2) === 'BM' &&
data.getUint32(2, true) === blob.size) {
// This is a BMP file
try {
var width, height;
if (data.getUint16(14, true) === 12) { // check format version
width = data.getUint16(18, true); // 16-bit little endian width
height = data.getUint16(20, true); // 16-bit little endian height
}
else { // newer versions of the format use 32-bit ints
width = data.getUint32(18, true); // 32-bit little endian width
height = data.getUint32(22, true); // 32-bit little endian height
}
callback({
type: 'bmp',
width: width,
height: height
});
}
catch (e) {
error(e.toString());
}
}
else if (magic.substring(0, 2) === '\xFF\xD8') {
parseJPEGMetadata(blob,
function(metadata) {
if (metadata.progressive) {
// If the jpeg parser tells us that this is
// a progressive jpeg, then treat that as a
// distinct image type because pjpegs have
// such different memory requirements than
// regular jpegs.
delete metadata.progressive;
metadata.type = 'pjpeg';
}
else {
metadata.type = 'jpeg';
}
// Modern Gecko decodes EXIF orientation itself, including
// canvas.drawImage(). Gaia must not rotate those pixels twice.
if (metadata.rotation || metadata.mirrored) {
if (metadata.rotation === 90 || metadata.rotation === 270) {
var width = metadata.width;
metadata.width = metadata.height;
metadata.height = width;
}
metadata.rotation = 0;
metadata.mirrored = false;
// Embedded thumbnails need not carry the parent's EXIF tag.
// Generate a matching preview from the oriented full image.
delete metadata.preview;
}
callback(metadata);
},
error);
}
else {
error('unknown image type');
}
});
}

View file

@ -0,0 +1,79 @@
{
"search_providers": {
"yahoo": {
"title": "Yahoo",
"search": {
"url": "https://search.yahoo.com/yhs/search",
"params": {
"p": "{searchTerms}"
}
},
"suggest": {
"url": "https://search.yahoo.com/sugg/ff",
"params": {
"command": "{searchTerms}",
"output": "fxjson"
}
}
},
"ddg": {
"title": "DuckDuckGo",
"search": {
"url": "https://duckduckgo.com/",
"params": {
"q": "{searchTerms}"
}
},
"suggest": {
"url": "https://ac.duckduckgo.com/ac/",
"params": {
"q": "{searchTerms}",
"type": "list"
}
}
},
"google": {
"title": "Google",
"search": {
"url": "https://www.google.com/search",
"params": {
"q": "{searchTerms}"
}
},
"suggest": {
"url": "https://www.google.com/complete/search",
"params": {
"q": "{searchTerms}",
"client": "firefox"
}
}
},
"bing": {
"title": "Bing",
"search": {
"url": "https://www.bing.com/search",
"params": {
"q": "{searchTerms}"
}
},
"suggest": {
"url": "https://www.bing.com/osjson.aspx",
"params": {
"query": "{searchTerms}"
}
}
}
},
"simConfigs": {},
"partnerConfig": {},
"defaultEngines": {
"defaultEngine": "ddg",
"providers": {
"yahoo": {},
"google": {},
"bing": {},
"ddg": {}
}
},
"locales": {}
}

View file

@ -1191,6 +1191,18 @@
ModalDialog.selectOne(title, options, selected);
};
AppChrome.prototype.onShare = function ac_onShare() {
this.hideOverflowMenu();
var url = this._currentURL;
if (!url || !/^https?:\/\//i.test(url)) {
return;
}
return new MozActivity({
name: 'share',
data: { type: 'url', url: url }
});
};
AppChrome.prototype.showWindows = function ac_showWindows() {
window.dispatchEvent(
new CustomEvent('taskmanagershow',

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Some files were not shown because too many files have changed in this diff Show more