diff --git a/.gitignore b/.gitignore
index ac57c53e..06812952 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,6 @@ __pycache__/
# Old B2G registry is unused by the modern host and contains historical paths.
/assets/webapps/webapps.json
+
+# Local issue tracking and roadmap; not included in publication.
+/tickets/
diff --git a/README.fr.md b/README.fr.md
index 1d2b8e5c..455b266c 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -3,7 +3,7 @@
Vulpes OS
L'esprit de Firefox OS. Un Gecko moderne. Une base Gaia commune. Un projet communautaire pour retrouver une expérience mobile fondée sur le Web, sur PC, Android et téléphone natif.
-
+
@@ -26,9 +26,9 @@ Ce dépôt contient désormais l'interface commune, l'hôte PC, l'application An
| **APK Android** | Preview installable ; Android et ses permissions restent en dessous | GeckoView **156.0** |
| **Pixel 3a · Sargo** | Sources Tundra ; build natif de développement testé sur notre appareil | Gecko **156.0.1** ARM64 |
-### Android — Preview 15
+### Android — Preview 17
-Téléchargez l'APK et `SHA256SUMS.txt` depuis [la release Preview 15](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15). Une installation par-dessus une ancienne Preview Vulpes signée conserve ses données.
+Téléchargez l'APK et `SHA256SUMS.txt` depuis [la release Preview 17](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.17). Une installation par-dessus une ancienne Preview Vulpes signée conserve ses données.
- Minimum déclaré : **Android 8 / API 26**. Cible : **API 37**.
- APK universel : **ARM64, ARMv7 et x86_64** ; environ **600 Mio**, moteur GeckoView inclus.
@@ -56,9 +56,20 @@ cd VulpesOS
Notre image de développement Sargo installée a passé les contrôles de démarrage et de persistance. Des appels réels et des SMS dans les deux sens ont été observés ; les derniers essais couvrent les conversations, les bascules de capteur photo, la capture JPEG pleine résolution, le focus tactile, le zoom numérique et l'arrêt du vibreur.
-**Cette release ne contient pas d'image générique à flasher.** Le boot privé dépend encore de la disposition du stockage du téléphone inventorié, d'images système/données déjà déployées et d'accès de maintenance. Publier ce fichier ne fournirait pas une installation fonctionnelle à un autre Pixel. Les recettes Tundra sont incluses pour les contributeurs ; elles nécessitent un inventaire, des sauvegardes vérifiées et des composants matériels fournis localement. Ne réutilisez pas la sauvegarde d'un autre appareil et ne considérez pas un boot isolé comme une ROM complète.
+**Une installation autonome est maintenant testée localement.** Le paquet Fastboot fournit le système, un espace de données vierge et les composants matériels associés au noyau. Il remplace `userdata`, `boot_a`, `dtbo_a` et `vbmeta_a` : il n’utilise plus les fichiers d’une ancienne installation Droidian et ne nécessite pas de session Android ni d’ADB. Deux démarrages depuis le stockage interne ont passé chacun onze contrôles Gaia et dix contrôles natifs, avec conservation des réglages.
-Les outils de préparation prennent désormais un dossier privé par appareil : inventaire, sauvegardes, construction RAM, transfert SSH et secours. La procédure reste réservée au noyau qualifié et à un stockage ext4/LVM compatible ; elle ne permet pas encore de partir directement d’Android d’origine.
+L’installation demande un **Pixel 3a (Sargo) déverrouillé** et efface toutes les données de `userdata`. Le bootloader et les calibrations propres au téléphone restent en place. Ce candidat réserve actuellement 8 Gio au volume, dont 1 Gio de données modifiables ; l’extension automatique n’est pas encore implémentée. Il a été testé sur notre Pixel, pas sur chaque combinaison de firmware existante.
+
+**Le paquet autonome n’est pas encore proposé en téléchargement dans cette release.** Les recettes et l’installateur sont préparés ; la revue de provenance et de redistribution des composants matériels reste à terminer. Les sauvegardes et les clés de maintenance du téléphone de développement ne font pas partie du paquet générique.
+
+## 🛠️ Dernières évolutions des sources · 8 octobre 2026
+
+- **Photo :** bascule des capteurs séquencée, libération fiable et transport des images corrigé. Capture avant/arrière, focus, zoom et ouverture dans Galerie testés sur notre Pixel 3a.
+- **Contacts :** fiche actualisée après édition, parcours SMS/sélecteur/Téléphone rétablis et sélection d’une photo dans Galerie. Les tests PC couvrent recherche, recadrage, favoris et suppression ; les tests natifs couvrent création, édition et navigation entre applications.
+- **Marketplace :** installation d’applications intégrée, permissions restreintes et retour contrôlé vers Gaia.
+- **Tundra :** outils de build autonome et de restauration, paquets Bluetooth verrouillés et certificats natifs. La découverte matérielle Bluetooth fonctionne ; l’appairage Gaia et les profils restent à terminer.
+
+**Ces changements de sources sont postérieurs à l’APK Preview 17 publié.** Les ressources Android sont préparées, mais aucun nouvel APK ni image native contenant tous ces correctifs n’est publié. Le dernier candidat natif est testé en RAM ; l’enregistrement cellulaire reste en cours d’analyse et l’audio des appels dans les deux sens n’est pas encore qualifié. L’historique commun PC/Android/Tundra se trouve dans [`changelog.json`](changelog.json).
## ✨ Ce que nous partageons
diff --git a/README.md b/README.md
index f21ba9c7..62c3a23a 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
Vulpes OS
The spirit of Firefox OS. A modern Gecko. One shared Gaia. A community project bringing a web-based phone experience to desktop, Android and native hardware.
-
+
@@ -26,9 +26,9 @@ This repository now contains the shared interface, desktop host, Android applica
| **Android APK** | Installable Preview; keeps Android and its permissions underneath | GeckoView **156.0** |
| **Pixel 3a · Sargo** | Tundra sources; native development build tested on our device | Gecko **156.0.1** ARM64 |
-### Android — Preview 15
+### Android — Preview 17
-Download the APK and `SHA256SUMS.txt` from [the Preview 15 release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.15). Installing over an earlier signed Vulpes Preview preserves its data.
+Download the APK and `SHA256SUMS.txt` from [the Preview 17 release](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.17). Installing over an earlier signed Vulpes Preview preserves its data.
- Minimum declared: **Android 8 / API 26**. Target: **API 37**.
- Universal APK: **ARM64, ARMv7 and x86_64**; approximately **600 MiB** including GeckoView.
@@ -56,9 +56,20 @@ cd VulpesOS
Our installed Sargo development image has passed startup and persistence checks. Real calls and two-way SMS have been observed; the latest checks cover conversation rendering, camera sensor switching, full-resolution JPEG capture, touch focus, digital zoom and bounded vibration.
-**A generic flashable image is not included in this release.** The current private boot depends on the inventoried phone's storage layout, staged system/data images and maintenance credentials. Publishing that file would not give another Pixel a working installation. Tundra's source recipes are included for contributors; they require device inventory, verified backups and locally supplied hardware components. Do not use another device's backup or treat a boot image as a complete ROM.
+**A standalone installation has now been tested locally.** The Fastboot package provides the system, fresh writable storage and the hardware components paired with the kernel. It replaces `userdata`, `boot_a`, `dtbo_a` and `vbmeta_a`: startup no longer uses files from an old Droidian installation and requires neither an Android session nor ADB. Two boots from internal storage passed eleven Gaia and ten native checks each and retained settings.
-Preparation tools now accept a private workspace per device: inventory, backups, RAM builds, SSH transfers and recovery. The procedure remains limited to the qualified kernel and compatible ext4/LVM storage; it does not yet install directly from stock Android.
+Installation requires an **unlocked Pixel 3a (Sargo)** and erases all existing `userdata`. The bootloader and device-specific calibration remain in place. This candidate currently reserves an 8 GiB volume, including 1 GiB of writable data; automatic expansion is not implemented yet. It has been tested on our Pixel, not every existing firmware combination.
+
+**The standalone package is not yet available as a download in this release.** Build recipes and the installer are prepared; hardware component provenance and redistribution review are still pending. Development-phone backups and maintenance keys are excluded from the generic package.
+
+## 🛠️ Latest source improvements · 8 October 2026
+
+- **Camera:** serialized sensor switching, reliable release and binary photo transport. Front/rear capture, focus, zoom and Gallery opening tested on our Pixel 3a.
+- **Contacts:** refreshed details after editing, restored SMS/number-picker/dialer flows, and Gallery photo selection. Desktop tests cover search, cropping, favourites and deletion; native tests cover creation, editing and cross-app navigation.
+- **Marketplace:** integrated application installation with permission restrictions and controlled return to Gaia.
+- **Tundra:** standalone build/recovery tools, pinned Bluetooth runtime packages and native trusted certificates. Bluetooth hardware discovery works; Gaia pairing and profiles remain unfinished.
+
+**These are source updates, newer than the published Preview 17 APK.** Updated Android assets are prepared, but no new APK or native image containing all these fixes is published. The latest native candidate is tested in RAM; cellular registration remains under investigation and two-way call audio is not yet qualified. See [`changelog.json`](changelog.json) for the shared desktop/Android/Tundra history.
## ✨ What is shared
diff --git a/android/app/src/main/java/org/vulpes_os/preview/GaiaServer.java b/android/app/src/main/java/org/vulpes_os/preview/GaiaServer.java
index 6ac6e23b..4319721d 100644
--- a/android/app/src/main/java/org/vulpes_os/preview/GaiaServer.java
+++ b/android/app/src/main/java/org/vulpes_os/preview/GaiaServer.java
@@ -14,11 +14,13 @@ import java.util.zip.*;
final class GaiaServer implements Closeable {
static final int PORT = 18765;
+ final InstalledApps installed;
private final ZipFile zip;
private final ServerSocket socket;
private final ExecutorService workers = Executors.newFixedThreadPool(4);
GaiaServer(Context context) throws IOException {
+ try {installed=new InstalledApps(context);}catch(Exception e){throw new IOException(e);}
File archive = new File(context.getCacheDir(), "gaia.zip");
// Atomic refresh on every process start; the APK is the source of truth.
File temp = new File(context.getCacheDir(), "gaia.zip.tmp");
@@ -94,7 +96,7 @@ final class GaiaServer implements Closeable {
}
String app = host.substring(0, host.indexOf('.'));
if (app.equals("theme")) app = "default_theme";
- if (zip.getEntry(app + "/manifest.webapp") == null) {
+ if (!InstalledApps.valid(app) && zip.getEntry(app + "/manifest.webapp") == null) {
error(out, 404);
return;
}
@@ -109,6 +111,16 @@ final class GaiaServer implements Closeable {
return;
}
if (path.endsWith("/")) path += "index.html";
+ if (InstalledApps.valid(app)) {
+ byte[] body=installed.read(app,path.substring(1));
+ if(body==null){error(out,404);return;}
+ String ext=path.substring(path.lastIndexOf('.')+1).toLowerCase(Locale.ROOT);
+ String mime=MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
+ if(ext.equals("js"))mime="text/javascript";
+ if(ext.equals("webapp"))mime="application/json";
+ if(mime==null)mime="application/octet-stream";
+ out.write(("HTTP/1.1 200 OK\r\nContent-Type: "+mime+"\r\nContent-Length: "+body.length+"\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\nCache-Control: no-store\r\nAccess-Control-Allow-Origin: *\r\nContent-Security-Policy: object-src 'none'; frame-ancestors http://*.localhost:"+PORT+"\r\n\r\n").getBytes(StandardCharsets.US_ASCII));out.write(body);return;
+ }
String key = path.startsWith("/_vulpes/") ? path.substring(1) : app + path;
ZipEntry entry = zip.getEntry(key);
if (entry == null && path.startsWith("/shared/")) entry = zip.getEntry(
diff --git a/android/app/src/main/java/org/vulpes_os/preview/InstalledApps.java b/android/app/src/main/java/org/vulpes_os/preview/InstalledApps.java
new file mode 100644
index 00000000..8ecd8840
--- /dev/null
+++ b/android/app/src/main/java/org/vulpes_os/preview/InstalledApps.java
@@ -0,0 +1,37 @@
+package org.vulpes_os.preview;
+
+import android.content.Context;
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.*;
+import java.util.*;
+import org.json.*;
+
+final class InstalledApps {
+ private final File root;
+ private final Map items = new HashMap<>();
+ InstalledApps(Context context) throws Exception {
+ root=new File(context.getFilesDir(),"installed-apps");root.mkdirs();
+ File[] files=root.listFiles((d,n)->n.matches("user-[a-f0-9]{24}\\.json"));
+ if(files!=null)for(File file:files){JSONObject item=new JSONObject(new String(Files.readAllBytes(file.toPath()),StandardCharsets.UTF_8));items.put(item.getString("id"),item);}
+ }
+ static boolean valid(String id){return id!=null && id.matches("user-[a-f0-9]{24}");}
+ private JSONObject record(JSONObject item) throws Exception {
+ String id=item.getString("id"),origin="http://"+id+".localhost:"+GaiaServer.PORT;
+ JSONObject manifest=new JSONObject(item.getJSONObject("manifest").toString());manifest.put("permissions",new JSONObject());manifest.put("type","web");
+ return new JSONObject().put("id",id).put("origin",origin).put("manifestURL",origin+"/manifest.webapp").put("manifest",manifest).put("removable",true).put("installed",true);
+ }
+ synchronized JSONArray list() throws Exception {JSONArray result=new JSONArray();for(JSONObject item:items.values())result.put(record(item));return result;}
+ synchronized void save(JSONObject item) throws Exception {
+ String id=item.getString("id");if(!valid(id) || !item.has("files"))throw new IOException("INVALID_PACKAGE");
+ byte[] data=item.toString().getBytes(StandardCharsets.UTF_8);if(data.length>70*1024*1024)throw new IOException("PACKAGE_TOO_LARGE");
+ File stage=new File(root,id+".tmp");try(FileOutputStream out=new FileOutputStream(stage)){out.write(data);out.getFD().sync();}
+ Files.move(stage.toPath(),new File(root,id+".json").toPath(),StandardCopyOption.REPLACE_EXISTING);items.put(id,item);
+ }
+ synchronized void remove(String id) throws Exception {if(!valid(id))throw new IOException("NOT_REMOVABLE");Files.deleteIfExists(new File(root,id+".json").toPath());items.remove(id);}
+ synchronized byte[] read(String id,String path) throws Exception {
+ JSONObject item=items.get(id);if(item==null)return null;
+ if(path.equals("manifest.webapp"))return record(item).getJSONObject("manifest").toString().getBytes(StandardCharsets.UTF_8);
+ String data=item.getJSONObject("files").optString(path,null);return data==null?null:android.util.Base64.decode(data,android.util.Base64.DEFAULT);
+ }
+}
diff --git a/android/app/src/main/java/org/vulpes_os/preview/MainActivity.java b/android/app/src/main/java/org/vulpes_os/preview/MainActivity.java
index 221e8f0c..5b4dcba7 100644
--- a/android/app/src/main/java/org/vulpes_os/preview/MainActivity.java
+++ b/android/app/src/main/java/org/vulpes_os/preview/MainActivity.java
@@ -45,8 +45,7 @@ public final class MainActivity extends Activity {
root.setOnApplyWindowInsetsListener((v, insets) -> {
if (Build.VERSION.SDK_INT >= 30) {
android.graphics.Insets bars = insets.getInsets(
- WindowInsets.Type.systemBars() |
- WindowInsets.Type.ime() |
+ WindowInsets.Type.ime() |
WindowInsets.Type.displayCutout()
);
v.setPadding(bars.left, bars.top, bars.right, bars.bottom);
@@ -54,11 +53,12 @@ public final class MainActivity extends Activity {
insets.getSystemWindowInsetLeft(),
insets.getSystemWindowInsetTop(),
insets.getSystemWindowInsetRight(),
- insets.getSystemWindowInsetBottom()
+ insets.getSystemWindowInsetBottom() > dp(100) ? insets.getSystemWindowInsetBottom() : 0
);
return insets;
});
setContentView(root);
+ immersive();
// Respect Android's screen timeout; Gaia must not keep the phone awake indefinitely.
float brightness = app.getSharedPreferences("android", 0).getFloat("brightness", -1);
if (brightness >= 0) {
@@ -166,6 +166,28 @@ public final class MainActivity extends Activity {
}
}
+ private void immersive() {
+ if (Build.VERSION.SDK_INT >= 30) {
+ getWindow().setDecorFitsSystemWindows(false);
+ WindowInsetsController controller = getWindow().getInsetsController();
+ if (controller != null) {
+ controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
+ controller.hide(WindowInsets.Type.systemBars());
+ }
+ } else {
+ getWindow().getDecorView().setSystemUiVisibility(
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN |
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
+ }
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean focused) {
+ super.onWindowFocusChanged(focused);
+ if (focused) immersive();
+ }
+
private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density);
}
@@ -274,7 +296,7 @@ public final class MainActivity extends Activity {
setHomeSurface(app.homeVisible);
}
- private void closeBrowser() {
+ void closeBrowser() {
webPanel.setVisibility(View.GONE);
gaiaView.setVisibility(View.VISIBLE);
setHomeSurface(app.homeVisible);
@@ -356,6 +378,7 @@ public final class MainActivity extends Activity {
public void onResume() {
super.onResume();
resumed = true;
+ immersive();
app.activity = this;
if (recoverGaia) {
recoverGaia = false;
diff --git a/android/app/src/main/java/org/vulpes_os/preview/VulpesApplication.java b/android/app/src/main/java/org/vulpes_os/preview/VulpesApplication.java
index d9baa910..3a46db7e 100644
--- a/android/app/src/main/java/org/vulpes_os/preview/VulpesApplication.java
+++ b/android/app/src/main/java/org/vulpes_os/preview/VulpesApplication.java
@@ -17,9 +17,32 @@ public final class VulpesApplication extends Application {
GeckoSession gaiaSession;
AndroidServices services;
AndroidNotifications notifications;
+ boolean pendingScreenLock;
boolean homeVisible;
boolean homeAtBottom;
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ android.content.BroadcastReceiver receiver = new android.content.BroadcastReceiver() {
+ @Override public void onReceive(android.content.Context context, android.content.Intent intent) {
+ pendingScreenLock = true;
+ sendScreenLock();
+ }
+ };
+ android.content.IntentFilter filter = new android.content.IntentFilter(android.content.Intent.ACTION_SCREEN_OFF);
+ if (android.os.Build.VERSION.SDK_INT >= 33) registerReceiver(receiver,filter,RECEIVER_NOT_EXPORTED);
+ else registerReceiver(receiver,filter);
+ }
+
+ void sendScreenLock() {
+ if (port == null || !pendingScreenLock) return;
+ try {
+ port.postMessage(new JSONObject().put("type","android-screen-off"));
+ pendingScreenLock = false;
+ } catch (Exception error) {Log.w("Vulpes","Screen lock pending",error);}
+ }
+
void prepare() throws IOException {
if (runtime != null) return;
server = new GaiaServer(this);
@@ -58,6 +81,7 @@ public final class VulpesApplication extends Application {
@Override
public void onConnect(WebExtension.Port p) {
port = p;
+ sendScreenLock();
if (activity != null) notifications.click(activity.getIntent());
p.setDelegate(
new WebExtension.PortDelegate() {
@@ -80,6 +104,18 @@ public final class VulpesApplication extends Application {
return GeckoResult.fromException(new SecurityException("EXTENSION_REQUIRED"));
}
JSONObject json = (JSONObject) message;
+ if (json.optString("type").equals("marketplace.return") && activity!=null) {activity.runOnUiThread(()->activity.closeBrowser());return GeckoResult.fromValue(null);}
+ if (json.optString("type").startsWith("packages.")) {
+ GeckoResult result=new GeckoResult<>();
+ new Thread(()->{try {
+ String operation=json.getString("type");
+ if(operation.equals("packages.save"))server.installed.save(json.getJSONObject("item"));
+ else if(operation.equals("packages.remove"))server.installed.remove(json.getString("id"));
+ else if(!operation.equals("packages.list"))throw new IOException("INVALID_OPERATION");
+ result.complete(server.installed.list().toString());
+ }catch(Exception error){result.completeExceptionally(error);}},"Vulpes-packages").start();
+ return result;
+ }
if (json.optString("type").equals("homeScroll")) {
homeAtBottom = json.optBoolean("bottom");
if (activity != null) activity.setHomeSurface(homeVisible);
@@ -124,6 +160,7 @@ public final class VulpesApplication extends Application {
}
void resumed() {
+ sendScreenLock();
if (port == null) return;
try {
port.postMessage(new JSONObject().put("type", "android-resume"));
diff --git a/android/extension/background.js b/android/extension/background.js
index c08d901a..e189b1d5 100644
--- a/android/extension/background.js
+++ b/android/extension/background.js
@@ -1,3 +1,5 @@
+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 { DataStoreService } from './services/datastores.mjs';
@@ -7,6 +9,9 @@ import { Messages } from './Messages.mjs';
import { Connections } from './Connections.mjs';
import { Alarms } from './Alarms.mjs';
const peers = new Set();
+let activities, packages;
+const nativePackages=(operation,data={})=>browser.runtime.sendNativeMessage('vulpes',{type:'packages.'+operation,...data}).then(JSON.parse);
+async function refreshInstalled(){const installed=await nativePackages('list');apps.splice(0,apps.length,...apps.filter(a=>!a.installed),...installed);}
let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage;
const identity = (peer) => peer.app;
function notify(peer, type, data) {
@@ -26,7 +31,7 @@ function grants(app) {
function broadcast(type, data, target) {
for (const peer of peers) {
if (target && peer.app.id !== target) continue;
- if (type === 'settingchange' && !grants(peer.app).includes('settings.read')) continue;
+ if (type === 'settingchange' && data.key !== 'language.current' && !grants(peer.app).includes('settings.read')) continue;
if (
type === 'contactchange' &&
!peer.app.manifest.permissions?.contacts?.access?.includes('read')
@@ -45,7 +50,7 @@ function broadcast(type, data, target) {
notify(peer, type, data);
}
}
-function launch(target, entryPoint, path) {
+function launch(target, entryPoint, path, reload = false) {
path ||=
target.manifest.entry_points?.[entryPoint]?.launch_path ||
target.manifest.launch_path ||
@@ -53,7 +58,7 @@ function launch(target, entryPoint, path) {
const url = new URL(path, target.origin);
if (url.origin !== target.origin) throw Error('INVALID_URL');
broadcast(
- 'launch',
+ reload ? 'activity-launch' : 'launch',
{
manifestURL: target.manifestURL,
origin: target.origin,
@@ -66,6 +71,8 @@ function launch(target, entryPoint, path) {
}
const ready = (async () => {
apps = await (await fetch('registry.json')).json();
+ await refreshInstalled();
+ packages=new Packages({save:item=>nativePackages('save',{item}),changed:refreshInstalled});
const defaults = await (await fetch('defaults.json')).json();
const saved = await browser.storage.local.get('settings');
values = { ...defaults, ...saved.settings };
@@ -82,6 +89,8 @@ const ready = (async () => {
values['wallpaper.image.valid'] = false;
await browser.storage.local.set({ settings: values });
}
+ activities = new Activities({identity,apps:()=>apps,notify,uuid:()=>crypto.randomUUID(),
+ launch:(target,path,{reload=false,entryPoint}={})=>launch(target,entryPoint,path,reload)});
let writes = Promise.resolve();
settings = new SettingsService(
{
@@ -123,6 +132,12 @@ const ready = (async () => {
// Java owns this port. Web pages have no access to native messaging.
let native;
let systemReady = false;
+let pendingScreenLock = false;
+function deliverScreenLock() {
+ if (!systemReady || !pendingScreenLock) return;
+ pendingScreenLock = false;
+ broadcast('screen-off', {}, 'system');
+}
let pendingNotification;
function deliverNotification() {
if (!systemReady || !pendingNotification) return;
@@ -140,7 +155,15 @@ function connectNative() {
native.onMessage.addListener(async (message) => {
await ready;
if (['home', 'holdhome'].includes(message.type)) broadcast(message.type, {}, 'system');
- if (message.type === 'android-resume') await refreshAndroid().catch(console.warn);
+ if (message.type === 'android-screen-off') {
+ pendingScreenLock = true;
+ deliverScreenLock();
+ }
+ if (message.type === 'android-resume') {
+ deliverScreenLock();
+ broadcast('screen-on', {}, 'system');
+ await refreshAndroid().catch(console.warn);
+ }
if (message.type === 'notification-click') {
pendingNotification = message.data;
deliverNotification();
@@ -206,6 +229,7 @@ async function handle(peer, request) {
if (method === 'android.systemReady') {
if (app.id !== 'system') throw Error('PERMISSION_DENIED');
systemReady = true;
+ deliverScreenLock();
deliverNotification();
return null;
}
@@ -278,6 +302,7 @@ async function handle(peer, request) {
}
if (!Object.keys(params.values).length) return null;
}
+ if (method === 'locale.current') return values['language.current'] || 'en-US';
if (method === 'settings.snapshot') {
if (!grants(app).includes('settings.read')) throw Error('PERMISSION_DENIED');
return { ...values };
@@ -287,6 +312,14 @@ async function handle(peer, request) {
if (reply.error) throw Error(reply.error.code);
return reply.result;
}
+ if(method.startsWith('marketplace.')) {
+ if(app.id!=='marketplace')throw Error('PERMISSION_DENIED');
+ if(method==='marketplace.poll'){const url=marketplaceDownload;marketplaceDownload=null;return url;}
+ if(method==='marketplace.cancel'){packages.cancel();return true;}
+ if(method==='marketplace.prepare')return packages.prepare(params.url);
+ if(method==='marketplace.install'){const id=await packages.install(params.token);const installed=apps.find(a=>a.id===id);broadcast('apps-installed',installed);return installed;}
+ throw Error('METHOD_NOT_SUPPORTED');
+ }
if (method === 'apps.self') {
console.info('VULPES app ready', app.id);
return app;
@@ -298,6 +331,10 @@ async function handle(peer, request) {
if (!manage) throw Error('PERMISSION_DENIED');
return apps;
}
+ if(method==='apps.uninstall') {
+ const installed=apps.find(a=>a.id===params.id && a.installed);if(!manage || !installed)throw Error('PERMISSION_DENIED');
+ await nativePackages('remove',{id:params.id});await refreshInstalled();broadcast('apps-uninstalled',installed);return true;
+ }
if (method === 'apps.launch') {
const target = apps.find((a) => a.manifestURL === params.manifestURL);
if (!target || (!manage && app.id !== target.id)) throw Error('PERMISSION_DENIED');
@@ -319,9 +356,13 @@ async function handle(peer, request) {
connections.close(peer, params.id);
return null;
}
+ if (method === 'activities.cancel') return activities.cancel(peer);
+ if (method === 'activities.subscribe') return activities.subscribe(peer);
+ if (method === 'activities.finish') return activities.finish(peer,params);
+ if (method === 'activities.start' && isGaiaActivity(params)) return activities.start(peer,params);
if (method === 'activities.start') {
if (params.name === 'record' && params.data?.type === 'photos') {
- if (app.id === 'gallery') {
+ if (['gallery', 'system'].includes(app.id)) {
const camera = apps.find((a) => a.id === 'camera');
if (!camera) throw Error('ACTIVITY_NOT_SUPPORTED');
launch(camera);
@@ -430,9 +471,21 @@ browser.runtime.onConnect.addListener((port) => {
peers.delete(peer);
if (peer.app.id === 'system' && ![...peers].some((p) => p.app.id === 'system'))
systemReady = false;
+ activities.cleanup(peer);
connections.cleanup(peer);
alarms.cleanup(peer);
})
.catch(() => {});
});
});
+
+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');
+ await ready;marketplaceDownload=packageURL(message.marketplaceDownload);
+ const app=apps.find(a=>a.id==='marketplace');launch(app);
+ await browser.runtime.sendNativeMessage('vulpes',{type:'marketplace.return'});
+ return true;
+});
diff --git a/android/extension/content.js b/android/extension/content.js
index ad150b58..17bbcc85 100644
--- a/android/extension/content.js
+++ b/android/extension/content.js
@@ -1,6 +1,6 @@
/* Extension world: the browser provides origin/tab/frame identity, never Gaia. */
(() => {
- if (location.port !== '18765') return;
+ if (location.port !== '18765' || location.hostname.startsWith('user-')) return;
const win = window.wrappedJSObject;
console.info('VULPES Android bridge', location.origin);
let port;
diff --git a/android/extension/manifest.json b/android/extension/manifest.json
index b5ec06a8..3220ceb0 100644
--- a/android/extension/manifest.json
+++ b/android/extension/manifest.json
@@ -1 +1,45 @@
-{"manifest_version":2,"name":"Vulpes Android host","version":"1.1","browser_specific_settings":{"gecko":{"id":"android-host@vulpes-os.org"}},"permissions":["storage","unlimitedStorage","nativeMessaging","geckoViewAddons","http://*.localhost/*"],"background":{"page":"background.html","persistent":true},"content_scripts":[{"matches":["http://*.localhost/*"],"js":["content.js"],"all_frames":true,"run_at":"document_start"}]}
+{
+ "manifest_version": 2,
+ "name": "Vulpes Android host",
+ "version": "1.1",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "android-host@vulpes-os.org"
+ }
+ },
+ "permissions": [
+ "storage",
+ "unlimitedStorage",
+ "nativeMessaging",
+ "geckoViewAddons",
+ "http://*.localhost/*",
+ "https://vulpes-os.org/*"
+ ],
+ "background": {
+ "page": "background.html",
+ "persistent": true
+ },
+ "content_scripts": [
+ {
+ "matches": [
+ "http://*.localhost/*"
+ ],
+ "js": [
+ "content.js"
+ ],
+ "all_frames": true,
+ "run_at": "document_start"
+ },
+ {
+ "matches": [
+ "https://vulpes-os.org/marketplace/*"
+ ],
+ "js": [
+ "marketplace-page.js",
+ "marketplace-content.js"
+ ],
+ "all_frames": true,
+ "run_at": "document_end"
+ }
+ ]
+}
diff --git a/android/extension/marketplace-content.js b/android/extension/marketplace-content.js
new file mode 100644
index 00000000..496dd521
--- /dev/null
+++ b/android/extension/marketplace-content.js
@@ -0,0 +1 @@
+installMarketplacePage(window,url=>browser.runtime.sendMessage({marketplaceDownload:url}).catch(console.error));
diff --git a/android/tests/settings-codec.test.mjs b/android/tests/settings-codec.test.mjs
index ce863a0b..e43eec1f 100644
--- a/android/tests/settings-codec.test.mjs
+++ b/android/tests/settings-codec.test.mjs
@@ -5,10 +5,10 @@ import { SettingsService } from '../../services/settings.mjs';
const context = vm.createContext({ window: {}, Blob, Uint8Array, btoa, atob });
vm.runInContext(
- await readFile(new URL('../ui/settings-codec.js', import.meta.url), 'utf8'),
+ await readFile(new URL('../../host/settings-codec.js', import.meta.url), 'utf8'),
context,
);
-const { encode, decode } = context.window.VulpesAndroidSettingsCodec;
+const { encode, decode } = context.window.VulpesSettingsCodec;
// A binary wallpaper larger than the original 1 MiB JSON ceiling, including
// every byte value. Exercise the actual service, JSON storage and observer path.
const pixels = Uint8Array.from({ length: 1200000 }, (_, i) => i % 256);
diff --git a/android/tools/pack-assets.py b/android/tools/pack-assets.py
index e3af8394..e6c9e1ed 100644
--- a/android/tools/pack-assets.py
+++ b/android/tools/pack-assets.py
@@ -12,7 +12,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools"))
-from project import build_info, validate_assets, release_info
+from project import build_info, validate_assets, release_info, application_manifest
validate_assets()
OUT = ROOT / "android/app/src/main/assets"
@@ -39,7 +39,7 @@ for manifest in sorted(APPS.glob("*/manifest.webapp")):
id=name,
origin=origin,
manifestURL=origin + "/manifest.webapp",
- manifest=json.loads(translate(manifest.read_text())),
+ manifest=application_manifest(name, json.loads(translate(manifest.read_text())), "--include-dev-apps" in sys.argv),
)
)
info = build_info()
@@ -126,7 +126,7 @@ if "--bridge-only" not in sys.argv or not (OUT / "gaia.zip").exists():
.read_text()
)
put("_vulpes/" + name, translate(text).encode())
- put("_vulpes/settings-codec.js", (ROOT / "android/ui/settings-codec.js").read_bytes())
+ put("_vulpes/settings-codec.js", (ROOT / "host/settings-codec.js").read_bytes())
put("_vulpes/android-ui.js", (ROOT / "android/ui/android-ui.js").read_bytes())
put(
"_vulpes/android.css",
@@ -145,6 +145,7 @@ for name in ["Connections", "Contacts", "Messages"]:
)
(ext / f"{name}.mjs").write_text(text)
shutil.copy2(ROOT / "host/Alarms.sys.mjs", ext / "Alarms.mjs")
+shutil.copy2(ROOT / "host/marketplace-page.js", ext / "marketplace-page.js")
(ext / "release.json").write_text(json.dumps(release))
(ext / "registry.json").write_text(json.dumps(registry, ensure_ascii=False))
settings = json.loads(translate((ROOT / "assets/settings-defaults.json").read_text()))
diff --git a/assets/webapps/communications.gaiamobile.org/contacts/js/activities.js b/assets/webapps/communications.gaiamobile.org/contacts/js/activities.js
index 12cb2a1f..84e333b1 100644
--- a/assets/webapps/communications.gaiamobile.org/contacts/js/activities.js
+++ b/assets/webapps/communications.gaiamobile.org/contacts/js/activities.js
@@ -19,7 +19,7 @@ const VCARD_MIME_TYPES = [
var ActivityHandler = {
_currentActivity: null,
- _launchedAsInlineActivity: (window.location.search == '?pick'),
+ _launchedAsInlineActivity: new URLSearchParams(window.location.search).has('pick'),
mozContactParam: null,
@@ -124,6 +124,7 @@ var ActivityHandler = {
this.launch_activity(activity, 'add-parameters');
break;
case 'pick':
+ if (window.VulpesCompat) this._launchedAsInlineActivity = true;
if (!this._launchedAsInlineActivity) {
return;
}
@@ -356,15 +357,18 @@ var ActivityHandler = {
postNewSuccess: function ah_postNewSuccess(contact) {
this._currentActivity.postResult({ contact: contact });
this._currentActivity = null;
+ if (window.VulpesCompat) this._launchedAsInlineActivity = false;
},
postPickSuccess: function ah_postPickSuccess(result) {
this._currentActivity.postResult(result);
this._currentActivity = null;
+ if (window.VulpesCompat) this._launchedAsInlineActivity = false;
},
postCancel: function ah_postCancel() {
this._currentActivity.postError('canceled');
this._currentActivity = null;
+ if (window.VulpesCompat) this._launchedAsInlineActivity = false;
}
};
diff --git a/assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js b/assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js
index 64dc91a5..4ea36df1 100644
--- a/assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js
+++ b/assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js
@@ -655,7 +655,9 @@ var Contacts = (function() {
utils.PerformanceHelper.contentInteractive();
utils.PerformanceHelper.chromeInteractive();
window.setTimeout(Contacts && Contacts.onLocalized);
- if (window.navigator.mozSetMessageHandler && window.self == window.top) {
+ // Vulpes embeds each app in its own permission-checked frame.
+ if (window.navigator.mozSetMessageHandler &&
+ (window.self == window.top || window.VulpesCompat)) {
LazyLoader.load(['/shared/js/contacts/import/utilities/misc.js',
'/shared/js/contacts/import/utilities/vcard_reader.js',
'/shared/js/contacts/import/utilities/vcard_parser.js'],
diff --git a/assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js b/assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js
index 865eead5..ef5e805c 100644
--- a/assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js
+++ b/assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js
@@ -1038,6 +1038,8 @@ contacts.Form = (function() {
// (Bug 1041455).
if (editingContact) {
Contacts.setCurrent(contact);
+ // The change event may arrive while the edit form is still visible.
+ Contacts.updateContactDetail(contact.id);
}
}
);
diff --git a/assets/webapps/homescreen.gaiamobile.org/gaia_build_defer_index.js b/assets/webapps/homescreen.gaiamobile.org/gaia_build_defer_index.js
index 20c50b10..6b936f7b 100644
--- a/assets/webapps/homescreen.gaiamobile.org/gaia_build_defer_index.js
+++ b/assets/webapps/homescreen.gaiamobile.org/gaia_build_defer_index.js
@@ -846,7 +846,7 @@ Apps.prototype={get iconSize(){if(!this._iconSize){var children=this.icons.child
return this._iconSize;},toggleSmall:function(small){if(this.icons.classList.contains('small')===small){return;}
this.icons.classList.toggle('small',small);this.icons.synchronise();this.refreshGridSize();this.snapScrollPosition();},toggleScrollSnapping:function(scrollSnapping){if(this.scrollable.classList.contains('snapping')===scrollSnapping){return;}
this.scrollable.classList.toggle('snapping',scrollSnapping);this.snapScrollPosition();},onVisualLoad:function(){this.visualLoadComplete=true;this.icons.thaw();this.icons.classList.remove('loading');window.performance.mark('visuallyLoaded');window.performance.mark('contentInteractive');},addApp:function(app){var manifest=app.manifest||app.updateManifest;if(!manifest){return;}
-if(BLACKLIST.includes(app.origin)){return;}
+if(HIDDEN_ROLES.includes(manifest.role)||BLACKLIST.includes(app.origin)){return;}
if(manifest.entry_points){for(var entryPoint in manifest.entry_points){this.addAppIcon(app,entryPoint);}}else{this.addAppIcon(app);}},addIconContainer:function(icon,entry){var container=document.createElement('div');container.classList.add('icon-container');container.order=-1;container.appendChild(icon);if(entry!==-1&&this.startupMetadata[entry].order>=0){container.order=this.startupMetadata[entry].order;var children=this.icons.children;for(var i=0,iLen=children.length;iMarketplace
+
+
+
+
diff --git a/assets/webapps/marketplace.gaiamobile.org/manifest.webapp b/assets/webapps/marketplace.gaiamobile.org/manifest.webapp
new file mode 100644
index 00000000..8cded036
--- /dev/null
+++ b/assets/webapps/marketplace.gaiamobile.org/manifest.webapp
@@ -0,0 +1,19 @@
+{
+ "name": "Marketplace",
+ "description": "Vulpes OS application catalogue",
+ "launch_path": "/index.html",
+ "type": "certified",
+ "icons": {
+ "512": "/icon.png"
+ },
+ "permissions": {
+ "browser": {},
+ "webapps-manage": {}
+ },
+ "locales": {
+ "fr": {
+ "name": "Marketplace",
+ "description": "Catalogue des applications Vulpes OS"
+ }
+ }
+}
diff --git a/assets/webapps/marketplace.gaiamobile.org/marketplace.css b/assets/webapps/marketplace.gaiamobile.org/marketplace.css
new file mode 100644
index 00000000..0125ce10
--- /dev/null
+++ b/assets/webapps/marketplace.gaiamobile.org/marketplace.css
@@ -0,0 +1 @@
+html,body{width:100%;height:100%;margin:0;background:#f4f4f4;font-family:Fira Sans,sans-serif}#catalogue{position:absolute;inset:0;width:100%;height:100%;border:0}#message{padding:3rem 2rem;text-align:center;font-size:1.8rem}#message button{display:block;width:100%;padding:1rem;margin:1rem 0;font:inherit}#open[hidden],[hidden]{display:none!important}#confirm{z-index:10}#confirm section{overflow:auto}#permissions{font-size:1.5rem}
diff --git a/assets/webapps/marketplace.gaiamobile.org/marketplace.js b/assets/webapps/marketplace.gaiamobile.org/marketplace.js
new file mode 100644
index 00000000..84dbc0c5
--- /dev/null
+++ b/assets/webapps/marketplace.gaiamobile.org/marketplace.js
@@ -0,0 +1,32 @@
+(async()=>{
+ const frame=document.getElementById('catalogue'),message=document.getElementById('message'),dialog=document.getElementById('confirm');
+ let attempt=0,busy=false,prepared=null,installed=null,language=await VulpesCompat.call('locale.current');
+ const fr=()=>/^fr(?:-|$)/i.test(language);
+ const text=(f,e)=>fr()?f:e;
+ function labels(){document.getElementById('browse').textContent=text('Ouvrir le catalogue','Open catalogue');document.getElementById('open').textContent=text('Ouvrir','Open');document.getElementById('install').textContent=text('Installer','Install');document.getElementById('cancel').textContent=text('Annuler','Cancel');}
+ function loadFailed(){status(text('Catalogue inaccessible. Vérifiez la connexion Internet, puis réessayez.','Catalogue unavailable. Check your Internet connection, then try again.'));document.getElementById('open').hidden=true;}
+ frame.addEventListener('mozbrowsererror',loadFailed);
+ function browse(){labels();message.hidden=true;dialog.hidden=true;frame.style.visibility='visible';frame.src='https://vulpes-os.org/marketplace/?mode=app&lang='+(fr()?'fr':'en');}
+ function status(value){frame.style.visibility='hidden';message.hidden=false;document.getElementById('status').textContent=value;}
+ async function prepare(url){
+ if(busy)return;const current=++attempt;busy=true;prepared=null;installed=null;document.getElementById('open').hidden=true;
+ status(text('Vérification de l’application…','Checking application…'));
+ try {const value=await VulpesCompat.call('marketplace.prepare',{url});if(current!==attempt)return;prepared=value;
+ document.getElementById('title').textContent=prepared.name;
+ document.getElementById('question').textContent=text('Installer cette application archivée ?','Install this archived application?')+' ('+(prepared.bytes/1048576).toFixed(1)+' Mio)';
+ document.getElementById('permissions').textContent=text('Elle sera exécutée comme une application web, sans accès aux fonctions système. Certains anciens services peuvent ne plus fonctionner.','It will run as a web application without system access. Some old services may no longer work.');
+ message.hidden=true;dialog.hidden=false;
+ }catch(error){if(current!==attempt)return;status(text('Installation impossible : ','Cannot install: ')+error.message);busy=false;}
+ }
+ dialog.addEventListener('submit',async event=>{event.preventDefault();if(!prepared)return;const token=prepared.token;prepared=null;dialog.hidden=true;status(text('Installation…','Installing…'));
+ try {installed=await VulpesCompat.call('marketplace.install',{token});status(text('Application ajoutée à l’accueil.','Application added to Home.'));document.getElementById('open').hidden=false;}
+ catch(error){status(text('Installation impossible : ','Cannot install: ')+error.message);}finally{busy=false;}
+ });
+ const cancel=()=>{attempt++;prepared=null;busy=false;VulpesCompat.call('marketplace.cancel').catch(console.error);browse();};
+ document.getElementById('cancel').onclick=cancel;
+ document.getElementById('browse').onclick=cancel;
+ document.getElementById('open').onclick=()=>installed && VulpesCompat.call('apps.launch',{manifestURL:installed.manifestURL});
+ addEventListener('languagechange',()=>{language=navigator.language;if(!busy)browse();});
+ browse();
+ setInterval(async()=>{if(busy)return;try{const url=await VulpesCompat.call('marketplace.poll');if(url)await prepare(url);}catch(error){console.error(error);}},750);
+})();
diff --git a/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js b/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js
index ecc52c01..3ec99130 100644
--- a/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js
+++ b/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js
@@ -471,7 +471,7 @@ this.continue();}else{resolve(results);}};cursor.onerror=function(){if(this.erro
return{remove:remove,get:get,list:list};})();;'use strict';function TonePlayer(){this._currentTone=null;this._isValid=true;this._player=new Audio();this._player.addEventListener('loadedmetadata',function(){if(this._player.src){this._isValid=true;this._player.dispatchEvent(new CustomEvent('validated',{detail:this._isValid}));}}.bind(this));this._player.addEventListener('error',function(){if(this._player.src){this._isValid=false;this._player.dispatchEvent(new CustomEvent('validated',{detail:this._isValid}));}}.bind(this));this._player.addEventListener('ended',function(){this._firePlayingCallback(false);}.bind(this));window.addEventListener('visibilitychange',function(){if(document.hidden){this.stop();this._setExclusiveMode(false);}}.bind(this));}
TonePlayer.prototype={setTone:function(tone,callback){if(tone!==this._currentTone){this._firePlayingCallback(false);this._currentTone=tone;this._playingCallback=callback;if(tone&&tone.url){this._isValid=undefined;this._player.src=tone.url;this._player.play();this._firePlayingCallback(true);this._setExclusiveMode(true);}else{this._isValid=true;this._player.removeAttribute('src');this._player.load();}}else{if(!this._isValid){return;}
if(this._player.paused||this._player.ended){this._player.currentTime=0;this._player.play();this._firePlayingCallback(true);this._setExclusiveMode(true);}else{this._player.pause();this._firePlayingCallback(false);}}},stop:function(){this._player.pause();this._firePlayingCallback(false);},get currentTone(){return this._currentTone;},isValid:function(callback){if(this._isValid!==undefined){callback(this._isValid);return;}
-this._player.addEventListener('validated',function validated(event){this.removeEventListener('validated',validated);callback(event.detail);});},_firePlayingCallback:function(playing){if(this._playingCallback){this._playingCallback(playing);}},_setExclusiveMode:function(exclusive){if(exclusive){if(!this._source){this._context=new AudioContext('ringer');this._source=this._context.createMediaElementSource(this._player);this._source.connect(this._context.destination);}}else{if(this._source){this._source.disconnect();this._context=null;this._source=null;}}}};;'use strict';var ToneList=(function(){var NONE_ID='none:none';function toneCompare(a,b){if(a.id===NONE_ID&&b.id===NONE_ID){return 0;}else if(a.id===NONE_ID){return-1;}else if(b.id===NONE_ID){return 1;}
+this._player.addEventListener('validated',function validated(event){this.removeEventListener('validated',validated);callback(event.detail);});},_firePlayingCallback:function(playing){if(this._playingCallback){this._playingCallback(playing);}},_setExclusiveMode:function(exclusive){if(exclusive){if(!this._source){this._context=new AudioContext();this._source=this._context.createMediaElementSource(this._player);this._source.connect(this._context.destination);}}else{if(this._source){this._source.disconnect();this._context=null;this._source=null;}}}};;'use strict';var ToneList=(function(){var NONE_ID='none:none';function toneCompare(a,b){if(a.id===NONE_ID&&b.id===NONE_ID){return 0;}else if(a.id===NONE_ID){return-1;}else if(b.id===NONE_ID){return 1;}
var aName=a.name.toLocaleLowerCase();var bName=b.name.toLocaleLowerCase();var cmp=aName.localeCompare(bName);if(cmp){return cmp;}
var aSubtitle=(a.subtitle||'').toLocaleLowerCase();var bSubtitle=(b.subtitle||'').toLocaleLowerCase();return aSubtitle.localeCompare(bSubtitle);}
function domify(htmlText){var dummyDiv=document.createElement('div');dummyDiv.innerHTML=htmlText;var element=dummyDiv.firstElementChild;return element;}
diff --git a/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js b/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js
index 22920cc3..504d9d47 100644
--- a/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js
+++ b/assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js
@@ -447,7 +447,7 @@ this.continue();}else{resolve(results);}};cursor.onerror=function(){if(this.erro
return{remove:remove,get:get,list:list};})();;;'use strict';function TonePlayer(){this._currentTone=null;this._isValid=true;this._player=new Audio();this._player.addEventListener('loadedmetadata',function(){if(this._player.src){this._isValid=true;this._player.dispatchEvent(new CustomEvent('validated',{detail:this._isValid}));}}.bind(this));this._player.addEventListener('error',function(){if(this._player.src){this._isValid=false;this._player.dispatchEvent(new CustomEvent('validated',{detail:this._isValid}));}}.bind(this));this._player.addEventListener('ended',function(){this._firePlayingCallback(false);}.bind(this));window.addEventListener('visibilitychange',function(){if(document.hidden){this.stop();this._setExclusiveMode(false);}}.bind(this));}
TonePlayer.prototype={setTone:function(tone,callback){if(tone!==this._currentTone){this._firePlayingCallback(false);this._currentTone=tone;this._playingCallback=callback;if(tone&&tone.url){this._isValid=undefined;this._player.src=tone.url;this._player.play();this._firePlayingCallback(true);this._setExclusiveMode(true);}else{this._isValid=true;this._player.removeAttribute('src');this._player.load();}}else{if(!this._isValid){return;}
if(this._player.paused||this._player.ended){this._player.currentTime=0;this._player.play();this._firePlayingCallback(true);this._setExclusiveMode(true);}else{this._player.pause();this._firePlayingCallback(false);}}},stop:function(){this._player.pause();this._firePlayingCallback(false);},get currentTone(){return this._currentTone;},isValid:function(callback){if(this._isValid!==undefined){callback(this._isValid);return;}
-this._player.addEventListener('validated',function validated(event){this.removeEventListener('validated',validated);callback(event.detail);});},_firePlayingCallback:function(playing){if(this._playingCallback){this._playingCallback(playing);}},_setExclusiveMode:function(exclusive){if(exclusive){if(!this._source){this._context=new AudioContext('ringer');this._source=this._context.createMediaElementSource(this._player);this._source.connect(this._context.destination);}}else{if(this._source){this._source.disconnect();this._context=null;this._source=null;}}}};;;'use strict';var ToneList=(function(){var NONE_ID='none:none';function toneCompare(a,b){if(a.id===NONE_ID&&b.id===NONE_ID){return 0;}else if(a.id===NONE_ID){return-1;}else if(b.id===NONE_ID){return 1;}
+this._player.addEventListener('validated',function validated(event){this.removeEventListener('validated',validated);callback(event.detail);});},_firePlayingCallback:function(playing){if(this._playingCallback){this._playingCallback(playing);}},_setExclusiveMode:function(exclusive){if(exclusive){if(!this._source){this._context=new AudioContext();this._source=this._context.createMediaElementSource(this._player);this._source.connect(this._context.destination);}}else{if(this._source){this._source.disconnect();this._context=null;this._source=null;}}}};;;'use strict';var ToneList=(function(){var NONE_ID='none:none';function toneCompare(a,b){if(a.id===NONE_ID&&b.id===NONE_ID){return 0;}else if(a.id===NONE_ID){return-1;}else if(b.id===NONE_ID){return 1;}
var aName=a.name.toLocaleLowerCase();var bName=b.name.toLocaleLowerCase();var cmp=aName.localeCompare(bName);if(cmp){return cmp;}
var aSubtitle=(a.subtitle||'').toLocaleLowerCase();var bSubtitle=(b.subtitle||'').toLocaleLowerCase();return aSubtitle.localeCompare(bSubtitle);}
function domify(htmlText){var dummyDiv=document.createElement('div');dummyDiv.innerHTML=htmlText;var element=dummyDiv.firstElementChild;return element;}
diff --git a/assets/webapps/ringtones.gaiamobile.org/js/tone_player.js b/assets/webapps/ringtones.gaiamobile.org/js/tone_player.js
index 18cf4537..dfb17d68 100644
--- a/assets/webapps/ringtones.gaiamobile.org/js/tone_player.js
+++ b/assets/webapps/ringtones.gaiamobile.org/js/tone_player.js
@@ -1,4 +1,4 @@
;;'use strict';function TonePlayer(){this._currentTone=null;this._isValid=true;this._player=new Audio();this._player.addEventListener('loadedmetadata',function(){if(this._player.src){this._isValid=true;this._player.dispatchEvent(new CustomEvent('validated',{detail:this._isValid}));}}.bind(this));this._player.addEventListener('error',function(){if(this._player.src){this._isValid=false;this._player.dispatchEvent(new CustomEvent('validated',{detail:this._isValid}));}}.bind(this));this._player.addEventListener('ended',function(){this._firePlayingCallback(false);}.bind(this));window.addEventListener('visibilitychange',function(){if(document.hidden){this.stop();this._setExclusiveMode(false);}}.bind(this));}
TonePlayer.prototype={setTone:function(tone,callback){if(tone!==this._currentTone){this._firePlayingCallback(false);this._currentTone=tone;this._playingCallback=callback;if(tone&&tone.url){this._isValid=undefined;this._player.src=tone.url;this._player.play();this._firePlayingCallback(true);this._setExclusiveMode(true);}else{this._isValid=true;this._player.removeAttribute('src');this._player.load();}}else{if(!this._isValid){return;}
if(this._player.paused||this._player.ended){this._player.currentTime=0;this._player.play();this._firePlayingCallback(true);this._setExclusiveMode(true);}else{this._player.pause();this._firePlayingCallback(false);}}},stop:function(){this._player.pause();this._firePlayingCallback(false);},get currentTone(){return this._currentTone;},isValid:function(callback){if(this._isValid!==undefined){callback(this._isValid);return;}
-this._player.addEventListener('validated',function validated(event){this.removeEventListener('validated',validated);callback(event.detail);});},_firePlayingCallback:function(playing){if(this._playingCallback){this._playingCallback(playing);}},_setExclusiveMode:function(exclusive){if(exclusive){if(!this._source){this._context=new AudioContext('ringer');this._source=this._context.createMediaElementSource(this._player);this._source.connect(this._context.destination);}}else{if(this._source){this._source.disconnect();this._context=null;this._source=null;}}}};
\ No newline at end of file
+this._player.addEventListener('validated',function validated(event){this.removeEventListener('validated',validated);callback(event.detail);});},_firePlayingCallback:function(playing){if(this._playingCallback){this._playingCallback(playing);}},_setExclusiveMode:function(exclusive){if(exclusive){if(!this._source){this._context=new AudioContext();this._source=this._context.createMediaElementSource(this._player);this._source.connect(this._context.destination);}}else{if(this._source){this._source.disconnect();this._context=null;this._source=null;}}}};
\ No newline at end of file
diff --git a/changelog.json b/changelog.json
index 81936731..4efc193f 100644
--- a/changelog.json
+++ b/changelog.json
@@ -5,6 +5,69 @@
"en": "# Changelog — Vulpes OS\n\n[Français](CHANGELOG.md)\n\nOne Gaia base, three platforms: **desktop**, **Android (APK)** and **Tundra / Sargo**.\nThis journal covers visible changes and major technical milestones.\n\n**Status:** “In development” refers to local sources, “APK built” to a test package,\nand “Published” to a public repository release. Shared source changes reach installed\nAPKs and phone images only after those packages are updated."
},
"entries": [
+ {
+ "id": "2026-10-08-source-publication",
+ "text": {
+ "fr": "## 8 octobre 2026 — Mise à jour des sources publiques\n\n### Commun · PC / Android / Tundra\n\n- Regrouper les correctifs récents de caméra, Contacts, SMS, Marketplace et les outils de construction et de restauration Sargo dans les sources publiques. Mettre à jour les README français et anglais avec les capacités et limites vérifiées.\n- Corriger le contrôle de parité Android pour créer son dossier de rapports sur une copie neuve du dépôt. Vérifier que la configuration Wi-Fi native embarque un répertoire de connexions vide.\n- Conserver les tickets, feuilles de route, documents internes, données personnelles et clés hors du dépôt.\n\n### Paquets et qualification\n\n- L’APK public reste Preview17 : les correctifs du 8 octobre nécessitent une prochaine compilation. Aucune nouvelle image native publiée ni flash dans cette étape.\n- Sur Sargo, l’enregistrement cellulaire reste en recherche lors du dernier contrôle ; audio bidirectionnel, appairage Bluetooth et profils restent à qualifier.",
+ "en": "## 8 October 2026 — Public source update\n\n### Shared · Desktop / Android / Tundra\n\n- Bring recent camera, Contacts, SMS and Marketplace fixes, plus Sargo build and recovery tools, into the public sources. Update both READMEs with verified capabilities and limitations.\n- Make the Android parity check create its report directory in a fresh checkout. Verify that native Wi-Fi configuration ships an empty connections directory.\n- Keep tickets, roadmaps, internal documents, personal data and keys outside the repository.\n\n### Packages and qualification\n\n- The public APK remains Preview17; the 8 October fixes require a future build. No new native image or flash in this step.\n- At the latest Sargo check, cellular registration is still searching; two-way call audio, Bluetooth pairing and profiles remain to be qualified."
+ }
+ },
+ {
+ "id": "development-2026-10-08-camera-lifecycle",
+ "text": {
+ "fr": "## 8 octobre 2026 — Caméra et parcours Contacts · en développement\n\n### Commun · Gaia / PC / Android / Tundra\n\n- Sérialiser les ouvertures de caméra, attendre la fermeture du capteur précédent et libérer les pistes et ressources vidéo même après un échec. Stabiliser l’identification avant/arrière sur PC et Android.\n- Conserver les images binaires lors des échanges PC/Tundra ; encoder explicitement les photos et dates des contacts pour le pont JSON Android.\n- Corriger l’actualisation de la fiche après modification d’un contact. Rétablir les activités Contacts → SMS, sélection d’un numéro et ouverture du pavé numérique, en préservant les demandes imbriquées et les points d’entrée de Gaia.\n- Réouvrir Galerie en mode sélection pour ajouter une photo à un contact, même lorsque Galerie est déjà ouverte en consultation. Sélection, recadrage et enregistrement de la photo validés sur PC.\n- Ajouter des tests de caméra, de stockage des contacts, de permissions, de transport binaire et de routage des activités.\n\n### PC\n\n- Vérifier le parcours original Appareil photo → capture JPEG → Galerie → ouverture de la photo → retour caméra.\n- Vérifier dans Gaia : création et modification d’un contact, actualisation de la fiche, recherche sans accents et sans résultats, favoris, destinataire SMS, sélection d’un numéro, pavé numérique prérempli, annulation puis confirmation de suppression. Le test n’émet ni appel ni SMS et supprime son contact temporaire.\n\n### Tundra · Sargo\n\n- Vérifier en RAM sur Pixel 3a : autofocus, zoom, quatre alternances avant/arrière avec nouvelles trames, capture JPEG depuis Gaia, stockage, ouverture dans Galerie et arrêt du capteur à l’accueil.\n- Vérifier les conversations déjà stockées, le clavier et les notifications. Recherche Wi-Fi : quatre réseaux détectés. Nouvelle émission/réception SMS et audio des appels encore en qualification.\n- Ajouter les paquets BlueZ et les bibliothèques nécessaires, verrouillés par SHA256. Le contrôleur Bluetooth démarre, s’active et lance une recherche ; l’appairage depuis Gaia et les profils ne sont pas encore validés.\n- Ajouter le magasin de certificats natif : HTTPS vérifié sans désactivation de TLS. Tester les archives altérées, collisions de fichiers et reconstructions répétées.\n\n- Corriger les fichiers Contacts embarqués dans le build de démarrage : la création, l’édition, le passage vers SMS, le sélecteur et le pavé numérique passent aussi avec les gestes tactiles sur Sargo.\n\n### Android · APK\n\n- Publier l’APK Preview17 existant, avec téléchargement et SHA256 vérifiés. Il précède les correctifs du 8 octobre.\n- Corriger la transmission des destinataires et du texte de Gaia vers l’application SMS Android, y compris la réponse à une conversation et le numéro saisi sans validation préalable. Tests du pont réussis ; essai Android réel encore nécessaire.\n- Réempaqueter les ressources communes corrigées pour la prochaine compilation ; aucun nouvel APK incluant ces correctifs n’est publié.\n\n**État :** builds natifs de qualification démarrés en RAM. Aucun flash, push de code ni publication de documentation privée dans cette étape. La qualification complète des fonctions de téléphone reste ouverte.\n",
+ "en": "## 8 October 2026 — Camera and Contacts workflows · in development\n\n### Shared · Gaia / Desktop / Android / Tundra\n\n- Serialize camera opening, await the previous sensor shutdown and release tracks and video resources after failures. Keep stable front/rear identities on desktop and Android.\n- Preserve binary images across desktop/Tundra transport; explicitly encode contact photos and dates for Android's JSON bridge.\n- Refresh contact details after editing. Restore Contacts → SMS, number picking and prefilled dialer activities, preserving nested requests and Gaia entry points.\n- Reopen Gallery in picker mode when adding a contact photo, even when Gallery is already open for browsing. Selection, cropping and photo persistence verified on desktop.\n- Add camera lifecycle, contact persistence, permissions, binary transport and activity routing tests.\n\n### Desktop\n\n- Verify the original Camera → JPEG capture → Gallery → photo opening → camera workflow.\n- Verify Gaia contact creation, editing, detail refresh, accent-insensitive search and empty results, favourites, SMS recipient, number picker, prefilled keypad, cancelled deletion and confirmed deletion. The test sends no message and makes no call; its temporary contact is removed.\n\n### Tundra · Sargo\n\n- Verify on a RAM-booted Pixel 3a: autofocus, zoom, four front/rear switches with fresh frames, shutter capture, JPEG persistence, Gallery opening and sensor shutdown on Home.\n- Check stored conversations, keyboard and notifications. Wi-Fi scan finds four networks. New SMS transmission/reception and call audio are still being qualified.\n- Add SHA256-pinned BlueZ packages and required libraries. The controller starts, powers on and discovers; pairing through Gaia and Bluetooth profiles are not yet validated.\n- Add native trusted certificates and verify HTTPS without disabling TLS. Test corrupted archives, file collisions and repeatable staging.\n\n- Include the corrected Contacts files in the boot build: contact creation, editing, SMS handoff, picking and dialer prefilling also pass using touch gestures on Sargo.\n\n### Android · APK\n\n- Publish the existing Preview17 APK and verify its download and SHA256. It predates the 8 October fixes.\n- Fix recipient and body handoff from Gaia to the Android SMS app, including replies and a typed recipient that was not yet committed. Bridge tests pass; real Android validation is still required.\n- Repackage corrected shared resources for the next build; no APK containing these new fixes has been published.\n\n**Status:** native qualification builds booted in RAM. No flashing, source push or private documentation publication in this step. Full phone qualification remains open.\n"
+ }
+ },
+ {
+ "id": "development-2026-10-06-marketplace-theme",
+ "text": {
+ "fr": "## 6 octobre 2026 — Thème Marketplace intégré · en développement\n\n### Commun · Gaia / PC / Android / Tundra\n\n- Ouvrir le catalogue avec `mode=app`, conservé dans les liens, la recherche, les filtres, les fiches et le changement de langue.\n- Préparer un thème web Vulpes dédié : Fira locale, navigation compacte, boutons tactiles, cartes lisibles, filtres repliables et captures défilantes. Retirer les en-têtes du site dans le mode intégré, tout en conservant l’interface du site classique.\n- Conserver les confirmations et messages système côté Gaia. Le thème ne donne aucun privilège supplémentaire aux pages du catalogue.\n- Vérifier 16 pages FR/EN, 2 pages en mode classique et les ressources du thème. Rendus mobiles de 320 et 360 pixels inspectés. Lanceurs PC et Sargo mis à jour ; les sources seront reprises au prochain empaquetage Android.\n\nFichiers du site prêts localement, non déployés sur le serveur public. Aucun push ni nouvelle publication APK/ROM.\n",
+ "en": "## 6 October 2026 — Embedded Marketplace theme · in development\n\n### Shared · Gaia / Desktop / Android / Tundra\n\n- Open the catalogue with `mode=app`, retained in navigation, search, filters, app details and language switches.\n- Prepare a dedicated Vulpes web theme with local Fira fonts, compact navigation, touch-friendly controls, readable cards, collapsible filters and scrolling screenshots. Omit website headers in embedded mode while preserving the standard website layout.\n- Keep confirmations and system messages in Gaia. The theme grants catalogue pages no additional privileges.\n- Check 16 French/English pages, 2 standard pages and theme assets. Inspect mobile renders at 320 and 360 pixels. Desktop and Sargo launchers updated; shared sources will be included in the next Android packaging run.\n\nWebsite files prepared locally, not deployed to the public server. No push or new APK/ROM publication.\n"
+ }
+ },
+ {
+ "id": "development-2026-10-06-system-banner",
+ "text": {
+ "fr": "## 6 octobre 2026 — Bandeaux Gaia · en développement\n\n### Commun · Gaia / PC / Android / Tundra\n\n- Faire disparaître le bandeau « Application installée » après son délai normal, même si la fin d’animation ne remonte pas du composant Gaia. Conserver son apparence et permettre sa fermeture au toucher.\n- Annuler les anciens délais lorsqu’un nouveau message remplace le précédent pour éviter une disparition prématurée.\n- Vérifier sur PC l’affichage, l’expiration, le remplacement et la fermeture au clic ; vérifier sur le Pixel le retrait effectif du bandeau après cinq secondes. Correctif déployé sur l’essai Sargo en RAM et disponible dans les sources communes pour le prochain APK.\n\nAucun flash ni publication dans cette étape.\n",
+ "en": "## 6 October 2026 — Gaia banners · in development\n\n### Shared · Gaia / Desktop / Android / Tundra\n\n- Dismiss the “Application installed” banner after its normal timeout even if the animation-end event does not reach the Gaia component host. Keep its appearance and tap-to-dismiss behavior.\n- Cancel old timers when a new message replaces the previous one, preventing premature dismissal.\n- Check display, expiry, replacement and click dismissal on desktop; verify actual removal after five seconds on the Pixel. Fix deployed to the Sargo RAM trial and available in shared sources for the next APK.\n\nNo flash or publication in this step.\n"
+ }
+ },
+ {
+ "id": "development-2026-10-06-wifi",
+ "text": {
+ "fr": "## 6 octobre 2026 — Wi-Fi et panneaux Paramètres · en développement\n\n### Commun · Gaia / PC / Android / Tundra\n\n- Terminer l’ouverture d’un panneau Paramètres même si Gecko ne produit pas l’événement de fin d’animation. Conserver les transitions Gaia et éviter les doubles initialisations.\n- Correctif dans les sources communes et le serveur PC ; inclus au prochain empaquetage Android. Aucun nouvel APK publié dans cette étape.\n\n### Tundra · Sargo\n\n- Corriger les événements de signal Wi-Fi : transmettre le réseau associé et ne pas annoncer de signal de connexion lorsque le téléphone est déconnecté. L’événement incomplet faisait échouer l’affichage des réseaux dans Gaia.\n- Sur le Pixel, vérifier le pilote, le service natif, le passage par Gecko et l’affichage du panneau corrigé : quatre points d’accès détectés, regroupés en deux réseaux, sans erreur de recherche.\n- Connexion Wi-Fi confirmée par l’utilisateur ; requête HTTPS réelle réussie depuis Gecko vers example.org (HTTP 200, vérification des certificats active). Correctifs appliqués à l’essai en RAM ; aucun flash ni push.\n",
+ "en": "## 6 October 2026 — Wi-Fi and Settings panels · in development\n\n### Shared · Gaia / Desktop / Android / Tundra\n\n- Complete Settings panel navigation even when Gecko does not emit an animation-end event. Preserve Gaia transitions and prevent duplicate initialization.\n- Updated shared sources and the desktop server; included in the next Android packaging run. No new APK published in this step.\n\n### Tundra · Sargo\n\n- Fix Wi-Fi signal events: include the associated network and do not emit connection information while disconnected. The incomplete event prevented Gaia from displaying scan results.\n- Check the driver, native service, Gecko bridge and corrected panel on the Pixel: four access points detected, grouped into two networks, with no scan error.\n- Wi-Fi connection confirmed by the user; a real HTTPS request from Gecko to example.org passed (HTTP 200, certificate verification enabled). Fixes applied to the RAM trial; no flash or Git push.\n"
+ }
+ },
+ {
+ "id": "development-2026-10-06-marketplace-views",
+ "text": {
+ "fr": "## 6 octobre 2026 — Marketplace · en développement\n\n### PC / Tundra · Sargo\n\n- Maintenir le catalogue sous la barre de statut et dans les limites de son application. Masquer sa vue web à l’accueil et dans le multitâche ; la retirer à la fermeture de l’application.\n- Éviter qu’une navigation annulée ou l’ancienne page d’erreur masque une nouvelle tentative.\n- Afficher un message local et proposer une nouvelle tentative lorsque le catalogue est inaccessible, au lieu de laisser sa page d’erreur recouvrir Gaia.\n- Sur PC : navigation, erreur réseau, nouvelle tentative, retour à l’accueil et fermeture depuis le multitâche vérifiés. Sept contrôles directs passent sur Sargo en RAM, sans fenêtre de débogage, avec une page locale et une précondition de déverrouillage forcée ; le catalogue distant nécessite une connexion Internet sur le téléphone.\n\n### Android · APK\n\n- Reconstruire l’APK local Preview17 avec le message d’erreur Marketplace commun. Signature, alignement et parité des ressources vérifiés ; nouvelle régression sur appareil Android non effectuée dans cette étape.\n\nAucune publication dans cette étape.\n",
+ "en": "## 6 October 2026 — Marketplace · in development\n\n### Desktop / Tundra · Sargo\n\n- Keep the catalogue below the status bar and inside its owning application. Hide its web view on Home and in the task switcher; remove it when the app closes.\n- Prevent a cancelled navigation or a previous error page from hiding a retry.\n- Show a local error message with a retry action when the catalogue is unavailable, instead of leaving its error page over Gaia.\n- Desktop navigation, network failure, retry, Home and closing from the task switcher checked. Seven direct checks pass on Sargo in RAM without a debugger window, using a local page and a forced-unlock precondition; the remote catalogue requires an Internet connection on the phone.\n\n### Android · APK\n\n- Rebuild the local Preview17 APK with the shared Marketplace error message. Signature, alignment and asset parity checked; no new Android device regression run in this step.\n\nNothing published in this step.\n"
+ }
+ },
+ {
+ "id": "2.7-preview.17",
+ "text": {
+ "fr": "## 2.7-preview.17 — APK construit · 5 octobre 2026\n\n### Commun · Gaia / PC\n\n- Remettre Marketplace sur l’accueil avec son icône originale et le catalogue Vulpes. Ouvrir sa version française pour les langues fr, anglaise sinon.\n- Adapter les téléchargements du catalogue en installations confirmées : vérifier les archives ZIP, ajouter l’application à l’accueil et permettre son lancement et sa désinstallation.\n- Conserver les applications installées séparément des sources Gaia, sur des origines distinctes et sans privilèges système hérités des anciens manifestes.\n- Valider sur PC le parcours réel avec 2048 : catalogue, confirmation, installation, lancement, persistance et désinstallation. Contrôler les archives corrompues, chemins invalides, limites, annulation et confirmations à usage unique.\n\n### Android · APK\n\n- Construire et signer Preview17 avec le même Marketplace et le même service d’installation. Utiliser la vue GeckoView existante pour le catalogue et le stockage privé Android pour les paquets.\n- Vérifier sur émulateur Android 16/API36 le clic Installer, la confirmation, l’apparition sur l’accueil et le lancement de 2048 sans accès au pont système Vulpes. Vérifier aussi la conservation après arrêt forcé et relance, puis la désinstallation.\n\n### Tundra · Sargo\n\n- Préparer une image de boot locale incluant Marketplace, l’installateur et le serveur des applications ajoutées. Démarrage en RAM effectué sur le Pixel le 6 octobre : accueil et ressources Marketplace vérifiés, sans flash. Neuf contrôles système sur dix passent ; le contrôle global Gaia relève une variation du viewport au déverrouillage. Installation de paquets sur Sargo encore à qualifier.\n\n**Limites :** compatibilité initiale avec les applications web empaquetées. Les anciennes API privilégiées, applications hébergées et services disparus ne sont pas restaurés automatiquement. Rien publié sur Forgejo ni sur le site.\n",
+ "en": "## 2.7-preview.17 — APK built · 5 October 2026\n\n### Shared · Gaia / Desktop\n\n- Restore Marketplace on Home with its original icon and the Vulpes catalogue. Select French for fr languages and English otherwise.\n- Turn catalogue downloads into confirmed installations: validate ZIP archives, add the application to Home and support launch and removal.\n- Store installed applications separately from Gaia sources, on distinct origins and without system privileges inherited from archived manifests.\n- Validate the real desktop workflow with 2048: catalogue, confirmation, installation, launch, persistence and removal. Check damaged archives, invalid paths, limits, cancellation and single-use confirmations.\n\n### Android · APK\n\n- Build and sign Preview17 with the same Marketplace and installation service. Use the existing GeckoView browser for the catalogue and private Android storage for packages.\n- Verify the Install click, confirmation, Home icon and 2048 launch without access to the Vulpes system bridge on an Android 16/API36 emulator. Also verify persistence after force-stop/relaunch and removal.\n\n### Tundra · Sargo\n\n- Prepare a local boot image containing Marketplace, the installer and the added-application server. RAM boot performed on the Pixel on 6 October: Home and Marketplace resources verified, without flashing. Nine of ten system checks pass; the overall Gaia check records a viewport change during unlocking. Package installation on Sargo still needs qualification.\n\n**Limitations:** initial support for packaged web applications. Old privileged APIs, hosted apps and discontinued services are not restored automatically. Nothing published to Forgejo or the website.\n"
+ }
+ },
+ {
+ "id": "2.7-preview.16",
+ "text": {
+ "fr": "## 2.7-preview.16 — APK construit · 5 octobre 2026\n\n### Commun · Gaia / PC\n\n- Rétablir les sélecteurs Gaia de fond d’écran et de sons, leur retour vers Paramètres et leur annulation par Accueil. Partager le transport des images et sons enregistrés entre PC et Android.\n- Propager les changements de langue à l’accueil, y compris sans permission de lecture générale des réglages. Vérifier les libellés FR/EN et la persistance.\n- Rétablir l’arrêt du balayage horizontal sur une page et masquer les applications de développement sur l’accueil, sans les supprimer des sources.\n- Utiliser le logo Vulpes fourni dans le splash et adapter les composants originaux de la liste des sons au moteur actuel.\n- Vérifier sur PC deux changements consécutifs de fond, le choix d’un son et les panneaux Gaia restaurés.\n\n### Android · APK\n\n- APK local Preview16 construit et signé ; contenu commun et version vérifiés. Aucune publication dans cette étape.\n- Valider Vulpes comme lanceur HOME, afficher le verrouillage Gaia après veille et rétablir le retour Android depuis la caméra ouverte au verrouillage.\n- Masquer les barres système Android en mode immersif, en conservant leur accès temporaire par balayage.\n- Tests sur émulateur Android 16/API36 : verrouillage, caméra/retour, FR/EN, accueil sans applications de test, fond d’écran et son. Fond conservé après arrêt forcé et relance, sans changement répété pendant 15 secondes. Autres appareils Android non qualifiés dans cette étape.\n\n### Tundra · Sargo\n\n- Inclure les mêmes correctifs et la politique des applications dans une nouvelle image de boot de test. Le boot précédent réappliquait une copie ancienne des fichiers et annulait les modifications à chaud.\n- Démarrage en RAM sur le Pixel réussi : six contrôles de langues/accueil/sélecteurs et dix contrôles système passent. Sélecteurs image/son vérifiés sans écraser les choix personnels. Le boot installé reste Preview15 ; aucune partition flashée dans cette étape.\n\n**Limites :** l’installation tierce FFAA reste ouverte. Le site dépend d’anciennes API mozApps et d’un script nomodule ; un installateur isolant les applications et leurs permissions reste à construire. Le choix d’un son Gaia ne modifie pas la sonnerie globale Android ; l’audio d’appel réel n’a pas été qualifié ici.",
+ "en": "## 2.7-preview.16 — APK built · 5 October 2026\n\n### Shared · Gaia / Desktop\n\n- Restore Gaia wallpaper and sound pickers, return navigation to Settings and cancellation through Home. Share persisted image and sound transport between desktop and Android.\n- Propagate language changes to Home without granting general settings read access. Check French/English labels and persistence.\n- Restore horizontal page snapping and hide development apps from Home without deleting their sources.\n- Use the supplied Vulpes logo in the splash and adapt the original sound-list components to the current engine.\n- Verify two consecutive wallpaper changes, sound selection and restored Gaia panels on desktop.\n\n### Android · APK\n\n- Build and sign local Preview16; verify shared payload and version. Nothing published in this step.\n- Validate Vulpes as the HOME launcher, restore Gaia locking after sleep and Android Back navigation from the lockscreen camera.\n- Hide Android system bars in immersive mode while retaining temporary swipe access.\n- Android 16/API36 emulator checks: locking, camera/Back, French/English, Home without test apps, wallpaper and sound. Wallpaper retained after force-stop/relaunch with no repeated changes over 15 seconds. Other Android devices not qualified in this step.\n\n### Tundra · Sargo\n\n- Include shared fixes and app policy in a new test boot image. The previous boot reapplied an old file snapshot, undoing hot updates.\n- RAM boot on Pixel passed: six language/Home/picker checks and ten system checks. Image/sound picker results verified without replacing personal selections. Installed boot remains Preview15; no partition flashed in this step.\n\n**Limitations:** third-party FFAA installation remains open. The site depends on old mozApps APIs and a nomodule script; an installer with application and permission isolation still needs implementation. Selecting a Gaia sound does not change the global Android ringtone; actual call audio was not qualified here."
+ }
+ },
+ {
+ "id": "2026-10-04-sargo-standalone",
+ "text": {
+ "fr": "## En développement — 4 octobre 2026 — Installation autonome Sargo\n\n### Tundra · Sargo\n\n- Construction d’un volume ext4 neuf directement destiné à userdata : système, données et composants matériels vendor/modem embarqués. Le démarrage ne cherche plus de fichiers dans une installation Droidian ; aucune session Android ni connexion ADB n’est nécessaire à l’installation.\n- Installateur Fastboot en Python : contrôle du modèle Sargo, du déverrouillage, des tailles et des empreintes, puis remplacement de userdata, dtbo_a, vbmeta_a et boot_a. Le bootloader reste déverrouillé ; les calibrations propres au téléphone sont conservées.\n- Sauvegarde complète de userdata hors ligne, comparaison intégrale avec le téléphone et vérification de l’archive de restauration avant le flash. Les sauvegardes restent privées ; une restauration réelle n’a pas été effectuée dans cette étape.\n- Deux démarrages depuis le stockage interne réussis : onze contrôles Gaia et dix contrôles natifs par démarrage, image de boot relue et vérifiée, réglages conservés. Le slot A est marqué réussi uniquement après validation du démarrage Gaia courant.\n- Variante générique sans serveur SSH ni clés de maintenance : flash de boot_a et démarrage réel réussis. La variante de développement est ensuite remise sur le Pixel pour poursuivre les travaux. Les images du paquet générique conservent leur état initial vierge.\n- Contrôles finaux sur le démarrage autonome de développement : Photo (dont bascules de capteur), clavier Gaia et notifications réussis.\n- Paquet Fastboot local assemblé avec empreintes et rapport de qualification. 44 tests ciblés passent. Il n’est pas encore publié : la provenance et la redistribution de certains composants matériels restent à finaliser.\n- Limites : qualification sur un Pixel 3a, pas sur toutes les combinaisons de firmware ; volume de 8 Gio comprenant 1 Gio de données modifiables, sans extension automatique. Les fonctionnalités matérielles restent expérimentales.\n\n### PC / Android · APK\n\n- Aucun changement d’interface ni nouveau binaire dans cette étape. Gaia reste partagé entre les trois plateformes.",
+ "en": "## In development — 4 October 2026 — Standalone Sargo installation\n\n### Tundra · Sargo\n\n- Build a fresh ext4 volume directly for userdata, including the system, writable data and vendor/modem hardware images. Startup no longer looks for files in a Droidian installation; installation requires neither an Android session nor ADB.\n- Python Fastboot installer checks the Sargo model, unlock state, partition sizes and checksums, then replaces userdata, dtbo_a, vbmeta_a and boot_a. The bootloader stays unlocked and device-specific calibration is retained.\n- Full offline userdata backup, complete comparison against the phone and recovery archive verification before flashing. Backups remain private; an actual restoration was not performed in this step.\n- Two successful boots from internal storage: eleven Gaia and ten native checks per boot, verified boot-image readback and retained settings. Slot A is marked successful only after validating Gaia startup in the current boot.\n- Generic variant without an SSH server or maintenance keys: actual boot_a flash and boot passed. The development variant was then restored on the Pixel for further work. Generic package images retain their original fresh state.\n- Final checks on the standalone development boot passed for Camera (including lens switches), the Gaia keyboard and notifications.\n- Local Fastboot package assembled with checksums and a qualification report. 44 targeted tests pass. It is not published yet: provenance and redistribution of some hardware components still need final review.\n- Limits: qualified on one Pixel 3a, not every firmware combination; an 8 GiB volume including 1 GiB of writable data, without automatic expansion. Hardware functionality remains experimental.\n\n### Desktop / Android · APK\n\n- No interface changes or new binary in this step. Gaia remains shared by all three platforms."
+ }
+ },
{
"id": "2026-10-03-sargo-per-device-installation",
"text": {
@@ -185,6 +248,12 @@
"version": "2.7-preview.15",
"after": "2.7-preview.14",
"summary": "## 🇫🇷 Vulpes OS — Preview 15\n\nPremière publication des **sources communes PC / Android / Tundra**, accompagnée d'un nouvel APK expérimental.\n\n### Télécharger et essayer\n- **Android :** installer `vulpes-os-preview-2.7-preview.15.apk`. Android 8 minimum déclaré, API 37 ciblée ; ARM64, ARMv7 et x86_64. L'APK garde la signature des précédentes Preview.\n- **PC Linux :** cloner le dépôt, puis exécuter `./setup.sh` et `./run.sh`. Le moteur Mozilla épinglé est téléchargé et son empreinte vérifiée.\n- **Sargo :** les sources Tundra sont incluses. **Aucune ROM générique flashable n'est fournie** : notre image privée dépend encore de l'installation et du partitionnement du téléphone de développement.\n\n### Depuis la Preview 14\n- Correction du format des dates des SMS qui laissait les conversations Gaia vides ; conservation des messages et des états d'envoi/échec côté hôte.\n- Libération et remplacement des flux caméra fiabilisés, pour empêcher une ancienne session de fermer le nouveau capteur.\n- Sources Tundra : focus tactile, zoom numérique et capture JPEG du capteur ; aperçu distinct de la photo en pleine résolution. Coupure du vibreur corrigée et arrêt de secours du service.\n- Sources PC, APK et Tundra réunies, scripts de préparation et de compilation inclus, README français et anglais remis à jour. Les profils privés et la documentation de travail ne sont pas publiés.\n\n**Validation :** 42 tests de services, démarrage et navigation sur PC avec Gecko 156.0.1 ; sur émulateur Android 16, capture Photo/Galerie, reprise de caméra, dix retours depuis l’arrière-plan et récupération après arrêt du processus d’affichage. APK compilé depuis `b38dea0` ; le tag ajoute uniquement un correctif des scripts de test PC.\n\n**Versions :** Vulpes `2.7-preview.15` · Gaia `2.7.0` · Gecko PC `156.0.1` · GeckoView Android `156.0`. Les fonctions matérielles diffèrent selon la plateforme : sous Android, appels et SMS sont délégués aux applications Android, pas au modem Tundra.\n\n**Limites connues :** build de développement signé, débogage Gecko accessible via ADB autorisé ; toutes les versions et tous les modèles Android ne sont pas qualifiés. Sur Sargo : aperçu caméra autour de 16 i/s, flash/vidéo, rotation automatique et veille profonde incomplets, audio des appels à qualifier. Le grand dialogue signalé après un envoi SMS reste à identifier. Cette release n'annonce pas la résolution de ces limites.\n\n## 🇬🇧 Vulpes OS — Preview 15\n\nFirst publication of the **shared desktop / Android / Tundra sources**, with a new experimental APK.\n\n### Download and try\n- **Android:** install `vulpes-os-preview-2.7-preview.15.apk`. Declared minimum Android 8, target API 37; ARM64, ARMv7 and x86_64. Signed with the same key as previous Preview builds.\n- **Linux desktop:** clone the repository, then run `./setup.sh` and `./run.sh`. The pinned Mozilla engine is downloaded and its checksum verified.\n- **Sargo:** Tundra sources are included. **No generic flashable ROM is supplied**: our private image still depends on the development phone's installation and storage layout.\n\n### Since Preview 14\n- Fix SMS date formatting that left Gaia conversations empty; retain messages and sent/failed states in the host.\n- Make camera stream release and replacement reliable, so an older session cannot close the new sensor.\n- Tundra sources: touch focus, digital zoom and sensor JPEG capture, separating preview from full-resolution photos. Fix vibration shutdown and add a service stop safeguard.\n- Publish desktop, APK and Tundra sources with setup/build scripts and updated English/French READMEs. Private profiles and internal working documents are excluded.\n\n**Validation:** 42 service tests, desktop startup/navigation on Gecko 156.0.1; Android 16 emulator Photo/Gallery capture, camera resume, ten background/resume cycles and recovery after renderer termination. APK built from `b38dea0`; the tag adds only a desktop test-script fix.\n\n**Versions:** Vulpes `2.7-preview.15` · Gaia `2.7.0` · desktop Gecko `156.0.1` · Android GeckoView `156.0`. Hardware capabilities differ by platform: Android calls and SMS hand off to Android apps, not to the Tundra modem.\n\n**Known limits:** signed development build, Gecko debugging through authorized ADB; not every Android version/device is qualified. On Sargo: approximately 16 fps camera preview, incomplete flash/video, automatic rotation and deep suspend, call audio awaiting qualification. A reported large dialog after SMS sending remains under investigation. This release does not claim those limits are fixed.\n"
+ },
+ "v2.7-preview.17": {
+ "version": "2.7-preview.17",
+ "after": "2.7-preview.15",
+ "url": "https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.17",
+ "summary": "# Français\n\nAPK expérimental Android : Vulpes OS 2.7-preview.17, Gaia 2.7.0, GeckoView 156.0. Installation par-dessus une Preview précédente signée avec la même clé. Android 8.0/API26 minimum ; essais réalisés sur émulateur Android 16/API36.\n\n## 2.7-preview.17 — APK construit · 5 octobre 2026\n\n### Commun · Gaia / PC\n\n- Remettre Marketplace sur l’accueil avec son icône originale et le catalogue Vulpes. Ouvrir sa version française pour les langues fr, anglaise sinon.\n- Adapter les téléchargements du catalogue en installations confirmées : vérifier les archives ZIP, ajouter l’application à l’accueil et permettre son lancement et sa désinstallation.\n- Conserver les applications installées séparément des sources Gaia, sur des origines distinctes et sans privilèges système hérités des anciens manifestes.\n- Valider sur PC le parcours réel avec 2048 : catalogue, confirmation, installation, lancement, persistance et désinstallation. Contrôler les archives corrompues, chemins invalides, limites, annulation et confirmations à usage unique.\n\n### Android · APK\n\n- Construire et signer Preview17 avec le même Marketplace et le même service d’installation. Utiliser la vue GeckoView existante pour le catalogue et le stockage privé Android pour les paquets.\n- Vérifier sur émulateur Android 16/API36 le clic Installer, la confirmation, l’apparition sur l’accueil et le lancement de 2048 sans accès au pont système Vulpes. Vérifier aussi la conservation après arrêt forcé et relance, puis la désinstallation.\n\n## 2.7-preview.16 — APK construit · 5 octobre 2026\n\n### Commun · Gaia / PC\n\n- Rétablir les sélecteurs Gaia de fond d’écran et de sons, leur retour vers Paramètres et leur annulation par Accueil. Partager le transport des images et sons enregistrés entre PC et Android.\n- Propager les changements de langue à l’accueil, y compris sans permission de lecture générale des réglages. Vérifier les libellés FR/EN et la persistance.\n- Rétablir l’arrêt du balayage horizontal sur une page et masquer les applications de développement sur l’accueil, sans les supprimer des sources.\n- Utiliser le logo Vulpes fourni dans le splash et adapter les composants originaux de la liste des sons au moteur actuel.\n- Vérifier sur PC deux changements consécutifs de fond, le choix d’un son et les panneaux Gaia restaurés.\n\n### Android · APK\n\n- APK local Preview16 construit et signé ; contenu commun et version vérifiés. Aucune publication dans cette étape.\n- Valider Vulpes comme lanceur HOME, afficher le verrouillage Gaia après veille et rétablir le retour Android depuis la caméra ouverte au verrouillage.\n- Masquer les barres système Android en mode immersif, en conservant leur accès temporaire par balayage.\n- Tests sur émulateur Android 16/API36 : verrouillage, caméra/retour, FR/EN, accueil sans applications de test, fond d’écran et son. Fond conservé après arrêt forcé et relance, sans changement répété pendant 15 secondes. Autres appareils Android non qualifiés dans cette étape.\n\nLes correctifs caméra du 8 octobre ainsi que les derniers correctifs Wi-Fi, bannière et thème Marketplace du 6 octobre ne sont pas inclus dans cet APK. La validation de la caméra avant sur Sargo reste ouverte. Cette publication concerne uniquement Android.\n\n# English\n\nExperimental Android APK: Vulpes OS 2.7-preview.17, Gaia 2.7.0, GeckoView 156.0. Install over an earlier Preview signed with the same key. Android 8.0/API26 minimum; tested on an Android 16/API36 emulator.\n\n## 2.7-preview.17 — APK built · 5 October 2026\n\n### Shared · Gaia / Desktop\n\n- Restore Marketplace on Home with its original icon and the Vulpes catalogue. Select French for fr languages and English otherwise.\n- Turn catalogue downloads into confirmed installations: validate ZIP archives, add the application to Home and support launch and removal.\n- Store installed applications separately from Gaia sources, on distinct origins and without system privileges inherited from archived manifests.\n- Validate the real desktop workflow with 2048: catalogue, confirmation, installation, launch, persistence and removal. Check damaged archives, invalid paths, limits, cancellation and single-use confirmations.\n\n### Android · APK\n\n- Build and sign Preview17 with the same Marketplace and installation service. Use the existing GeckoView browser for the catalogue and private Android storage for packages.\n- Verify the Install click, confirmation, Home icon and 2048 launch without access to the Vulpes system bridge on an Android 16/API36 emulator. Also verify persistence after force-stop/relaunch and removal.\n\n## 2.7-preview.16 — APK built · 5 October 2026\n\n### Shared · Gaia / Desktop\n\n- Restore Gaia wallpaper and sound pickers, return navigation to Settings and cancellation through Home. Share persisted image and sound transport between desktop and Android.\n- Propagate language changes to Home without granting general settings read access. Check French/English labels and persistence.\n- Restore horizontal page snapping and hide development apps from Home without deleting their sources.\n- Use the supplied Vulpes logo in the splash and adapt the original sound-list components to the current engine.\n- Verify two consecutive wallpaper changes, sound selection and restored Gaia panels on desktop.\n\n### Android · APK\n\n- Build and sign local Preview16; verify shared payload and version. Nothing published in this step.\n- Validate Vulpes as the HOME launcher, restore Gaia locking after sleep and Android Back navigation from the lockscreen camera.\n- Hide Android system bars in immersive mode while retaining temporary swipe access.\n- Android 16/API36 emulator checks: locking, camera/Back, French/English, Home without test apps, wallpaper and sound. Wallpaper retained after force-stop/relaunch with no repeated changes over 15 seconds. Other Android devices not qualified in this step.\n\nThe October 8 camera changes and the later October 6 Wi-Fi, banner and Marketplace theme changes are not included in this APK. Front-camera validation on Sargo remains open. This release concerns Android only."
}
}
}
diff --git a/gaia/apps/communications/contacts/js/activities.js b/gaia/apps/communications/contacts/js/activities.js
index 12cb2a1f..84e333b1 100755
--- a/gaia/apps/communications/contacts/js/activities.js
+++ b/gaia/apps/communications/contacts/js/activities.js
@@ -19,7 +19,7 @@ const VCARD_MIME_TYPES = [
var ActivityHandler = {
_currentActivity: null,
- _launchedAsInlineActivity: (window.location.search == '?pick'),
+ _launchedAsInlineActivity: new URLSearchParams(window.location.search).has('pick'),
mozContactParam: null,
@@ -124,6 +124,7 @@ var ActivityHandler = {
this.launch_activity(activity, 'add-parameters');
break;
case 'pick':
+ if (window.VulpesCompat) this._launchedAsInlineActivity = true;
if (!this._launchedAsInlineActivity) {
return;
}
@@ -356,15 +357,18 @@ var ActivityHandler = {
postNewSuccess: function ah_postNewSuccess(contact) {
this._currentActivity.postResult({ contact: contact });
this._currentActivity = null;
+ if (window.VulpesCompat) this._launchedAsInlineActivity = false;
},
postPickSuccess: function ah_postPickSuccess(result) {
this._currentActivity.postResult(result);
this._currentActivity = null;
+ if (window.VulpesCompat) this._launchedAsInlineActivity = false;
},
postCancel: function ah_postCancel() {
this._currentActivity.postError('canceled');
this._currentActivity = null;
+ if (window.VulpesCompat) this._launchedAsInlineActivity = false;
}
};
diff --git a/gaia/apps/communications/contacts/js/contacts.js b/gaia/apps/communications/contacts/js/contacts.js
index 64dc91a5..4ea36df1 100755
--- a/gaia/apps/communications/contacts/js/contacts.js
+++ b/gaia/apps/communications/contacts/js/contacts.js
@@ -655,7 +655,9 @@ var Contacts = (function() {
utils.PerformanceHelper.contentInteractive();
utils.PerformanceHelper.chromeInteractive();
window.setTimeout(Contacts && Contacts.onLocalized);
- if (window.navigator.mozSetMessageHandler && window.self == window.top) {
+ // Vulpes embeds each app in its own permission-checked frame.
+ if (window.navigator.mozSetMessageHandler &&
+ (window.self == window.top || window.VulpesCompat)) {
LazyLoader.load(['/shared/js/contacts/import/utilities/misc.js',
'/shared/js/contacts/import/utilities/vcard_reader.js',
'/shared/js/contacts/import/utilities/vcard_parser.js'],
diff --git a/gaia/apps/communications/contacts/js/views/form.js b/gaia/apps/communications/contacts/js/views/form.js
index 865eead5..ef5e805c 100755
--- a/gaia/apps/communications/contacts/js/views/form.js
+++ b/gaia/apps/communications/contacts/js/views/form.js
@@ -1038,6 +1038,8 @@ contacts.Form = (function() {
// (Bug 1041455).
if (editingContact) {
Contacts.setCurrent(contact);
+ // The change event may arrive while the edit form is still visible.
+ Contacts.updateContactDetail(contact.id);
}
}
);
diff --git a/gaia/apps/homescreen/js/apps.js b/gaia/apps/homescreen/js/apps.js
index 9a982135..e7725e97 100755
--- a/gaia/apps/homescreen/js/apps.js
+++ b/gaia/apps/homescreen/js/apps.js
@@ -408,6 +408,10 @@
return;
}
+ if (HIDDEN_ROLES.includes(manifest.role)) {
+ return;
+ }
+
// Do not add blacklisted apps
if (BLACKLIST.includes(app.origin)) {
return;
diff --git a/gaia/apps/marketplace/icon.png b/gaia/apps/marketplace/icon.png
new file mode 100644
index 00000000..9880f58a
Binary files /dev/null and b/gaia/apps/marketplace/icon.png differ
diff --git a/gaia/apps/marketplace/index.html b/gaia/apps/marketplace/index.html
new file mode 100644
index 00000000..bee23bd1
--- /dev/null
+++ b/gaia/apps/marketplace/index.html
@@ -0,0 +1,5 @@
+Marketplace
+
+
+
+
diff --git a/gaia/apps/marketplace/manifest.webapp b/gaia/apps/marketplace/manifest.webapp
new file mode 100644
index 00000000..8cded036
--- /dev/null
+++ b/gaia/apps/marketplace/manifest.webapp
@@ -0,0 +1,19 @@
+{
+ "name": "Marketplace",
+ "description": "Vulpes OS application catalogue",
+ "launch_path": "/index.html",
+ "type": "certified",
+ "icons": {
+ "512": "/icon.png"
+ },
+ "permissions": {
+ "browser": {},
+ "webapps-manage": {}
+ },
+ "locales": {
+ "fr": {
+ "name": "Marketplace",
+ "description": "Catalogue des applications Vulpes OS"
+ }
+ }
+}
diff --git a/gaia/apps/marketplace/marketplace.css b/gaia/apps/marketplace/marketplace.css
new file mode 100644
index 00000000..0125ce10
--- /dev/null
+++ b/gaia/apps/marketplace/marketplace.css
@@ -0,0 +1 @@
+html,body{width:100%;height:100%;margin:0;background:#f4f4f4;font-family:Fira Sans,sans-serif}#catalogue{position:absolute;inset:0;width:100%;height:100%;border:0}#message{padding:3rem 2rem;text-align:center;font-size:1.8rem}#message button{display:block;width:100%;padding:1rem;margin:1rem 0;font:inherit}#open[hidden],[hidden]{display:none!important}#confirm{z-index:10}#confirm section{overflow:auto}#permissions{font-size:1.5rem}
diff --git a/gaia/apps/marketplace/marketplace.js b/gaia/apps/marketplace/marketplace.js
new file mode 100644
index 00000000..84dbc0c5
--- /dev/null
+++ b/gaia/apps/marketplace/marketplace.js
@@ -0,0 +1,32 @@
+(async()=>{
+ const frame=document.getElementById('catalogue'),message=document.getElementById('message'),dialog=document.getElementById('confirm');
+ let attempt=0,busy=false,prepared=null,installed=null,language=await VulpesCompat.call('locale.current');
+ const fr=()=>/^fr(?:-|$)/i.test(language);
+ const text=(f,e)=>fr()?f:e;
+ function labels(){document.getElementById('browse').textContent=text('Ouvrir le catalogue','Open catalogue');document.getElementById('open').textContent=text('Ouvrir','Open');document.getElementById('install').textContent=text('Installer','Install');document.getElementById('cancel').textContent=text('Annuler','Cancel');}
+ function loadFailed(){status(text('Catalogue inaccessible. Vérifiez la connexion Internet, puis réessayez.','Catalogue unavailable. Check your Internet connection, then try again.'));document.getElementById('open').hidden=true;}
+ frame.addEventListener('mozbrowsererror',loadFailed);
+ function browse(){labels();message.hidden=true;dialog.hidden=true;frame.style.visibility='visible';frame.src='https://vulpes-os.org/marketplace/?mode=app&lang='+(fr()?'fr':'en');}
+ function status(value){frame.style.visibility='hidden';message.hidden=false;document.getElementById('status').textContent=value;}
+ async function prepare(url){
+ if(busy)return;const current=++attempt;busy=true;prepared=null;installed=null;document.getElementById('open').hidden=true;
+ status(text('Vérification de l’application…','Checking application…'));
+ try {const value=await VulpesCompat.call('marketplace.prepare',{url});if(current!==attempt)return;prepared=value;
+ document.getElementById('title').textContent=prepared.name;
+ document.getElementById('question').textContent=text('Installer cette application archivée ?','Install this archived application?')+' ('+(prepared.bytes/1048576).toFixed(1)+' Mio)';
+ document.getElementById('permissions').textContent=text('Elle sera exécutée comme une application web, sans accès aux fonctions système. Certains anciens services peuvent ne plus fonctionner.','It will run as a web application without system access. Some old services may no longer work.');
+ message.hidden=true;dialog.hidden=false;
+ }catch(error){if(current!==attempt)return;status(text('Installation impossible : ','Cannot install: ')+error.message);busy=false;}
+ }
+ dialog.addEventListener('submit',async event=>{event.preventDefault();if(!prepared)return;const token=prepared.token;prepared=null;dialog.hidden=true;status(text('Installation…','Installing…'));
+ try {installed=await VulpesCompat.call('marketplace.install',{token});status(text('Application ajoutée à l’accueil.','Application added to Home.'));document.getElementById('open').hidden=false;}
+ catch(error){status(text('Installation impossible : ','Cannot install: ')+error.message);}finally{busy=false;}
+ });
+ const cancel=()=>{attempt++;prepared=null;busy=false;VulpesCompat.call('marketplace.cancel').catch(console.error);browse();};
+ document.getElementById('cancel').onclick=cancel;
+ document.getElementById('browse').onclick=cancel;
+ document.getElementById('open').onclick=()=>installed && VulpesCompat.call('apps.launch',{manifestURL:installed.manifestURL});
+ addEventListener('languagechange',()=>{language=navigator.language;if(!busy)browse();});
+ browse();
+ setInterval(async()=>{if(busy)return;try{const url=await VulpesCompat.call('marketplace.poll');if(url)await prepare(url);}catch(error){console.error(error);}},750);
+})();
diff --git a/gaia/apps/ringtones/js/tone_player.js b/gaia/apps/ringtones/js/tone_player.js
index aa92cd42..97358a05 100755
--- a/gaia/apps/ringtones/js/tone_player.js
+++ b/gaia/apps/ringtones/js/tone_player.js
@@ -143,7 +143,7 @@ TonePlayer.prototype = {
_setExclusiveMode: function(exclusive) {
if (exclusive) {
if (!this._source) {
- this._context = new AudioContext('ringer');
+ this._context = new AudioContext();
this._source = this._context.createMediaElementSource(this._player);
this._source.connect(this._context.destination);
}
diff --git a/gaia/apps/settings/js/modules/page_transitions.js b/gaia/apps/settings/js/modules/page_transitions.js
index 11951804..59f84c11 100755
--- a/gaia/apps/settings/js/modules/page_transitions.js
+++ b/gaia/apps/settings/js/modules/page_transitions.js
@@ -43,7 +43,39 @@ define(function() {
return;
}
+ // A hidden or newly inserted panel may not animate. Complete navigation
+ // in that case too, otherwise its onShow hook never runs.
+ var completed = false;
+ var fallback;
+ function paintWait(event) {
+ if (event && event.target !== newPanel) {
+ return;
+ }
+ if (completed) {
+ return;
+ }
+ completed = true;
+ clearTimeout(fallback);
+ newPanel.removeEventListener('transitionend', paintWait);
+ newPanel.removeEventListener('transitioncancel', paintWait);
+ _sendPanelReady(oldPanel && '#' + oldPanel.id, '#' + newPanel.id);
+ if (callback) {
+ callback();
+ }
+ }
+ newPanel.addEventListener('transitionend', paintWait);
+ newPanel.addEventListener('transitioncancel', paintWait);
newPanel.className = 'current';
+ var style = window.getComputedStyle(newPanel);
+ var seconds = function(value) {
+ return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
+ };
+ var durations = style.transitionDuration.split(',').map(seconds);
+ var delays = style.transitionDelay.split(',').map(seconds);
+ var duration = Math.max.apply(Math, durations.map(function(value, index) {
+ return value + delays[index % delays.length];
+ }));
+ fallback = setTimeout(paintWait, Math.max(0, duration) + 100);
/**
* Most browsers now scroll content into view taking CSS transforms into
@@ -57,22 +89,6 @@ define(function() {
window.scrollTo(0, 0);
}
- newPanel.addEventListener('transitionend', function paintWait() {
- newPanel.removeEventListener('transitionend', paintWait);
- if (oldPanel) {
- _sendPanelReady('#' + oldPanel.id, '#' + newPanel.id);
-
- if (oldPanel.className === 'current') {
- return;
- }
- } else {
- _sendPanelReady(null, '#' + newPanel.id);
- }
-
- if (callback) {
- callback();
- }
- });
},
/**
diff --git a/gaia/apps/system/js/system_banner.js b/gaia/apps/system/js/system_banner.js
index 943eadaf..c88f6b55 100755
--- a/gaia/apps/system/js/system_banner.js
+++ b/gaia/apps/system/js/system_banner.js
@@ -10,7 +10,9 @@
* application installation, crash reporter, and low storage notices.
* @class SystemBanner
*/
- function SystemBanner() {}
+ function SystemBanner() {
+ this._generation = 0;
+ }
/**
* Amount of time before banner is automatically dismissed.
@@ -77,10 +79,18 @@
* { label: l10nAttrs, callback: ..., dismiss: ... }
*/
show: function(messages, buttonParams) {
+ var generation = ++this._generation;
+ clearTimeout(this._dismissTimer);
+ if (this._cancelRemoval) {
+ this._cancelRemoval();
+ }
this._clicked = false;
this._showing = true;
return this.getBanner().then(banner => {
+ if (generation !== this._generation || !this._showing) {
+ return;
+ }
// Clear the button and message containers.
var messageContainer = banner.querySelector('.messages');
var buttonContainer = banner.querySelector('.buttons');
@@ -110,13 +120,16 @@
// Hide banner and invoke button dismiss() on click or timeout.
var hideBanner = () => {
+ if (generation !== this._generation || !this._showing) {
+ return;
+ }
this.hide();
if (!this._clicked) {
buttonParams && buttonParams.dismiss && buttonParams.dismiss();
}
};
banner.onclick = hideBanner;
- setTimeout(hideBanner, SystemBanner.TIMEOUT);
+ this._dismissTimer = setTimeout(hideBanner, SystemBanner.TIMEOUT);
document.getElementById('screen').appendChild(banner);
banner.show();
@@ -131,15 +144,37 @@
return;
}
this._showing = false;
+ clearTimeout(this._dismissTimer);
+ var generation = this._generation;
return this.getBanner().then(banner => {
- banner.addEventListener('animationend', function removeBanner() {
- banner.removeEventListener('animationend', removeBanner);
- // Only remove banner if we haven't called show() in the meantime.
- if (!this._showing) {
+ if (generation !== this._generation || this._showing) {
+ return;
+ }
+ // Animation events inside a modern shadow root do not reach the host.
+ var animation = banner.shadowRoot &&
+ banner.shadowRoot.querySelector('.bread') || banner;
+ var cleanup = () => {
+ clearTimeout(this._removeTimer);
+ animation.removeEventListener('animationend', removeBanner);
+ animation.removeEventListener('animationcancel', removeBanner);
+ this._cancelRemoval = null;
+ };
+ var removeBanner = event => {
+ if (event && event.target !== animation) {
+ return;
+ }
+ cleanup();
+ if (!this._showing && generation === this._generation) {
banner.parentNode && banner.parentNode.removeChild(banner);
}
- }.bind(this));
+ };
+ this._cancelRemoval = cleanup;
+ animation.addEventListener('animationend', removeBanner);
+ animation.addEventListener('animationcancel', removeBanner);
+ // Gaia's exit animation lasts 600 ms. Also dismiss if it is disabled
+ // or no animation event is produced.
+ this._removeTimer = setTimeout(removeBanner, 700);
banner.hide();
});
}
diff --git a/host/Runtime.sys.mjs b/host/Runtime.sys.mjs
index 0b11845d..e328a542 100644
--- a/host/Runtime.sys.mjs
+++ b/host/Runtime.sys.mjs
@@ -1,6 +1,8 @@
-import { setInterval } from 'resource://gre/modules/Timer.sys.mjs';
+import { setInterval, setTimeout, clearTimeout } from 'resource://gre/modules/Timer.sys.mjs';
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 { DataStoreService } from 'resource://vulpes/services/datastores.mjs';
import { DataStoresIndexedDB } from 'resource://vulpes/adapters/datastores-indexeddb.mjs';
@@ -29,6 +31,7 @@ export const Runtime = {
wakeLocks: new Map(),
idleListeners: new Map(),
cleanup(actor) {
+ this.activities?.cleanup(actor);
this.input?.cleanup(actor);
this.platform?.nativeCamera.cleanup(actor);
this.connections?.cleanup(actor);
@@ -45,6 +48,22 @@ export const Runtime = {
this.input = new Input(this);
const root = Services.env.get('VULPES_WORKSPACE_ROOT');
this.apps = await IOUtils.readJSON(PathUtils.join(root, 'assets', 'registry.json'));
+ const storeURL = 'http://system.localhost:8765/_vulpes/packages/';
+ const store = async (operation, item) => {
+ const token = await IOUtils.readUTF8(PathUtils.join(root,'profiles','installed-apps','.token'));
+ const response = await fetch(storeURL+operation,{method:'POST',headers:{'X-Vulpes-Install':token,'Content-Type':'application/json'},body:JSON.stringify(item)});
+ if(!response.ok)throw Error('PACKAGE_STORAGE_FAILED');
+ return response.json();
+ };
+ this.refreshInstalled = async () => {
+ const response=await fetch(storeURL+'list');if(!response.ok)throw Error('PACKAGE_STORAGE_FAILED');
+ const installed=await response.json();
+ this.apps.splice(0,this.apps.length,...this.apps.filter(a=>!a.installed),...installed);
+ return installed;
+ };
+ this.packages = new Packages({schedule:setTimeout,unschedule:clearTimeout,save:item=>store('save',item),changed:()=>this.refreshInstalled(),uuid:()=>Services.uuid.generateUUID().toString()});
+ this.removePackage = async id => {const app=this.apps.find(a=>a.id===id && a.installed);if(!app)throw Error('NOT_REMOVABLE');
+ await store('remove',{id});await this.refreshInstalled();this.broadcast('apps-uninstalled',app);};
// Installed Gaia audio apps must be able to ring when an alarm fires. This
// permission is scoped to their origins in this profile, never web pages.
for (const app of this.apps)
@@ -91,6 +110,11 @@ export const Runtime = {
this.values['vulpes.gaia-restoration.v1'] = true;
}
Services.prefs.setStringPref('intl.accept_languages', this.values['language.current']);
+ this.activities = new Activities({identity:actor=>this.identity(actor),apps:()=>this.apps,
+ uuid:()=>Services.uuid.generateUUID().toString(),
+ 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')});
this.writeQueue = Promise.resolve();
this.settings = new SettingsService({
get: async (key) => this.values[key],
@@ -115,7 +139,7 @@ export const Runtime = {
this.writeQueue = write.catch(() => {});
return write;
},
- });
+ }, {maxBytes:32 * 1024 * 1024});
const storage = new DataStoresIndexedDB(indexedDB, IDBKeyRange, () =>
Services.uuid.generateUUID().toString(),
);
@@ -202,6 +226,16 @@ export const Runtime = {
this.actors.add(actor);
const { method, params } = request;
if (typeof method !== 'string') return { error: { code: 'INVALID_REQUEST' } };
+ if(method.startsWith('marketplace.')) {
+ if(app.id!=='marketplace')return {error:{code:'PERMISSION_DENIED'}};
+ try {
+ if(method==='marketplace.poll'){const url=this.marketplaceDownload;this.marketplaceDownload=null;return {result:url||null};}
+ if(method==='marketplace.cancel'){this.packages.cancel();return {result:true};}
+ if(method==='marketplace.prepare')return {result:await this.packages.prepare(params.url)};
+ if(method==='marketplace.install') {const id=await this.packages.install(params.token);const installed=this.apps.find(a=>a.id===id);this.broadcast('apps-installed',installed);return {result:installed};}
+ } catch(error) {return {error:{code:error.message}};}
+ 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'}};
const pattern=params?.pattern;
@@ -267,10 +301,19 @@ export const Runtime = {
}
}
if (typeof method !== 'string') return { error: { code: 'INVALID_REQUEST' } };
+ if (['activities.subscribe','activities.finish','activities.cancel'].includes(method) || (method === 'activities.start' && isGaiaActivity(params))) {
+ try {
+ const result = method === 'activities.cancel' ? this.activities.cancel(actor)
+ : method === 'activities.subscribe' ? this.activities.subscribe(actor)
+ : method === 'activities.finish' ? this.activities.finish(actor,params)
+ : await this.activities.start(actor,params);
+ return {result:result ?? null};
+ } catch(error) {return {error:{code:error.message}};}
+ }
if (method === 'activities.start') {
if (params?.name === 'record' && params.data?.type === 'photos') {
- // Gallery's Photo button opens the shared Gaia UI; it expects no result file.
- if (app.id === 'gallery') {
+ // Gallery and the lockscreen open Gaia Camera without expecting a file result.
+ if (['gallery', 'system'].includes(app.id)) {
const camera = this.apps.find((a) => a.id === 'camera');
if (!camera) return { error: { code: 'ACTIVITY_NOT_SUPPORTED' } };
this.broadcast(
@@ -316,6 +359,7 @@ export const Runtime = {
return { error: { code: error.message || 'VIEW_ERROR' } };
}
}
+ if (method === 'locale.current') return { result: this.values['language.current'] || 'en-US' };
if (method === 'settings.snapshot')
return this.grants(app).includes('settings.read')
? { result: { ...this.values } }
@@ -441,6 +485,10 @@ export const Runtime = {
if (method === 'apps.self') return { result: app };
if (method === 'apps.list')
return manage ? { result: this.apps } : { error: { code: 'PERMISSION_DENIED' } };
+ if(method==='apps.uninstall') {
+ if(!manage)return {error:{code:'PERMISSION_DENIED'}};
+ try {await this.removePackage(params.id);return {result:true};}catch(error){return {error:{code:error.message}};}
+ }
if (method === 'apps.launch') {
const target = this.apps.find((a) => a.manifestURL === params?.manifestURL);
if (!target || (!manage && app.id !== target.id))
@@ -469,7 +517,7 @@ export const Runtime = {
try {
const app = this.identity(actor);
if (!app || (target && app.id !== target)) continue;
- if (type === 'settingchange' && !this.grants(app).includes('settings.read')) continue;
+ if (type === 'settingchange' && data.key !== 'language.current' && !this.grants(app).includes('settings.read')) continue;
if (
type === 'mediachange' &&
!app.manifest.permissions?.['device-storage:' + data.type]?.access?.includes('read')
diff --git a/host/Views.sys.mjs b/host/Views.sys.mjs
index 20ebed76..3884c1cd 100644
--- a/host/Views.sys.mjs
+++ b/host/Views.sys.mjs
@@ -15,12 +15,52 @@ export const Views = {
);
Services.ppmm.sharedData.flush();
},
+ hideAll() {
+ for (const entry of this.entries.values()) entry.browser.style.visibility='hidden';
+ },
+ async layout(entry) {
+ const geometry=entry.geometry;
+ if (!geometry) return;
+ let {left,top,width,height,visible}=geometry;
+ let right=left+width,bottom=top+height;
+ try {
+ // A child document can remain visible while its owning Gaia app is hidden.
+ // Check every embedding frame in its own process, up to the system app.
+ for (let child=entry.actor.browsingContext;child.parent;child=child.parent) {
+ const state=await child.parent.currentWindowGlobal.getActor('Vulpes').sendQuery(
+ 'Vulpes:ViewState',{contextId:child.id});
+ visible=visible && state.visible;
+ if (!visible) break;
+ left=Math.max(left,state.left);top=Math.max(top,state.top);
+ right=Math.min(right,state.right);bottom=Math.min(bottom,state.bottom);
+ }
+ } catch (_) {visible=false;}
+ if (entry.geometry!==geometry || !entry.browser.isConnected) return;
+ const shell=entry.browser.ownerDocument.defaultView;
+ const bounds=shell.document.getElementById('screen').getBoundingClientRect();
+ const x=shell.mozInnerScreenX+bounds.left,y=shell.mozInnerScreenY+bounds.top;
+ left=Math.max(x,left);top=Math.max(y,top);
+ right=Math.min(x+bounds.width,right);bottom=Math.min(y+bounds.height,bottom);
+ 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'});
+ },
prune() {
for (const [id, entry] of this.entries) {
if (!BrowsingContext.get(id)?.parent) {
this.remove(id, entry);
continue;
}
+ this.layout(entry);
+ const document=entry.browser.browsingContext?.currentWindowGlobal;
+ const page=document?.documentURI.spec || '';
+ // A retry keeps the previous error document until the next navigation commits.
+ if (document?.innerWindowId!==entry.previousDocumentId &&
+ /^about:(neterror|certerror|tabcrashed)/.test(page) && entry.errorPage!==page) {
+ entry.errorPage=page;
+ try {entry.actor.sendAsyncMessage('Vulpes:Event',{type:'view',
+ data:{contextId:id,type:'error',detail:{type:'other'}}});} catch (_) {}
+ }
// A detached iframe's BrowsingContext can outlive its DOM element.
// Ask the trusted child actor about actual ownership, not just the BC tree.
if (entry.checking) continue;
@@ -123,7 +163,9 @@ export const Views = {
if (flags & Ci.nsIWebProgressListener.STATE_STOP) {
emit('loadend', { backgroundColor: 'rgb(255, 255, 255)' });
emit('titlechange', browser.contentTitle || browser.currentURI.spec);
- if (!Components.isSuccessCode(status)) emit('error', { type: 'other', status });
+ // Replacing a navigation cancels the previous request normally.
+ if (!Components.isSuccessCode(status) && status !== Cr.NS_BINDING_ABORTED)
+ emit('error', { type: 'other', status });
}
},
onProgressChange() {},
@@ -143,6 +185,8 @@ export const Views = {
this.entries.set(contextId, entry);
}
entry.url = url;
+ entry.errorPage=null;
+ entry.previousDocumentId=entry.browser.browsingContext?.currentWindowGlobal?.innerWindowId;
entry.browser.loadURI(uri, {
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
});
@@ -153,22 +197,8 @@ export const Views = {
if (operation === 'geometry') {
const { left, top, width, height, visible } = geometry || {};
if (![left, top, width, height].every(Number.isFinite)) throw new Error('INVALID_GEOMETRY');
- const shell = browser.ownerDocument.defaultView,
- screen = shell.document.getElementById('screen');
- const bounds = screen.getBoundingClientRect();
- const x = left - shell.mozInnerScreenX - bounds.left;
- const y = top - shell.mozInnerScreenY - bounds.top;
- const right = Math.min(480, x + width),
- bottom = Math.min(800, y + height);
- const clippedX = Math.max(0, x),
- clippedY = Math.max(0, y);
- Object.assign(browser.style, {
- left: clippedX + 'px',
- top: clippedY + 'px',
- width: Math.max(0, right - clippedX) + 'px',
- height: Math.max(0, bottom - clippedY) + 'px',
- visibility: visible && right > clippedX && bottom > clippedY ? 'visible' : 'hidden',
- });
+ entry.geometry={left,top,width,height,visible};
+ await this.layout(entry);
return null;
}
if (operation === 'back') {
diff --git a/host/actors/MarketplaceChild.sys.mjs b/host/actors/MarketplaceChild.sys.mjs
new file mode 100644
index 00000000..7181ffd4
--- /dev/null
+++ b/host/actors/MarketplaceChild.sys.mjs
@@ -0,0 +1,7 @@
+export class VulpesMarketplaceChild extends JSWindowActorChild {
+ handleEvent() {
+ const scope={};
+ Services.scriptloader.loadSubScript('resource://vulpes/host/marketplace-page.js',scope);
+ scope.installMarketplacePage(this.contentWindow,url=>this.sendAsyncMessage('Marketplace:Download',{url}));
+ }
+}
diff --git a/host/actors/MarketplaceParent.sys.mjs b/host/actors/MarketplaceParent.sys.mjs
new file mode 100644
index 00000000..f59695ca
--- /dev/null
+++ b/host/actors/MarketplaceParent.sys.mjs
@@ -0,0 +1,13 @@
+import { Runtime } from 'resource://vulpes/host/Runtime.sys.mjs';
+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;
+ const view=[...Views.entries.values()].find(v=>v.browser.browsingContext.top.id===this.browsingContext.top.id);
+ if(!view || !Runtime.identity(view.actor))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');
+ }
+}
diff --git a/host/actors/VulpesChild.sys.mjs b/host/actors/VulpesChild.sys.mjs
index ed38d710..c69ed27f 100644
--- a/host/actors/VulpesChild.sys.mjs
+++ b/host/actors/VulpesChild.sys.mjs
@@ -141,6 +141,27 @@ export class VulpesChild extends JSWindowActorChild {
}
receiveMessage(message) {
if(message.name==='Vulpes:Edit') return this.edit(message.data);
+ if (message.name === 'Vulpes:ViewState') {
+ const frame = Array.from(this.document.querySelectorAll('iframe')).find(
+ f => f.browsingContext?.id === message.data.contextId);
+ if (!frame?.isConnected) return {visible:false};
+ const win = this.contentWindow, rect = frame.getBoundingClientRect();
+ const visible = !this.document.hidden && frame.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}) &&
+ [[.5,.5],[.05,.05],[.95,.05],[.05,.95],[.95,.95]].every(([x,y]) =>
+ this.document.elementFromPoint(rect.left+rect.width*x,rect.top+rect.height*y)===frame);
+ let top = Math.max(0,rect.top);
+ // Gaia's status bar belongs above all app content, including native web views.
+ if (this.document.location.hostname === 'system.localhost') {
+ const bar=this.document.getElementById('statusbar');
+ if (bar?.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) {
+ const r=bar.getBoundingClientRect();
+ if (r.width>0 && r.bottom>top && r.top<=top) top=r.bottom;
+ }
+ }
+ return {visible,left:Math.max(0,rect.left)+win.mozInnerScreenX,
+ top:top+win.mozInnerScreenY,right:Math.min(win.innerWidth,rect.right)+win.mozInnerScreenX,
+ bottom:Math.min(win.innerHeight,rect.bottom)+win.mozInnerScreenY};
+ }
if (message.name === 'Vulpes:ViewAlive') {
return Array.from(this.document.querySelectorAll('iframe')).some(
(frame) => frame.isConnected && frame.browsingContext?.id === message.data.contextId,
diff --git a/host/desktop.css b/host/desktop.css
index d7e4cb4b..40a36351 100644
--- a/host/desktop.css
+++ b/host/desktop.css
@@ -196,3 +196,7 @@ html[data-vulpes-app='communications'] body {
/* Camera gestures must not magnify the application chrome. */
html[data-vulpes-app='camera'], html[data-vulpes-app='camera'] body { touch-action:none; }
+
+/* Homescreen pages must settle at their boundary after a drag (Forgejo #3). */
+html[data-vulpes-app="homescreen"] #panels { scroll-snap-type:x mandatory; }
+html[data-vulpes-app="homescreen"] #panels > * { scroll-snap-align:start; }
diff --git a/host/gaia-camera.js b/host/gaia-camera.js
index 304c704a..b056ff2d 100644
--- a/host/gaia-camera.js
+++ b/host/gaia-camera.js
@@ -6,6 +6,26 @@
current = null,
generation = 0;
let cameraError = null;
+ let opening = Promise.resolve(), closing = Promise.resolve();
+ const releases = new WeakMap();
+ function releaseStream(stream, preview) {
+ if (releases.has(stream)) return releases.get(stream);
+ if (current === stream) current = null;
+ const release = Promise.resolve().then(async () => {
+ for (const video of document.querySelectorAll('video')) {
+ if (video.srcObject === stream) {
+ video.pause(); video.srcObject = null;
+ if ('vulpesCapture' in video.dataset) video.remove();
+ }
+ }
+ preview?.remove();
+ try { await stream.vulpesCameraClose?.(); }
+ finally { stream.getTracks().forEach(track => track.stop()); }
+ });
+ releases.set(stream, release);
+ closing = release.catch(recordError);
+ return release;
+ }
function recordError(error) {
cameraError = { name: error.name || 'Error', message: error.message || '' };
console.warn('Vulpes Camera:', cameraError.name, cameraError.message);
@@ -87,7 +107,7 @@
Object.assign(event, detail);
target.dispatchEvent(event);
}
- async function discover(stream) {
+ async function discover(stream, name) {
if (stream.vulpesNative) {
cameras = ['back', 'front'];
return;
@@ -104,12 +124,15 @@
const active = list.find((d) => d.deviceId === settings.deviceId) || list[0];
const back = list.find((d) => /back|rear|environment/i.test(d.label));
const front = list.find((d) => /front|user|face/i.test(d.label));
- const first =
- back ||
- (settings.facingMode === 'user' && list.length > 1 ? list.find((d) => d !== active) : active);
+ // Unlabelled webcams must not exchange identities every time we switch.
+ const previousBack = list.find(d => d.deviceId === devices.get('back'));
+ const previousFront = list.find(d => d.deviceId === devices.get('front'));
+ const first = back || previousBack ||
+ ((settings.facingMode === 'user' || name === 'front') && list.length > 1
+ ? list.find(d => d !== active) : active);
devices = new Map();
if (first) devices.set('back', first.deviceId);
- const second = front && front !== first ? front : list.find((d) => d !== first);
+ const second = [front, previousFront].find(d => d && d !== first) || list.find(d => d !== first);
if (second) devices.set('front', second.deviceId);
cameras = devices.size ? [...devices.keys()] : ['back'];
}
@@ -129,75 +152,79 @@
await call({action:'stop'});
throw new DOMException('Camera preview requires WebGL', 'NotSupportedError');
}
- function shader(type, source) {
- const value = gl.createShader(type);
- gl.shaderSource(value, source); gl.compileShader(value);
- if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw Error('Camera shader compilation failed');
- return value;
- }
- const program = gl.createProgram();
- gl.attachShader(program, shader(gl.VERTEX_SHADER, `
- attribute vec2 position; varying vec2 coord;
- void main() { gl_Position=vec4(position,0.,1.); coord=vec2(position.x*.5+.5,.5-position.y*.5); }
- `));
- gl.attachShader(program, shader(gl.FRAGMENT_SHADER, `
- precision mediump float; varying vec2 coord;
- uniform sampler2D luma; uniform sampler2D chroma; uniform float rotation;
- void main() {
- vec2 p=coord;
- if(rotation==90.) p=vec2(coord.y,1.-coord.x);
- else if(rotation==270.) p=vec2(1.-coord.y,coord.x);
- else if(rotation==180.) p=1.-coord;
- float y=1.16438356*(texture2D(luma,p).r-16./255.);
- vec4 vu=texture2D(chroma,p);
- float v=vu.r-128./255.; float u=vu.a-128./255.;
- gl_FragColor=vec4(y+1.59602678*v,y-.39176229*u-.81296764*v,y+2.01723214*u,1.);
- }
- `));
- gl.linkProgram(program);
- if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error('Camera shader link failed');
- gl.useProgram(program);
- const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buffer);
- gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);
- const position=gl.getAttribLocation(program,'position');
- gl.enableVertexAttribArray(position);gl.vertexAttribPointer(position,2,gl.FLOAT,false,0,0);
- gl.uniform1f(gl.getUniformLocation(program,'rotation'),meta.orientation);
- const textures = ['luma','chroma'].map((name,index) => {
- const texture=gl.createTexture();gl.activeTexture(gl.TEXTURE0+index);gl.bindTexture(gl.TEXTURE_2D,texture);
- gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
- gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
- gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
- gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
- gl.uniform1i(gl.getUniformLocation(program,name),index);return texture;
- });
const timing = {frames:0, startedAt:performance.now(), conversionMs:0};
- let stopped = false, timer, stream, sequence = -1, closing;
+ let stopped = false, timer, stream, sequence = -1, closing, program, buffer;
+ const textures = [], shaders = [];
const stop = () => {
if (closing) return closing;
stopped = true; clearTimeout(timer);
closing = call({action:'stop'}).finally(() => {
textures.forEach(texture => gl.deleteTexture(texture));
- gl.deleteBuffer(buffer); gl.deleteProgram(program);
+ shaders.forEach(value => gl.deleteShader(value));
+ if (buffer) gl.deleteBuffer(buffer);
+ if (program) gl.deleteProgram(program);
gl.getExtension('WEBGL_lose_context')?.loseContext();
});
return closing;
};
- const draw = async () => {
- const frame = await call({action:'frame'});
- if (stopped || !frame.data || frame.sequence === sequence) return false;
- const data = new Uint8Array(frame.data), w = meta.width, h = meta.height;
- if (data.length !== w * h * 3 / 2) throw Error('Invalid native camera frame');
- sequence = frame.sequence;
- const start=performance.now();
- gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,textures[0]);
- gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE,w,h,0,gl.LUMINANCE,gl.UNSIGNED_BYTE,data.subarray(0,w*h));
- gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,textures[1]);
- gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE_ALPHA,w/2,h/2,0,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,data.subarray(w*h));
- gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
- timing.frames++;timing.conversionMs+=performance.now()-start;
- return true;
- };
try {
+ function shader(type, source) {
+ const value = gl.createShader(type);
+ shaders.push(value);
+ gl.shaderSource(value, source); gl.compileShader(value);
+ if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw Error('Camera shader compilation failed');
+ return value;
+ }
+ program = gl.createProgram();
+ gl.attachShader(program, shader(gl.VERTEX_SHADER, `
+ attribute vec2 position; varying vec2 coord;
+ void main() { gl_Position=vec4(position,0.,1.); coord=vec2(position.x*.5+.5,.5-position.y*.5); }
+ `));
+ gl.attachShader(program, shader(gl.FRAGMENT_SHADER, `
+ precision mediump float; varying vec2 coord;
+ uniform sampler2D luma; uniform sampler2D chroma; uniform float rotation;
+ void main() {
+ vec2 p=coord;
+ if(rotation==90.) p=vec2(coord.y,1.-coord.x);
+ else if(rotation==270.) p=vec2(1.-coord.y,coord.x);
+ else if(rotation==180.) p=1.-coord;
+ float y=1.16438356*(texture2D(luma,p).r-16./255.);
+ vec4 vu=texture2D(chroma,p);
+ float v=vu.r-128./255.; float u=vu.a-128./255.;
+ gl_FragColor=vec4(y+1.59602678*v,y-.39176229*u-.81296764*v,y+2.01723214*u,1.);
+ }
+ `));
+ gl.linkProgram(program);
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error('Camera shader link failed');
+ gl.useProgram(program);
+ buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buffer);
+ gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);
+ const position=gl.getAttribLocation(program,'position');
+ gl.enableVertexAttribArray(position);gl.vertexAttribPointer(position,2,gl.FLOAT,false,0,0);
+ gl.uniform1f(gl.getUniformLocation(program,'rotation'),meta.orientation);
+ ['luma','chroma'].forEach((name,index) => {
+ const texture=gl.createTexture();textures.push(texture);gl.activeTexture(gl.TEXTURE0+index);gl.bindTexture(gl.TEXTURE_2D,texture);
+ gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
+ gl.uniform1i(gl.getUniformLocation(program,name),index);
+ });
+ const draw = async () => {
+ const frame = await call({action:'frame'});
+ if (stopped || !frame.data || frame.sequence === sequence) return false;
+ const data = new Uint8Array(frame.data), w = meta.width, h = meta.height;
+ if (data.length !== w * h * 3 / 2) throw Error('Invalid native camera frame');
+ sequence = frame.sequence;
+ const start=performance.now();
+ gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,textures[0]);
+ gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE,w,h,0,gl.LUMINANCE,gl.UNSIGNED_BYTE,data.subarray(0,w*h));
+ gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,textures[1]);
+ gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE_ALPHA,w/2,h/2,0,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,data.subarray(w*h));
+ gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
+ timing.frames++;timing.conversionMs+=performance.now()-start;
+ return true;
+ };
const deadline = Date.now() + 6000;
while (!(await draw())) {
if (Date.now() > deadline) throw new DOMException('No camera frames', 'NotReadableError');
@@ -227,11 +254,19 @@
return stream;
} catch (error) { await stop().catch(() => {}); throw error; }
}
- async function getCamera(name, config = {}) {
+ function getCamera(name, config = {}) {
+ const ticket = ++generation;
+ const request = opening.then(() => openCamera(name, config, ticket));
+ opening = request.catch(() => {});
+ return request;
+ }
+ async function openCamera(name, config, ticket) {
if (config.mode && config.mode !== 'picture')
throw new DOMException('Video capture is not ported yet', 'NotSupportedError');
- const ticket = ++generation;
- await current?.release?.();
+ if (current) await releaseStream(current);
+ await closing;
+ if (document.hidden || ticket !== generation)
+ throw new DOMException('Camera request superseded or hidden', 'AbortError');
const selected = devices.get(name);
cameraError = null;
@@ -245,7 +280,7 @@
video: {
...(selected
? { deviceId: { exact: selected } }
- : { facingMode: { ideal: 'environment' } }),
+ : { facingMode: { ideal: name === 'front' ? 'user' : 'environment' } }),
width: { ideal: 1920 },
height: { ideal: 1080 },
},
@@ -255,7 +290,7 @@
throw error;
}
if (document.hidden || ticket !== generation) {
- stream.getTracks().forEach((t) => t.stop());
+ await releaseStream(stream);
const error = new DOMException('Camera hidden', 'AbortError');
recordError(error);
throw error;
@@ -264,7 +299,7 @@
current = stream;
let preview;
try {
- await discover(stream);
+ await discover(stream, name);
if (document.hidden || ticket !== generation)
throw new DOMException('Camera hidden', 'AbortError');
@@ -294,7 +329,7 @@
const native = stream.vulpesCameraMetadata;
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, releasing;
+ let focusArea = null, zoom = 1;
const configuration = () => ({
mode: 'picture',
previewSize: size,
@@ -378,19 +413,7 @@
emit(stream, 'previewstatechange', { newState: 'started' });
},
release() {
- if (releasing) return releasing;
- // Detach first: visibility changes and Gaia may release the same stream
- // again while its hardware worker is still shutting down.
- if (current === stream) current = null;
- releasing = Promise.resolve().then(async () => {
- for (const video of document.querySelectorAll('video')) {
- if (video.srcObject === stream) { video.pause(); video.srcObject = null; }
- }
- preview.remove();
- await stream.vulpesCameraClose?.();
- stream.getTracks().forEach(t => t.stop());
- });
- return releasing;
+ return releaseStream(stream, preview);
},
async startRecording() {
throw new DOMException('Video unavailable', 'NotSupportedError');
@@ -416,12 +439,7 @@
return { camera: stream, configuration: configuration() };
} catch (error) {
recordError(error);
- stream.getTracks().forEach((t) => t.stop());
- if (preview) {
- preview.srcObject = null;
- preview.remove();
- }
- if (current === stream) current = null;
+ await releaseStream(stream, preview).catch(recordError);
throw error;
}
}
@@ -433,8 +451,7 @@
// Keep that request valid; its completion still checks visibility.
if (!current) return;
++generation;
- if (current?.release) current.release().catch(recordError);
- else current?.getTracks().forEach((t) => t.stop());
+ releaseStream(current).catch(recordError);
};
addEventListener('pagehide', stop);
addEventListener('visibilitychange', () => {
diff --git a/host/gaia-compat.js b/host/gaia-compat.js
index 5631b14a..b0c4627b 100644
--- a/host/gaia-compat.js
+++ b/host/gaia-compat.js
@@ -21,7 +21,7 @@
addEventListener('error', (event) =>
record('error', [event.message, event.filename, event.lineno]),
);
- // Desktop transports Blobs directly; Android installs a JSON-compatible codec.
+ // Binary settings use the same persistence format on every host.
const battery = Object.assign(new EventTarget(), {level:NaN, charging:false, chargingTime:Infinity, dischargingTime:Infinity});
Object.defineProperty(navigator, 'battery', {value:battery});
async function refreshBattery() {
@@ -128,15 +128,16 @@
Object.defineProperty(navigator, 'mozPhoneNumberService', {value:{
normalize: number => String(number || '').normalize('NFKC').replace(/[^0-9+*#]/g, '')
}});
- const settingsCodec = window.VulpesAndroidSettingsCodec;
+ const settingsCodec = window.VulpesSettingsCodec;
async function call(method, params) {
if (!navigator.vulpesRequest) return Promise.reject(new Error('VULPES_HOST_UNAVAILABLE'));
- if (settingsCodec && ['settings.set', 'media.request'].includes(method))
+ if (settingsCodec && (method === 'settings.set' ||
+ (window.VulpesPlatform === 'android' && ['media.request', 'activities.finish', 'contacts.request'].includes(method))))
params = await settingsCodec.encode(params);
return navigator.vulpesRequest({ version: 1, id: ++sequence, method, params }).then((reply) => {
if (reply.error) throw new DOMException(reply.error.code, reply.error.code);
return settingsCodec &&
- ['settings.get', 'settings.snapshot', 'media.request'].includes(method)
+ ['settings.get', 'settings.snapshot', 'media.request', 'activities.start', 'contacts.request'].includes(method)
? settingsCodec.decode(reply.result)
: reply.result;
});
@@ -165,6 +166,16 @@
}
return r;
}
+ let language = navigator.language;
+ function setLanguage(value) {
+ if (typeof value !== 'string' || !/^[a-z]{2,3}(?:-[a-zA-Z0-9]{2,8})*$/.test(value)) return;
+ const changed = language !== value;
+ language = value;
+ if (changed) dispatchEvent(new Event('languagechange'));
+ }
+ Object.defineProperty(navigator, 'language', {get:() => language});
+ Object.defineProperty(navigator, 'languages', {get:() => Object.freeze([language])});
+ call('locale.current').then(setLanguage).catch(() => {});
const observers = new Map();
const settings = new EventTarget();
settings.createLock = () => ({
@@ -196,6 +207,7 @@
addEventListener('vulpes-service-event', (event) => {
const { type, data } = event.detail;
if (type === 'settingchange') {
+ if (data.key === 'language.current') setLanguage(data.value);
const change = {
settingName: data.key,
settingValue: settingsCodec ? settingsCodec.decode(data.value) : data.value,
@@ -205,8 +217,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-launch') dispatchEvent(new CustomEvent('open-app', {detail:{...data,showApp:true,onlyShowApp:false}}));
if (type === 'launch') dispatchEvent(new CustomEvent('webapps-launch', { detail: data }));
- if (type === 'home' || type === 'holdhome') dispatchEvent(new CustomEvent(type));
+ if (type === 'screen-off') {
+ dispatchEvent(new CustomEvent('secure-killapps'));
+ window.Service?.request('lock').catch(console.error);
+ }
+ if (type === 'screen-on') window.ScreenManager?.turnScreenOn(true);
+ if (type === 'home' || type === 'holdhome') {
+ call('activities.cancel', {}).catch(console.error);
+ dispatchEvent(new CustomEvent(type));
+ }
if (type === 'activity-view') dispatchEvent(new CustomEvent('activity-view', { detail: data }));
});
function application(record) {
@@ -215,7 +236,7 @@
const { id, ...publicRecord } = record;
const app = Object.assign(new EventTarget(), publicRecord, {
installState: 'installed',
- removable: false,
+ removable: record.removable === true,
installTime: 0,
updateTime: 0,
downloadAvailable: false,
@@ -238,6 +259,14 @@
}
const mgmt = new EventTarget();
mgmt.getAll = () => request(call('apps.list').then((apps) => apps.map(application)));
+ mgmt.uninstall = app => request(call('apps.uninstall',{id:new URL(app.origin).hostname.split('.')[0]}));
+ addEventListener('vulpes-service-event',event=>{
+ const {type,data}=event.detail;
+ if(!['apps-installed','apps-uninstalled'].includes(type))return;
+ const name=type==='apps-installed'?'install':'uninstall';
+ const change=new Event(name);Object.defineProperty(change,'application',{value:application(data)});
+ mgmt.dispatchEvent(change);if(typeof mgmt['on'+name]==='function')mgmt['on'+name](change);
+ });
mgmt.getNotInstalled = () => request(Promise.resolve([]));
mgmt.getIcon = async (app, size, entry) => {
const manifest = app.manifest.entry_points?.[entry] || app.manifest;
@@ -319,10 +348,19 @@
const handlers = new Map();
navigator.mozSetMessageHandler = (name, fn) => {
handlers.set(name, fn);
+ if (name === 'activity') call('activities.subscribe', {}).catch(console.error);
if (name === 'connection') call('iac.subscribe', {}).catch(console.error);
if (name === 'alarm') call('alarms.request', { operation: 'subscribe' }).catch(console.error);
};
- navigator.mozHasPendingMessage = () => false;
+ addEventListener('vulpes-service-event', ({detail}) => {
+ if (detail.type !== 'activity') return;
+ const {id,source} = detail.data;
+ setTimeout(() => handlers.get('activity')?.({source,
+ postResult: result => call('activities.finish',{id,result}).catch(console.error),
+ postError: error => call('activities.finish',{id,error:String(error)}).catch(console.error)
+ }),0);
+ });
+ navigator.mozHasPendingMessage = name => name === 'activity' && (new URLSearchParams(location.search).has('activity') || /\/(pick|share)\.html$/.test(location.pathname));
navigator.mozSetMessageHandlerPromise = (promise) => promise;
window.VulpesCompat = { call, request, handlers };
window.MozActivity = function (source) {
@@ -657,13 +695,29 @@
Object.setPrototypeOf(GaiaElement.prototype, prototype);
customElements.define(name, GaiaElement);
registered.add(name);
- return GaiaElement;
+ function LegacyGaiaElement() {
+ return initialize(nativeCreate(name));
+ }
+ LegacyGaiaElement.prototype = GaiaElement.prototype;
+ return LegacyGaiaElement;
};
document.createElement = function (name, options) {
const element = nativeCreate(name, options);
if (name.toLowerCase() === 'iframe') wire(element);
return registered.has(name) ? initialize(element) : element;
};
+ // v0 created callbacks ran for detached fragments too. Gaia reads component
+ // state immediately after parsing its templates, before attaching them.
+ const innerHTML = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
+ Object.defineProperty(Element.prototype, 'innerHTML', {
+ ...innerHTML,
+ set(value) {
+ innerHTML.set.call(this, value);
+ for (const element of this.querySelectorAll('*')) {
+ if (registered.has(element.localName)) initialize(element);
+ }
+ },
+ });
HTMLElement.prototype.createShadowRoot = function () {
const host = this,
root = this.attachShadow({ mode: 'open' });
diff --git a/host/gaia-restoration.js b/host/gaia-restoration.js
index f4e974b6..d5d72a6f 100644
--- a/host/gaia-restoration.js
+++ b/host/gaia-restoration.js
@@ -84,9 +84,15 @@
if (!event.target.closest('#messages-send-button')) return;
event.preventDefault(); event.stopImmediatePropagation();
// Android owns final confirmation and delivery. Do not fabricate a sent SMS.
+ const view = window.ConversationView;
+ view?.assimilateRecipients();
+ const numbers = window.Navigation?.isCurrentPanel('composer') ?
+ view?.recipients?.numbers : view?.activeThread?.participants;
+ const body = window.Compose?.getText?.() || '';
+ if (!numbers?.length || !body) return;
+ if (window.Compose.type !== 'sms') { notice(unavailable()); return; }
VulpesCompat.call('platform.sms', {
- number:window.ThreadUI?.recipients?.numbers?.join(';') || '',
- body:window.Compose?.getText?.() || ''
+ number:numbers.join(';'), body
}).catch(() => notice(unavailable()));
}, true);
}
diff --git a/host/gaia-wifi.js b/host/gaia-wifi.js
index a632cd95..e2d81f9d 100644
--- a/host/gaia-wifi.js
+++ b/host/gaia-wifi.js
@@ -23,7 +23,9 @@
manager.connection = {status, network};
manager.connectionInformation = network ? {relSignalStrength:network.relSignalStrength} : null;
if (changed) emit('statuschange', manager.connection);
- emit('connectioninfoupdate', manager.connectionInformation || {});
+ // Gaia's signal listeners require the associated network. A scan while
+ // disconnected must not announce connection information for a missing AP.
+ if (network) emit('connectioninfoupdate', {network, ...manager.connectionInformation});
}
let scanPending;
function scan() {
diff --git a/host/marketplace-page.js b/host/marketplace-page.js
new file mode 100644
index 00000000..e2cf40e9
--- /dev/null
+++ b/host/marketplace-page.js
@@ -0,0 +1,14 @@
+// Runs only on our remote catalogue. It exposes no privileged API to the page.
+function installMarketplacePage(win, send) {
+ 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]')) {
+ const url=new URL(link.href);
+ if(url.origin!=='https://vulpes-os.org' || url.pathname!=='/marketplace/index.php' || !/^\d{1,10}$/.test(url.searchParams.get('download')||''))continue;
+ link.textContent=doc.documentElement.lang==='fr'?'Installer dans Vulpes':'Install in Vulpes';
+ link.addEventListener('click',event=>{
+ if(!event.isTrusted || event.button!==0 || event.ctrlKey || event.metaKey || event.shiftKey)return;
+ event.preventDefault();send(url.href);
+ });
+ }
+}
diff --git a/host/settings-codec.js b/host/settings-codec.js
new file mode 100644
index 00000000..bb361377
--- /dev/null
+++ b/host/settings-codec.js
@@ -0,0 +1,41 @@
+// Settings persistence and Android RPC use JSON. Preserve binary Gaia values across
+// both boundaries and reconstruct Blobs in the application's own JS realm.
+(() => {
+ const marker = '__vulpesBlobV1';
+ async function encode(value) {
+ if (value instanceof Date) return value.toJSON();
+ if (value instanceof Blob) {
+ if (value.size > 16 * 1024 * 1024) throw new Error('SETTING_BLOB_TOO_LARGE');
+ const bytes = new Uint8Array(await value.arrayBuffer());
+ const chunks = [];
+ for (let i = 0; i < bytes.length; i += 16384) {
+ chunks.push(String.fromCharCode(...bytes.subarray(i, i + 16384)));
+ }
+ return { [marker]: true, type: value.type, base64: btoa(chunks.join('')) };
+ }
+ if (Array.isArray(value)) return Promise.all(value.map(encode));
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(
+ await Promise.all(
+ Object.entries(value).map(async ([key, item]) => [key, await encode(item)]),
+ ),
+ );
+ }
+ return value;
+ }
+ function decode(value) {
+ if (!value || typeof value !== 'object' || value instanceof Blob || value instanceof Date) return value;
+ if (
+ value[marker] === true &&
+ typeof value.type === 'string' &&
+ typeof value.base64 === 'string' &&
+ Object.keys(value).length === 3
+ ) {
+ const bytes = Uint8Array.from(atob(value.base64), (c) => c.charCodeAt(0));
+ return new Blob([bytes], { type: value.type });
+ }
+ if (Array.isArray(value)) return value.map(decode);
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, decode(item)]));
+ }
+ window.VulpesSettingsCodec = Object.freeze({ encode, decode });
+})();
diff --git a/host/shell.js b/host/shell.js
index b1e22863..2c57bc75 100644
--- a/host/shell.js
+++ b/host/shell.js
@@ -50,7 +50,12 @@ window.addEventListener('load', async () => {
},
allFrames: true,
safeForUntrustedWebProcess: true,
- matches: Runtime.apps.map((app) => 'http://' + Services.io.newURI(app.origin).host + '/*'),
+ matches: Runtime.apps.filter(app=>!app.installed).map((app) => 'http://' + Services.io.newURI(app.origin).host + '/*'),
+ });
+ 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/*']
});
ChromeUtils.registerWindowActor('VulpesTouch', {
child: {
@@ -154,6 +159,7 @@ window.addEventListener('load', async () => {
home.setPointerCapture(event.pointerId);
holdTimer = setTimeout(() => {
held = true;
+ Views.hideAll();
Runtime.broadcast('holdhome', {}, 'system');
}, 650);
});
@@ -162,12 +168,13 @@ window.addEventListener('load', async () => {
home.addEventListener('pointerup', cancel);
home.addEventListener('pointercancel', cancel);
home.addEventListener('click', () => {
- if (!held) Runtime.broadcast('home', {}, 'system');
+ if (!held) {Views.hideAll();Runtime.broadcast('home', {}, 'system');}
held = false;
});
window.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.code === 'Space') {
event.preventDefault();
+ Views.hideAll();
Runtime.broadcast('holdhome', {}, 'system');
}
});
diff --git a/overrides/settings.gaiamobile.org/js/main.js b/overrides/settings.gaiamobile.org/js/main.js
new file mode 100644
index 00000000..fe93178a
--- /dev/null
+++ b/overrides/settings.gaiamobile.org/js/main.js
@@ -0,0 +1,238 @@
+
+require.config({baseUrl:'/js',paths:{'modules':'modules','panels':'panels','shared':'../shared/js','views':'../views'},waitSeconds:0,shim:{'dsds_settings':{exports:'DsdsSettings'},'settings':{exports:'Settings'},'shared/addons/match_pattern':{exports:'MatchPattern'},'shared/airplane_mode_helper':{exports:'AirplaneModeHelper'},'shared/apn_helper':{exports:'ApnHelper'},'shared/async_storage':{exports:'asyncStorage'},'shared/bluetooth_helper':{exports:'BluetoothHelper'},'shared/device_storage/enumerate_all':{exports:'enumerateAll'},'shared/download/download_formatter':{exports:'DownloadFormatter'},'shared/download/download_store':{exports:'DownloadStore'},'shared/download/download_ui':{exports:'DownloadUI'},'shared/download/download_helper':{exports:'DownloadHelper'},'shared/fxa_iac_client':{exports:'FxAccountsIACHelper'},'shared/homescreens/homescreen_settings':{exports:'homescreenSettings'},'shared/icc_helper':{exports:'IccHelper'},'shared/keyboard_helper':{exports:'KeyboardHelper',deps:['shared/input_mgmt/input_app_list']},'shared/language_list':{exports:'LanguageList'},'shared/lazy_loader':{exports:'LazyLoader'},'shared/manifest_helper':{exports:'ManifestHelper'},'shared/mime_mapper':{exports:'MimeMapper'},'shared/mobile_operator':{exports:'MobileOperator'},'shared/omadrm/fl':{exports:'ForwardLock'},'shared/passcode_helper':{exports:'PasscodeHelper'},'shared/sanitizer':{exports:'Sanitizer'},'shared/screen_layout':{exports:'ScreenLayout'},'shared/search_provider':{exports:'SearchProvider'},'shared/settings_helper':{exports:'SettingsHelper'},'shared/settings_listener':{exports:'SettingsListener'},'shared/settings_url':{exports:'SettingsURL'},'shared/sim_settings_helper':{exports:'SimSettingsHelper'},'shared/simslot':{exports:'SIMSlot'},'shared/simslot_manager':{exports:'SIMSlotManager',deps:['shared/simslot']},'shared/stk_helper':{exports:'STKHelper'},'shared/text_normalizer':{exports:'Normalizer'},'shared/toaster':{exports:'Toaster'},'shared/tz_select':{exports:'tzSelect',deps:['shared/icc_helper']},'shared/uuid':{exports:'uuid'},'shared/findmydevice_iac_api':{exports:'wakeUpFindMyDevice'},'shared/wifi_helper':{exports:'WifiHelper'},'vendor/jszip':{exports:'JSZip'}},modules:[{name:'main'},{name:'modules/apn/apn_settings_manager',exclude:['main','modules/async_storage']},{name:'modules/dialog_service',exclude:['main']},{name:'panels/about/panel',exclude:['main']},{name:'panels/about_more_info/panel',exclude:['main','modules/bluetooth/bluetooth_context']},{name:'panels/app_permissions_detail/panel',exclude:['main']},{name:'panels/app_permissions_list/panel',exclude:['main','modules/apps_cache']},{name:'panels/app_storage/panel',exclude:['main','modules/app_storage','modules/storage_helper']},{name:'views/phone/bluetooth/panel',exclude:['main','modules/mvvm/list_view','modules/dialog_service','modules/bluetooth/bluetooth_context']},{name:'panels/browsing_privacy/panel',exclude:['main','modules/dialog_service']},{name:'panels/call_barring/panel',exclude:['main']},{name:'panels/call_barring_passcode_change/panel',exclude:['main']},{name:'panels/date_time/panel',exclude:['main','modules/date_time']},{name:'panels/developer/panel',exclude:['main','modules/dialog_service','modules/apps_cache']},{name:'panels/developer_hud/panel',exclude:['main']},{name:'panels/display/panel',exclude:['main']},{name:'panels/feedback_choose/panel',exclude:['main']},{name:'panels/feedback_send/panel',exclude:['main']},{name:'panels/findmydevice/panel',exclude:['main','modules/settings_utils']},{name:'panels/firefox_accounts/panel',exclude:['main']},{name:'panels/firefox_sync/panel',exclude:['main','modules/settings_utils']},{name:'panels/frame/panel',exclude:['main']},{name:'panels/help/panel',exclude:['main']},{name:'panels/homescreens/panel',exclude:['main','modules/apps_cache']},{name:'panels/hotspot/panel',exclude:['main','modules/dialog_service']},{name:'panels/hotspot_wifi_settings/panel',exclude:['main']},{name:'panels/keyboard/panel',exclude:['main','modules/mvvm/list_view','modules/keyboard_context']},{name:'panels/keyboard_add_layouts/panel',exclude:['main','modules/mvvm/list_view','modules/keyboard_context','shared/keyboard_helper']},{name:'panels/languages/panel',exclude:['main','shared/keyboard_helper','modules/date_time']},{name:'panels/messaging/panel',exclude:['main','modules/messaging','modules/settings_utils']},{name:'panels/messaging_details/panel',exclude:['main','modules/messaging','modules/settings_utils']},{name:'panels/operator_settings/panel',exclude:['main','dsds_settings','modules/defer','modules/state_model','modules/mvvm/list_view','modules/dialog_service','modules/customized_network_type_map','modules/mobile/supported_network_info']},{name:'panels/root/panel',exclude:['main','panels/root/low_priority_items','modules/apps_cache','modules/addon_manager','modules/storage_helper']},{name:'panels/root/low_priority_items',exclude:['main','modules/app_storage','modules/battery','modules/bluetooth/bluetooth_context','modules/media_storage','modules/sim_security','modules/wifi_context']},{name:'panels/screen_lock/panel',exclude:['main']},{name:'panels/screen_lock_passcode/panel',exclude:['main','modules/settings_utils']},{name:'panels/search/panel',exclude:['main']},{name:'panels/simcard_manager/panel',exclude:['main']},{name:'panels/simpin/panel',exclude:['main','modules/sim_security']},{name:'panels/sound/panel',exclude:['main','modules/mobile/supported_network_info']},{name:'panels/usb_storage/panel',exclude:['main','modules/media_storage']},{name:'panels/wifi/panel',exclude:['main','modules/dialog_service']},{name:'panels/wifi_auth/panel',exclude:['main']},{name:'panels/wifi_enter_certificate_nickname/panel',exclude:['main']},{name:'panels/wifi_join_hidden/panel',exclude:['main']},{name:'panels/wifi_manage_certificates/panel',exclude:['main','modules/settings_utils']},{name:'panels/wifi_manage_networks/panel',exclude:['main','modules/dialog_service']},{name:'panels/wifi_select_certificate_file/panel',exclude:['main','modules/settings_utils']},{name:'panels/wifi_status/panel',exclude:['main']},{name:'panels/wifi_wps/panel',exclude:['main']}]});define("config/require",function(){});define('modules/base/module',[],function(){'use strict';const LOG_LEVEL={NONE:0,DEBUG:1,INFO:2,WARN:3,ERROR:4,ALL:5};var _constructorMap=(function(){var _ctorMap=new Map();var _getConstructor=function(Module){return _ctorMap.get(Module)||function(){};};var _registerConstructor=function(Module,constructor){_ctorMap.set(Module,constructor||function(){});};return{getConstructor:_getConstructor,registerConstructor:_registerConstructor};})();var _emptyFunction=function(){};var _createLogger=function(name){switch(name){case'DEBUG':return function(msg){console.log(this._msgPrefix+msg);};case'INFO':return function(msg){console.info(this._msgPrefix+msg);};case'WARN':return function(msg){console.warn(this._msgPrefix+msg);};case'ERROR':return function(msg){console.error(this._msgPrefix+msg);};}};var ModulePrototype={get _msgPrefix(){return'['+this.$name+']: ';},debug:_emptyFunction,info:_emptyFunction,warn:_emptyFunction,error:_emptyFunction,throw:function(msg){throw new Error(this._msgPrefix+msg);},set _logLevel(value){Object.keys(LOG_LEVEL).forEach((name)=>{var level=LOG_LEVEL[name];if(value>=level&&value>0){this[name.toLowerCase()]=_createLogger(name);}});},super:_constructorMap.getConstructor};var _extend=function(){switch(arguments.length){case 0:break;case 1:var Module=arguments[0];for(var prop in Module.prototype){if(prop==='$name'){continue;}
+var pd=Object.getOwnPropertyDescriptor(Module.prototype,prop);if(pd){Object.defineProperty(this.prototype,prop,pd);}}
+break;default:Array.prototype.forEach.call(arguments,(Module)=>{_extend.call(this,Module);});break;}
+return this;};function _create(constructor){if(constructor&&typeof constructor!=='function'){throw new Error('[Module]: Invalid constructor');}
+var ModuleFunc=function(){};ModuleFunc.prototype=Object.create(ModulePrototype);ModuleFunc.prototype.$name=constructor&&constructor.name||'';var Module=function(){var instance=new ModuleFunc();if(constructor){constructor.apply(instance,arguments);}
+return instance;};_constructorMap.registerConstructor(Module,constructor);Module.extend=_extend;Module.prototype=ModuleFunc.prototype;return Module;}
+return{LOG_LEVEL:LOG_LEVEL,create:_create};});define('modules/base/dependency_graph',['require','modules/base/module'],function(require){'use strict';var Module=require('modules/base/module');var DependencyGraph=Module.create(function DependencyGraph(dpGraph){this._nodes=dpGraph?JSON.parse(JSON.stringify(dpGraph._nodes)):{};});DependencyGraph.prototype._printNodes=function dg_printNodes(){Object.keys(this._nodes).forEach((name)=>{var node=this._nodes[name];console.log('name: '+name);console.log('children: '+
+node.children.reduce((result,name)=>{return result+' '+name;},''));console.log('dependent nodes: '+
+node.dependentNodes.reduce((result,name)=>{return result+' '+name;},''));console.log('======================');});};DependencyGraph.prototype._addDependentNodes=function dg_addDependentNodes(node,dependentNodes,traversedNodes){traversedNodes=traversedNodes||[];if(traversedNodes.indexOf(node.name)>=0){traversedNodes.push(node.name);var error=traversedNodes.join(' -> ');this.throw('circular dependency detected! '+error);}
+if(dependentNodes){dependentNodes.forEach((name)=>{if(node.dependentNodes.indexOf(name)<0){node.dependentNodes.push(name);}});}else{dependentNodes=Array.prototype.slice.call(node.dependentNodes);}
+dependentNodes.push(node.name);traversedNodes.push(node.name);node.children.forEach((name)=>{this._addDependentNodes(this._nodes[name],dependentNodes,traversedNodes);});dependentNodes.pop();traversedNodes.pop();};DependencyGraph.prototype.addDependency=function dg_addDep(name1,name2){var node1=this._nodes[name1];var node2=this._nodes[name2];if(!node1){node1=this._nodes[name1]={name:name1,children:[],dependentNodes:[]};}
+if(!node2){node2=this._nodes[name2]={name:name2,children:[],dependentNodes:[]};}
+if(node1.children.indexOf(name2)<0){node1.children.push(name2);this._addDependentNodes(node1);}};DependencyGraph.prototype.getAllDependent=function dg_getAllDep(name){var node=this._nodes[name];return node&&node.dependentNodes;};return DependencyGraph;});define('modules/mvvm/observable',['require','modules/base/module','modules/base/dependency_graph'],function(require){'use strict';var Module=require('modules/base/module');var DependencyGraph=require('modules/base/dependency_graph');var OP_PREFIX=(name)=>{return'$OP_'+name;};var Observable=Module.create(function Observable(object){this._observers={};if(object){this._initWithObject(object);}});Observable.prototype._initWithObject=function o_init(object){for(var name in object){if(typeof object[name]==='function'){this[name]=object[name];}else{_defineObservableProperty(this,name,{value:object[name]});}}};Observable.prototype._notify=function o__notify(name,newValue,oldValue){var observers=this._observers[name];if(observers){observers.forEach(function(observer){observer(newValue,oldValue);});}};Observable.prototype._removeObserver=function o__removeObserver(observer,name){var observers=this._observers[name];if(observers){var index=observers.indexOf(observer);if(index>=0){observers.splice(index,1);}}};Observable.prototype.observe=function o_observe(name,observer){if(typeof observer!=='function'){return;}
+(this._observers[name]=this._observers[name]||[]).push(observer);};Observable.prototype.unobserve=function o_unobserve(name,observer){if(typeof name==='function'){Object.keys(this._observers).forEach(this._removeObserver.bind(this,name));}else{if(observer){this._removeObserver(observer,name);}else if(name in this._observers){this._observers[name]=[];}}};var _dependencyGraphs=new Map();function _getDependencyGraph(modulePrototype){var dependencyGraph=_dependencyGraphs.get(modulePrototype);if(!dependencyGraph){dependencyGraph=DependencyGraph(modulePrototype._dependencyGraph);modulePrototype._dependencyGraph=dependencyGraph;_dependencyGraphs.set(modulePrototype,dependencyGraph);}
+return dependencyGraph;}
+function _getAllDependentValues(observable,sourceProperty){var dependentList=observable._dependencyGraph&&observable._dependencyGraph.getAllDependent(sourceProperty);if(dependentList&&dependentList.length){return dependentList.map((name)=>{return{name:name,value:observable[name]};});}else{return null;}}
+function _getterTemplate(name,defaultValue){return function(){var value=this[OP_PREFIX(name)];if(typeof value==='undefined'){value=this[OP_PREFIX(name)]=defaultValue;}
+return value;};}
+function _setterTemplate(name){return function(value){var oldValue=this[name];if(oldValue!==value){var dependentValues=_getAllDependentValues(this,name);this[OP_PREFIX(name)]=value;this._notify(name,value,oldValue);if(dependentValues){dependentValues.forEach((obj)=>{this._notify(obj.name,this[obj.name],obj.value);});}}};}
+function _defineObservablePropertyCore(object,name,options){var dependency=options&&options.dependency;if(dependency){var dependencyGraph=_getDependencyGraph(object);dependency.forEach((dependentName)=>{dependencyGraph.addDependency(name,dependentName);});}
+Object.defineProperty(object,name,{enumerable:true,get:options.get,set:options.set});}
+function _defineObservableProperty(object,name,options){if(options&&options.readonly){var internalName='_'+name;_defineObservablePropertyCore(object,name,{dependency:[internalName],get:function(){return this[internalName];}});_defineObservablePropertyCore(object,internalName,{get:_getterTemplate(internalName,options&&options.value),set:_setterTemplate(internalName)});}else if(options&&options.dependency&&options.dependency.length){if(typeof options.get!=='function'){throw new Error('Observable: getter of '+name+' is invalid');}
+_defineObservablePropertyCore(object,name,{dependency:options.dependency,get:options.get});}else{_defineObservablePropertyCore(object,name,{get:_getterTemplate(name,options&&options.value),set:_setterTemplate(name)});}}
+Object.defineProperty(Observable,'defineObservableProperty',{get:function(){return _defineObservableProperty;}});return Observable;});define('modules/base/event_emitter',['require','modules/base/module'],function(require){'use strict';var Module=require('modules/base/module');var EventEmitter=Module.create(function EventEmitter(eventNames){if(eventNames&&eventNames.length){this._eventListeners=eventNames.reduce((result,eventName)=>{result[eventName]=[];return result;},{});}else{this.throw('no valid registered events');}});EventEmitter.prototype._emitEvent=function(eventName,value){var listeners=this._eventListeners[eventName];if(!listeners){this.throw('invalid event name: '+eventName);}
+this.debug('_emitEvent:'+eventName+' '+value);var eventObj={type:eventName,detail:value};listeners.forEach((listener)=>{listener.call(this,eventObj);});};EventEmitter.prototype.addEventListener=function(eventName,listener){var listeners=this._eventListeners[eventName];if(listener&&listeners){switch(typeof listener){case'function':break;case'object':if(typeof listener.handleEvent==='function'){listener=listener.handleEvent;}else{listener=null;}
+break;default:listener=null;break;}
+if(listener&&listeners.indexOf(listener)<0){listeners.push(listener);}}else{this.error('addEventListener: invalid listener for '+eventName);}};EventEmitter.prototype.removeEventListener=function(eventName,listener){var listeners=this._eventListeners[eventName];if(listener&&listeners){var index=-1;var type=typeof listener;switch(type){case'function':index=listeners.indexOf(listener);break;case'object':index=listeners.indexOf(listener.handleEvent);break;}
+if(index>=0){listeners.splice(index,1);}}else{this.error('removeEventListener: invalid listener for '+eventName);}};return EventEmitter;});define('modules/mvvm/observable_array',['require','modules/base/module','modules/base/event_emitter','modules/mvvm/observable'],function(require){'use strict';var Module=require('modules/base/module');var EventEmitter=require('modules/base/event_emitter');var Observable=require('modules/mvvm/observable');var Events=['insert','remove','replace','reset'];var ReadOnlyMethods=['forEach','map','every','some','indexOf','lastIndexOf','reduce','reduceRight'];var ObservableArray=Module.create(function ObservableArray(array){this.super(Observable).call(this);this.super(EventEmitter).call(this,Events);this._array=array||[];this._length=this._array.length;}).extend(Observable,EventEmitter);Observable.defineObservableProperty(ObservableArray.prototype,'length',{readonly:true});Observable.defineObservableProperty(ObservableArray.prototype,'array',{readonly:true});ReadOnlyMethods.forEach(function(op){ObservableArray.prototype[op]=function(){return this._array[op].apply(this._array,arguments);};});ObservableArray.prototype.push=function(item){this._array.push(item);this._length=this._array.length;this._emitEvent('insert',{index:this._array.length-1,count:1,items:[item]});};ObservableArray.prototype.pop=function(){if(!this._array.length){return null;}
+var item=this._array.pop();this._length=this._array.length;this._emitEvent('remove',{index:this._array.length,count:1,items:[item]});return item;};ObservableArray.prototype.splice=function(index,count){if(arguments.length<2){return[];}
+if(index>=this._length){index=this._length-1;}else if(index<0){index=this._length+index;}
+if(count<0){count=0;}
+var addedItems=Array.prototype.slice.call(arguments,2);var removedItems=this._array.splice.apply(this._array,arguments);this._length=this._array.length;if(removedItems.length){this._emitEvent('remove',{index:index,count:count,items:removedItems});}
+if(addedItems.length){this._emitEvent('insert',{index:index,count:addedItems.length,items:addedItems});}
+return removedItems;};ObservableArray.prototype.set=function(index,value){if(index<0||index>=this._array.length){this.throw('set: out of range');}
+var oldValue=this._array[index];this._array[index]=value;this._emitEvent('replace',{index:index,oldValue:oldValue,newValue:value});};ObservableArray.prototype.get=function(index){return this._array[index];};ObservableArray.prototype.reset=function(array){this._array=array||[];this._length=this._array.length;this._emitEvent('reset',{items:this._array});};return ObservableArray;});/**
+ * PageTransitions provides transition functions used when navigating panels.
+ *
+ * @module PageTransitions
+ */
+define('modules/page_transitions',[],function() {
+ 'use strict';
+
+ var _sendPanelReady = function _send_panel_ready(oldPanelHash, newPanelHash) {
+ var detail = {
+ previous: oldPanelHash,
+ current: newPanelHash
+ };
+ var event = new CustomEvent('panelready', {detail: detail});
+ window.dispatchEvent(event);
+ };
+
+ return {
+ /**
+ * Typically used with phone size device layouts.
+ *
+ * @alias module:PageTransitions#oneColumn
+ * @param {String} oldPanel
+ * @param {String} newPanel
+ * @param {Function} callback
+ */
+ oneColumn: function pt_one_column(oldPanel, newPanel, callback) {
+ if (oldPanel === newPanel) {
+ callback();
+ return;
+ }
+
+ // switch previous/current classes
+ if (oldPanel) {
+ oldPanel.className = newPanel.className ? '' : 'previous';
+ }
+ if (newPanel.className === 'current') {
+ _sendPanelReady(oldPanel && '#' + oldPanel.id, '#' + newPanel.id);
+
+ if (callback) {
+ callback();
+ }
+ return;
+ }
+
+ // A hidden or newly inserted panel may not animate. Complete navigation
+ // in that case too, otherwise its onShow hook never runs.
+ var completed = false;
+ var fallback;
+ function paintWait(event) {
+ if (event && event.target !== newPanel) {
+ return;
+ }
+ if (completed) {
+ return;
+ }
+ completed = true;
+ clearTimeout(fallback);
+ newPanel.removeEventListener('transitionend', paintWait);
+ newPanel.removeEventListener('transitioncancel', paintWait);
+ _sendPanelReady(oldPanel && '#' + oldPanel.id, '#' + newPanel.id);
+ if (callback) {
+ callback();
+ }
+ }
+ newPanel.addEventListener('transitionend', paintWait);
+ newPanel.addEventListener('transitioncancel', paintWait);
+ newPanel.className = 'current';
+ var style = window.getComputedStyle(newPanel);
+ var seconds = function(value) {
+ return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
+ };
+ var durations = style.transitionDuration.split(',').map(seconds);
+ var delays = style.transitionDelay.split(',').map(seconds);
+ var duration = Math.max.apply(Math, durations.map(function(value, index) {
+ return value + delays[index % delays.length];
+ }));
+ fallback = setTimeout(paintWait, Math.max(0, duration) + 100);
+
+ /**
+ * Most browsers now scroll content into view taking CSS transforms into
+ * account. That's not what we want when moving between s,
+ * because the being-moved-to section is offscreen when we navigate to its
+ * #hash. The transitions assume the viewport is always at document 0,0.
+ * So add a hack here to make that assumption true again.
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=803170
+ */
+ if ((window.scrollX !== 0) || (window.scrollY !== 0)) {
+ window.scrollTo(0, 0);
+ }
+
+ },
+
+ /**
+ * Typically used with tablet size device layouts.
+ *
+ * @alias module:PageTransitions#twoColumn
+ * @param {String} oldPanel
+ * @param {String} newPanel
+ * @param {Function} callback
+ */
+ twoColumn: function pt_two_column(oldPanel, newPanel, callback) {
+ if (oldPanel === newPanel) {
+ callback();
+ return;
+ }
+
+ if (oldPanel) {
+ oldPanel.className = newPanel.className ? '' : 'previous';
+ newPanel.className = 'current';
+ _sendPanelReady('#' + oldPanel.id, '#' + newPanel.id);
+ } else {
+ newPanel.className = 'current';
+ _sendPanelReady(null, '#' + newPanel.id);
+ }
+
+ if (callback) {
+ callback();
+ }
+ }
+ };
+});
+
+define('modules/panel',[],function(){'use strict';var _emptyFunc=function panel_emptyFunc(){};var Panel=function ctor_panel(options){var _initialized=false;options=options||{};options.onInit=options.onInit||_emptyFunc;options.onUninit=options.onUninit||_emptyFunc;options.onShow=options.onShow||_emptyFunc;options.onHide=options.onHide||_emptyFunc;options.onBeforeShow=options.onBeforeShow||_emptyFunc;options.onBeforeHide=options.onBeforeHide||_emptyFunc;return{get initialized(){return _initialized;},init:function(panel,initOptions){if(_initialized){return;}
+_initialized=true;return options.onInit(panel,initOptions);},uninit:function(){if(!_initialized){return;}
+_initialized=false;options.onUninit();},show:function(panel,showOptions){return Promise.resolve(this.init(panel,showOptions)).then(function(){return options.onShow(panel,showOptions);});},hide:function(){return options.onHide();},beforeShow:function(panel,beforeShowOptions){return Promise.resolve(this.init(panel,beforeShowOptions)).then(function(){return options.onBeforeShow(panel,beforeShowOptions);});},beforeHide:function(){return options.onBeforeHide();}};};return Panel;});define('modules/settings_cache',[],function(){'use strict';var _settings=window.navigator.mozSettings;var _settingsCache=null;var _settingsCacheRequestSent=null;var _pendingSettingsCallbacks=[];var _callbacks=[];var _onSettingsChange=function sc_onSettingsChange(event){var key=event.settingName;var value=event.settingValue;if(_settingsCache){_settingsCache[key]=value;}
+_callbacks.forEach(function(callback){callback(event);});};if(_settings){_settings.onsettingchange=_onSettingsChange;}
+var SettingsCache={reset:function sc_reset(){_settings=window.navigator.mozSettings;if(_settings){_settings.onsettingchange=_onSettingsChange;}
+_settingsCache=null;_settingsCacheRequestSent=null;_pendingSettingsCallbacks=[];_callbacks=[];},get cache(){return _settingsCache;},getSettings:function sc_getSettings(callback){if(!_settings){return;}
+if(_settingsCache&&callback){callback(_settingsCache);return;}
+if(!_settingsCacheRequestSent&&!_settingsCache){_settingsCacheRequestSent=true;var lock=_settings.createLock();var request=lock.get('*');request.onsuccess=function(e){var result=request.result;var cachedResult={};for(var attr in result){cachedResult[attr]=result[attr];}
+_settingsCache=cachedResult;var cbk;while((cbk=_pendingSettingsCallbacks.pop())){cbk(result);}};}
+if(callback){_pendingSettingsCallbacks.push(callback);}},addEventListener:function sc_addEventListener(eventName,callback){if(eventName!=='settingsChange'){return;}
+var index=_callbacks.indexOf(callback);if(index===-1){_callbacks.push(callback);}},removeEventListener:function sc_removeEventListsner(eventName,callback){if(eventName!=='settingsChange'){return;}
+var index=_callbacks.indexOf(callback);if(index!==-1){_callbacks.splice(index,1);}}};SettingsCache.getSettings(null);return SettingsCache;});define('modules/panel_utils',['require','settings','modules/settings_cache','shared/lazy_loader'],function(require){'use strict';var Settings=require('settings');var SettingsCache=require('modules/settings_cache');var LazyLoader=require('shared/lazy_loader');var _settings=navigator.mozSettings;function openDialog(dialogID,onSubmit,onReset){if('#'+dialogID===Settings.currentPanel){return;}
+var origin=Settings.currentPanel;Settings.currentPanel=dialogID;var dialog=document.getElementById(dialogID);var submit=dialog.querySelector('[type=submit]');if(submit){submit.onclick=function onsubmit(){if(typeof onSubmit==='function'){(onSubmit.bind(dialog))();}
+Settings.currentPanel=origin;};}
+var reset=dialog.querySelector('[type=reset]');if(reset){reset.onclick=function onreset(){if(typeof onReset==='function'){(onReset.bind(dialog))();}
+Settings.currentPanel=origin;};}}
+function openLink(url){if(url.startsWith('tel:')){var telActivity=new window.MozActivity({name:'dial',data:{type:'webtelephony/number',number:url.substr(4)}});telActivity.onsuccess=function(){};}else if(!url.startsWith('#')){var linkActivity=new window.MozActivity({name:'view',data:{type:'url',url:url}});linkActivity.onsuccess=function(){};}}
+var _openDialog=function pu_openDialog(dialogID){var dialog=document.getElementById(dialogID);var fields=Array.prototype.slice.call(dialog.querySelectorAll('[data-setting]:not([data-ignore])'));var updateInput=function(lock,input){var key=input.dataset.setting;var request=lock.get(key);request.onsuccess=function(){switch(input.type){case'gaia-radio':input.checked=(input.value==request.result[key]);break;case'checkbox':case'gaia-checkbox':input.checked=request.result[key]||false;break;case'select-one':input.value=request.result[key]||'';break;default:input.value=request.result[key]||'';break;}};};function reset(){if(_settings){var lock=_settings.createLock();fields.forEach(updateInput.bind(null,lock));}}
+function submit(){if(_settings){fields=Array.prototype.slice.call(dialog.querySelectorAll('[data-setting]:not([data-ignore])'));var cset={},key;var lock=_settings.createLock();fields.forEach(function(input){key=input.dataset.setting;switch(input.type){case'gaia-radio':if(input.checked){cset[key]=input.value;}
+break;case'checkbox':case'gaia-checkbox':cset[key]=input.checked;break;default:cset[key]=input.value;break;}});lock.set(cset);}}
+reset();openDialog(dialogID,submit);};return{activate:function pu_activate(panel){var scripts=panel.getElementsByTagName('script');var scripts_src=Array.prototype.map.call(scripts,function(script){return script.getAttribute('src');});LazyLoader.load(scripts_src);var _onclick=function(evt){if(!this.dataset.href){this.dataset.href=this.href;this.href='#';}
+var href=this.dataset.href;if(!href.startsWith('#')){evt.target.blur();openLink(href);}else if(!href.endsWith('Settings')){openDialog(href.substr(1));}else{_openDialog(href.substr(1));}
+return false;};var rule='a[href^="http"], a[href^="tel"], [data-href]';var links=panel.querySelectorAll(rule);var i,count;for(i=0,count=links.length;i{switch(input.type){case'gaia-switch':case'gaia-checkbox':case'checkbox':case'switch':value=!!value;if(input.checked===value){return;}
+input.checked=value;break;case'range':if(input.value===value){return;}
+input.value=value;break;case'select':for(i=0,count=input.options.length;i gaia-header';var header=document.querySelector(headerSelector);return(header&&header.dataset.href||'').replace('#','');};var _shallCloseActivity=function ss_shallCloseActivity(panelId){if(panelId==='close'){return true;}
+if(!_currentNavigation){return false;}
+var parentPanelId=_retriveParentPanelId(_currentNavigation.panelId);return _currentNavigation.panelId===_activityHandler.targetPanelId&&panelId===parentPanelId;};var _transit=function ss_transit(oldPanel,newPanel,callback){var promise=new Promise(function(resolve){var wrappedCallback=function(){if(typeof callback==='function'){callback();}
+resolve();};if(_isTabletAndLandscape()){PageTransitions.twoColumn(oldPanel,newPanel,wrappedCallback);}else{PageTransitions.oneColumn(oldPanel,newPanel,wrappedCallback);}});return promise;};var _loadPanel=function ss_loadPanel(panelId,callback){var panelElement=document.getElementById(panelId);if(panelElement.dataset.rendered){callback();return;}
+panelElement.dataset.rendered=true;if(panelElement.dataset.requireSubPanels){var selector='section[id^="'+panelElement.id+'-"]';var subPanels=document.querySelectorAll(selector);for(var i=0,il=subPanels.length;i1){pendingTargetPanel='call-iccs';}
+SettingsService.navigate(pendingTargetPanel);break;default:if(pendingTargetPanel){SettingsService.navigate(pendingTargetPanel);}
+break;}}
+window.removeEventListener('panelready',onPanelReady);window.performance.mark('visuallyLoaded');document.body.dataset.ready=true;},false);window.addEventListener('telephony-settings-loaded',function onTelephonySettingsLoaded(){window.removeEventListener('telephony-settings-loaded',onTelephonySettingsLoaded);window.performance.mark('fullyLoaded');});SettingsService.init({rootPanelId:'root',context:window.LaunchContext});var options={SettingsService:SettingsService,ScreenLayout:ScreenLayout};Settings.init(options);if(navigator.mozAudioChannelManager){navigator.mozAudioChannelManager.volumeControlChannel='notification';}});require(['boot']);});define("main",function(){});
\ No newline at end of file
diff --git a/overrides/shared/resources/branding/vulpes-symbol.png b/overrides/shared/resources/branding/vulpes-symbol.png
new file mode 100644
index 00000000..7dd01e63
Binary files /dev/null and b/overrides/shared/resources/branding/vulpes-symbol.png differ
diff --git a/overrides/system.gaiamobile.org/js/system_banner.js b/overrides/system.gaiamobile.org/js/system_banner.js
new file mode 100755
index 00000000..c88f6b55
--- /dev/null
+++ b/overrides/system.gaiamobile.org/js/system_banner.js
@@ -0,0 +1,203 @@
+/* global LazyLoader */
+'use strict';
+
+(function(exports) {
+
+ /**
+ * SystemBanner displays a type of notification to the user in certain cases.
+ * It is a type of temporary notification that does not live in the
+ * notifications tray. Examples of the SystemBanner implementation include
+ * application installation, crash reporter, and low storage notices.
+ * @class SystemBanner
+ */
+ function SystemBanner() {
+ this._generation = 0;
+ }
+
+ /**
+ * Amount of time before banner is automatically dismissed.
+ */
+ SystemBanner.TIMEOUT = 4000;
+
+ SystemBanner.prototype = {
+
+ /**
+ * A reference to the SystemBanner element
+ * @memberof SystemBanner.prototype
+ * @type {Element}
+ */
+ _banner: null,
+
+ /**
+ * Set when the clicked callback called. Bypasses dismiss.
+ * @memberof SystemBanner.prototype
+ * @type {Boolean}
+ */
+ _clicked: false,
+
+ /**
+ * Set to true when the banner is currently showing to the user.
+ * @memberof SystemBanner.prototype
+ * @type {Boolean}
+ */
+ _showing: false,
+
+ /**
+ * Generates and returns the banner if it does not exist
+ * @memberof SystemBanner.prototype
+ * @type {Function}
+ */
+ getBanner: function() {
+ if (this._banner) {
+ return Promise.resolve(this._banner);
+ }
+
+ return LazyLoader.load('shared/elements/gaia-component/gaia-component.js')
+ .then(() => {
+ return LazyLoader.load('shared/elements/gaia-toast/gaia-toast.js');
+ })
+ .then(() => {
+ this._banner = document.createElement('gaia-toast');
+ this._banner.timeout = SystemBanner.TIMEOUT;
+ this._banner.className = 'banner';
+ this._banner.dataset.zIndexLevel = 'system-notification-banner';
+ var container = createChild(this._banner, 'div', 'container');
+ createChild(container, 'div', 'messages');
+ createChild(container, 'div', 'buttons');
+ return this._banner;
+ });
+ },
+
+ /**
+ * Shows a banner with a given message.
+ * Optionally shows a button with a given label/callback/dismiss.
+ * 'dismiss' is called when the banner is dismissed and button
+ * has not been clicked. It is optional.
+ * @memberof SystemBanner.prototype
+ * @param {String|Array} messages The messages to display
+ * @param {Object} buttonParams
+ * { label: l10nAttrs, callback: ..., dismiss: ... }
+ */
+ show: function(messages, buttonParams) {
+ var generation = ++this._generation;
+ clearTimeout(this._dismissTimer);
+ if (this._cancelRemoval) {
+ this._cancelRemoval();
+ }
+ this._clicked = false;
+ this._showing = true;
+
+ return this.getBanner().then(banner => {
+ if (generation !== this._generation || !this._showing) {
+ return;
+ }
+ // Clear the button and message containers.
+ var messageContainer = banner.querySelector('.messages');
+ var buttonContainer = banner.querySelector('.buttons');
+ messageContainer.innerHTML = '';
+ buttonContainer.innerHTML = '';
+
+ // Populate banner with messages.
+ if (!Array.isArray(messages)) {
+ messages = [messages];
+ }
+ messages.forEach((chunk) => {
+ var p = createChild(messageContainer, 'p');
+ setElementL10n(p, chunk);
+ });
+
+ // Populate buttons if specified.
+ if (buttonParams) {
+ var button = createChild(buttonContainer, 'span');
+ button.className = 'button';
+ setElementL10n(button, buttonParams.label);
+ button.addEventListener('click', () => {
+ this._clicked = true;
+ this.hide();
+ buttonParams.callback && buttonParams.callback();
+ });
+ }
+
+ // Hide banner and invoke button dismiss() on click or timeout.
+ var hideBanner = () => {
+ if (generation !== this._generation || !this._showing) {
+ return;
+ }
+ this.hide();
+ if (!this._clicked) {
+ buttonParams && buttonParams.dismiss && buttonParams.dismiss();
+ }
+ };
+ banner.onclick = hideBanner;
+ this._dismissTimer = setTimeout(hideBanner, SystemBanner.TIMEOUT);
+
+ document.getElementById('screen').appendChild(banner);
+ banner.show();
+ });
+ },
+
+ /**
+ * Hide the System Banner and remove it from the DOM.
+ */
+ hide: function() {
+ if (!this._showing) {
+ return;
+ }
+ this._showing = false;
+ clearTimeout(this._dismissTimer);
+ var generation = this._generation;
+
+ return this.getBanner().then(banner => {
+ if (generation !== this._generation || this._showing) {
+ return;
+ }
+ // Animation events inside a modern shadow root do not reach the host.
+ var animation = banner.shadowRoot &&
+ banner.shadowRoot.querySelector('.bread') || banner;
+ var cleanup = () => {
+ clearTimeout(this._removeTimer);
+ animation.removeEventListener('animationend', removeBanner);
+ animation.removeEventListener('animationcancel', removeBanner);
+ this._cancelRemoval = null;
+ };
+ var removeBanner = event => {
+ if (event && event.target !== animation) {
+ return;
+ }
+ cleanup();
+ if (!this._showing && generation === this._generation) {
+ banner.parentNode && banner.parentNode.removeChild(banner);
+ }
+ };
+ this._cancelRemoval = cleanup;
+ animation.addEventListener('animationend', removeBanner);
+ animation.addEventListener('animationcancel', removeBanner);
+ // Gaia's exit animation lasts 600 ms. Also dismiss if it is disabled
+ // or no animation event is produced.
+ this._removeTimer = setTimeout(removeBanner, 700);
+ banner.hide();
+ });
+ }
+ };
+
+ function createChild(parentEl, type, className) {
+ var el = document.createElement(type);
+ if (className) {
+ el.className = className;
+ }
+ parentEl.appendChild(el);
+ return el;
+ }
+
+ function setElementL10n(element, l10nAttrs) {
+ if (typeof(l10nAttrs) === 'string') {
+ element.setAttribute('data-l10n-id', l10nAttrs);
+ } else {
+ navigator.mozL10n.setAttributes(
+ element, l10nAttrs.id, l10nAttrs.args);
+ }
+ }
+
+ exports.SystemBanner = SystemBanner;
+
+}(window));
diff --git a/release.json b/release.json
index ac787205..2e7fb88a 100644
--- a/release.json
+++ b/release.json
@@ -1,15 +1,15 @@
{
"gaia": "2.7.0",
"channel": "preview",
- "revision": 15,
- "androidVersionCode": 15,
+ "revision": 17,
+ "androidVersionCode": 17,
"geckoview": "156.0.20260921121718",
"engineAlignment": {
"family": "156",
"patchParity": false,
"reason": "Firefox desktop 156.0.1; Mozilla's GeckoView release repository currently offers 156.0 only. Keep the desktop patch and report actual engine versions."
},
- "version": "2.7-preview.15",
+ "version": "2.7-preview.17",
"adapters": {
"desktop": "0.1.0-dev",
"android": "0.1.0-dev"
diff --git a/services/activities.mjs b/services/activities.mjs
new file mode 100644
index 00000000..4072ca49
--- /dev/null
+++ b/services/activities.mjs
@@ -0,0 +1,114 @@
+export function isGaiaActivity(source) {
+ return source?.name === 'pick' ||
+ (source?.name === 'dial' && source.data?.type === 'webtelephony/number') ||
+ (source?.name === 'new' && source.data?.type === 'websms/sms') ||
+ (['new','update','open'].includes(source?.name) && source.data?.type === 'webcontacts/contact');
+}
+
+function entryPointFor(app, path) {
+ if (!path) return undefined;
+ const pathname=new URL(path,app.origin).pathname;
+ return Object.entries(app.manifest?.entry_points || {}).find(([,point])=>
+ new URL(point.launch_path || '/',app.origin).pathname===pathname)?.[0];
+}
+
+// Route Gaia's built-in pickers; only the selected provider can finish a request.
+export class Activities {
+ constructor({identity, apps, launch, notify, uuid}) {
+ Object.assign(this, {identity, apps, launch, notify, uuid});
+ this.pending = new Map();
+ this.listeners = new Map();
+ }
+ route(source, caller) {
+ const types = [].concat(source?.data?.type || []), name=source?.name;
+ let id;
+ if (name === 'pick') {
+ if (types.some(t=>['ringtone','alerttone'].includes(t))) id='ringtones';
+ else if (types.some(t=>['webcontacts/contact','webcontacts/tel','webcontacts/email','webcontacts/select'].includes(t))) id='communications';
+ else if (types.some(t=>typeof t==='string' && t.startsWith('image/'))) id=caller?.id==='communications'?'gallery':'wallpaper';
+ } else if (name === 'dial' && types.includes('webtelephony/number')) id='communications';
+ else if (name === 'new' && types.includes('websms/sms')) id='sms';
+ else if (['new','update','open'].includes(name) && types.includes('webcontacts/contact')) id='communications';
+ const app = this.apps().find(a=>a.id===id);
+ const candidates = [].concat(app?.manifest.activities?.[name] || []);
+ const definition = candidates.find(d=>{
+ const filter=d.filters?.type;
+ const accepted=[].concat(filter?.value || filter || []);
+ return !accepted.length || types.some(t=>accepted.includes(t));
+ });
+ if (!app || !definition?.href) throw Error('ACTIVITY_NOT_SUPPORTED');
+ const url=new URL(definition.href,app.origin);
+ if (url.origin!==new URL(app.origin).origin) throw Error('ACTIVITY_NOT_SUPPORTED');
+ // SMS handles the activity after startup; its old manifest fragment is
+ // not a Navigation view in the packaged Gaia build.
+ if (id==='sms' && name==='new') url.hash='';
+ const entryPoint=entryPointFor(app,url.href);
+ return {app,definition,url,entryPoint};
+ }
+ provider(source) { return this.route(source).app; }
+ start(caller, source) {
+ const app = this.identity(caller);
+ if (!app || !['settings','system','homescreen','verticalhome','ringtones','sms','communications','email','calendar'].includes(app.id))
+ throw Error('PERMISSION_DENIED');
+ const {app:provider,definition,url,entryPoint}=this.route(source,app);
+ if ([...this.pending.values()].some(p=>p.provider.id===provider.id)) throw Error('ACTIVITY_BUSY');
+ const id = this.uuid();
+ return new Promise((resolve,reject) => {
+ const row = {id,caller,app,provider,source,resolve,reject,url,entryPoint,
+ returnURL:caller.manager?.documentURI?.spec,returns:definition.disposition!=='window'};
+ this.pending.set(id,row);
+ const listener=[...this.listeners.values()].find(peer=>
+ peer.manager?.documentURI?.spec && this.identity(peer)?.id===provider.id && this.matches(peer,row));
+ if (listener) {
+ this.launch(provider,listener.manager.documentURI.spec,{entryPoint});
+ this.deliver(row,listener);
+ } else {
+ url.searchParams.set('activity',id);
+ this.launch(provider,url.href,{reload:true,entryPoint});
+ }
+ });
+ }
+ matches(peer,row) {
+ const spec=peer.manager?.documentURI?.spec;
+ if (!spec) return true;
+ const current=new URL(spec);
+ if (current.pathname!==row.url.pathname) return false;
+ // Gallery selects its picker mode once at startup from #pick. Reusing a
+ // normal gallery window would never enable selection/cropping controls.
+ return row.provider.id!=='gallery' || current.hash===row.url.hash;
+ }
+ subscribe(peer) {
+ const app = this.identity(peer);
+ if (!app) throw Error('PERMISSION_DENIED');
+ this.listeners.set(peer,peer);
+ for (const row of this.pending.values())
+ if (row.provider.id===app.id && this.matches(peer,row)) this.deliver(row,peer);
+ }
+ deliver(row,peer) {
+ if (!peer || row.delivered === peer) return;
+ row.delivered = peer;
+ this.notify(peer,'activity',{id:row.id,source:row.source});
+ if (!row.returns) {this.pending.delete(row.id);row.resolve(null);}
+ }
+ finish(peer, {id,result,error}) {
+ const row = this.pending.get(id);
+ if (!row || row.delivered !== peer) throw Error('PERMISSION_DENIED');
+ this.pending.delete(id);
+ this.launch(row.app,row.returnURL,{entryPoint:entryPointFor(row.app,row.returnURL)});
+ if (error) row.reject(Error('ACTIVITY_CANCELLED'));
+ else row.resolve(result);
+ }
+ cancel(peer) {
+ if (this.identity(peer)?.id !== 'system') throw Error('PERMISSION_DENIED');
+ for (const row of this.pending.values()) row.reject(Error('ACTIVITY_CANCELLED'));
+ this.pending.clear();
+ }
+ cleanup(peer) {
+ for (const [app, listener] of this.listeners) if (listener === peer) this.listeners.delete(app);
+ for (const [id,row] of this.pending) {
+ if (row.caller === peer || row.delivered === peer) {
+ this.pending.delete(id);row.reject(Error('ACTIVITY_CANCELLED'));
+ }
+ }
+ }
+}
diff --git a/services/packages.mjs b/services/packages.mjs
new file mode 100644
index 00000000..7885c980
--- /dev/null
+++ b/services/packages.mjs
@@ -0,0 +1,77 @@
+// Archived Marketplace packages run as ordinary web apps, never certified Gaia apps.
+const MAX_ZIP = 16 * 1024 * 1024, MAX_FILES = 2048, MAX_EXPANDED = 48 * 1024 * 1024;
+export function packageURL(value) {
+ const url = new URL(value);
+ if (url.origin !== 'https://vulpes-os.org' || url.pathname !== '/marketplace/index.php' ||
+ !/^\d{1,10}$/.test(url.searchParams.get('download') || '') || url.username || url.password)
+ throw Error('INVALID_PACKAGE_URL');
+ return url.origin+url.pathname+'?download='+url.searchParams.get('download');
+}
+async function bounded(stream, limit) {
+ const reader=stream.getReader(), chunks=[]; let size=0;
+ try { for (;;) {const {done,value}=await reader.read();if(done)break;size+=value.length;
+ if(size>limit)throw Error('PACKAGE_TOO_LARGE');chunks.push(value);}
+ } finally {await reader.cancel().catch(()=>{});}
+ const bytes=new Uint8Array(size);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}return bytes;
+}
+function crc32(bytes) {let crc=0xffffffff;for(const byte of bytes){crc^=byte;for(let i=0;i<8;i++)crc=(crc>>>1)^((crc&1)?0xedb88320:0);}return (crc^0xffffffff)>>>0;}
+function base64(bytes) {let text='';for(let i=0;iMAX_ZIP)throw Error('PACKAGE_TOO_LARGE');
+ const view=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength), decoder=new TextDecoder('utf-8',{fatal:true});
+ let end=-1;
+ for(let p=bytes.length-22;p>=Math.max(0,bytes.length-65557);p--)
+ if(view.getUint32(p,true)===0x06054b50 && p+22+view.getUint16(p+20,true)===bytes.length){end=p;break;}
+ if(end<0 || view.getUint16(end+4,true) || view.getUint16(end+6,true))throw Error('INVALID_ZIP');
+ const count=view.getUint16(end+10,true), centralSize=view.getUint32(end+12,true),start=view.getUint32(end+16,true);
+ if(!count || count>MAX_FILES || start+centralSize!==end || view.getUint16(end+8,true)!==count)throw Error('INVALID_ZIP');
+ const files=Object.create(null);let cursor=start,total=0;
+ for(let i=0;iend || view.getUint32(cursor,true)!==0x02014b50)throw Error('INVALID_ZIP');
+ const flags=view.getUint16(cursor+8,true),method=view.getUint16(cursor+10,true),crc=view.getUint32(cursor+16,true),
+ packed=view.getUint32(cursor+20,true),size=view.getUint32(cursor+24,true),length=view.getUint16(cursor+28,true),
+ extra=view.getUint16(cursor+30,true),comment=view.getUint16(cursor+32,true),attrs=view.getUint32(cursor+38,true),offset=view.getUint32(cursor+42,true);
+ if(cursor+46+length+extra+comment>end || flags&1 || ![0,8].includes(method) || ((attrs>>>16)&0xf000)===0xa000)throw Error('UNSUPPORTED_ZIP');
+ const name=decoder.decode(bytes.subarray(cursor+46,cursor+46+length));cursor+=46+length+extra+comment;
+ if(!name || name.startsWith('/') || name.includes('\\') || /[\x00-\x1f:]/.test(name) || name.split('/').some(p=>p==='..'||p==='.') || name.length>512)throw Error('INVALID_PATH');
+ if(name.endsWith('/'))continue;
+ if(Object.hasOwn(files,name) || (total+=size)>MAX_EXPANDED || offset+30>start || view.getUint32(offset,true)!==0x04034b50)throw Error('INVALID_ZIP');
+ const localName=view.getUint16(offset+26,true),localExtra=view.getUint16(offset+28,true),data=offset+30+localName+localExtra;
+ if(data+packed>start || decoder.decode(bytes.subarray(offset+30,offset+30+localName))!==name || view.getUint16(offset+8,true)!==method)throw Error('INVALID_ZIP');
+ let content=bytes.subarray(data,data+packed);
+ if(method===8)content=await bounded(new Blob([content]).stream().pipeThrough(new DecompressionStream('deflate-raw')),size);
+ if(content.length!==size || crc32(content)!==crc)throw Error('CORRUPT_PACKAGE');
+ files[name]=base64(content);
+ }
+ if(cursor!==end || !files['manifest.webapp'])throw Error('MISSING_MANIFEST');
+ const original=JSON.parse(decoder.decode(Uint8Array.from(atob(files['manifest.webapp']),c=>c.charCodeAt(0))));
+ if(typeof original.name!=='string' || !original.name.trim() || original.name.length>120)throw Error('INVALID_MANIFEST');
+ function path(value) {if(typeof value!=='string')throw Error('INVALID_MANIFEST');const u=new URL(value,'https://app.invalid/');
+ if(u.origin!=='https://app.invalid' || !Object.hasOwn(files,decodeURIComponent(u.pathname.slice(1))))throw Error('MISSING_APP_FILE');return u.pathname+u.search+u.hash;}
+ const launch=path(original.launch_path || '/index.html'),icons={};
+ for(const [size,value] of Object.entries(original.icons || {}))if(/^\d+$/.test(size)) {try {icons[size]=path(value);}catch(_){}}
+ if(!Object.keys(icons).length)throw Error('MISSING_ICON');
+ const hash=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(source));
+ const id='user-'+Array.from(new Uint8Array(hash)).map(n=>n.toString(16).padStart(2,'0')).join('').slice(0,24);
+ const manifest={name:original.name,description:String(original.description||'').slice(0,1000),version:String(original.version||'1.0').slice(0,60),launch_path:launch,icons,type:'web',permissions:{}};
+ return {id,manifest,files,source,bytes:total,requestedPermissions:Object.keys(original.permissions||{})};
+}
+export class Packages {
+ constructor({save,fetchPackage=(...args)=>fetch(...args),uuid=()=>crypto.randomUUID(),changed,schedule=(fn,delay)=>setTimeout(fn,delay),unschedule=id=>clearTimeout(id)}) {Object.assign(this,{save,fetchPackage,uuid,changed,schedule,unschedule});this.pending=new Map();}
+ async prepare(url) {
+ this.cancel();const controller=this.controller=new AbortController();
+ const timer=this.schedule(()=>controller.abort(),60000);
+ try {
+ const source=packageURL(url),response=await this.fetchPackage(source,{redirect:'error',credentials:'omit',signal:controller.signal});
+ if(!response.ok)throw Error('DOWNLOAD_FAILED');
+ const item=await unpackPackage(await bounded(response.body,MAX_ZIP),source);
+ if(controller.signal.aborted)throw Error('DOWNLOAD_CANCELLED');
+ this.pending.clear();const token=this.uuid();this.pending.set(token,{item,expires:Date.now()+300000});
+ return {token,name:item.manifest.name,bytes:item.bytes,permissions:item.requestedPermissions};
+ } finally {this.unschedule(timer);if(this.controller===controller)this.controller=null;}
+ }
+ cancel(){this.controller?.abort();this.pending.clear();}
+ async install(token) {const row=this.pending.get(token);this.pending.delete(token);if(!row || row.expires=win.innerWidth || y>=win.innerHeight) throw Error('INVALID_TEST_POINT');
+ const {setTimeout}=ChromeUtils.importESModule('resource://gre/modules/Timer.sys.mjs');
+ for (const type of ['mousemove','mousedown','mouseup']) {
+ win.synthesizeMouseEvent(type,x,y,{button:0,clickCount:1},{isAsyncEnabled:false});
+ await new Promise(resolve=>setTimeout(resolve,100));
+ }
+ return true;
+ }
if (message.name === 'Tap') {
const {x,y}=message.data, win=this.contentWindow;
if (!Number.isFinite(x) || !Number.isFinite(y) || x<0 || y<0 ||
diff --git a/tests/activities.test.mjs b/tests/activities.test.mjs
new file mode 100644
index 00000000..0caca7f8
--- /dev/null
+++ b/tests/activities.test.mjs
@@ -0,0 +1,83 @@
+import assert from 'node:assert/strict';
+import {Activities} from '../services/activities.mjs';
+const apps=[{id:'settings',origin:'http://settings.localhost'},{id:'wallpaper',origin:'http://wallpaper.localhost',manifest:{activities:{pick:{href:'/pick.html'}}}},{id:'ringtones',origin:'http://ringtones.localhost',manifest:{activities:{pick:{href:'/pick.html'}}}}];
+const caller={app:apps[0],manager:{documentURI:{spec:'http://settings.localhost/#display'}}};
+const picker={app:apps[1]}, stranger={app:apps[2]};
+const events=[],launches=[];
+const broker=new Activities({identity:p=>p.app,apps:()=>apps,launch:(...a)=>launches.push(a),notify:(...a)=>events.push(a),uuid:()=> 'request-1'});
+const pending=broker.start(caller,{name:'pick',data:{type:'image/*'}});
+assert.equal(events.length,0);
+broker.subscribe(picker);assert.equal(events.length,1);
+assert.throws(()=>broker.finish(stranger,{id:'request-1',result:{}}),/PERMISSION_DENIED/);
+assert.throws(()=>broker.start(caller,{name:'pick',data:{type:'image/*'}}),/ACTIVITY_BUSY/);
+broker.finish(picker,{id:'request-1',result:{blob:{__vulpesBlobV1:true,type:'image/png',base64:'YWJj'}}});
+assert.equal((await pending).blob.base64,'YWJj');assert.equal(launches.at(-1)[1],'http://settings.localhost/#display');
+const cancelled=broker.start(caller,{name:'pick',data:{type:'image/*'}});
+broker.subscribe(picker);broker.cleanup(picker);await assert.rejects(cancelled,/ACTIVITY_CANCELLED/);
+assert.throws(()=>broker.start({app:{id:'untrusted'}},{name:'pick',data:{type:'image/*'}}),/PERMISSION_DENIED/);
+assert.throws(()=>broker.provider({name:'pick',data:{type:'video/mp4'}}),/ACTIVITY_NOT_SUPPORTED/);
+const abandoned = broker.start(caller,{name:'pick',data:{type:'image/*'}});
+assert.throws(()=>broker.cancel(stranger),/PERMISSION_DENIED/);
+broker.cancel({app:{id:'system'}});
+await assert.rejects(abandoned,/ACTIVITY_CANCELLED/);
+const reopened = broker.start(caller,{name:'pick',data:{type:'image/*'}});
+broker.subscribe(picker);
+broker.finish(picker,{id:'request-1',result:{reopened:true}});
+assert.equal((await reopened).reopened,true);
+console.log('Activity routing, result ownership, cancellation and return navigation: OK');
+const phone={id:'communications',origin:'http://communications.localhost',manifest:{activities:{
+ pick:{href:'/contacts/index.html?pick',filters:{type:{value:['webcontacts/tel']}}},
+ dial:{href:'/dialer/index.html#keyboard-view',filters:{type:'webtelephony/number'},disposition:'window'},
+ open:[{href:'/contacts/views/details/details.html',filters:{type:'webcontacts/contact'}},
+ {href:'/contacts/views/vcard_load/vcard_load.html',filters:{type:'text/vcard'}}],
+}}};
+const sms={id:'sms',origin:'http://sms.localhost',manifest:{activities:{new:{href:'/index.html#/activity-new',filters:{type:'websms/sms'},returnValue:true}}}};
+apps.push(phone,sms);
+const smsPeer={app:sms,manager:{documentURI:{spec:'http://sms.localhost/index.html#composer'}}};
+const phonePeer={app:phone};
+const choose=broker.start(smsPeer,{name:'pick',data:{type:'webcontacts/tel'}});
+assert.equal(new URL(launches.at(-1)[1]).searchParams.has('pick'),true);
+broker.subscribe(phonePeer);
+broker.finish(phonePeer,{id:'request-1',result:{number:'5550199',name:'Test'}});
+assert.equal((await choose).number,'5550199');
+assert.equal(launches.at(-1)[1],'http://sms.localhost/index.html#composer');
+const dial=broker.start(smsPeer,{name:'dial',data:{type:'webtelephony/number',number:'5550199'}});
+broker.subscribe(phonePeer);await dial;
+assert.equal(broker.pending.size,0,'dial only opens the keypad, with no stale activity left behind');
+const draft=broker.start(phonePeer,{name:'new',data:{type:'websms/sms',target:'5550199'}});
+assert.equal(new URL(launches.at(-1)[1]).hash,'','activity handler chooses the composer after startup');
+broker.subscribe(smsPeer);
+assert.equal(events.at(-1)[2].source.data.target,'5550199');
+broker.finish(smsPeer,{id:'request-1',result:null});await draft;
+assert.equal(broker.route({name:'open',data:{type:'webcontacts/contact'}}).url.pathname,'/contacts/views/details/details.html');
+assert.throws(()=>broker.route({name:'open',data:{type:'url'}}),/ACTIVITY_NOT_SUPPORTED/);
+console.log('Contacts/SMS picker, contact details, SMS draft and dialer routing: OK');
+
+let serial=0;
+const nested=new Activities({identity:p=>p.app,apps:()=>apps,launch:(...a)=>launches.push(a),notify:(...a)=>events.push(a),uuid:()=>String(++serial)});
+const contactsPeer={app:phone,manager:{documentURI:{spec:'http://communications.localhost/contacts/index.html'}}};
+nested.subscribe(contactsPeer);
+const nestedDraft=nested.start(contactsPeer,{name:'new',data:{type:'websms/sms',target:'5550199'}});
+nested.subscribe(smsPeer);
+const nestedPicker=nested.start(smsPeer,{name:'pick',data:{type:'webcontacts/tel'}});
+assert.equal(launches.at(-1)[2].reload,undefined,'reuse the address book without destroying the outer SMS activity');
+assert.equal(events.at(-1)[0],contactsPeer);
+nested.finish(contactsPeer,{id:'2',result:{number:'5550199'}});
+assert.equal((await nestedPicker).number,'5550199');
+assert.equal(nested.pending.size,1);
+nested.finish(smsPeer,{id:'1',result:null});await nestedDraft;
+assert.equal(nested.pending.size,0);
+
+const gallery={id:'gallery',origin:'http://gallery.localhost',manifest:{activities:{pick:{href:'/index.html#pick',filters:{type:['image/jpeg']},returnValue:true}}}};
+apps.push(gallery);
+const normalGallery={app:gallery,manager:{documentURI:{spec:'http://gallery.localhost/index.html'}}};
+nested.subscribe(normalGallery);
+const photo=nested.start(contactsPeer,{name:'pick',data:{type:'image/jpeg'}});
+assert.equal(launches.at(-1)[2].reload,true,'normal Gallery must reload in picker mode');
+assert.equal(new URL(launches.at(-1)[1]).hash,'#pick');
+const galleryPicker={app:gallery,manager:{documentURI:{spec:launches.at(-1)[1]}}};
+nested.subscribe(galleryPicker);
+const photoRequest=events.at(-1)[2].id;
+assert.throws(()=>nested.finish(normalGallery,{id:photoRequest,result:{}}),/PERMISSION_DENIED/);
+nested.finish(galleryPicker,{id:photoRequest,result:{blob:new Blob(['image'])}});
+assert.equal(await (await photo).blob.text(),'image');
diff --git a/tests/android-sms-handoff.test.mjs b/tests/android-sms-handoff.test.mjs
new file mode 100644
index 00000000..7eea152d
--- /dev/null
+++ b/tests/android-sms-handoff.test.mjs
@@ -0,0 +1,38 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import vm from 'node:vm';
+import {readFileSync} from 'node:fs';
+
+function setup(composer) {
+ let click, assimilated=false;
+ const calls=[];
+ const recipients={numbers:[]};
+ const context={console,location:{hostname:'sms.localhost',pathname:'/index.html'},
+ document:{readyState:'complete',addEventListener:(name,handler)=>{if(name==='click')click=handler;}},
+ VulpesCompat:{call:async(method,params)=>{calls.push({method,params});}},
+ VulpesPlatform:'android',Navigation:{isCurrentPanel:()=>composer},
+ ConversationView:{recipients,activeThread:{participants:['5550123']},assimilateRecipients(){assimilated=true;recipients.numbers=['5550199'];}},
+ Compose:{type:'sms',getText:()=> 'Message à confirmer'},
+ };
+ context.window=context;
+ vm.runInNewContext(readFileSync('host/gaia-restoration.js','utf8'),context);
+ return {calls,context,send(){
+ click({target:{closest:()=>true},preventDefault(){},stopImmediatePropagation(){}});
+ assert.equal(assimilated,true);
+ }};
+}
+test('Android handoff commits the typed recipient before opening its SMS app',()=>{
+ const s=setup(true);s.send();
+ assert.equal(s.calls[0].method,'platform.sms');
+ assert.equal(s.calls[0].params.number,'5550199');
+ assert.equal(s.calls[0].params.body,'Message à confirmer');
+});
+test('Android reply uses the conversation participants and preserves the draft',()=>{
+ const s=setup(false);s.send();
+ assert.equal(s.calls[0].params.number,'5550123');
+ assert.equal(s.context.Compose.getText(),'Message à confirmer');
+});
+test('empty Android messages are not handed off',()=>{
+ const s=setup(true);s.context.Compose.getText=()=>'';s.send();
+ assert.equal(s.calls.length,0);
+});
diff --git a/tests/camera-lifecycle.test.mjs b/tests/camera-lifecycle.test.mjs
new file mode 100644
index 00000000..a42577de
--- /dev/null
+++ b/tests/camera-lifecycle.test.mjs
@@ -0,0 +1,152 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import vm from 'node:vm';
+import {readFileSync} from 'node:fs';
+
+function deferred() {
+ let resolve;
+ const promise = new Promise(r => { resolve = r; });
+ return {promise, resolve};
+}
+
+// Execute the shared adapter, with controllable media promises instead of a sensor.
+function setup(native = false) {
+ const videos = [], streams = [], requests = [], events = new EventTarget();
+ const nativeRequests=[];
+ let hidden = false, captureGate, playGate, labels = true;
+ class Document extends EventTarget { get hidden() { return hidden; } }
+ class Video {
+ get srcObject() { return this.stream; }
+ set srcObject(value) { this.stream = value; }
+ constructor() { this.dataset = {}; this.style = {}; }
+ play() { return playGate?.promise || Promise.resolve(); }
+ pause() {}
+ remove() { const index=videos.indexOf(this); if(index!==-1)videos.splice(index, 1); }
+ }
+ const document = new Document();
+ Object.assign(document, {
+ documentElement: {lang:'en'}, body: {append:video => videos.push(video)},
+ querySelector: () => null, querySelectorAll: () => [...videos],
+ createElement: tag => tag === 'canvas' ? {getContext:()=>({
+ createShader:()=>({}), shaderSource(){}, compileShader(){},
+ getShaderParameter:()=>false, createProgram:()=>({}),
+ deleteShader(){}, deleteProgram(){}, getExtension:()=>null,
+ })} : new Video(),
+ });
+ const navigator = {language:'en', mediaDevices: {
+ async enumerateDevices() {
+ return ['rear','selfie'].map(deviceId => ({kind:'videoinput', deviceId,
+ label: labels ? (deviceId === 'rear' ? 'Back camera' : 'Front camera') : ''}));
+ },
+ async getUserMedia(constraints) {
+ requests.push(constraints);
+ const id = constraints.video.deviceId?.exact ||
+ (constraints.video.facingMode?.ideal === 'user' ? 'selfie' : 'rear');
+ const gate = captureGate;
+ captureGate = null;
+ if (gate) await gate.promise;
+ const track = {readyState:'live', stop() { this.readyState = 'ended'; },
+ getSettings: () => ({deviceId:id, width:640, height:480})};
+ const stream = Object.assign(new EventTarget(), {
+ getTracks: () => [track], getVideoTracks: () => [track],
+ });
+ streams.push(stream);
+ return stream;
+ },
+ }};
+ const context = {document, Document, navigator, HTMLMediaElement:Video, console, performance,
+ location:{hostname:'camera.localhost',port:'8765'}, parent:{},
+ MutationObserver:class {observe() {}},
+ Event, DOMException, CustomEvent:class extends Event {constructor(type,opts){super(type);this.detail=opts.detail;}},
+ setTimeout, clearTimeout, queueMicrotask, setInterval:()=>0, clearInterval(){},
+ addEventListener:events.addEventListener.bind(events), dispatchEvent:events.dispatchEvent.bind(events),
+ VulpesCompat:{async call(method,params) {
+ if(method==='platform.capabilities')return {platform:native?'tundra':'desktop',camera:true};
+ nativeRequests.push(params);
+ if(params.action==='start')return {session:42,width:640,height:480,orientation:90};
+ }},
+ };
+ context.window = context;
+ vm.runInNewContext(readFileSync(new URL('../host/gaia-camera.js', import.meta.url),'utf8'), context);
+ return {api:navigator.mozCameras, streams, requests, videos, nativeRequests,
+ unlabelled:()=>{labels=false;},
+ blockCapture:()=>captureGate=deferred(), blockPlay:()=>playGate=deferred(),
+ hide:()=>{hidden=true;events.dispatchEvent(new Event('visibilitychange'));},
+ show:()=>{hidden=false;events.dispatchEvent(new Event('visibilitychange'));},
+ };
+}
+
+test('unlabelled device identities stay stable after front/back switches', async()=>{
+ const s=setup();s.unlabelled();
+ for (const [name,id] of [['back','rear'],['front','selfie'],['back','rear'],['front','selfie']]) {
+ const {camera}=await s.api.getCamera(name);
+ assert.equal(camera.getVideoTracks()[0].getSettings().deviceId,id);
+ }
+ await s.streams.at(-1).release();
+});
+
+test('native preview setup failure releases the sensor before returning an error', async()=>{
+ const s=setup(true);
+ await assert.rejects(s.api.getCamera('front'),/Camera shader compilation failed/);
+ assert.equal(s.nativeRequests.length,2);
+ assert.equal(s.nativeRequests[1].action,'stop');
+ assert.equal(s.nativeRequests[1].session,42);
+});
+
+test('switch waits for asynchronous close even after release has detached the current stream', async()=>{
+ const s=setup();
+ const {camera}=await s.api.getCamera('back');
+ const gate=deferred();let calls=0;
+ camera.vulpesCameraClose=()=>{calls++;return gate.promise;};
+ const release=camera.release();
+ assert.equal(camera.release(),release);
+ const next=s.api.getCamera('front');
+ await new Promise(resolve=>setImmediate(resolve));
+ assert.equal(s.requests.length,1);
+ gate.resolve();await release;
+ const replacement=await next;
+ assert.equal(calls,1);
+ assert.equal(camera.getTracks()[0].readyState,'ended');
+ assert.equal(s.videos.length,1,'only the active capture video is retained');
+ await replacement.camera.release();
+ assert.equal(s.videos.length,0);
+});
+
+test('failed hardware close still stops tracks and allows reopening', async()=>{
+ const s=setup();const {camera}=await s.api.getCamera('back');
+ camera.vulpesCameraClose=async()=>{throw Error('device disconnected');};
+ await assert.rejects(camera.release(),/device disconnected/);
+ assert.equal(camera.getTracks()[0].readyState,'ended');
+ const next=await s.api.getCamera('front');
+ assert.equal(next.camera.getTracks()[0].readyState,'live');
+ await next.camera.release();
+});
+
+test('concurrent switch waits for a pending open and disposes its stale stream', async()=>{
+ const s=setup(),gate=s.blockCapture();
+ const first=s.api.getCamera('back');
+ const rejected=assert.rejects(first,{name:'AbortError'});
+ await new Promise(resolve=>setImmediate(resolve));
+ const second=s.api.getCamera('front');
+ await new Promise(resolve=>setImmediate(resolve));
+ assert.equal(s.requests.length,1,'second sensor must not open before first settles');
+ gate.resolve();await rejected;
+ const {camera}=await second;
+ assert.equal(s.streams[0].getTracks()[0].readyState,'ended');
+ assert.equal(camera.getVideoTracks()[0].getSettings().deviceId,'selfie');
+ await camera.release();
+});
+
+test('hide/show during preview startup leaves no live abandoned stream', async()=>{
+ const s=setup(),gate=s.blockPlay();
+ const first=s.api.getCamera('back');
+ const rejected=assert.rejects(first,{name:'AbortError'});
+ await new Promise(resolve=>setImmediate(resolve));
+ s.hide();s.show();
+ const next=s.api.getCamera('front');
+ gate.resolve();await rejected;
+ const {camera}=await next;
+ assert.equal(s.streams[0].getTracks()[0].readyState,'ended');
+ assert.equal(camera.getTracks()[0].readyState,'live');
+ await camera.release();
+});
diff --git a/tests/contacts.test.mjs b/tests/contacts.test.mjs
new file mode 100644
index 00000000..fca77a98
--- /dev/null
+++ b/tests/contacts.test.mjs
@@ -0,0 +1,59 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {Contacts} from '../host/Contacts.sys.mjs';
+
+let serial=0;
+globalThis.Services={uuid:{generateUUID:()=>({toString:()=>`test-${++serial}`})}};
+const writer={manifest:{permissions:{contacts:{access:'readwrite'}}}};
+const reader={manifest:{permissions:{contacts:{access:'readonly'}}}};
+
+async function addressBook() {
+ let stored;
+ const events=[];
+ const storage={async run(_store,operation,args) {
+ if(operation==='get') return {value:structuredClone(stored)};
+ stored=structuredClone(args[0]);
+ }};
+ const contacts=new Contacts(storage,event=>events.push(event));
+ await contacts.init();
+ return {contacts,events,storage};
+}
+
+test('contact edits, photo, date and favourite survive reopening the store',async()=>{
+ const {contacts,events,storage}=await addressBook();
+ const first=await contacts.request(writer,{operation:'save',contact:{
+ givenName:['Élodie'],familyName:['Test'],tel:[{value:'+33 6 00 00 00 00'}],
+ photo:[new Blob(['portrait'],{type:'image/jpeg'})],bday:new Date('1995-08-19T00:00:00Z'),
+ }});
+ const edited=await contacts.request(writer,{operation:'save',contact:{...first,familyName:['Modifiée'],category:['favorite']}});
+ assert.equal(edited.id,first.id);
+ assert.equal(edited.published,first.published);
+ const reopened=new Contacts(storage,()=>{});await reopened.init();
+ const [row]=await reopened.request(reader,{operation:'find',options:{filterBy:['id'],filterOp:'equals',filterValue:first.id}});
+ assert.deepEqual(row.familyName,['Modifiée']);
+ assert.deepEqual(row.category,['favorite']);
+ assert.equal(await row.photo[0].text(),'portrait');
+ assert.equal(row.bday.toISOString(),'1995-08-19T00:00:00.000Z');
+ assert.deepEqual(events.map(e=>e.reason),['create','update']);
+ await contacts.request(writer,{operation:'remove',id:first.id});
+ assert.equal(await contacts.request(reader,{operation:'count'}),0);
+});
+
+test('a failed write emits no contactchange and does not poison later saves',async()=>{
+ const {contacts,events,storage}=await addressBook();
+ const run=storage.run;
+ storage.run=async()=>{throw Error('disk full');};
+ await assert.rejects(contacts.request(writer,{operation:'save',contact:{name:['First']}}),/disk full/);
+ assert.equal(events.length,0);
+ assert.equal(await contacts.request(reader,{operation:'count'}),0);
+ storage.run=run;
+ await contacts.request(writer,{operation:'save',contact:{name:['Second']}});
+ assert.equal(await contacts.request(reader,{operation:'count'}),1);
+});
+
+test('read-only callers cannot change the address book',async()=>{
+ const {contacts}=await addressBook();
+ for(const operation of ['save','remove','clear'])
+ await assert.rejects(contacts.request(reader,{operation,contact:{name:['Denied']},id:'anything'}),/PERMISSION_DENIED/);
+ await assert.rejects(contacts.request({manifest:{}},{operation:'find'}),/PERMISSION_DENIED/);
+});
diff --git a/tests/installed_apps_test.py b/tests/installed_apps_test.py
new file mode 100644
index 00000000..f85116dd
--- /dev/null
+++ b/tests/installed_apps_test.py
@@ -0,0 +1,28 @@
+import base64
+import json
+from pathlib import Path
+import sys
+import tempfile
+import unittest
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'tools'))
+from installed_apps import InstalledApps
+
+class StoreTests(unittest.TestCase):
+ def test_persistence_removal_and_permission_boundary(self):
+ with tempfile.TemporaryDirectory() as folder:
+ store=InstalledApps(folder,8765)
+ item=dict(id='user-'+'1'*24,manifest=dict(name='Test',type='certified',permissions={'settings':{}}),files={'index.html':base64.b64encode(b'hello').decode()})
+ store.save(item)
+ restored=InstalledApps(folder,8765)
+ self.assertEqual(restored.read(item['id'],'index.html'),b'hello')
+ self.assertEqual(restored.records()[0]['manifest']['permissions'],{})
+ self.assertEqual(restored.records()[0]['manifest']['type'],'web')
+ self.assertEqual(json.loads(restored.read(item['id'],'manifest.webapp'))['permissions'],{})
+ self.assertEqual((Path(folder)/'.token').stat().st_mode & 0o777,0o600)
+ self.assertIsNone(restored.read(item['id'],'../.token'))
+ with self.assertRaises(ValueError):restored.remove('system')
+ with self.assertRaises(ValueError):restored.save({**item,'id':'../escape'})
+ restored.remove(item['id'])
+ self.assertEqual(InstalledApps(folder,8765).records(),[])
+
+if __name__=='__main__':unittest.main()
diff --git a/tests/packages.test.mjs b/tests/packages.test.mjs
new file mode 100644
index 00000000..b5652611
--- /dev/null
+++ b/tests/packages.test.mjs
@@ -0,0 +1,35 @@
+import assert from 'node:assert/strict';
+import {execFileSync} from 'node:child_process';
+import {unpackPackage,packageURL,Packages} from '../services/packages.mjs';
+const source='https://vulpes-os.org/marketplace/index.php?download=42';
+function zip(extra={},manifest={}) {
+ return new Uint8Array(execFileSync('python3',['-c',`import sys,io,zipfile,json
+b=io.BytesIO();args=json.loads(sys.argv[1])
+with zipfile.ZipFile(b,'w',zipfile.ZIP_DEFLATED) as z:
+ z.writestr('manifest.webapp',json.dumps(dict(name='Test',launch_path='/index.html',icons={'128':'/icon.png'},**args['manifest'])))
+ z.writestr('index.html','Test ');z.writestr('icon.png',b'icon')
+ for name,value in args['extra'].items():z.writestr(name,value)
+sys.stdout.buffer.write(b.getvalue())`,JSON.stringify({extra,manifest})]));
+}
+assert.equal(packageURL(source+'&lang=fr'),source);
+for(const url of ['http://vulpes-os.org/marketplace/index.php?download=42','https://evil.test/marketplace/index.php?download=42','https://vulpes-os.org/x?download=42','https://vulpes-os.org/marketplace/index.php?download=../42'])assert.throws(()=>packageURL(url));
+const bytes=zip({}, {type:'certified',permissions:{settings:{access:'readwrite'},'webapps-manage':{}}});
+const item=await unpackPackage(bytes,source);
+assert.equal(item.manifest.type,'web');assert.deepEqual(item.manifest.permissions,{});assert.match(item.id,/^user-[a-f0-9]{24}$/);
+for(const name of ['../escape','/absolute','dir/../../escape','dir\\escape','a:b'])await assert.rejects(unpackPackage(zip({[name]:'bad'}),source));
+await assert.rejects(unpackPackage(bytes.slice(0,-2),source));
+const corrupt=bytes.slice();corrupt[100]^=255;await assert.rejects(unpackPackage(corrupt,source));
+let saved=null,changes=0;
+const service=new Packages({save:async value=>saved=value,changed:async()=>changes++,uuid:()=> 'token',fetchPackage:async()=>new Response(bytes)});
+const prepared=await service.prepare(source);assert.equal(saved,null);assert.equal(prepared.name,'Test');
+assert.equal(await service.install(prepared.token),item.id);assert.equal(changes,1);assert.equal(saved.id,item.id);
+await assert.rejects(service.install(prepared.token),/INSTALL_EXPIRED/);
+console.log('Package URL policy, ZIP boundaries/CRC, permission stripping and one-use confirmation: OK');
+
+// Cancellation during download must not produce a confirmation token.
+let finishDownload;
+const cancelled=new Packages({save:async()=>assert.fail('unexpected save'),changed:async()=>{},
+ fetchPackage:()=>new Promise(resolve=>{finishDownload=resolve;})});
+const pending=cancelled.prepare(source);cancelled.cancel();finishDownload(new Response(bytes));
+await assert.rejects(pending,/DOWNLOAD_CANCELLED/);assert.equal(cancelled.pending.size,0);
+console.log('Cancelled download cannot be installed: OK');
diff --git a/tests/settings-codec.test.mjs b/tests/settings-codec.test.mjs
new file mode 100644
index 00000000..970495b0
--- /dev/null
+++ b/tests/settings-codec.test.mjs
@@ -0,0 +1,35 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import vm from 'node:vm';
+import {readFileSync} from 'node:fs';
+
+const context = {window:{}, Blob, Date, btoa, atob, Uint8Array};
+vm.runInNewContext(readFileSync(new URL('../host/settings-codec.js',import.meta.url),'utf8'),context);
+const codec=context.window.VulpesSettingsCodec;
+test('native media replies retain the structured-cloned Blob and date', async()=>{
+ const blob=new Blob(['jpeg bytes'],{type:'image/jpeg'}), date=new Date();
+ const result=codec.decode({blob,lastModified:date});
+ assert.equal(result.blob,blob);
+ assert.equal(result.lastModified,date);
+ assert.equal(await result.blob.text(),'jpeg bytes');
+});
+test('Android JSON media and settings still round-trip binary content',async()=>{
+ const source=new Blob([new Uint8Array([0,128,255,10])],{type:'image/png'});
+ const wire=JSON.parse(JSON.stringify(await codec.encode({photo:source})));
+ const result=codec.decode(wire);
+ assert.equal(result.photo.type,'image/png');
+ assert.deepEqual(new Uint8Array(await result.photo.arrayBuffer()),new Uint8Array([0,128,255,10]));
+});
+test('a near-match marker is ordinary data, not an injected Blob',()=>{
+ const result=codec.decode({__vulpesBlobV1:true,type:'image/png',base64:'AA==',other:1});
+ assert.equal(result.other,1);
+ assert.equal(result instanceof Blob,false);
+});
+
+test('Android contact birthdays keep their ISO date instead of becoming empty objects',async()=>{
+ const date=new Date('1995-08-19T00:00:00.000Z');
+ const wire=await codec.encode({bday:date,photo:[new Blob(['portrait'],{type:'image/jpeg'})]});
+ const result=codec.decode(JSON.parse(JSON.stringify(wire)));
+ assert.equal(result.bday,date.toISOString());
+ assert.equal(await result.photo[0].text(),'portrait');
+});
diff --git a/tests/settings-transitions.test.mjs b/tests/settings-transitions.test.mjs
new file mode 100644
index 00000000..dbd37cca
--- /dev/null
+++ b/tests/settings-transitions.test.mjs
@@ -0,0 +1,29 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import vm from 'node:vm';
+import {readFile} from 'node:fs/promises';
+const source=await readFile(new URL('../gaia/apps/settings/js/modules/page_transitions.js',import.meta.url),'utf8');
+function fixture(duration='0.4s') {
+ let module, timer, delay, calls=0, ready=0;
+ const panel=Object.assign(new EventTarget(),{id:'wifi',className:''});
+ vm.runInNewContext(source,{define:fn=>{module=fn();},CustomEvent,
+ window:{scrollX:0,scrollY:0,dispatchEvent:()=>ready++,getComputedStyle:()=>({transitionDuration:duration,transitionDelay:'0s'})},
+ setTimeout:(cb,ms)=>{timer=cb;delay=ms;return 1;},clearTimeout:()=>{}});
+ module.oneColumn({id:'root',className:'current'},panel,()=>calls++);
+ return {panel,finish:()=>timer(),state:()=>({calls,ready,delay})};
+}
+test('Settings reaches onShow when no transition event arrives',()=>{
+ const f=fixture();assert.equal(f.state().calls,0);f.finish();
+ assert.deepEqual(f.state(),{calls:1,ready:1,delay:500});
+ f.panel.dispatchEvent(new Event('transitionend'));f.finish();
+ assert.equal(f.state().calls,1);
+});
+test('normal and cancelled transitions finish exactly once',()=>{
+ for(const type of ['transitionend','transitioncancel']){
+ const f=fixture();f.panel.dispatchEvent(new Event(type));f.finish();
+ assert.equal(f.state().calls,1);assert.equal(f.state().ready,1);
+ }
+});
+test('disabled animations do not leave navigation pending',()=>{
+ const f=fixture('0s');assert.equal(f.state().delay,100);f.finish();assert.equal(f.state().calls,1);
+});
diff --git a/tests/wifi-throttle.test.mjs b/tests/wifi-throttle.test.mjs
index fb478211..1d155965 100644
--- a/tests/wifi-throttle.test.mjs
+++ b/tests/wifi-throttle.test.mjs
@@ -107,3 +107,27 @@ test('Native Wi-Fi acknowledges unchanged settings and completes delayed radio c
assert.equal(enabled,1);
assert.equal(navigator.mozWifiManager.enabled,true);
});
+
+test('scan without an association does not send Gaia an invalid signal event', async () => {
+ const {readFile}=await import('node:fs/promises');
+ const vm=await import('node:vm');
+ let connected=false, signals=0;
+ const navigator={};
+ const context=vm.createContext({navigator,console,Event,EventTarget,DOMException,
+ window:{VulpesNativeWifi:true},location:{hostname:'settings.localhost'},addEventListener(){},
+ VulpesCompat:{request:p=>p,call:async()=>({enabled:true,networks:[
+ {ssid:'Test network',id:'/ap/1',signal:80,security:'wpa-psk',connected}
+ ]})}});
+ vm.runInContext(await readFile(new URL('../host/gaia-wifi.js',import.meta.url),'utf8'),context);
+ const manager=navigator.mozWifiManager;
+ manager.onconnectioninfoupdate=event=>{
+ // The original Settings WifiUtils reads event.network.ssid unconditionally.
+ assert.equal(event.network.ssid,'Test network');
+ assert.equal(event.relSignalStrength,80);signals++;
+ };
+ assert.equal((await manager.getNetworks()).length,1);
+ assert.equal(signals,0);assert.equal(manager.connectionInformation,null);
+ connected=true;await manager.getNetworks();
+ assert.equal(signals,1);assert.equal(manager.connection.status,'connected');
+ connected=false;await manager.getNetworks();assert.equal(signals,1);
+});
diff --git a/tools/check-parity.py b/tools/check-parity.py
index 7730afec..916a4819 100644
--- a/tools/check-parity.py
+++ b/tools/check-parity.py
@@ -1,11 +1,13 @@
#!/usr/bin/env python3
"""Reject stale Android payloads and inconsistent common release metadata."""
-import argparse, hashlib, io, json, re, zipfile
+import argparse, contextlib, hashlib, io, json, re, zipfile
from project import ROOT, release_info
p = argparse.ArgumentParser()
-p.add_argument("--apk", type=str)
+group = p.add_mutually_exclusive_group()
+group.add_argument("--apk", type=str)
+group.add_argument("--assets", action="store_true", help="Check staged Android resources, not a compiled APK")
args = p.parse_args()
r = release_info()
apk = ROOT / (args.apk or f'android/dist/vulpes-os-preview-{r["version"]}.apk')
@@ -18,8 +20,15 @@ def translate(s):
return pattern.sub(lambda m: f"http://{m[1] or m[2]}.localhost:18765", s)
+class StagedAssets:
+ def read(self, name):
+ if not name.startswith('assets/') or '..' in name.split('/'):
+ raise ValueError('Invalid packaged path')
+ return (ROOT / 'android/app/src/main' / name).read_bytes()
+
results = {}
-with zipfile.ZipFile(apk) as archive:
+source = contextlib.nullcontext(StagedAssets()) if args.assets else zipfile.ZipFile(apk)
+with source as archive:
metadata = json.loads(archive.read("assets/build-info.json"))
packaged = json.loads(archive.read("assets/bridge/release.json"))
assert metadata["version"] == r["version"], metadata
@@ -30,6 +39,7 @@ with zipfile.ZipFile(apk) as archive:
"platform-ui.js",
"gaia-camera.js",
"gaia-compat.js",
+ "settings-codec.js",
"gaia-services.js",
"gaia-styles.js",
"gaia-restoration.js",
@@ -65,6 +75,9 @@ with zipfile.ZipFile(apk) as archive:
("calendar", "js/bundle.js", "overrides"),
("gallery", "js/metadata_scripts.js", "assets/webapps"),
("gallery", "js/ImageEditor.js", "assets/webapps"),
+ ("communications", "contacts/js/views/form.js", "assets/webapps"),
+ ("communications", "contacts/js/contacts.js", "assets/webapps"),
+ ("communications", "contacts/js/activities.js", "assets/webapps"),
]:
expected = translate((ROOT / source / (app + ".gaiamobile.org") / path).read_text())
for old, new in [("-moz-calc(", "calc("), ("-moz-box-sizing:", "box-sizing:"),
@@ -76,13 +89,17 @@ with zipfile.ZipFile(apk) as archive:
actual = gaia.read(app + "/" + path)
assert actual == expected, "Stale application workflow: " + app + "/" + path
results[app + "/" + path] = hashlib.sha256(actual).hexdigest()
+ for name in ("marketplace.js", "marketplace.css", "icon.png"):
+ expected = (ROOT / "assets/webapps/marketplace.gaiamobile.org" / name).read_bytes()
+ if name.endswith(".js"): expected = translate(expected.decode()).encode()
+ assert gaia.read("marketplace/" + name) == expected, "Stale Marketplace: " + name
for path in (ROOT / "overrides/search.gaiamobile.org/home").rglob("*"):
if path.is_file():
relative = path.relative_to(ROOT / "overrides/search.gaiamobile.org")
assert gaia.read("search/" + relative.as_posix()) == path.read_bytes(), str(relative)
assert b"vulpes-local-home" in gaia.read("search/newtab.html")
assert archive.read("assets/bridge/Messages.mjs") == (ROOT / "host/Messages.sys.mjs").read_bytes()
- for name in ["platform.mjs", "settings.mjs", "datastores.mjs", "screen-power.mjs"]:
+ for name in ["platform.mjs", "settings.mjs", "datastores.mjs", "screen-power.mjs", "activities.mjs", "packages.mjs"]:
assert (
archive.read("assets/bridge/services/" + name)
== (ROOT / "services" / name).read_bytes()
@@ -99,8 +116,11 @@ report = {
"androidEngine": r["androidEngine"],
"exactEngineMatch": r["desktopEngine"] == r["androidEngine"],
"sharedFiles": results,
- "apk": str(apk),
+ "apk": None if args.assets else str(apk),
+ "stagedAssetsOnly": args.assets,
"scope": "Payload and metadata parity; hardware behavior requires separate integration tests.",
}
-(ROOT / "logs/parity-payload.json").write_text(json.dumps(report, indent=2) + "\n")
+report_path = ROOT / ("logs/parity-staged.json" if args.assets else "logs/parity-payload.json")
+report_path.parent.mkdir(parents=True, exist_ok=True)
+report_path.write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps(report, indent=2))
diff --git a/tools/installed_apps.py b/tools/installed_apps.py
new file mode 100644
index 00000000..44dae493
--- /dev/null
+++ b/tools/installed_apps.py
@@ -0,0 +1,72 @@
+"""Private ordinary-web-app store. No Gaia permissions are read from packages."""
+import base64
+import json
+import os
+from pathlib import Path
+import re
+import secrets
+import threading
+
+class InstalledApps:
+ def __init__(self, directory, port):
+ self.root = Path(directory)
+ self.root.mkdir(parents=True, exist_ok=True)
+ self.port = port
+ self.lock = threading.RLock()
+ self.token = secrets.token_hex(32)
+ token = self.root / '.token'
+ token.write_text(self.token)
+ token.chmod(0o600)
+ self.items = {}
+ for path in self.root.glob('user-*.json'):
+ item = json.loads(path.read_text())
+ if self.valid_id(item.get('id')):
+ self.items[item['id']] = item
+
+ @staticmethod
+ def valid_id(value):
+ return isinstance(value, str) and re.fullmatch(r'user-[a-f0-9]{24}', value)
+
+ def records(self):
+ with self.lock:
+ return [self.record(item) for item in self.items.values()]
+
+ def record(self, item):
+ origin = f'http://{item["id"]}.localhost:{self.port}'
+ manifest = {**item['manifest'], 'permissions': {}, 'type': 'web'}
+ return dict(id=item['id'], origin=origin, manifestURL=origin+'/manifest.webapp',
+ manifest=manifest, removable=True, installed=True)
+
+ def save(self, item):
+ if not self.valid_id(item.get('id')) or not isinstance(item.get('files'), dict):
+ raise ValueError('INVALID_PACKAGE')
+ # A second boundary check keeps the storage API independent of ZIP parsing.
+ for name, content in item['files'].items():
+ if not isinstance(name, str) or not isinstance(content, str):
+ raise ValueError('INVALID_PACKAGE')
+ with self.lock:
+ target = self.root / (item['id']+'.json')
+ stage = target.with_suffix('.tmp')
+ with stage.open('w') as stream:
+ json.dump(item, stream)
+ stream.flush()
+ os.fsync(stream.fileno())
+ stage.replace(target)
+ self.items[item['id']] = item
+
+ def remove(self, app_id):
+ if not self.valid_id(app_id):
+ raise ValueError('NOT_REMOVABLE')
+ with self.lock:
+ (self.root/(app_id+'.json')).unlink(missing_ok=True)
+ self.items.pop(app_id, None)
+
+ def read(self, app_id, path):
+ with self.lock:
+ item = self.items.get(app_id)
+ if not item:
+ return None
+ if path == 'manifest.webapp':
+ return json.dumps(self.record(item)['manifest']).encode()
+ content = item['files'].get(path)
+ return base64.b64decode(content, validate=True) if content is not None else None
diff --git a/tools/project.py b/tools/project.py
index 1e8f25b7..92f5c7fd 100644
--- a/tools/project.py
+++ b/tools/project.py
@@ -65,3 +65,12 @@ def release_info():
if not release["androidVersionCode"] > 4:
raise ValueError("Android versionCode must preserve upgrades from Preview 4")
return release
+
+
+def application_manifest(app_id, manifest, include_development=False):
+ """Keep development providers installed but hide their launcher entries."""
+ # Runtime exports omit dev_apps sources; keep the same policy in every package.
+ development = app_id in ('contacts-ds-provider1', 'contacts-ds-provider2', 'contacts-manager', 'ds-test', 'geoloc', 'in_app_pay_test', 'l20n-app', 'marketplace-dev.allizom.org', 'marketplace.allizom.org', 'membuster', 'mobile-wallet', 'mochitest', 'music-oga', 'nfc-api-test', 'share-receiver', 'template', 'test-agent', 'test-container', 'test-iac-publisher', 'test-iac-subscriber', 'test-ime', 'test-keyboard-app', 'test-otasp', 'test-receiver-1', 'test-receiver-2', 'test-receiver-inline', 'test-sensors', 'test-wappush', 'testpermission', 'theme-test-1', 'theme-test-2', 'theme-test-3', 'uitest', 'uitest-privileged', 'upnp-test', 'test-startup-limit')
+ if development and not include_development:
+ manifest = {**manifest, "role": "system"}
+ return manifest
diff --git a/tools/refresh-settings-panels.py b/tools/refresh-settings-panels.py
index a677e376..ae19c69c 100644
--- a/tools/refresh-settings-panels.py
+++ b/tools/refresh-settings-panels.py
@@ -3,15 +3,23 @@
from pathlib import Path
import re
ROOT=Path(__file__).resolve().parents[1]
-for panel, module in [('display','slider_handler'),('wifi','wifi_network_list')]:
- name=f'panels/{panel}/{module}'
- relative=f'js/panels/{panel}/panel.js'
+modules = [
+ ('panels/display/slider_handler', 'js/panels/display/panel.js'),
+ ('panels/wifi/wifi_network_list', 'js/panels/wifi/panel.js'),
+ ('modules/page_transitions', 'js/main.js'),
+]
+for name, relative in modules:
original=ROOT/'assets/webapps/settings.gaiamobile.org'/relative
source=ROOT/'overrides/settings.gaiamobile.org'/f'js/{name}.js'
+ if not source.exists():
+ source=ROOT/'gaia/apps/settings'/f'js/{name}.js'
text=source.read_text()
dependencies=list(dict.fromkeys(re.findall(r"require\(['\"]([^'\"]+)['\"]\)",text)))
import json
- text=text.replace('define(function(require)', 'define('+repr(name)+','+json.dumps(['require',*dependencies])+',function(require)',1)
+ if 'define(function(require)' in text:
+ text=text.replace('define(function(require)', 'define('+repr(name)+','+json.dumps(['require',*dependencies])+',function(require)',1)
+ else:
+ text=text.replace('define(function()', 'define('+repr(name)+',[],function()',1)
bundle=original.read_text()
start=bundle.index("define('"+name+"'")
end=bundle.find("define('",start+8)
diff --git a/tools/serve-gaia.py b/tools/serve-gaia.py
index 561b6135..03bf60c1 100644
--- a/tools/serve-gaia.py
+++ b/tools/serve-gaia.py
@@ -15,7 +15,9 @@ parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--legacy-apps", action="store_true")
args = parser.parse_args()
-from project import build_info, validate_assets, release_info
+from project import build_info, validate_assets, release_info, application_manifest
+from installed_apps import InstalledApps
+INSTALLED = InstalledApps(ROOT / "profiles/installed-apps", args.port)
validate_assets()
info = build_info()
@@ -44,7 +46,7 @@ for manifest_path in manifests:
if not re.fullmatch("[a-z0-9_-]+", name):
continue
origin = f"http://{name}.localhost:{args.port}"
- manifest = json.loads(translate(manifest_path.read_text()))
+ manifest = application_manifest(name, json.loads(translate(manifest_path.read_text())), os.environ.get("VULPES_SHOW_DEV_APPS") == "1")
ORIGINS[urlsplit(origin).netloc] = manifest_path.parent.resolve()
REGISTRY.append(
{
@@ -56,7 +58,7 @@ for manifest_path in manifests:
)
ORIGINS[f"theme.localhost:{args.port}"] = ORIGINS[f"default_theme.localhost:{args.port}"]
(ROOT / "assets/registry.json").write_text(
- json.dumps(REGISTRY, ensure_ascii=False, indent=2) + "\n"
+ json.dumps(REGISTRY + INSTALLED.records(), ensure_ascii=False, indent=2) + "\n"
)
(ROOT / "assets/settings-current-defaults.json").write_text(
translate((ROOT / "assets/settings-defaults.json").read_text())
@@ -64,7 +66,45 @@ ORIGINS[f"theme.localhost:{args.port}"] = ORIGINS[f"default_theme.localhost:{arg
class Handler(BaseHTTPRequestHandler):
+ def json_reply(self, value):
+ data = json.dumps(value).encode()
+ self.send_response(200)
+ self.send_header('Content-Type', 'application/json')
+ self.send_header('Content-Length', str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+
+ def do_POST(self):
+ import secrets
+ if self.headers.get('Host') != f'system.localhost:{args.port}' or not secrets.compare_digest(self.headers.get('X-Vulpes-Install', ''), INSTALLED.token):
+ self.send_error(403); return
+ try:
+ length = int(self.headers.get('Content-Length', '0'))
+ if not 0 < length <= 70*1024*1024: raise ValueError('INVALID_SIZE')
+ data = json.loads(self.rfile.read(length))
+ if self.path == '/_vulpes/packages/save': INSTALLED.save(data)
+ elif self.path == '/_vulpes/packages/remove': INSTALLED.remove(data['id'])
+ else: self.send_error(404); return
+ self.json_reply(INSTALLED.records())
+ except (ValueError, KeyError, OSError): self.send_error(400)
+
def do_GET(self):
+ host = self.headers.get('Host', '').lower()
+ if host == f'system.localhost:{args.port}' and self.path == '/_vulpes/packages/list':
+ self.json_reply(INSTALLED.records()); return
+ app_id = host.removesuffix(f'.localhost:{args.port}')
+ if INSTALLED.valid_id(app_id) and host == f'{app_id}.localhost:{args.port}':
+ path = unquote(urlsplit(self.path).path).lstrip('/') or 'index.html'
+ data = INSTALLED.read(app_id, path)
+ if data is None: self.send_error(404); return
+ self.send_response(200)
+ self.send_header('Content-Type', mimetypes.guess_type(path)[0] or 'application/octet-stream')
+ self.send_header('Content-Length', str(len(data)))
+ self.send_header('Cache-Control', 'no-store')
+ self.send_header('Access-Control-Allow-Origin', '*')
+ self.send_header('X-Content-Type-Options', 'nosniff')
+ self.send_header('Content-Security-Policy', "object-src 'none'; frame-ancestors http://*.localhost:"+str(args.port))
+ self.end_headers(); self.wfile.write(data); return
root = ORIGINS.get(self.headers.get("Host", "").lower())
if root is None:
self.send_error(421, "Unregistered application host")
@@ -93,7 +133,7 @@ class Handler(BaseHTTPRequestHandler):
# Only this non-privileged compatibility layer is exposed, never host modules.
if path in [
"/_vulpes/" + name
- for name in ["gaia-compat.js", "gaia-input.js", "gaia-services.js", "gaia-styles.js", "gaia-restoration.js", "gaia-wifi.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]
+ for name in ["settings-codec.js", "gaia-compat.js", "gaia-input.js", "gaia-services.js", "gaia-styles.js", "gaia-restoration.js", "gaia-wifi.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]
]:
file = ROOT / "host" / path.rsplit("/", 1)[1]
else:
@@ -157,13 +197,13 @@ class Handler(BaseHTTPRequestHandler):
if file.suffix == ".html" and root in LEGACY_ROOTS:
data = data.replace(
b"",
- b'',
+ b'',
1,
)
elif file.suffix == ".html" and not (root.name == "search.gaiamobile.org" and path.startswith("/home/")):
data = data.replace(
b"",
- b' ',
+ b' ',
1,
)
if (
diff --git a/tools/test-camera.py b/tools/test-camera.py
index cf3d2f7a..685e1308 100644
--- a/tools/test-camera.py
+++ b/tools/test-camera.py
@@ -76,12 +76,13 @@ with Client(2857) as c:
"camera",
'return app.camera.mode==="picture" && app.camera.mozCamera.flashMode==="off";',
)
- # Native touch injection targets the camera frame even on high-DPI desktops.
- point = ev("camera", 'const r=document.querySelector(".js-capture").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};')
+ # Drive the desktop mouse through the normal touch simulator. Wait for
+ # layout after reload: a decoded video can precede a visible app frame.
+ point = wait("camera", 'const b=document.querySelector(".js-capture"),r=b.getBoundingClientRect(),x=r.x+r.width/2,y=r.y+r.height/2;return x>0 && y>0 && xdone(true),e=>done({error:String(e)}));""".replace('POINT',json.dumps(point))
+find(root).currentWindowGlobal.getActor('VulpesProbe').sendQuery('MouseClick',POINT).then(()=>done(true),e=>done({error:String(e)}));""".replace('POINT',json.dumps(point))
assert c.call('WebDriver:ExecuteAsyncScript',{'script':script,'args':[],
'newSandbox':True,'sandbox':'system','scriptTimeout':10000})['value'] is True
photo = wait(
diff --git a/tools/test-contacts.py b/tools/test-contacts.py
new file mode 100644
index 00000000..3a8dd226
--- /dev/null
+++ b/tools/test-contacts.py
@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+"""Exercise Gaia Contacts and its SMS/dialer activities in the desktop test profile.
+
+Creates one temporary contact; never sends a message or starts a call.
+"""
+import importlib.util
+import json
+import time
+import sys
+from pathlib import Path
+from marionette_client import Client
+
+spec = importlib.util.spec_from_file_location('probe', Path(__file__).with_name('probe-desktop.py'))
+probe = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(probe)
+results = {}
+contact_id = None
+photo_name = None
+name = 'Vulpes QA ' + str(time.time_ns())
+
+with Client(2857) as client:
+ client.socket.settimeout(90)
+ probe.register(client)
+
+ def ev(app, code):
+ result = probe.evaluate(client, 'communications' if app == 'dialer' else app, code, '/dialer/' if app == 'dialer' else '/contacts/' if app == 'communications' else '/')
+ if 'error' in result:
+ raise AssertionError(result)
+ return result.get('value')
+
+ def wait(app, code):
+ for _ in range(100):
+ try:
+ value = ev(app, code)
+ except AssertionError as error:
+ if 'Actor' not in str(error) or 'destroyed' not in str(error):
+ raise
+ value = None
+ if value:
+ return value
+ time.sleep(.15)
+ raise TimeoutError(app + ': ' + code)
+
+ def click(app, selector):
+ print("click", app, selector, file=sys.stderr, flush=True)
+ point = wait(app, 'for(const e of document.querySelectorAll(' + json.dumps(selector) + ')) {'
+ 'const r=e.getBoundingClientRect(),x=r.x+r.width/2,y=r.y+r.height/2;'
+ 'if(x>0&&y>0&&xdone(true),e=>done({error:String(e)}));'''.replace('PREFIX', json.dumps('http://' + app + '.localhost:8765/')).replace('POINT', json.dumps(point))
+ clicked = client.call('WebDriver:ExecuteAsyncScript', {'script': script, 'args': [],
+ 'newSandbox': True, 'sandbox': 'system', 'scriptTimeout': 10000})['value']
+ assert clicked is True, clicked
+
+ def field(selector, value):
+ wait('communications', 'return !!document.querySelector(' + json.dumps(selector) + ');')
+ ev('communications', 'const e=document.querySelector(' + json.dumps(selector) + ');'
+ 'e.value=' + json.dumps(value) + ';e.dispatchEvent(new Event("input",{bubbles:true}));return true;')
+
+ def contact():
+ return ev('communications', '''const r=navigator.mozContacts.find({filterBy:['givenName'],
+filterOp:'equals',filterValue:NAME});return await new Promise((ok,no)=>{
+r.onsuccess=()=>ok(r.result[0]||null);r.onerror=()=>no(r.error);});'''.replace('NAME', json.dumps(name)))
+
+ try:
+ wait('system', 'return document.body?.getAttribute("ready-state")==="fullyLoaded";')
+ ev('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;")
+ wait('communications', 'return document.readyState==="complete"&&!document.hidden;')
+ ev('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://gallery.localhost:8765/manifest.webapp'});return true;")
+ wait('gallery', 'return document.readyState==="complete"&&!document.hidden;')
+ fixture='vulpes-contacts-qa-'+str(time.time_ns())+'.jpg'
+ photo_name=ev('gallery', 'const c=document.createElement("canvas");c.width=c.height=400;'
+ 'const ctx=c.getContext("2d");ctx.fillStyle="#2372ae";ctx.fillRect(0,0,400,400);'
+ 'const blob=await new Promise(r=>c.toBlob(r,"image/jpeg"));'
+ 'return await VulpesCompat.call("media.request",{operation:"add",type:"pictures",name:'+json.dumps(fixture)+',blob});')
+ ev('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;")
+ token = str(time.time_ns())
+ ev('communications', 'setTimeout(()=>location.href=location.origin+"/contacts/index.html?qa='+token+'",0);return true;')
+ wait('communications', 'return location.search==="?qa='+token+'" && document.readyState==="complete";')
+ wait('communications', 'return typeof Loader!=="undefined" && !!window.Contacts && MainNavigation.currentView()==="view-contacts-list";')
+ click('communications', '#add-contact-button')
+ wait('communications', 'return window.MainNavigation?.currentView()==="view-contact-form" && !!document.querySelector("#number_0");')
+ field('#givenName', name)
+ field('#familyName', 'Élodie Test')
+ field('#number_0', '5550199')
+ field('#email_0', 'qa@example.invalid')
+ click('communications', '#save-button')
+ for _ in range(80):
+ row = contact()
+ if row:
+ contact_id = row['id']
+ break
+ time.sleep(.15)
+ assert contact_id
+ results['create'] = row['tel'][0]['value'] == '5550199'
+ item = '[data-uuid=' + json.dumps(contact_id) + ']'
+ click('communications', '#search-start')
+ wait('communications', "return MainNavigation.currentView()==='search-view' && !!document.querySelector('#search-contact');")
+ field('#search-contact', 'elodie')
+ results['search_without_accent'] = bool(wait('communications', 'return !!document.querySelector(' + json.dumps('#search-list [data-uuid="' + contact_id + '"]') + ');'))
+ field('#search-contact', name + ' absent')
+ results['search_no_results'] = bool(wait('communications', "return !document.querySelector('#no-result').classList.contains('hide');"))
+ click('communications', '#cancel-search')
+ wait('communications', "return MainNavigation.currentView()==='view-contacts-list';")
+ click('communications', item)
+ click('communications', '#edit-contact-button')
+ wait('communications', 'return MainNavigation.currentView()==="view-contact-form";')
+ click('communications', '#photo-button')
+ wait('gallery', 'return !document.hidden && typeof picking!=="undefined" && picking;')
+ thumb='.thumbnailImage[data-filename='+json.dumps(photo_name)+']'
+ click('gallery',thumb)
+ wait('gallery','return !document.querySelector("#crop-done-button").disabled;')
+ click('gallery','#crop-done-button')
+ wait('communications', 'return !document.hidden && getComputedStyle(document.querySelector("#thumbnail-photo")).backgroundImage!=="none";')
+ field('#familyName', 'Élodie Modifiée')
+ click('communications', '#save-button')
+ results['edit_detail_refresh'] = bool(wait('communications', 'return document.querySelector("#contact-name-title")?.textContent.includes("Élodie Modifiée");'))
+ assert contact()['familyName'] == ['Élodie Modifiée']
+ results['contact_photo']=bool(ev('communications', 'const r=navigator.mozContacts.find({filterBy:["id"],filterOp:"equals",filterValue:'+json.dumps(contact_id)+'});const c=await new Promise(ok=>{r.onsuccess=()=>ok(r.result[0]);});return c.photo?.[0] instanceof Blob && c.photo[0].size>0;'))
+ assert results['contact_photo']
+ ev('communications', 'document.querySelector("#toggle-favorite").scrollIntoView({block:"center"});return true;')
+ click('communications', '#toggle-favorite')
+ results['favorite'] = bool(wait('communications', 'const r=navigator.mozContacts.find({filterBy:["id"],filterOp:"equals",filterValue:'+json.dumps(contact_id)+'});const c=await new Promise(ok=>{r.onsuccess=()=>ok(r.result[0]);});return c.category?.includes("favorite");'))
+ ev('communications', 'document.querySelector("#send-sms-button-0").scrollIntoView({block:"center"});return true;')
+ click('communications', '#send-sms-button-0')
+ results['sms_recipient'] = bool(wait('sms', 'return !document.hidden&&window.ConversationView?.recipients?.numbers.includes("5550199");'))
+ click('sms', '#messages-contact-pick-button')
+ wait('communications', 'return !document.hidden&&ActivityHandler.currentlyHandling;')
+ click('communications', item)
+ wait('communications', 'return !!document.querySelector("[data-l10n-id=pick_destination]");')
+ click('communications', '[data-l10n-id=pick_destination]')
+ wait('communications', 'return !ActivityHandler.currentlyHandling;')
+ results['picker_return'] = bool(wait('sms', 'return !document.hidden&&ConversationView.recipients.numbers.includes("5550199");'))
+ # This activity only prefills the original dialer; do not press Call.
+ ev('sms', 'window.__dialQA=new MozActivity({name:"dial",data:{type:"webtelephony/number",number:"5550199"}});return true;')
+ results['dialer_prefill'] = bool(wait('dialer', 'return !document.hidden&&document.querySelector("#phone-number-view")?.value==="5550199";'))
+ ev('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;")
+ wait('communications', 'return !document.hidden;')
+ click('communications', item)
+ click('communications', '#edit-contact-button')
+ wait('communications', 'return MainNavigation.currentView()==="view-contact-form";')
+ ev('communications', 'document.querySelector("#delete-contact").scrollIntoView({block:"center"});return true;')
+ click('communications', '#delete-contact')
+ click('communications', '#confirmation-message button[data-l10n-id=cancel]')
+ results['delete_cancel'] = contact() is not None
+ wait('communications', 'return document.querySelector("#confirmation-message").classList.contains("hide");')
+ click('communications', '#delete-contact')
+ click('communications', '#confirmation-message button[data-l10n-id=delete]')
+ for _ in range(100):
+ if contact() is None:
+ results['delete_confirm'] = True
+ results['temporary_contact_removed'] = True
+ contact_id = None
+ break
+ time.sleep(.15)
+ assert results.get('delete_confirm')
+ results['passed'] = all(results.values())
+ finally:
+ try:
+ if contact_id:
+ # Return to the address book before removing only our temporary entry.
+ ev('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;")
+ ev('communications', 'const r=navigator.mozContacts.remove(' + json.dumps(contact_id) + ');await new Promise((ok,no)=>{r.onsuccess=ok;r.onerror=()=>no(r.error);});return true;')
+ results['temporary_contact_removed'] = contact() is None
+ except Exception as error:
+ results['cleanup_error'] = str(error)
+ results['temporary_contact_id'] = contact_id
+ finally:
+ if photo_name:
+ try:
+ ev('gallery','await VulpesCompat.call("media.request",{operation:"delete",type:"pictures",name:'+json.dumps(photo_name)+'});return true;')
+ results['temporary_photo_removed']=True
+ except Exception as error:
+ results['photo_cleanup_error']=str(error)
+ results['temporary_photo_name']=photo_name
+ print(json.dumps(results, indent=2, ensure_ascii=False))
+
+if not results.get('passed') or results.get('cleanup_error') or results.get('photo_cleanup_error'):
+ raise SystemExit('Contacts qualification failed; see the report above.')
diff --git a/tools/test-system-banner.py b/tools/test-system-banner.py
new file mode 100644
index 00000000..ceb9a395
--- /dev/null
+++ b/tools/test-system-banner.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+"""Exercise Gaia's transient banner in the isolated desktop host."""
+import importlib.util
+import json
+from pathlib import Path
+from marionette_client import Client
+spec = importlib.util.spec_from_file_location('probe', Path(__file__).with_name('probe-desktop.py'))
+probe = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(probe)
+with Client(2857) as client:
+ probe.register(client)
+ result = probe.evaluate(client, 'system', '''
+await LazyLoader.load('js/system_banner.js');
+const banner=new SystemBanner();
+const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
+try {
+ await banner.show({id:'app-install-success',args:{appName:'Vulpes test'}});
+ const shown=banner._banner.isConnected;
+ await sleep(4900);
+ const dismissed=!banner._banner.isConnected;
+ await banner.show('app-install-success');
+ await sleep(1000);
+ await banner.show('app-install-success');
+ await sleep(3300);
+ const replacementRetained=banner._banner.isConnected;
+ await sleep(1600);
+ const replacementDismissed=!banner._banner.isConnected;
+ await banner.show('app-install-success');
+ banner._banner.click();
+ await sleep(800);
+ const clickDismissed=!banner._banner.isConnected;
+ return {shown,dismissed,replacementRetained,replacementDismissed,clickDismissed};
+} finally {await banner.hide();banner._banner?.remove();}
+''')
+ assert 'error' not in result, result
+ checks=result['value']
+ assert all(checks.values()), checks
+ print(json.dumps(checks, indent=2))
diff --git a/tundra/boot/sparse-backup.c b/tundra/boot/sparse-backup.c
new file mode 100644
index 00000000..bfb82c98
--- /dev/null
+++ b/tundra/boot/sparse-backup.c
@@ -0,0 +1,46 @@
+/* SPDX-License-Identifier: MPL-2.0
+ * Read stdin only. Emit offsets and nonzero 64 KiB blocks for a host-side backup.
+ * No device is opened or written by this process.
+ */
+#define _start unused_stage0_start
+#include "init.c"
+#undef _start
+static unsigned long buffer[8192];
+static void stop(long code) {
+ call(NR_exit, code, 0, 0, 0, 0);
+ for (;;) {}
+}
+static void write_all(const void *data, long length) {
+ const char *p = data;
+ while (length) {
+ long n = call(NR_write, 1, (long)p, length, 0, 0);
+ if (n == -4) continue;
+ if (n <= 0) stop(3);
+ p += n;
+ length -= n;
+ }
+}
+__attribute__((noreturn)) void _start(void) {
+ unsigned long offset = 0;
+ write_all("TUNRAW1\0", 8);
+ for (;;) {
+ long n = call(NR_read, 0, (long)buffer, sizeof(buffer), 0, 0);
+ if (n == -4) continue;
+ if (n < 0) stop(2);
+ if (!n) break;
+ unsigned long bits = 0;
+ long i = 0;
+ for (; i + 8 <= n; i += 8) bits |= buffer[i / 8];
+ for (; i < n; ++i) bits |= ((unsigned char *)buffer)[i];
+ if (bits) {
+ unsigned long header[2] = {offset, (unsigned long)n};
+ write_all(header, sizeof(header));
+ write_all(buffer, n);
+ }
+ offset += n;
+ }
+ unsigned long end[2] = {offset, 0};
+ write_all(end, sizeof(end));
+ call(NR_exit, 0, 0, 0, 0, 0);
+ for (;;) {}
+}
diff --git a/tundra/boot/standalone-mount.sh b/tundra/boot/standalone-mount.sh
new file mode 100644
index 00000000..384d0438
--- /dev/null
+++ b/tundra/boot/standalone-mount.sh
@@ -0,0 +1,14 @@
+#!/bin/busybox sh
+# SPDX-License-Identifier: MPL-2.0
+# Mount only the prepared Tundra volume. Never format or guess an old filesystem.
+set -eu
+# VOLUME_CONFIGURATION
+mkdir -p /volume
+blkid "$VOLUME_DEVICE" | grep -F "UUID=\"$VOLUME_UUID\"" >/dev/null
+repaired=0
+e2fsck -p "$VOLUME_DEVICE" || repaired=$?
+test "$repaired" -le 1
+mount -t ext4 -o "$VOLUME_OPTIONS" "$VOLUME_DEVICE" /volume
+test "$(cat /volume/tundra-volume-id)" = "$VOLUME_UUID"
+echo TUNDRA_STANDALONE_VOLUME_READY
+exec /bin/sh /native-root.sh
diff --git a/tundra/droidian/probe.sys.mjs b/tundra/droidian/probe.sys.mjs
index 0949a2aa..2879a786 100644
--- a/tundra/droidian/probe.sys.mjs
+++ b/tundra/droidian/probe.sys.mjs
@@ -67,8 +67,10 @@ export async function run(win) {
.then(async response=>dump(`TUNDRA_HTTP ${response.status} ${(await response.text()).length}\n`))
.catch(error=>dump('TUNDRA_HTTP_ERROR '+error+' offline='+Services.io.offline+'\n'));
async function evaluate(app, code, message = 'Evaluate') {
+ const prefix=app==='contacts'?'http://communications.localhost:8765/contacts/':
+ app==='dialer'?'http://communications.localhost:8765/dialer/':`http://${app}.localhost:8765/`;
function find(context) {
- if(context.currentWindowGlobal?.documentURI.spec.startsWith(`http://${app}.localhost:8765/`)) return context;
+ if(context.currentWindowGlobal?.documentURI.spec.startsWith(prefix)) return context;
for(const child of context.children) {const found=find(child); if(found) return found;}
return null;
}
@@ -90,14 +92,15 @@ export async function run(win) {
}
throw new Error(`${app}: timeout: ${last}`);
}
- async function tap(app, selector) {
- if(app==='keyboard') selector='.keyboard-type-container[data-active] '+selector;
- await wait(app, `const e=document.querySelector(${JSON.stringify(selector)});if(!e)return false;const r=e.getBoundingClientRect();return r.width>0 && r.height>0 && e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2));`);
- await sleep(600);
- const point = await evaluate(app, `const e=document.querySelector(${JSON.stringify(selector)});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};`);
- await evaluate(app,point,'Tap');
- await sleep(350);
- }
+ async function tap(app, selector) {
+ if(app==='keyboard') selector='.keyboard-type-container[data-active] '+selector;
+ const point=await wait(app, `for(const e of document.querySelectorAll(${JSON.stringify(selector)})) {
+ const r=e.getBoundingClientRect(),x=r.x+r.width/2,y=r.y+r.height/2;
+ if(x>0 && y>0 && x0;");
- // Gaia remembers the selected lens; establish a known starting state.
- await evaluate('camera', "if(app.settings.cameras.selected('key')!=='back')app.settings.cameras.next();return true;");
- await wait('camera', "return app.settings.cameras.selected('key')==='back' && !!app.camera.mozCamera && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
- cameraReport.gaia = await evaluate('camera', `
- const video=document.querySelector('video');
- const camera=app.camera.mozCamera;
- window.__cameraBeforeSwitch=camera;
- const capabilities=camera.capabilities;
- let focus=null;
- if(capabilities.maxFocusAreas>0) {
- camera.setFocusAreas([{top:-150,left:-150,bottom:150,right:150}]);
- await camera.autoFocus();focus=true;
- }
- if(capabilities.zoomRatios.length>1) {
- camera.zoom=2;
- await new Promise(r=>setTimeout(r,700));
- }
- const testedZoom=camera.zoom;
- camera.zoom=1;
- const photo=await camera.takePicture();
- const bitmap=await createImageBitmap(photo);
- const picture={width:bitmap.width,height:bitmap.height};bitmap.close();
- return {width:video.videoWidth,height:video.videoHeight,native:camera.vulpesNative===true,
- picture,focus,zoom:testedZoom,focusAreas:capabilities.maxFocusAreas,
- jpegBytes:photo.size,jpegType:photo.type,errors:window.__vulpesDiagnostics};
- `);
- cameraReport.performance = await evaluate('camera', `
- const stream=app.camera.mozCamera, before=stream.vulpesTiming?.frames||0;
- const start=performance.now();await new Promise(r=>setTimeout(r,4000));
- const elapsed=performance.now()-start;
- const hud=document.querySelector('.hud');
- const button=document.querySelector('.js-camera');
- return {fps:(stream.vulpesTiming.frames-before)*1000/elapsed,
- conversionMs:stream.vulpesTiming.conversionMs/stream.vulpesTiming.frames,
- switchVisible:getComputedStyle(button).opacity!=='0' && hud.getAttribute('camera-enabled')==='true',
- flashVisible:getComputedStyle(document.querySelector('.js-flash')).visibility==='visible'};
- `);
- await evaluate('camera', "document.querySelector('.js-camera').click();return true;");
- await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__cameraBeforeSwitch && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
- cameraReport.switched = await evaluate('camera', `
- const photo=await app.camera.mozCamera.takePicture();
- window.__cameraBeforeHome=app.camera.mozCamera;
- return {camera:app.settings.cameras.selected('key'),jpegBytes:photo.size,
- previousStopped:window.__cameraBeforeSwitch.getTracks().every(t=>t.readyState==='ended')};
- `);
- cameraReport.roundTrips = [];
- for (let i=0; i<4; i++) {
- await evaluate('camera', "window.__switchFrom=app.camera.mozCamera;return true;");
- await tap('camera', '.js-camera');
- await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__switchFrom && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
- cameraReport.roundTrips.push(await evaluate('camera', `
- const camera=app.camera.mozCamera, before=camera.vulpesTiming.frames;
- await new Promise(r=>setTimeout(r,700));
- return {camera:app.settings.cameras.selected('key'),
- frames:camera.vulpesTiming.frames-before,
- previousStopped:window.__switchFrom.getTracks().every(t=>t.readyState==='ended')};
- `));
- }
- await evaluate('camera', 'window.__cameraBeforeHome=app.camera.mozCamera;return true;');
- // Test Gaia's Home action, independent of a held pointer on the shell button.
- cameraReport.beforeHome = await evaluate('system', "return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
- await evaluate('system', "dispatchEvent(new CustomEvent('home'));return true;");
- cameraReport.afterHome = await evaluate('system', "await new Promise(r=>setTimeout(r,1000));return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
- cameraReport.hidden = await wait('camera', 'return document.hidden;');
- cameraReport.stopped = await wait('camera', "return window.__cameraBeforeHome.getTracks().every(t=>t.readyState==='ended');");
- cameraReport.passed=cameraReport.gaia.native && cameraReport.gaia.picture.width*cameraReport.gaia.picture.height>10000000 && cameraReport.gaia.focus && cameraReport.gaia.zoom>1 && cameraReport.gaia.jpegBytes>1000 && cameraReport.gaia.jpegType==='image/jpeg' && cameraReport.switched.jpegBytes>1000 && cameraReport.switched.previousStopped && cameraReport.stopped && cameraReport.roundTrips.every(r=>r.frames>0 && r.previousStopped);
- } catch(error) { cameraReport.error=String(error); }
- finally { win.document.getElementById('home')?.click(); }
- await IOUtils.writeJSON(`${root}/logs/camera.json`, cameraReport);
- }
- const smsMarker = `${root}/logs/test-sms-display`;
- if (await IOUtils.exists(smsMarker)) {
- await IOUtils.remove(smsMarker);
- const smsReport = {bootId:report.bootId, passed:false, sent:false};
- try {
- await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;");
- await wait('sms', "return !!window.Navigation && !!window.MessageManager;");
- smsReport.threads = await evaluate('sms', `
- const threads=await new Promise((resolve,reject)=>{const rows=[];const c=navigator.mozMobileMessage.getThreads();c.onsuccess=()=>{if(c.done)resolve(rows);else{rows.push(c.result);c.continue();}};c.onerror=()=>reject(c.error);});
- window.__smsReviewThreads=threads.map(t=>t.id);
- return {count:threads.length,validDates:threads.every(t=>Number.isFinite(+t.timestamp))};
- `);
- smsReport.conversations=[];
- for(let i=0;iNumber.isFinite(+e.querySelector('time')?.dataset.time)),
- nonempty:nodes.every(e=>!!e.querySelector('.message-content-body')?.textContent),
- errorCount:window.__vulpesDiagnostics?.length||0};
- `));
- }
- smsReport.passed=smsReport.threads.validDates && smsReport.conversations.length>0 && smsReport.conversations.every(c=>c.bubbles>0 && c.validDates && c.nonempty);
- } catch(error) {smsReport.error=String(error);}
- finally {win.document.getElementById('home')?.click();}
- await IOUtils.writeJSON(`${root}/logs/sms-display.json`,smsReport);
- }
- const inputMarker = `${root}/logs/test-keyboard`;
- if (await IOUtils.exists(inputMarker)) {
- await IOUtils.remove(inputMarker);
- const inputReport = {bootId:report.bootId,recordedAt:Date.now(),passed:false,sent:false};
- try {
- await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
- await sleep(1500);
- await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
- await wait('sms', 'return document.querySelector("#threads-composer-link") && innerWidth>0;');
- await evaluate('sms', 'await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
- await wait('sms','return !!document.querySelector(".recipient[contenteditable]");');
- await tap('sms','.recipient[contenteditable]');
- await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="tel" && !!document.querySelector("button[aria-label=\\"2\\"]");');
- await tap('keyboard','button[aria-label="2"]');
- await tap('keyboard','button[aria-label="3"]');
- inputReport.recipientTyped = await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent.endsWith("23");');
- const recipientBefore=await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent;');
- await tap('sms','#messages-input');
- await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="text" && !!document.querySelector("button[aria-label=\\"a\\"]");');
- inputReport.keyboard = await evaluate('keyboard', `const e=document.querySelector('button[aria-label="a"]');const r=e.getBoundingClientRect();
- window.__keyTrial=[];for(const type of ['touchstart','touchend','pointerdown','click']) e.addEventListener(type,()=>__keyTrial.push(type),{once:true});
- return {width:innerWidth,height:innerHeight,key:r.toJSON(),font:getComputedStyle(document.documentElement).fontSize,portrait:app.viewManager.screenInPortraitMode(),engine:app.inputMethodManager.currentIMEngine?.constructor?.name};`);
- await tap('keyboard','button[aria-label="a"]');
- await sleep(2000);
- inputReport.context = await evaluate('keyboard', 'const c=navigator.mozInputMethod.inputcontext;return c&&{id:c.id,type:c.type,inputType:c.inputType,textLength:c.text.length};');
- inputReport.message = await evaluate('sms', 'const text=document.querySelector("#messages-input").textContent;return {length:text.length,lowercase:text.includes("a"),uppercase:text.includes("A")};');
- inputReport.messageTyped = inputReport.message.lowercase || inputReport.message.uppercase;
- inputReport.events = await evaluate('keyboard','return window.__keyTrial;');
- inputReport.recipientUnchanged=await evaluate('sms',`return document.querySelector(".recipient[contenteditable]").textContent===${JSON.stringify(recipientBefore)};`);
- inputReport.passed=inputReport.recipientTyped && inputReport.messageTyped && inputReport.recipientUnchanged && inputReport.keyboard.portrait && inputReport.keyboard.key.height>=44;
- if(!inputReport.messageTyped) {
- try {
- await evaluate('keyboard','await navigator.mozInputMethod.inputcontext.sendKey(0,97);return true;');
- inputReport.directKey = await evaluate('sms','return document.querySelector("#messages-input").textContent.length>0;');
- } catch(error) {inputReport.directKeyError=String(error);}
- }
- inputReport.fonts=await evaluate('sms',null,'RenderedFonts');
- for (const face of inputReport.fonts) delete face.samples;
- } catch(error) {inputReport.error=String(error);}
- finally {win.document.getElementById('home').click();}
- await IOUtils.writeJSON(`${root}/logs/keyboard.json`,inputReport);
- }
- const sendMarker=`${root}/logs/test-send-sms.json`;
- if(await IOUtils.exists(sendMarker)) {
- const request=await IOUtils.readJSON(sendMarker);
- await IOUtils.remove(sendMarker);
- const result={bootId:report.bootId,recordedAt:Date.now(),submitted:false,deliveryConfirmed:false};
- try {
- if(typeof request.recipient!=='string' || !/^\+[0-9]{8,15}$/.test(request.recipient)) throw Error('INVALID_TEST_RECIPIENT');
- const {tundraRadio}=ChromeUtils.importESModule('resource://vulpes/host/Tundra.sys.mjs');
- const state=await tundraRadio('status');
- if(!['registered','roaming'].includes(state.registration?.[0]?.Status?.data)) throw Error('RADIO_NOT_REGISTERED');
- await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
- await sleep(1000);
- await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
- await wait('sms','return !!window.Navigation;');
- await evaluate('sms','await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
- await evaluate('sms','if(ConversationView.recipients.length || document.querySelector(".recipient[contenteditable]").textContent.trim() || Compose.getText())throw Error("TEST_DRAFT_PRESENT");return true;');
- await tap('sms','.recipient[contenteditable]');
- await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="tel";');
- await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(request.recipient)},0,0);return true;`);
- await tap('sms','#messages-input');
- await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="text";');
- const body='Vulpes OS - test SMS '+new Date().toISOString();
- await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(body)},0,0);return true;`);
- await wait('sms','return !document.querySelector("#messages-send-button").disabled;');
- await evaluate('sms',`if(!Navigation.isCurrentPanel('composer') ||
- JSON.stringify(ConversationView.recipients.numbers)!==JSON.stringify([${JSON.stringify(request.recipient)}]) ||
- Compose.getText()!==${JSON.stringify(body)})throw Error('TEST_MESSAGE_MISMATCH');
- document.querySelector('#messages-send-button').click();return true;`);
- result.submitted=await wait('sms',`return await new Promise((resolve,reject)=>{
- const cursor=navigator.mozMobileMessage.getMessages(null,true);
- cursor.onsuccess=()=>{const m=cursor.result;if(!m){resolve(false);return;}
- if(m.body===${JSON.stringify(body)}){if(m.delivery==='error'){reject(Error('MODEM_TRANSMISSION_FAILED'));return;}resolve(m.delivery==='sent');return;}cursor.continue();};
- cursor.onerror=()=>reject(cursor.error);
- });`);
- } catch(error) {result.error=String(error);}
- finally {win.document.getElementById('home').click();}
- await IOUtils.writeJSON(`${root}/logs/send-sms.json`,result);
- }
- const notificationMarker = `${root}/logs/test-notifications`;
- if (await IOUtils.exists(notificationMarker)) {
- await IOUtils.remove(notificationMarker);
- const result = {bootId:report.bootId,passed:false,silent:true};
- let id;
- try {
- await evaluate('system', `ScreenManager.turnScreenOn(true);await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
- await wait('sms', 'return typeof Notification.get === "function";');
- await evaluate('system', 'if(!Service.query("locked"))await Service.request("lock");return true;');
- result.lockState=await evaluate('system','return {service:Service.query("locked"),direct:lockScreen.locked,preview:NotificationScreen.lockscreenPreview};');
- await wait('system', 'return Service.query("locked") && NotificationScreen.lockscreenPreview;');
- id = await evaluate('sms', `return await new Promise((resolve,reject)=>{
- const n=new Notification('Vulpes — test local',{body:'Notification de qualification',tag:'vulpes-qualification',silent:true});
- n.onshow=()=>resolve(n.id);n.onerror=()=>reject(n.error);
- });`);
- const selector = `[data-notification-id="${id}"]`;
- result.tray = await wait('system', `return !!NotificationScreen.container.querySelector(${JSON.stringify(selector)});`);
- result.lockscreen = await wait('system', `return !!NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
- await evaluate('sms', `const rows=await Notification.get({tag:'vulpes-qualification'});rows.forEach(n=>n.close());return true;`);
- result.removed = await wait('system', `return !NotificationScreen.container.querySelector(${JSON.stringify(selector)}) && !NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
- result.passed = result.tray && result.lockscreen && result.removed;
- } catch(error) { result.error=String(error); }
- finally {
- if(id) await evaluate('sms', `for(const n of await Notification.get({tag:'vulpes-qualification'})) n.close();return true;`).catch(()=>{});
- win.document.getElementById('home').click();
- }
- await IOUtils.writeJSON(`${root}/logs/notifications.json`,result);
- }
- }
- await requestedUiChecks();
- let uiCheckRunning=false;
- const uiCheckTimer=setInterval(async()=>{
- if(uiCheckRunning) return;
- uiCheckRunning=true;
- try {
- const reloadMarker=`${root}/logs/test-reload-gaia`;
- if(await IOUtils.exists(reloadMarker)) {
- await IOUtils.remove(reloadMarker);
- win.document.querySelector('browser').reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE);
- await wait('system', "return document.body?.getAttribute('ready-state')==='fullyLoaded';");
- await sleep(1500);
- }
- await requestedUiChecks();
- } catch(error) {dump('TUNDRA_UI_CHECK_ERROR '+error+'\n');}
- finally {uiCheckRunning=false;}
- },3000);
- win.addEventListener('unload',()=>clearInterval(uiCheckTimer),{once:true});
+ async function requestedControls() {
const controlsMarker = `${root}/logs/test-controls`;
if (await IOUtils.exists(controlsMarker)) {
await IOUtils.remove(controlsMarker);
@@ -569,6 +338,392 @@ export async function run(win) {
controls.brightness=brightnessCheck;
await IOUtils.writeJSON(`${root}/logs/controls.json`, controls);
}
+ }
+ async function requestedUiChecks() {
+ await requestedControls();
+ const marketplaceMarker = `${root}/logs/test-marketplace-views`;
+ if (await IOUtils.exists(marketplaceMarker)) {
+ await IOUtils.remove(marketplaceMarker);
+ const result = {bootId:report.bootId,passed:false,checks:{},fixture:'http://127.0.0.1:8767/'};
+ const {Views}=ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs');
+ const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');
+ const until=async check=>{
+ for(let i=0;i<80;i++){if(await check())return true;await sleep(250);}
+ throw Error('Marketplace view check timed out');
+ };
+ const visible=()=>[...Views.entries.values()].some(e=>e.browser.style.visibility==='visible');
+ const open=async()=>{
+ await evaluate('system', 'await VulpesCompat.call("apps.launch",{manifestURL:"http://marketplace.localhost:8765/manifest.webapp"});return true;');
+ await wait('marketplace', 'return !!document.getElementById("browse");');
+ await sleep(1000);
+ await evaluate('marketplace', 'document.getElementById("browse").click();document.getElementById("catalogue").src="http://127.0.0.1:8767/";return true;');
+ await until(visible);
+ };
+ try {
+ await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
+ await sleep(1000);
+ // This fixture tests web-view ownership, not the lockscreen gesture.
+ // Service.unlock can leave the legacy overlay visible during clock teardown.
+ result.unlockFixture=await evaluate('system', 'let note=null;if(window.lockScreen){try{if(lockScreen.locked)lockScreen.unlock(true);}catch(e){note=String(e);}lockScreen.locked=false;lockScreen.overlay.classList.add("unlocked");}return {forced:true,cleanupError:note};');
+ await open();
+ result.checks.belowStatusBar=[...Views.entries.values()].filter(e=>e.browser.style.visibility==='visible').every(e=>parseFloat(e.browser.style.top)>=20);
+ await evaluate('marketplace', 'document.getElementById("catalogue").src="https://marketplace-offline.invalid/";return true;');
+ result.checks.offlineMessage=await wait('marketplace', 'return !document.getElementById("message").hidden && /inaccessible|unavailable/.test(document.getElementById("status").textContent);');
+ result.checks.errorHidden=await until(()=>!visible());
+ await open();
+ result.checks.retry=true;
+ win.document.getElementById('home').click();
+ result.checks.home=await until(()=>!visible());
+ await sleep(1000);
+ await open();
+ Runtime.broadcast('holdhome',{},'system');
+ await wait('system','return appWindowManager.taskManager.isActive();');
+ result.checks.taskSwitcher=await until(()=>!visible());
+ await sleep(1000);
+ await evaluate('system', 'const row=[...appWindowManager.taskManager.appToCardMap].find(([app])=>app.origin.includes("marketplace.localhost"));row[1].element.querySelector(".close-button").click();return true;');
+ result.checks.closed=await until(()=>Views.entries.size===0);
+ result.passed=Object.values(result.checks).every(Boolean);
+ } catch(error) {
+ result.error=String(error);
+ result.views=[...Views.entries.values()].map(e=>({geometry:e.geometry,style:e.browser.style.cssText}));
+ } finally {win.document.getElementById('home').click();}
+ await IOUtils.writeJSON(`${root}/logs/marketplace-views.json`,result);
+ }
+ const ticketMarker = `${root}/logs/test-tickets-20261005`;
+ if (await IOUtils.exists(ticketMarker)) {
+ await IOUtils.remove(ticketMarker);
+ const result = {bootId:report.bootId,passed:false,checks:{}};
+ try {
+ await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;");
+ await wait('system', "return !Service.query('locked');");
+ await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://settings.localhost:8765/manifest.webapp'});return true;");
+ await wait('settings', "return document.readyState==='complete';");
+ await evaluate('settings', "window.__ticketLanguage=(await VulpesCompat.call('settings.get',{key:'language.current'})).value;return true;");
+ for (const lang of ['en-US','fr']) {
+ await evaluate('settings', `await VulpesCompat.call('settings.set',{values:{'language.current':${JSON.stringify(lang)}}});return true;`);
+ result.checks['language_'+lang] = await wait('homescreen', `return navigator.language===${JSON.stringify(lang)} && document.documentElement.lang===${JSON.stringify(lang)};`);
+ }
+ result.checks.developmentHidden = await evaluate('homescreen', "return [...document.querySelectorAll('gaia-app-icon')].every(e=>!/(uitest|test-|membuster|template|ds-test)/.test(e.app?.origin || ''));");
+ result.checks.snap = await evaluate('homescreen', "return getComputedStyle(document.getElementById('panels')).scrollSnapType==='x mandatory';");
+ await evaluate('settings', "window.__ticketPicker=new MozActivity({name:'pick',data:{type:'image/*'}});return true;");
+ await wait('wallpaper', "return !!window.Wallpaper?.pickActivity && document.querySelectorAll('.wallpaper').length>1;");
+ await evaluate('wallpaper', "document.querySelectorAll('.wallpaper')[1].click();return true;");
+ result.checks.wallpaperPicker = await wait('settings', "return __ticketPicker.readyState==='done' && __ticketPicker.result?.blob instanceof Blob && __ticketPicker.result.blob.size>0;");
+ await evaluate('settings', "window.__ticketPicker=new MozActivity({name:'pick',data:{type:'alerttone'}});return true;");
+ await wait('ringtones', "return document.body.dataset.ready==='true' && document.querySelectorAll('gaia-radio').length>3;");
+ await evaluate('ringtones', "document.querySelectorAll('gaia-radio')[2].click();return true;");
+ await wait('ringtones', "return !document.getElementById('set').disabled;");
+ await evaluate('ringtones', "document.getElementById('set').click();return true;");
+ result.checks.tonePicker = await wait('settings', "return __ticketPicker.readyState==='done' && __ticketPicker.result?.blob instanceof Blob && __ticketPicker.result.blob.size>0;");
+ result.passed=Object.values(result.checks).every(Boolean);
+ } catch(error) {result.error=String(error);}
+ finally {
+ await evaluate('settings', "if(window.__ticketLanguage)await VulpesCompat.call('settings.set',{values:{'language.current':__ticketLanguage}});return true;").catch(()=>{});
+ win.document.getElementById('home').click();
+ }
+ await IOUtils.writeJSON(`${root}/logs/tickets-20261005.json`,result);
+ }
+ const cameraMarker = `${root}/logs/test-camera`;
+ if (await IOUtils.exists(cameraMarker)) {
+ await IOUtils.remove(cameraMarker);
+ const cameraReport = {bootId:report.bootId, imagesRetained:false, passed:false};
+ try {
+ await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;");
+ await wait('system', "return !Service.query('locked');");
+ await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://camera.localhost:8765/manifest.webapp'});return true;");
+ await wait('camera', "return !!window.app?.camera?.mozCamera && document.querySelector('video')?.videoWidth>0;");
+ // Gaia remembers the selected lens; establish a known starting state.
+ await evaluate('camera', "if(app.settings.cameras.selected('key')!=='back')app.settings.cameras.next();return true;");
+ await wait('camera', "return app.settings.cameras.selected('key')==='back' && !!app.camera.mozCamera && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
+ cameraReport.gaia = await evaluate('camera', `
+ const video=document.querySelector('video');
+ const camera=app.camera.mozCamera;
+ window.__cameraBeforeSwitch=camera;
+ const capabilities=camera.capabilities;
+ let focus=null, focusError=null;
+ if(capabilities.maxFocusAreas>0) {
+ try {
+ camera.setFocusAreas([{top:-150,left:-150,bottom:150,right:150}]);
+ await camera.autoFocus();focus=true;
+ } catch(error) { focus=false;focusError=String(error); }
+ }
+ if(capabilities.zoomRatios.length>1) {
+ camera.zoom=2;
+ await new Promise(r=>setTimeout(r,700));
+ }
+ const testedZoom=camera.zoom;
+ camera.zoom=1;
+ const photo=await camera.takePicture();
+ const bitmap=await createImageBitmap(photo);
+ const picture={width:bitmap.width,height:bitmap.height};bitmap.close();
+ return {width:video.videoWidth,height:video.videoHeight,native:camera.vulpesNative===true,
+ picture,focus,focusError,zoom:testedZoom,focusAreas:capabilities.maxFocusAreas,
+ jpegBytes:photo.size,jpegType:photo.type,errors:window.__vulpesDiagnostics};
+ `);
+ cameraReport.performance = await evaluate('camera', `
+ const stream=app.camera.mozCamera, before=stream.vulpesTiming?.frames||0;
+ const start=performance.now();await new Promise(r=>setTimeout(r,4000));
+ const elapsed=performance.now()-start;
+ const hud=document.querySelector('.hud');
+ const button=document.querySelector('.js-camera');
+ return {fps:(stream.vulpesTiming.frames-before)*1000/elapsed,
+ conversionMs:stream.vulpesTiming.conversionMs/stream.vulpesTiming.frames,
+ switchVisible:getComputedStyle(button).opacity!=='0' && hud.getAttribute('camera-enabled')==='true',
+ flashVisible:getComputedStyle(document.querySelector('.js-flash')).visibility==='visible'};
+ `);
+ await evaluate('camera', "document.querySelector('.js-camera').click();return true;");
+ await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__cameraBeforeSwitch && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
+ cameraReport.switched = await evaluate('camera', `
+ const photo=await app.camera.mozCamera.takePicture();
+ window.__cameraBeforeHome=app.camera.mozCamera;
+ return {camera:app.settings.cameras.selected('key'),jpegBytes:photo.size,
+ previousStopped:window.__cameraBeforeSwitch.getTracks().every(t=>t.readyState==='ended')};
+ `);
+ cameraReport.roundTrips = [];
+ for (let i=0; i<4; i++) {
+ await evaluate('camera', "window.__switchFrom=app.camera.mozCamera;return true;");
+ await tap('camera', '.js-camera');
+ await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__switchFrom && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
+ cameraReport.roundTrips.push(await evaluate('camera', `
+ const camera=app.camera.mozCamera, before=camera.vulpesTiming.frames;
+ await new Promise(r=>setTimeout(r,700));
+ return {camera:app.settings.cameras.selected('key'),
+ frames:camera.vulpesTiming.frames-before,
+ previousStopped:window.__switchFrom.getTracks().every(t=>t.readyState==='ended')};
+ `));
+ }
+ await evaluate('camera', 'window.__cameraBeforeHome=app.camera.mozCamera;return true;');
+ // Test Gaia's Home action, independent of a held pointer on the shell button.
+ await evaluate('camera', "window.__cameraFilesBefore=new Set((await VulpesCompat.call('media.request',{type:'pictures',operation:'enumerate'})).map(f=>f.name));return true;");
+ await tap('camera','.js-capture');
+ await wait('camera', "const files=await VulpesCompat.call('media.request',{type:'pictures',operation:'enumerate'});window.__cameraSavedPhoto=files.find(f=>!__cameraFilesBefore.has(f.name));return !!__cameraSavedPhoto;");
+ cameraReport.savedPhoto=await evaluate('camera', 'return {name:__cameraSavedPhoto.name,size:__cameraSavedPhoto.size,type:__cameraSavedPhoto.type};');
+ await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://gallery.localhost:8765/manifest.webapp'});return true;");
+ await wait('gallery', `return typeof files!=='undefined' && files.some(f=>f.name===${JSON.stringify(cameraReport.savedPhoto.name)} && f.metadata.width>0 && f.metadata.thumbnail);`);
+ await evaluate('gallery', `document.querySelector('[data-filename="'+${JSON.stringify(cameraReport.savedPhoto.name)}+'"]')?.click();return true;`);
+ cameraReport.galleryOpened=await wait('gallery', "return document.body.classList.contains('fullscreenView');");
+ await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://camera.localhost:8765/manifest.webapp'});return true;");
+ await wait('camera', "return !!app.camera.mozCamera && !app.camera.isBusy && !document.hidden;");
+ await evaluate('camera', `await navigator.getDeviceStorage('pictures').delete(${JSON.stringify(cameraReport.savedPhoto.name)});window.__cameraBeforeHome=app.camera.mozCamera;return true;`);
+ cameraReport.captureStored=cameraReport.savedPhoto.size>1000;
+ delete cameraReport.savedPhoto.name;
+ cameraReport.beforeHome = await evaluate('system', "return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
+ await evaluate('system', "dispatchEvent(new CustomEvent('home'));return true;");
+ cameraReport.afterHome = await evaluate('system', "await new Promise(r=>setTimeout(r,1000));return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
+ cameraReport.hidden = await wait('camera', 'return document.hidden;');
+ cameraReport.stopped = await wait('camera', "return window.__cameraBeforeHome.getTracks().every(t=>t.readyState==='ended');");
+ cameraReport.passed=cameraReport.captureStored && cameraReport.galleryOpened && cameraReport.gaia.native && cameraReport.gaia.picture.width*cameraReport.gaia.picture.height>10000000 && cameraReport.gaia.focus && cameraReport.gaia.zoom>1 && cameraReport.gaia.jpegBytes>1000 && cameraReport.gaia.jpegType==='image/jpeg' && cameraReport.switched.jpegBytes>1000 && cameraReport.switched.previousStopped && cameraReport.stopped && cameraReport.roundTrips.every(r=>r.frames>0 && r.previousStopped);
+ } catch(error) { cameraReport.error=String(error); }
+ finally { win.document.getElementById('home')?.click(); }
+ await IOUtils.writeJSON(`${root}/logs/camera.json`, cameraReport);
+ }
+ const contactsMarker=`${root}/logs/test-contacts`;
+ if(await IOUtils.exists(contactsMarker)) {
+ await IOUtils.remove(contactsMarker);
+ const result={bootId:report.bootId,passed:false}, name='Vulpes QA '+Date.now();
+ let id;
+ const lookup=`const r=navigator.mozContacts.find({filterBy:['givenName'],filterOp:'equals',filterValue:${JSON.stringify(name)}});
+ return await new Promise((ok,no)=>{r.onsuccess=()=>ok(r.result[0]||null);r.onerror=()=>no(r.error);});`;
+ const field=async(selector,value)=>evaluate('contacts',`const e=document.querySelector(${JSON.stringify(selector)});
+ e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true;`);
+ try {
+ await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;");
+ await wait('contacts', "return typeof Loader!=='undefined' && window.MainNavigation?.currentView()==='view-contacts-list';");
+ await tap('contacts','#add-contact-button');
+ await wait('contacts', "return MainNavigation.currentView()==='view-contact-form' && !!document.querySelector('#number_0');");
+ await field('#givenName',name);await field('#familyName','Élodie Test');
+ await field('#number_0','5550199');await field('#email_0','qa@example.invalid');
+ await tap('contacts','#save-button');
+ const saved=await wait('contacts',lookup);id=saved.id;
+ result.created=saved.tel[0].value==='5550199';
+ const selector='[data-uuid="'+id+'"]';
+ await tap('contacts',selector);await tap('contacts','#edit-contact-button');
+ await wait('contacts', "return MainNavigation.currentView()==='view-contact-form';");
+ await field('#familyName','Élodie Modifiée');await tap('contacts','#save-button');
+ result.edited=await wait('contacts', "return document.querySelector('#contact-name-title')?.textContent.includes('Élodie Modifiée');");
+ await evaluate('contacts', "document.querySelector('#send-sms-button-0').scrollIntoView({block:'center'});return true;");
+ await tap('contacts','#send-sms-button-0');
+ result.sms=await wait('sms', "return !document.hidden && window.ConversationView?.recipients?.numbers.includes('5550199');");
+ await tap('sms','#messages-contact-pick-button');
+ await wait('contacts', 'return !document.hidden && ActivityHandler.currentlyHandling;');
+ await tap('contacts',selector);await tap('contacts','[data-l10n-id=pick_destination]');
+ await wait('contacts','return !ActivityHandler.currentlyHandling;');
+ result.picker=await wait('sms', "return !document.hidden && ConversationView.recipients.numbers.includes('5550199');");
+ await evaluate('sms', "new MozActivity({name:'dial',data:{type:'webtelephony/number',number:'5550199'}});return true;");
+ result.dialer=await wait('dialer', "return !document.hidden && document.querySelector('#phone-number-view')?.value==='5550199';");
+ result.passed=result.created&&result.edited&&result.sms&&result.picker&&result.dialer;
+ } catch(error) {result.error=String(error);}
+ finally {
+ try {
+ if(!id) id=(await evaluate('contacts',lookup))?.id;
+ if(id) {
+ await evaluate('contacts', `const r=navigator.mozContacts.remove(${JSON.stringify(id)});await new Promise((ok,no)=>{r.onsuccess=ok;r.onerror=()=>no(r.error);});return true;`);
+ result.temporaryContactRemoved=!(await evaluate('contacts',lookup));
+ }
+ } catch(error) {result.cleanupError=String(error);}
+ win.document.getElementById('home')?.click();
+ }
+ await IOUtils.writeJSON(`${root}/logs/contacts.json`,result);
+ }
+ const smsMarker = `${root}/logs/test-sms-display`;
+ if (await IOUtils.exists(smsMarker)) {
+ await IOUtils.remove(smsMarker);
+ const smsReport = {bootId:report.bootId, passed:false, sent:false};
+ try {
+ await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;");
+ await wait('sms', "return !!window.Navigation && !!window.MessageManager;");
+ smsReport.threads = await evaluate('sms', `
+ const threads=await new Promise((resolve,reject)=>{const rows=[];const c=navigator.mozMobileMessage.getThreads();c.onsuccess=()=>{if(c.done)resolve(rows);else{rows.push(c.result);c.continue();}};c.onerror=()=>reject(c.error);});
+ window.__smsReviewThreads=threads.map(t=>t.id);
+ return {count:threads.length,validDates:threads.every(t=>Number.isFinite(+t.timestamp))};
+ `);
+ smsReport.conversations=[];
+ for(let i=0;iNumber.isFinite(+e.querySelector('time')?.dataset.time)),
+ nonempty:nodes.every(e=>!!e.querySelector('.message-content-body')?.textContent),
+ errorCount:window.__vulpesDiagnostics?.length||0};
+ `));
+ }
+ smsReport.passed=smsReport.threads.validDates && smsReport.conversations.length>0 && smsReport.conversations.every(c=>c.bubbles>0 && c.validDates && c.nonempty);
+ } catch(error) {smsReport.error=String(error);}
+ finally {win.document.getElementById('home')?.click();}
+ await IOUtils.writeJSON(`${root}/logs/sms-display.json`,smsReport);
+ }
+ const inputMarker = `${root}/logs/test-keyboard`;
+ if (await IOUtils.exists(inputMarker)) {
+ await IOUtils.remove(inputMarker);
+ const inputReport = {bootId:report.bootId,recordedAt:Date.now(),passed:false,sent:false};
+ try {
+ await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
+ await sleep(1500);
+ await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
+ await wait('sms', 'return document.querySelector("#threads-composer-link") && innerWidth>0;');
+ await evaluate('sms', 'await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
+ await wait('sms','return !!document.querySelector(".recipient[contenteditable]");');
+ await tap('sms','.recipient[contenteditable]');
+ await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="tel" && !!document.querySelector("button[aria-label=\\"2\\"]");');
+ await tap('keyboard','button[aria-label="2"]');
+ await tap('keyboard','button[aria-label="3"]');
+ inputReport.recipientTyped = await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent.endsWith("23");');
+ const recipientBefore=await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent;');
+ await tap('sms','#messages-input');
+ await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="text" && !!document.querySelector("button[aria-label=\\"a\\"]");');
+ inputReport.keyboard = await evaluate('keyboard', `const e=document.querySelector('button[aria-label="a"]');const r=e.getBoundingClientRect();
+ window.__keyTrial=[];for(const type of ['touchstart','touchend','pointerdown','click']) e.addEventListener(type,()=>__keyTrial.push(type),{once:true});
+ return {width:innerWidth,height:innerHeight,key:r.toJSON(),font:getComputedStyle(document.documentElement).fontSize,portrait:app.viewManager.screenInPortraitMode(),engine:app.inputMethodManager.currentIMEngine?.constructor?.name};`);
+ await tap('keyboard','button[aria-label="a"]');
+ await sleep(2000);
+ inputReport.context = await evaluate('keyboard', 'const c=navigator.mozInputMethod.inputcontext;return c&&{id:c.id,type:c.type,inputType:c.inputType,textLength:c.text.length};');
+ inputReport.message = await evaluate('sms', 'const text=document.querySelector("#messages-input").textContent;return {length:text.length,lowercase:text.includes("a"),uppercase:text.includes("A")};');
+ inputReport.messageTyped = inputReport.message.lowercase || inputReport.message.uppercase;
+ inputReport.events = await evaluate('keyboard','return window.__keyTrial;');
+ inputReport.recipientUnchanged=await evaluate('sms',`return document.querySelector(".recipient[contenteditable]").textContent===${JSON.stringify(recipientBefore)};`);
+ inputReport.passed=inputReport.recipientTyped && inputReport.messageTyped && inputReport.recipientUnchanged && inputReport.keyboard.portrait && inputReport.keyboard.key.height>=44;
+ if(!inputReport.messageTyped) {
+ try {
+ await evaluate('keyboard','await navigator.mozInputMethod.inputcontext.sendKey(0,97);return true;');
+ inputReport.directKey = await evaluate('sms','return document.querySelector("#messages-input").textContent.length>0;');
+ } catch(error) {inputReport.directKeyError=String(error);}
+ }
+ inputReport.fonts=await evaluate('sms',null,'RenderedFonts');
+ for (const face of inputReport.fonts) delete face.samples;
+ } catch(error) {inputReport.error=String(error);}
+ finally {win.document.getElementById('home').click();}
+ await IOUtils.writeJSON(`${root}/logs/keyboard.json`,inputReport);
+ }
+ const sendMarker=`${root}/logs/test-send-sms.json`;
+ if(await IOUtils.exists(sendMarker)) {
+ const request=await IOUtils.readJSON(sendMarker);
+ await IOUtils.remove(sendMarker);
+ const result={bootId:report.bootId,recordedAt:Date.now(),submitted:false,deliveryConfirmed:false};
+ try {
+ if(typeof request.recipient!=='string' || !/^\+[0-9]{8,15}$/.test(request.recipient)) throw Error('INVALID_TEST_RECIPIENT');
+ const {tundraRadio}=ChromeUtils.importESModule('resource://vulpes/host/Tundra.sys.mjs');
+ const state=await tundraRadio('status');
+ if(!['registered','roaming'].includes(state.registration?.[0]?.Status?.data)) throw Error('RADIO_NOT_REGISTERED');
+ await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
+ await sleep(1000);
+ await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
+ await wait('sms','return !!window.Navigation;');
+ await evaluate('sms','await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
+ await evaluate('sms','if(ConversationView.recipients.length || document.querySelector(".recipient[contenteditable]").textContent.trim() || Compose.getText())throw Error("TEST_DRAFT_PRESENT");return true;');
+ await tap('sms','.recipient[contenteditable]');
+ await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="tel";');
+ await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(request.recipient)},0,0);return true;`);
+ await tap('sms','#messages-input');
+ await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="text";');
+ const body='Vulpes OS - test SMS '+new Date().toISOString();
+ await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(body)},0,0);return true;`);
+ await wait('sms','return !document.querySelector("#messages-send-button").disabled;');
+ result.preSend=await evaluate('sms',`return {composer:Navigation.isCurrentPanel('composer'),
+ recipientMatch:JSON.stringify(ConversationView.recipients.numbers.map(n=>navigator.mozPhoneNumberService.normalize(n)))===JSON.stringify([${JSON.stringify(request.recipient)}]),
+ recipientCount:ConversationView.recipients.numbers.length,
+ bodyMatch:Compose.getText()===${JSON.stringify(body)},bodyLength:Compose.getText().length,
+ expectedLength:${body.length}};`);
+ if(!result.preSend.composer || !result.preSend.recipientMatch || !result.preSend.bodyMatch)
+ throw Error('TEST_MESSAGE_MISMATCH');
+ await evaluate('sms',"document.querySelector('#messages-send-button').click();return true;");
+ result.submitted=await wait('sms',`return await new Promise((resolve,reject)=>{
+ const cursor=navigator.mozMobileMessage.getMessages(null,true);
+ cursor.onsuccess=()=>{const m=cursor.result;if(!m){resolve(false);return;}
+ if(m.body===${JSON.stringify(body)}){if(m.delivery==='error'){reject(Error('MODEM_TRANSMISSION_FAILED'));return;}resolve(m.delivery==='sent');return;}cursor.continue();};
+ cursor.onerror=()=>reject(cursor.error);
+ });`);
+ } catch(error) {result.error=String(error);}
+ finally {win.document.getElementById('home').click();}
+ await IOUtils.writeJSON(`${root}/logs/send-sms.json`,result);
+ }
+ const notificationMarker = `${root}/logs/test-notifications`;
+ if (await IOUtils.exists(notificationMarker)) {
+ await IOUtils.remove(notificationMarker);
+ const result = {bootId:report.bootId,passed:false,silent:true};
+ let id;
+ try {
+ await evaluate('system', `ScreenManager.turnScreenOn(true);await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
+ await wait('sms', 'return typeof Notification.get === "function";');
+ await evaluate('system', 'if(!Service.query("locked"))await Service.request("lock");return true;');
+ result.lockState=await evaluate('system','return {service:Service.query("locked"),direct:lockScreen.locked,preview:NotificationScreen.lockscreenPreview};');
+ await wait('system', 'return Service.query("locked") && NotificationScreen.lockscreenPreview;');
+ id = await evaluate('sms', `return await new Promise((resolve,reject)=>{
+ const n=new Notification('Vulpes — test local',{body:'Notification de qualification',tag:'vulpes-qualification',silent:true});
+ n.onshow=()=>resolve(n.id);n.onerror=()=>reject(n.error);
+ });`);
+ const selector = `[data-notification-id="${id}"]`;
+ result.tray = await wait('system', `return !!NotificationScreen.container.querySelector(${JSON.stringify(selector)});`);
+ result.lockscreen = await wait('system', `return !!NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
+ await evaluate('sms', `const rows=await Notification.get({tag:'vulpes-qualification'});rows.forEach(n=>n.close());return true;`);
+ result.removed = await wait('system', `return !NotificationScreen.container.querySelector(${JSON.stringify(selector)}) && !NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
+ result.passed = result.tray && result.lockscreen && result.removed;
+ } catch(error) { result.error=String(error); }
+ finally {
+ if(id) await evaluate('sms', `for(const n of await Notification.get({tag:'vulpes-qualification'})) n.close();return true;`).catch(()=>{});
+ win.document.getElementById('home').click();
+ }
+ await IOUtils.writeJSON(`${root}/logs/notifications.json`,result);
+ }
+ }
+ await requestedUiChecks();
+ let uiCheckRunning=false;
+ const uiCheckTimer=setInterval(async()=>{
+ if(uiCheckRunning) return;
+ uiCheckRunning=true;
+ try {
+ const reloadMarker=`${root}/logs/test-reload-gaia`;
+ if(await IOUtils.exists(reloadMarker)) {
+ await IOUtils.remove(reloadMarker);
+ win.document.querySelector('browser').reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE);
+ await wait('system', "return document.body?.getAttribute('ready-state')==='fullyLoaded';");
+ await sleep(1500);
+ }
+ await requestedUiChecks();
+ } catch(error) {dump('TUNDRA_UI_CHECK_ERROR '+error+'\n');}
+ finally {uiCheckRunning=false;}
+ },3000);
+ win.addEventListener('unload',()=>clearInterval(uiCheckTimer),{once:true});
report.layout=await evaluate('homescreen',"return {width:innerWidth,height:innerHeight,dpr:devicePixelRatio,rootFont:getComputedStyle(document.documentElement).fontSize,bodyFont:getComputedStyle(document.body).fontFamily,zoom:visualViewport.scale,rootWidth:document.documentElement.scrollWidth};");
report.textFonts = await evaluate('homescreen', `
await document.fonts.ready;
diff --git a/tundra/manifests/sargo-hardware.json b/tundra/manifests/sargo-hardware.json
new file mode 100644
index 00000000..3e8748bd
--- /dev/null
+++ b/tundra/manifests/sargo-hardware.json
@@ -0,0 +1,22 @@
+{
+ "schema": 1,
+ "device": "sargo",
+ "scope": "Pinned hardware image inputs for local standalone builds; redistribution not reviewed",
+ "images": {
+ "vendor.img": {
+ "source": "vendor_a.img",
+ "sha256": "d584429873c88db741b81144b55977bbd8126b3e877445901bee6a0bda7fe4d0",
+ "bytes": 805306368
+ },
+ "modem.img": {
+ "source": "modem_a.img",
+ "sha256": "fd0624b37ac1d80c064a9763c7e2d2d6f94c3fd5e4617965b735ad411a473d0a",
+ "bytes": 115343360
+ }
+ },
+ "dtbo": {
+ "sha256": "0e43a57d6423e8d24cd944fcad0939ed81160e847a6d4203e2f7c16a86d69c03",
+ "bytes": 8417280
+ },
+ "vendorFingerprint": "google/sargo/sargo:9/PD2A.190115.029/5320088:user/release-keys"
+}
diff --git a/tundra/runtime/hal_policy.py b/tundra/runtime/hal_policy.py
index 287a3c9e..8530ff0c 100644
--- a/tundra/runtime/hal_policy.py
+++ b/tundra/runtime/hal_policy.py
@@ -20,7 +20,7 @@ NETWORK_SERVICES = frozenset(('irsc_util', 'vendor.per_mgr', 'vendor.rmt_storage
'vendor.tftp_server', 'cnss-daemon', 'pd_mapper'))
-HARDWARE_SERVICES = frozenset(('vendor.qcrild', 'vendor.google.radioext@1.0',
+HARDWARE_SERVICES = frozenset(('vendor.bluetooth-1-0', 'vendor.qcrild', 'vendor.google.radioext@1.0',
'vendor.audio-hal-2-0', 'vendor.camera-provider-2-4',
'camera_service', 'minimedia', 'minisf', 'miniaf'))
diff --git a/tundra/runtime/mark-boot-success.py b/tundra/runtime/mark-boot-success.py
new file mode 100644
index 00000000..b1c2fa95
--- /dev/null
+++ b/tundra/runtime/mark-boot-success.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MPL-2.0
+"""Acknowledge slot A only after this standalone installation has started Gaia."""
+import json
+from pathlib import Path
+import subprocess
+import time
+
+
+def eligible(boot_id, gaia, mount, cmdline, services_ready):
+ return (cmdline.get('androidboot.slot_suffix') == '_a'
+ and mount.get('source') == '/dev/mmcblk0p72'
+ and mount.get('fstype') == 'ext4'
+ and gaia.get('bootId') == boot_id and gaia.get('passed') is True
+ and bool(gaia.get('checks')) and all(gaia['checks'].values())
+ and services_ready)
+
+
+def main():
+ cmdline=dict(x.split('=',1) for x in Path('/proc/cmdline').read_text().split() if '=' in x)
+ boot_id=Path('/proc/sys/kernel/random/boot_id').read_text().strip()
+ mounts=json.loads(subprocess.check_output(['findmnt','--json','--mountpoint','/reference/tundra-volume','--output','SOURCE,FSTYPE'],text=True))['filesystems']
+ if len(mounts)!=1 or mounts[0]['source']!='/dev/mmcblk0p72':
+ return # Container/RAM experiments must not mark a flashed slot successful.
+ for _ in range(120):
+ try:
+ gaia=json.loads(Path('/opt/vulpes/logs/test.json').read_text())
+ ready=subprocess.run(['systemctl','is-active','--quiet','tundra-hal','tundra-display','tundra-radio']).returncode==0
+ if eligible(boot_id,gaia,mounts[0],cmdline,ready):break
+ except (OSError,ValueError):pass
+ time.sleep(5)
+ else:raise RuntimeError('Gaia did not pass startup; slot success left unchanged')
+ subprocess.run(['/usr/libexec/tundra-qbootctl','-m','a'],check=True)
+ Path('/var/lib/tundra').mkdir(parents=True,exist_ok=True)
+ Path('/var/lib/tundra/boot-success.json').write_text(json.dumps({'bootId':boot_id,'gaiaPassed':True,'slot':'a'})+'\n')
+
+
+if __name__=='__main__':main()
diff --git a/tundra/runtime/prepare-bluetooth.py b/tundra/runtime/prepare-bluetooth.py
new file mode 100644
index 00000000..f69656e5
--- /dev/null
+++ b/tundra/runtime/prepare-bluetooth.py
@@ -0,0 +1,34 @@
+# SPDX-License-Identifier: MPL-2.0
+"""Connect the Sargo Bluetooth HAL to BlueZ; pairing stays user initiated."""
+
+def prepare(root, write):
+ write('etc/dbus-1/system.d/tundra-bluetooth.conf', '''
+
+''')
+ write('etc/bluetooth/main.conf', '[General]\nName=Vulpes OS\nDiscoverableTimeout=120\n[Policy]\nAutoEnable=false\n')
+ write('etc/systemd/system/tundra-bluebinder.service', '''[Unit]
+Description=Sargo Bluetooth HAL bridge
+Requires=tundra-hal.service
+After=tundra-hal.service
+Before=bluetooth.service
+[Service]
+Type=notify
+ExecStartPre=/usr/bin/binder-wait android.hardware.bluetooth@1.0::IBluetoothHci/default
+ExecStart=/usr/sbin/bluebinder
+Restart=on-failure
+RestartSec=5
+TimeoutStartSec=60
+NoNewPrivileges=yes
+ProtectHome=yes
+ProtectSystem=strict
+PrivateTmp=yes
+''')
+ write('etc/systemd/system/bluetooth.service.d/tundra.conf', '''[Unit]
+Requires=tundra-bluebinder.service dbus.service
+After=tundra-bluebinder.service dbus.service
+''')
+ wants = root/'etc/systemd/system/tundra.target.wants'
+ wants.mkdir(parents=True, exist_ok=True)
+ (wants/'bluetooth.service').symlink_to('/usr/lib/systemd/system/bluetooth.service')
diff --git a/tundra/runtime/prepare-hardware.py b/tundra/runtime/prepare-hardware.py
index 3b588f80..e5d790c3 100644
--- a/tundra/runtime/prepare-hardware.py
+++ b/tundra/runtime/prepare-hardware.py
@@ -3,10 +3,13 @@
"""Configure isolated oFono/PulseAudio diagnostics; no call or SMS on startup."""
from pathlib import Path
import shutil
+import runpy
def prepare(root, write):
write('etc/tundra/hardware-enabled', 'sargo\n')
+ runpy.run_path(str(Path(__file__).with_name('runtime-packages.py')))['stage'](root)
+ runpy.run_path(str(Path(__file__).with_name('prepare-bluetooth.py')))['prepare'](root, write)
shutil.copy2(Path(__file__).with_name('modem-recovery.py'),
root/'etc/tundra/modem-recovery.py')
write('etc/systemd/system/tundra-hal.service.d/modem-recovery.conf', '''[Service]
diff --git a/tundra/runtime/runtime-packages.py b/tundra/runtime/runtime-packages.py
new file mode 100644
index 00000000..904db1c0
--- /dev/null
+++ b/tundra/runtime/runtime-packages.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MPL-2.0
+"""Stage pinned package payloads missing from the original ARM64 reference."""
+import hashlib
+import io
+import json
+from pathlib import Path
+import shutil
+import subprocess
+import tarfile
+import tempfile
+import urllib.request
+
+PACKAGES = (
+ ('libdw1t64_0.196-1_arm64.deb', 'e/elfutils', 'a82634454de2b28d73374f693e4ab6c1c2634fa53b0139312c5f439c294dad2b'),
+ ('libelf1t64_0.196-1_arm64.deb', 'e/elfutils', 'ead8a1244824d982ce51fb82be4904312d5dc44ddc157e589def58c257738476'),
+ ('bluez_5.87-4_arm64.deb', 'b/bluez', 'fe733034dbdfa6146fc94112ad8ca0fce71d8e01d22a59ddb49ef2df3777d6b5'),
+ ('ca-certificates_20260816_all.deb', 'c/ca-certificates', '1cb8b73c74c47677b329fad4095fb9370227d4b8f1969372411478a4586c46cf'),
+)
+
+
+# Existing libelf 0.194-4 files in the inventoried runtime; reject other replacements.
+REPLACES = {'usr/lib/aarch64-linux-gnu/libelf-0.194.so': 'ade23632ea253cb4ecfe95d5436bfeb3d40ae2003c5fe10bf1ac1c5b30bdf2e2', 'usr/share/doc/libelf1t64/changelog.Debian.gz': 'ffb0ea69fdbf6f32053059985048676931fd7d5c4458f8ca4bc54750cf4b1b31', 'usr/share/doc/libelf1t64/changelog.gz': '2e8e0af5c39ab744ac6934fe07419b5fd5504a50cd2f42110dd04315c548a708', 'usr/share/doc/libelf1t64/copyright': '2409860c86675ab33f8ad22480608faf849cbc2559d6478f708c23c508a1dbf2', 'usr/share/locale/de/LC_MESSAGES/elfutils.mo': 'f72328e130e63f231acb8e1a56ce5f4f3e91d0fc290db95d7ba4e4b2448d4d93', 'usr/share/locale/en@boldquot/LC_MESSAGES/elfutils.mo': '90158a24e3f85aaacc64085ef2644ba19075cb847c754341c9f245aaa69e94c6', 'usr/share/locale/en@quot/LC_MESSAGES/elfutils.mo': '33c6cda39c2145463dfdb2eb849922dbf6ccf51a9e4bfa8fd757744712d9abfa', 'usr/share/locale/es/LC_MESSAGES/elfutils.mo': '1ebff7e5583759b3f08eb5caaf18dd1a6ed6eb5ec71a02502ed8e21b134ad54f', 'usr/share/locale/ja/LC_MESSAGES/elfutils.mo': '764ed859f3aebd861da0b73c69da4d72d0f252952bb3be1c9c66360258a1661c', 'usr/share/locale/pl/LC_MESSAGES/elfutils.mo': '8abb000f43024994a9ec3032b52eb10858e004b81bc61c7ceea96f7467d13fdd', 'usr/share/locale/uk/LC_MESSAGES/elfutils.mo': 'ce0d747f478ee91a1cdef66bfd1e230a3ff1e787358f4c6e1ffb6513df713117'}
+
+def stage(root, cache=None):
+ cache = cache or Path(__file__).resolve().parents[1]/'downloads/native-packages-20261008'
+ cache.mkdir(parents=True, exist_ok=True)
+ records = []
+ for name, directory, sha in PACKAGES:
+ url = 'https://deb.debian.org/debian/pool/main/'+directory+'/'+name
+ package = cache/name
+ if not package.exists():
+ with urllib.request.urlopen(url, timeout=60) as response:
+ data = response.read()
+ if hashlib.sha256(data).hexdigest() != sha:
+ raise ValueError('Package checksum mismatch: '+name)
+ package.write_bytes(data)
+ if hashlib.sha256(package.read_bytes()).hexdigest() != sha:
+ raise ValueError('Package checksum mismatch: '+name)
+ members = subprocess.check_output(['ar', 't', str(package)], text=True).splitlines()
+ member = next(m for m in members if m.startswith('data.tar'))
+ payload = subprocess.check_output(['ar', 'p', str(package), member])
+ with tempfile.TemporaryDirectory() as folder:
+ source = Path(folder)
+ with tarfile.open(fileobj=io.BytesIO(payload)) as archive:
+ archive.extractall(source, filter='data')
+ if name.startswith('ca-certificates'):
+ certificates = sorted((source/'usr/share/ca-certificates/mozilla').glob('*.crt'))
+ if len(certificates) < 50:
+ raise ValueError('Incomplete CA bundle')
+ target = root/'etc/ssl/certs/ca-certificates.crt'
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_bytes(b'\n'.join(p.read_bytes() for p in certificates)+b'\n')
+ target.chmod(0o644)
+ cert_link = root/'usr/lib/ssl/cert.pem'
+ cert_link.parent.mkdir(parents=True, exist_ok=True)
+ if not cert_link.exists() and not cert_link.is_symlink():
+ cert_link.symlink_to('../../../etc/ssl/certs/ca-certificates.crt')
+ selected = [source/'usr/share/doc/ca-certificates/copyright']
+ else:
+ selected = [p for p in (source/'usr').rglob('*') if p.is_file() or p.is_symlink()]
+ for path in selected:
+ target = root/path.relative_to(source)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ if path.is_symlink():
+ if target.exists() or target.is_symlink():
+ if not target.is_symlink() or target.readlink() != path.readlink():
+ raise ValueError('Conflicting package link: '+str(target))
+ else:
+ target.symlink_to(path.readlink())
+ else:
+ if target.exists() and target.read_bytes() != path.read_bytes():
+ expected = REPLACES.get(str(path.relative_to(source)))
+ if not expected or hashlib.sha256(target.read_bytes()).hexdigest() != expected:
+ raise ValueError('Conflicting package file: '+str(target))
+ shutil.copyfile(path, target)
+ target.chmod(path.stat().st_mode & 0o777)
+ records.append({'file': name, 'url': url, 'sha256': sha})
+ record = root/'usr/share/vulpes/runtime-packages.json'
+ record.parent.mkdir(parents=True, exist_ok=True)
+ record.write_text(json.dumps(records, indent=2)+'\n')
+ return records
diff --git a/tundra/runtime/setup-hal.py b/tundra/runtime/setup-hal.py
index 7ee84bb2..c87b67c0 100644
--- a/tundra/runtime/setup-hal.py
+++ b/tundra/runtime/setup-hal.py
@@ -53,7 +53,19 @@ def main():
with image.open('rb') as f:sha=hashlib.file_digest(f,'sha256').hexdigest()
if sha!='132d0e18c6a6a111293447e940267f35aa2b14fa00a872ef160b20dc2014d886':raise RuntimeError('Android reference changed')
run('mount','-t','ext4','-o','loop,ro,noload',str(image),'/run/tundra/android-original')
- run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p70','/run/tundra/vendor-original')
+ hardware = Path('/etc/tundra/hardware-images.json')
+ bundled = json.loads(hardware.read_text())['images'] if hardware.exists() else None
+ if bundled:
+ if set(bundled) != {'vendor.img', 'modem.img'}:
+ raise RuntimeError('Expected both standalone hardware images')
+ for name, record in bundled.items():
+ path = Path('/reference')/name
+ with path.open('rb') as source:
+ if path.stat().st_size != record['bytes'] or hashlib.file_digest(source, 'sha256').hexdigest() != record['sha256']:
+ raise RuntimeError('Standalone hardware image changed: '+name)
+ run('mount','-t','ext4','-o','loop,ro,noload','/reference/vendor.img','/run/tundra/vendor-original')
+ else:
+ run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p70','/run/tundra/vendor-original')
run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p48','/mnt/vendor/persist')
overlay('/run/tundra/android-original','/android','android')
overlay('/run/tundra/vendor-original','/android/vendor','vendor')
@@ -65,7 +77,10 @@ def main():
if count != 1:raise RuntimeError('Expected exactly one Sargo WLAN IPA setting')
config.write_text(text) # vendor overlay upper is tmpfs, never the partition
directory('/android/vendor/firmware_mnt')
- run('mount','-t','vfat','-o','ro','/dev/mmcblk0p21','/android/vendor/firmware_mnt')
+ if bundled:
+ run('mount','-t','vfat','-o','loop,ro','/reference/modem.img','/android/vendor/firmware_mnt')
+ else:
+ run('mount','-t','vfat','-o','ro','/dev/mmcblk0p21','/android/vendor/firmware_mnt')
report={}
for p in sorted(Path('/android').rglob('*.rc')):
if not p.is_file() or p.is_symlink() or not is_init_script(p):continue
diff --git a/tundra/tests/test_hardware_status.py b/tundra/tests/test_hardware_status.py
index 6a5745bc..190bcebe 100644
--- a/tundra/tests/test_hardware_status.py
+++ b/tundra/tests/test_hardware_status.py
@@ -50,7 +50,11 @@ class HardwareTests(unittest.TestCase):
self.assertIn('unmanaged-devices=*,except:interface-name:wlan0', config)
self.assertIn('auth-polkit=root-only', config)
self.assertIn('enabled=false', config)
- self.assertFalse((root/'etc/NetworkManager/system-connections').exists())
+ connections = root/'etc/NetworkManager/system-connections'
+ self.assertTrue(connections.is_dir())
+ self.assertEqual(list(connections.iterdir()), [])
+ wifi_service = (root/'etc/systemd/system/tundra-wifi.service').read_text()
+ self.assertIn('ReadWritePaths=/run /etc/NetworkManager/system-connections', wifi_service)
self.assertIn('VULPES_TUNDRA=1', (root/'etc/systemd/system/tundra-display.service').read_text())
diff --git a/tundra/tests/test_runtime_packages.py b/tundra/tests/test_runtime_packages.py
new file mode 100644
index 00000000..2f6531aa
--- /dev/null
+++ b/tundra/tests/test_runtime_packages.py
@@ -0,0 +1,47 @@
+"""Check the pinned Bluetooth libraries and native TLS trust store offline."""
+import importlib.util
+import json
+from pathlib import Path
+import ssl
+import tempfile
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+spec = importlib.util.spec_from_file_location('packages', ROOT/'runtime/runtime-packages.py')
+packages = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(packages)
+CACHE = ROOT/'downloads/native-packages-20261008'
+
+
+class RuntimePackages(unittest.TestCase):
+ def test_corrupt_archive_is_rejected_before_extraction(self):
+ with tempfile.TemporaryDirectory() as folder:
+ base = Path(folder)
+ cache = base/'cache'
+ cache.mkdir()
+ (cache/packages.PACKAGES[0][0]).write_bytes(b'corrupt')
+ with self.assertRaisesRegex(ValueError, 'checksum mismatch'):
+ packages.stage(base/'root', cache)
+ self.assertFalse((base/'root').exists())
+
+ @unittest.skipUnless(all((CACHE/name).exists() for name, _, _ in packages.PACKAGES),
+ 'Pinned package cache is not installed')
+ def test_staged_payload_is_repeatable_and_certificates_are_usable(self):
+ with tempfile.TemporaryDirectory() as folder:
+ root = Path(folder)
+ first = packages.stage(root, CACHE)
+ self.assertEqual(packages.stage(root, CACHE), first)
+ self.assertEqual(json.loads((root/'usr/share/vulpes/runtime-packages.json').read_text()), first)
+ self.assertTrue((root/'usr/libexec/bluetooth/bluetoothd').is_file())
+ context = ssl.create_default_context(cafile=str(root/'etc/ssl/certs/ca-certificates.crt'))
+ self.assertGreater(context.cert_store_stats()['x509_ca'], 50)
+ self.assertTrue((root/'usr/lib/ssl/cert.pem').is_file())
+ target = root/'usr/libexec/bluetooth/bluetoothd'
+ target.write_bytes(b'unknown local binary')
+ with self.assertRaisesRegex(ValueError, 'Conflicting package file'):
+ packages.stage(root, CACHE)
+ self.assertEqual(target.read_bytes(), b'unknown local binary')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tundra/tests/test_sparse_backup.py b/tundra/tests/test_sparse_backup.py
new file mode 100644
index 00000000..2305b36d
--- /dev/null
+++ b/tundra/tests/test_sparse_backup.py
@@ -0,0 +1,37 @@
+# SPDX-License-Identifier: MPL-2.0
+import io
+from pathlib import Path
+import struct
+import sys
+import unittest
+sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
+from sparse_backup import receive
+
+
+def record(offset,data):return struct.pack(' /newroot/reference/'+name,
+ 'mount --bind /volume/tundra/hardware/'+name+' /newroot/reference/'+name,
+ 'mount -o remount,bind,ro /newroot/reference/'+name]
+ config = json.dumps({'images':volume['hardwareImages']}, separators=(',',':'))
+ lines += ['mkdir -p /newroot/etc/tundra',
+ 'printf \'%s\\n\' '+shlex.quote(config)+' > /newroot/etc/tundra/hardware-images.json']
+ native = native.replace(b'# Installed lower layers', ('\n'.join(lines)+'\n# Installed lower layers').encode())
+ if not trial:
+ if b'/dev/mapper' in init or b'EXPECTED_STORAGE_HASH' in init:
+ # The old expected hash assignment is unnecessary in the raw image.
+ init = b'\n'.join(line for line in init.split(b'\n') if not line.startswith(b'EXPECTED_STORAGE_HASH='))
+ if b'/dev/mapper' in init or b'/installed/' in native:
+ raise ValueError('Legacy storage leaked into standalone boot')
+ device = '/run/legacy-storage/tundra/standalone/'+volume['uuid']+'.ext4' if trial else '/dev/mmcblk0p72'
+ config = 'VOLUME_DEVICE='+shlex.quote(device)+'\nVOLUME_UUID='+shlex.quote(volume['uuid'])+'\nVOLUME_OPTIONS='+('loop,rw' if trial else 'rw')+'\n'
+ mount = (ROOT/'boot/standalone-mount.sh').read_bytes().replace(b'# VOLUME_CONFIGURATION', config.encode())
+ return init, native, mount
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--volume', type=Path, required=True)
+ parser.add_argument('--prepared', type=Path, required=True)
+ parser.add_argument('--device-workspace', type=Path, required=True)
+ parser.add_argument('--output', type=Path, required=True)
+ parser.add_argument('--container-trial', action='store_true')
+ parser.add_argument('--no-deadline', action='store_true')
+ parser.add_argument('--boot-control', type=Path, help='Pinned ARM64 qbootctl binary')
+ parser.add_argument('--no-maintenance', action='store_true', help='Exclude private USB SSH credentials and server')
+ parser.add_argument('--local-debugger', action='store_true', help='Private RAM tests: loopback Marionette, never a public image')
+ args = parser.parse_args(); os.umask(0o077)
+ if args.local_debugger and args.no_maintenance: parser.error('Local debugger requires a private maintenance image')
+ volume = checked_volume(args.volume)
+ if args.no_deadline and not args.container_trial and not args.boot_control:
+ parser.error('Persistent standalone images require --boot-control')
+ if args.boot_control and digest(args.boot_control) != 'dcb00e4c4f038071a5d9979263128325e16b848903371f6c00d49cf3e25c34d9':
+ raise ValueError('Unqualified boot-control binary')
+ if args.boot_control and digest(args.boot_control.with_name('copyright')) != '8969e659aff7ed5bb3249341764c2bd048d5abe7400076f01485650e727e8c42':
+ raise ValueError('Boot-control license notice missing or changed')
+ system = json.loads((args.prepared/'system.json').read_text())
+ state = json.loads((args.prepared/'data/state.json').read_text())
+ if volume['systemImageSha256'] != system['imageSha256'] or volume['stateUuid'] != state['uuid']:
+ raise ValueError('Volume does not contain this system/data pair')
+ command = [sys.executable,str(ROOT/'tools/build-rescue-boot.py'), '--output',str(args.output),
+ '--device-workspace',str(args.device_workspace),'--layout',str(args.device_workspace/'layout.json'),
+ '--native','--runtime-config','--network','--hardware',
+ '--system-image',str(args.prepared/'system.json'),'--state',str(args.prepared/'data/state.json')]
+ if args.no_deadline: command.append('--no-deadline')
+ if args.local_debugger: command.append('--local-debugger')
+ subprocess.run(command,check=True)
+ out = args.output.resolve(); build = json.loads((out/'build.json').read_text())
+ ramdisk = out/'initramfs.cpio.gz'; files = read_newc(ramdisk.read_bytes())
+ init,native,mount = storage_scripts(files['init']['data'],files['native-root.sh']['data'],volume,args.container_trial)
+ if args.boot_control:
+ files['sbin/tundra-qbootctl']={'mode':stat.S_IFREG|0o755,'data':args.boot_control.read_bytes()}
+ files['qbootctl-copyright']={'mode':stat.S_IFREG|0o644,'data':args.boot_control.with_name('copyright').read_bytes()}
+ files['standalone-boot-success.py']={'mode':stat.S_IFREG|0o755,'data':(ROOT/'runtime/mark-boot-success.py').read_bytes()}
+ unit=b'[Unit]\nDescription=Acknowledge a healthy standalone Vulpes boot\nAfter=tundra-display.service\n[Service]\nType=oneshot\nTimeoutStartSec=650\nExecStart=/usr/bin/python3 /usr/libexec/tundra-mark-boot-success.py\n'
+ files['standalone-boot-success.service']={'mode':stat.S_IFREG|0o644,'data':unit}
+ setup=b"""mkdir -p /newroot/usr/libexec /newroot/etc/systemd/system/tundra.target.wants
+mkdir -p /newroot/usr/share/doc/qbootctl
+cp /qbootctl-copyright /newroot/usr/share/doc/qbootctl/copyright
+cp /sbin/tundra-qbootctl /newroot/usr/libexec/tundra-qbootctl
+cp /standalone-boot-success.py /newroot/usr/libexec/tundra-mark-boot-success.py
+cp /standalone-boot-success.service /newroot/etc/systemd/system/tundra-boot-success.service
+ln -sf ../tundra-boot-success.service /newroot/etc/systemd/system/tundra.target.wants/tundra-boot-success.service
+"""
+ native=native.replace(b'# Installed lower layers',setup+b'# Installed lower layers')
+ if args.no_maintenance:
+ init=b'\n'.join(line for line in init.split(b'\n') if not line.startswith(b'/sbin/dropbear '))
+ init=init.replace(b'TUNDRA_USB_SSH_READY',b'TUNDRA_USB_READY')
+ start=native.index(b'# Keep a minimal, already tested rescue environment')
+ end=native.index(b'for fs in dev proc sys run;',start)
+ native=native[:start]+b'# Public boot: no USB maintenance account or shared SSH key.\n'+native[end:]
+ for name in ('etc/tundra-hostkey','root/.ssh/authorized_keys','sbin/dropbear'):
+ files.pop(name,None)
+ files['init']['data']=init; files['native-root.sh']['data']=native
+ files['standalone-mount.sh']={'mode':stat.S_IFREG|0o755,'data':mount}
+ files['volume']={'mode':stat.S_IFDIR|0o755,'data':b''}
+ entries=[]
+ for name, item in sorted(files.items()):
+ entries.append((name,item['mode'],item['data'],5 if name=='dev/console' else 0,1 if name=='dev/console' else 0))
+ ramdisk.write_bytes(cpio(entries))
+ packager=checked_source('aosp-mkbootimg'); original=out/build['image']
+ with tempfile.TemporaryDirectory() as temp:
+ unpack=Path(temp)/'unpack'
+ text=subprocess.check_output([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(original),'--out',str(unpack),'--format=mkbootimg'],text=True)
+ options=shlex.split(text); fields=dict(zip(options[::2],options[1::2]));fields['--ramdisk']=str(ramdisk)
+ image=out/'boot.img'; command=[sys.executable,str(packager/'mkbootimg.py')]
+ for key,value in fields.items():command.extend([key,value])
+ subprocess.run(command+['-o',str(image)],check=True)
+ verify=Path(temp)/'verify'
+ subprocess.run([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(image),'--out',str(verify)],check=True,stdout=subprocess.DEVNULL)
+ if digest(verify/'kernel')!=build['kernelSha256'] or digest(verify/'ramdisk')!=digest(ramdisk):
+ raise ValueError('Standalone boot roundtrip failed')
+ original.unlink()
+ for path in (Path(__file__).resolve(),ROOT/'tools/standalone_volume.py',ROOT/'boot/standalone-mount.sh',ROOT/'runtime/mark-boot-success.py'):
+ relative=str(path.relative_to(ROOT)); target=out/'sources/tundra'/relative
+ target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(path,target);build['sources'][relative]=digest(path)
+ build.update(image='boot.img',imageSha256=digest(image),ramdiskSha256=digest(ramdisk),
+ storageMode='container-test' if args.container_trial else 'raw-userdata-ext4',
+ standaloneVolume=volume,flashReady=False,bootControlSha256=digest(args.boot_control) if args.boot_control else None,
+ containsPrivateTrialHostKey=not args.no_maintenance,maintenanceEnabled=not args.no_maintenance,
+ scope='Standalone Tundra boot candidate; hardware qualification required')
+ (out/'build.json').write_text(json.dumps(build,indent=2)+'\n')
+ print(image)
+
+
+if __name__=='__main__':main()
diff --git a/tundra/tools/build-standalone-volume.py b/tundra/tools/build-standalone-volume.py
new file mode 100644
index 00000000..bc2e28a7
--- /dev/null
+++ b/tundra/tools/build-standalone-volume.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MPL-2.0
+"""Build a fresh ext4 userdata image. No connection to a phone or partition writes."""
+import argparse
+import importlib.util
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import uuid
+from persistent_state import FEATURES
+from tundra import digest
+from standalone_volume import checked_volume
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--prepared', required=True, type=Path)
+ parser.add_argument('--output', required=True, type=Path)
+ parser.add_argument('--hardware', type=Path, help='Locally supplied pinned vendor/modem images')
+ parser.add_argument('--size-mib', type=int, default=8192)
+ args = parser.parse_args()
+ if not 5120 <= args.size_mib <= 32768:
+ parser.error('Choose 5120–32768 MiB')
+ spec = importlib.util.spec_from_file_location('stage', Path(__file__).with_name('stage-sargo-files.py'))
+ stage = importlib.util.module_from_spec(spec); spec.loader.exec_module(stage)
+ files = stage.inputs(args.prepared)
+ hardware = {}
+ if args.hardware:
+ lock = json.loads((Path(__file__).resolve().parents[1]/'manifests/sargo-hardware.json').read_text())
+ hardware = lock['images']
+ for name, record in hardware.items():
+ source = args.hardware/record['source']
+ if source.is_symlink() or source.stat().st_size != record['bytes'] or digest(source) != record['sha256']:
+ raise ValueError('Hardware image changed: '+name)
+ payload_bytes = sum(p.stat().st_size for _,_,p,_ in files)
+ payload_bytes += sum(record['bytes'] for record in hardware.values())
+ if args.size_mib*1024**2 < payload_bytes+512*1024**2:
+ raise ValueError('Insufficient space for payload and filesystem metadata')
+ os.umask(0o077)
+ out = args.output.resolve(); out.mkdir(parents=True, exist_ok=False)
+ (out/'.incomplete').touch()
+ seed = out/'seed'; seed.mkdir()
+ for kind, name, source, record in files:
+ target = seed/'tundra'/kind/name; target.parent.mkdir(parents=True, exist_ok=True)
+ # mke2fs reads the file; a hard link avoids another multi-gigabyte copy.
+ os.link(source, target)
+ if hardware:
+ directory = seed/'tundra/hardware'; directory.mkdir()
+ for name, record in hardware.items():
+ os.link(args.hardware/record['source'], directory/name)
+ system = json.loads((args.prepared/'system.json').read_text())
+ state = json.loads((args.prepared/'data/state.json').read_text())
+ identifier = str(uuid.uuid4())
+ (seed/'tundra-volume-id').write_text(identifier+'\n')
+ image = out/'userdata.ext4'
+ with image.open('xb') as f: f.truncate(args.size_mib*1024**2)
+ subprocess.run(['mke2fs','-q','-t','ext4','-F','-b','4096','-I','256','-O',FEATURES,
+ '-U',identifier,'-L','TUNDRA_VOLUME','-d',str(seed),str(image)],check=True)
+ names=['/tundra-volume-id','/tundra','/tundra/images','/tundra/data']
+ names += ['/tundra/'+kind+'/'+name for kind,name,_,_ in files]
+ if hardware:
+ names += ['/tundra/hardware', *['/tundra/hardware/'+name for name in hardware]]
+ for name in names:
+ for field in ('uid','gid'):
+ subprocess.run(['debugfs','-w','-R',f'set_inode_field {name} {field} 0',str(image)],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
+ result = subprocess.run(['e2fsck','-f','-n',str(image)],capture_output=True,text=True)
+ (out/'filesystem-check.txt').write_text(result.stdout+result.stderr)
+ if result.returncode: raise ValueError('Standalone filesystem check failed')
+ manifest={'schema':1,'device':'sargo','storage':'raw-userdata-ext4','image':image.name,
+ 'uuid':identifier,'bytes':image.stat().st_size,'imageSha256':digest(image),
+ 'systemImageSha256':system['imageSha256'],'stateUuid':state['uuid'],
+ 'filesystemCheckPassed':True,'flashReady':False,'hardwareImages':hardware,
+ 'scope':'Fresh standalone storage; flashing userdata destroys previous user data'}
+ (out/'volume.json').write_text(json.dumps(manifest,indent=2)+'\n')
+ checked_volume(out/'volume.json')
+ (out/'.incomplete').unlink()
+ print(out/'volume.json')
+
+
+if __name__ == '__main__': main()
diff --git a/tundra/tools/build-system-image.py b/tundra/tools/build-system-image.py
index 898afae2..fffd8014 100644
--- a/tundra/tools/build-system-image.py
+++ b/tundra/tools/build-system-image.py
@@ -128,7 +128,7 @@ def main():
ignore=shutil.ignore_patterns('__pycache__', 'test', 'tests'))
for name in ('tools', 'tests', 'logs', 'profiles'):
(product/name).mkdir(exist_ok=True)
- for name in ('serve-gaia.py', 'project.py', 'run-host.py'):
+ for name in ('serve-gaia.py', 'project.py', 'run-host.py', 'installed_apps.py'):
shutil.copy2(PROJECT/'tools'/name, product/'tools'/name)
shutil.copy2(PROJECT/'tests/ProbeChild.sys.mjs', product/'tests/ProbeChild.sys.mjs')
shutil.copy2(PROJECT/'release.json', product/'release.json')
diff --git a/tundra/tools/flash-standalone-sargo.py b/tundra/tools/flash-standalone-sargo.py
new file mode 100644
index 00000000..f7a01d1b
--- /dev/null
+++ b/tundra/tools/flash-standalone-sargo.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MPL-2.0
+"""Install a standalone Sargo package using Fastboot only (Python 3, any host OS).
+
+Default: verify files and display the destructive installation plan.
+--install --erase-userdata replaces the old OS and ALL files in userdata.
+Keep a separate backup. The unlocked bootloader and device calibration remain.
+"""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import re
+import shutil
+import struct
+import subprocess
+import sys
+
+PARTITIONS=('userdata','dtbo_a','vbmeta_a','boot_a')
+NAMES={'userdata':'userdata.img','dtbo_a':'dtbo.img','vbmeta_a':'vbmeta.img','boot_a':'boot.img'}
+
+
+def digest(path):
+ sha=hashlib.sha256()
+ with path.open('rb') as source:
+ while block:=source.read(1024*1024):sha.update(block)
+ return sha.hexdigest()
+
+
+def sparse_size(path):
+ with path.open('rb') as source:header=source.read(28)
+ if len(header)!=28:raise ValueError('Truncated userdata image')
+ magic,major,minor,file_header,chunk_header,block_size,blocks,chunks,crc=struct.unpack('size:raise ValueError('Image exceeds partition: '+partition)
+ return info
+
+
+def main():
+ parser=argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--package',type=Path,default=Path(__file__).resolve().parent)
+ parser.add_argument('--serial')
+ parser.add_argument('--install',action='store_true')
+ parser.add_argument('--erase-userdata',action='store_true')
+ parser.add_argument('--experimental',action='store_true')
+ parser.add_argument('--reboot',action='store_true')
+ args=parser.parse_args();value=package(args.package)
+ print('Pixel 3a / Sargo: replace userdata, dtbo_a, vbmeta_a and boot_a; activate slot A.')
+ print('ALL existing user data and the old OS in userdata will be lost. Bootloader remains unlocked.')
+ print('No existing Android or Droidian installation is required.')
+ if not args.install:
+ print('Review only; no device command sent.');return
+ if not args.erase_userdata:parser.error('--install requires --erase-userdata')
+ if not value.get('flashQualified') and not args.experimental:
+ parser.error('This candidate is not qualified; private tests require --experimental')
+ fastboot=shutil.which('fastboot')
+ if not fastboot:raise ValueError('Install Android platform-tools (fastboot) on the host computer')
+ devices=subprocess.check_output([fastboot,'devices'],text=True,timeout=10).splitlines()
+ serials=[line.split()[0] for line in devices if len(line.split())==2 and line.split()[1]=='fastboot']
+ serial=args.serial
+ if serial is None:
+ if len(serials)!=1:raise ValueError('Connect exactly one Pixel or specify --serial')
+ serial=serials[0]
+ if serial not in serials:raise ValueError('Selected device not in Fastboot')
+ info=preflight(fastboot,serial,value)
+ report={'deviceSha256':hashlib.sha256(serial.encode()).hexdigest(),'preflight':info,'completed':[],'passed':False}
+ path=args.package/'install-report.json'
+ def save():path.write_text(json.dumps(report,indent=2)+'\n')
+ save()
+ subprocess.run([fastboot,'-s',serial,'erase','userdata'],check=True,timeout=300)
+ report['completed'].append('erase:userdata');save()
+ for partition in PARTITIONS:
+ subprocess.run([fastboot,'-s',serial,'flash',partition,str((args.package/NAMES[partition]).resolve())],check=True,timeout=1200)
+ report['completed'].append(partition);save()
+ subprocess.run([fastboot,'-s',serial,'set_active','a'],check=True,timeout=30)
+ report['passed']=True;save()
+ if args.reboot:subprocess.run([fastboot,'-s',serial,'reboot'],check=True,timeout=30)
+ print('Images written. Keep the bootloader unlocked. Select Start to launch Vulpes.')
+
+
+if __name__=='__main__':main()
diff --git a/tundra/tools/install-sargo.py b/tundra/tools/install-sargo.py
index c626b64b..6263b261 100644
--- a/tundra/tools/install-sargo.py
+++ b/tundra/tools/install-sargo.py
@@ -23,6 +23,8 @@ def load(name, path):
def checked_build(folder):
folder=Path(folder).resolve()
build=json.loads((folder/'build.json').read_text())
+ if build.get('storageMode') in ('raw-userdata-ext4', 'container-test'):
+ raise ValueError('Use the standalone installer; the old boot-only plan is incompatible')
if build.get('localDebugger'):raise ValueError('Diagnostic debugger image cannot be flashed')
qualification=json.loads((folder/'qualification.json').read_text())
if (not qualification.get('passed') or not qualification.get('privateInstallReady')
diff --git a/tundra/tools/package-standalone-sargo.py b/tundra/tools/package-standalone-sargo.py
new file mode 100644
index 00000000..0390514b
--- /dev/null
+++ b/tundra/tools/package-standalone-sargo.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MPL-2.0
+"""Package boot, fresh userdata and boot metadata for a standalone installation.
+
+Builds local artifacts only. A candidate is never marked flash-qualified here.
+Private development credentials, if present in boot, prohibit redistribution.
+"""
+import argparse
+import importlib.util
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+from standalone_volume import checked_volume
+from tundra import ROOT,digest
+
+
+def main():
+ parser=argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--build',required=True,type=Path)
+ parser.add_argument('--volume',required=True,type=Path)
+ parser.add_argument('--dtbo',required=True,type=Path)
+ parser.add_argument('--output',required=True,type=Path)
+ args=parser.parse_args();os.umask(0o077)
+ volume=checked_volume(args.volume);build=json.loads((args.build/'build.json').read_text())
+ if build.get('storageMode')!='raw-userdata-ext4' or build.get('watchdogSeconds') is not None or build.get('localDebugger'):
+ raise ValueError('Expected a persistent standalone boot without debugger or timed reboot')
+ if build['standaloneVolume']!=volume or not volume.get('hardwareImages'):
+ raise ValueError('Boot must match a volume with bundled vendor/modem images')
+ boot=args.build/build['image']
+ if digest(boot)!=build['imageSha256']:raise ValueError('Boot image changed')
+ spec=importlib.util.spec_from_file_location('sources',ROOT/'tools/test-rescue-boot.py')
+ sources=importlib.util.module_from_spec(spec);spec.loader.exec_module(sources);sources.verify_sources(args.build,build)
+ lock=json.loads((ROOT/'manifests/sargo-hardware.json').read_text())
+ if args.dtbo.stat().st_size!=lock['dtbo']['bytes'] or digest(args.dtbo)!=lock['dtbo']['sha256']:
+ raise ValueError('Unqualified device-tree overlay')
+ out=args.output.resolve();out.mkdir(parents=True,exist_ok=False)
+ (out/'.incomplete').touch()
+ shutil.copyfile(boot,out/'boot.img');shutil.copyfile(args.dtbo,out/'dtbo.img')
+ subprocess.run(['avbtool','make_vbmeta_image','--flags','3','--padding_size','4096','--algorithm','NONE','--output',str(out/'vbmeta.img')],check=True)
+ subprocess.run(['img2simg',str(args.volume.parent/volume['image']),str(out/'userdata.img')],check=True)
+ # Roundtrip verifies the actual sparse payload, not only its header.
+ restored=out/'userdata-roundtrip.ext4'
+ subprocess.run(['simg2img',str(out/'userdata.img'),str(restored)],check=True)
+ if digest(restored)!=volume['imageSha256']:raise ValueError('Sparse image roundtrip mismatch')
+ restored.unlink()
+ files={name:{'file':file,'bytes':(out/file).stat().st_size,'sha256':digest(out/file)}
+ for name,file in [('boot_a','boot.img'),('userdata','userdata.img'),('dtbo_a','dtbo.img'),('vbmeta_a','vbmeta.img')]}
+ value={'schema':1,'device':'sargo','storage':'raw-userdata-ext4','partitions':files,
+ 'userdataBytes':volume['bytes'],'userdataRawSha256':volume['imageSha256'],
+ 'volumeUuid':volume['uuid'],'systemImageSha256':volume['systemImageSha256'],
+ 'flashQualified':False,'containsPrivateMaintenanceKey':bool(build.get('containsPrivateTrialHostKey')),
+ 'redistributionReady':False,'previousOsRequired':False,
+ 'requirements':['Unlocked Pixel 3a (sargo)','Original device calibration partitions retained'],
+ 'scope':'Experimental standalone installation candidate; userdata is replaced in full'}
+ (out/'package.json').write_text(json.dumps(value,indent=2)+'\n')
+ shutil.copyfile(ROOT/'tools/flash-standalone-sargo.py',out/'flash-standalone-sargo.py')
+ (out/'SHA256SUMS').write_text(''.join(digest(p)+' '+p.name+'\n' for p in sorted(out.iterdir()) if p.is_file() and p.name not in ('.incomplete','SHA256SUMS')))
+ (out/'.incomplete').unlink();print(out/'package.json')
+
+
+if __name__=='__main__':main()
diff --git a/tundra/tools/restore-standalone-sargo.py b/tundra/tools/restore-standalone-sargo.py
new file mode 100644
index 00000000..e08f0b9e
--- /dev/null
+++ b/tundra/tools/restore-standalone-sargo.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MPL-2.0
+"""Restore a private full-storage backup after a standalone Sargo test.
+
+Default: verify the archive and partition backups only. --restore overwrites
+userdata and the three slot-A boot partitions. Backups are device-specific.
+"""
+import argparse
+import gzip
+import hashlib
+import json
+from pathlib import Path
+import shutil
+import subprocess
+
+
+def digest(path):
+ sha=hashlib.sha256()
+ with path.open('rb') as source:
+ while block:=source.read(4*1024**2):sha.update(block)
+ return sha.hexdigest()
+
+
+def main():
+ parser=argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--backup',required=True,type=Path)
+ parser.add_argument('--restore',action='store_true')
+ args=parser.parse_args();folder=args.backup.resolve()
+ manifest=json.loads((folder/'recovery.json').read_text())
+ if manifest.get('device')!='sargo' or manifest.get('activeSlot')!='a':raise ValueError('Wrong recovery target')
+ data=json.loads((folder/'userdata.json').read_text())
+ if not data.get('checked') or data.get('archive')!='userdata-original.raw.gz':raise ValueError('Unverified userdata backup')
+ if not data.get('sourceWasUnmounted') or manifest.get('userdataSha256')!=data['sha256'] or manifest.get('userdataBytes')!=data['bytes']:
+ raise ValueError('Storage backup is not bound to this recovery kit')
+ for name,record in manifest['files'].items():
+ if name not in ('boot_a.img','dtbo_a.img','vbmeta_a.img'):raise ValueError('Unexpected recovery partition')
+ path=folder/name
+ if path.is_symlink() or path.stat().st_size!=record['bytes'] or digest(path)!=record['sha256']:raise ValueError('Partition backup changed: '+name)
+ if set(manifest['files'])!={'boot_a.img','dtbo_a.img','vbmeta_a.img'}:raise ValueError('Incomplete boot backup set')
+ archive=folder/data['archive'];sha=hashlib.sha256();count=0
+ with gzip.open(archive,'rb') as source:
+ while block:=source.read(4*1024**2):
+ count+=len(block)
+ if count>data['bytes']:raise ValueError('Oversized backup')
+ sha.update(block)
+ if count!=data['bytes'] or sha.hexdigest()!=data['sha256']:raise ValueError('Storage backup changed')
+ print('Private boot and full userdata backups verified.')
+ if not args.restore:
+ print('No phone action. --restore replaces its current userdata.');return
+ fastboot=shutil.which('fastboot')
+ if not fastboot:raise ValueError('Fastboot is required')
+ serials=[line.split()[0] for line in subprocess.check_output([fastboot,'devices'],text=True).splitlines() if len(line.split())==2]
+ matches=[serial for serial in serials if hashlib.sha256(serial.encode()).hexdigest()==manifest['serialSha256']]
+ if len(matches)!=1:raise ValueError('Original phone not present in Fastboot')
+ serial=matches[0]
+ # Use the standalone installer's strict Fastboot variable parser.
+ import importlib.util
+ spec=importlib.util.spec_from_file_location('flash',Path(__file__).with_name('flash-standalone-sargo.py'))
+ flash=importlib.util.module_from_spec(spec);spec.loader.exec_module(flash)
+ if flash.getvar(fastboot,serial,'product')!='sargo' or flash.getvar(fastboot,serial,'unlocked')!='yes':raise ValueError('Wrong or locked phone')
+ if int(flash.getvar(fastboot,serial,'partition-size:userdata'),16)!=data['bytes']:raise ValueError('Storage geometry changed')
+ raw=folder/'userdata-restore.raw'
+ if raw.exists():
+ if raw.stat().st_size!=data['bytes'] or digest(raw)!=data['sha256']:raise ValueError('Existing restoration file changed')
+ else:
+ if shutil.disk_usage(folder).free65536 or offset+length>expected_size:raise ValueError('Invalid backup extent')
+ target.seek(offset);target.write(exact(stream,length));end=offset+length
diff --git a/tundra/tools/standalone_volume.py b/tundra/tools/standalone_volume.py
new file mode 100644
index 00000000..452f145b
--- /dev/null
+++ b/tundra/tools/standalone_volume.py
@@ -0,0 +1,35 @@
+# SPDX-License-Identifier: MPL-2.0
+"""Validate the fresh userdata filesystem used by standalone Sargo images."""
+import json
+from pathlib import Path
+import re
+import uuid
+from tundra import digest
+
+
+def checked_volume(path, verify_image=True):
+ path = Path(path)
+ value = json.loads(path.read_text())
+ if (value.get('schema') != 1 or value.get('device') != 'sargo'
+ or value.get('storage') != 'raw-userdata-ext4'
+ or value.get('image') != 'userdata.ext4'
+ or value.get('filesystemCheckPassed') is not True):
+ raise ValueError('Expected a checked standalone Sargo volume')
+ identifier = value.get('uuid')
+ if not isinstance(identifier, str) or str(uuid.UUID(identifier)) != identifier:
+ raise ValueError('Invalid volume UUID')
+ for key in ('imageSha256', 'systemImageSha256'):
+ if not re.fullmatch(r'[a-f0-9]{64}', value.get(key, '')):
+ raise ValueError('Invalid '+key)
+ if type(value.get('bytes')) is not int or not 5*1024**3 <= value['bytes'] <= 32*1024**3:
+ raise ValueError('Invalid standalone filesystem size')
+ hardware = value.get('hardwareImages', {})
+ if hardware:
+ lock = json.loads((Path(__file__).resolve().parents[1]/'manifests/sargo-hardware.json').read_text())
+ if hardware != lock['images']:
+ raise ValueError('Unqualified standalone hardware images')
+ if verify_image:
+ image = path.parent/value['image']
+ if image.is_symlink() or image.stat().st_size != value['bytes'] or digest(image) != value['imageSha256']:
+ raise ValueError('Standalone volume changed')
+ return value