Update shared Gaia workflows and Sargo development tooling

Fix camera switching and Contacts/SMS activity routing across shared hosts.
Integrate Marketplace installation and native standalone recovery tools,
Bluetooth runtime packages and trusted certificates.

Update English/French READMEs and the shared changelog with tested scope
and remaining radio/audio/Bluetooth limitations. Preview17 remains the
existing APK; no new native image is released.

Validation: shared JavaScript and native Python tests; Android packaging
and payload parity from an isolated publication checkout.
This commit is contained in:
OverSu 2026-10-08 14:27:17 +02:00
commit 0041cd9660
101 changed files with 3883 additions and 335 deletions

3
.gitignore vendored
View file

@ -19,3 +19,6 @@ __pycache__/
# Old B2G registry is unused by the modern host and contains historical paths. # Old B2G registry is unused by the modern host and contains historical paths.
/assets/webapps/webapps.json /assets/webapps/webapps.json
# Local issue tracking and roadmap; not included in publication.
/tickets/

View file

@ -3,7 +3,7 @@
<h1 align="center">Vulpes OS</h1> <h1 align="center">Vulpes OS</h1>
<p align="center"><strong>L'esprit de Firefox OS. Un Gecko moderne. Une base Gaia commune.</strong><br>Un projet communautaire pour retrouver une expérience mobile fondée sur le Web, sur PC, Android et téléphone natif.</p> <p align="center"><strong>L'esprit de Firefox OS. Un Gecko moderne. Une base Gaia commune.</strong><br>Un projet communautaire pour retrouver une expérience mobile fondée sur le Web, sur PC, Android et téléphone natif.</p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Vulpes-2.7_preview.15-0755be?style=flat-square" alt="Vulpes Preview 15"> <img src="https://img.shields.io/badge/Vulpes-2.7_preview.17-0755be?style=flat-square" alt="Vulpes Preview 17">
<img src="https://img.shields.io/badge/Gecko_bureau-156.0.1-0099cc?style=flat-square" alt="Gecko bureau 156.0.1"> <img src="https://img.shields.io/badge/Gecko_bureau-156.0.1-0099cc?style=flat-square" alt="Gecko bureau 156.0.1">
<img src="https://img.shields.io/badge/Statut-Expérimental-f3a712?style=flat-square" alt="Expérimental"> <img src="https://img.shields.io/badge/Statut-Expérimental-f3a712?style=flat-square" alt="Expérimental">
</p> </p>
@ -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** | | **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 | | **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**. - Minimum déclaré : **Android 8 / API 26**. Cible : **API 37**.
- APK universel : **ARM64, ARMv7 et x86_64** ; environ **600 Mio**, moteur GeckoView inclus. - 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. 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 ## ✨ Ce que nous partageons

View file

@ -3,7 +3,7 @@
<h1 align="center">Vulpes OS</h1> <h1 align="center">Vulpes OS</h1>
<p align="center"><strong>The spirit of Firefox OS. A modern Gecko. One shared Gaia.</strong><br>A community project bringing a web-based phone experience to desktop, Android and native hardware.</p> <p align="center"><strong>The spirit of Firefox OS. A modern Gecko. One shared Gaia.</strong><br>A community project bringing a web-based phone experience to desktop, Android and native hardware.</p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Vulpes-2.7_preview.15-0755be?style=flat-square" alt="Vulpes Preview 15"> <img src="https://img.shields.io/badge/Vulpes-2.7_preview.17-0755be?style=flat-square" alt="Vulpes Preview 17">
<img src="https://img.shields.io/badge/Desktop_Gecko-156.0.1-0099cc?style=flat-square" alt="Desktop Gecko 156.0.1"> <img src="https://img.shields.io/badge/Desktop_Gecko-156.0.1-0099cc?style=flat-square" alt="Desktop Gecko 156.0.1">
<img src="https://img.shields.io/badge/Status-Experimental-f3a712?style=flat-square" alt="Experimental"> <img src="https://img.shields.io/badge/Status-Experimental-f3a712?style=flat-square" alt="Experimental">
</p> </p>
@ -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** | | **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 | | **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**. - Minimum declared: **Android 8 / API 26**. Target: **API 37**.
- Universal APK: **ARM64, ARMv7 and x86_64**; approximately **600 MiB** including GeckoView. - 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. 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 ## ✨ What is shared

View file

@ -14,11 +14,13 @@ import java.util.zip.*;
final class GaiaServer implements Closeable { final class GaiaServer implements Closeable {
static final int PORT = 18765; static final int PORT = 18765;
final InstalledApps installed;
private final ZipFile zip; private final ZipFile zip;
private final ServerSocket socket; private final ServerSocket socket;
private final ExecutorService workers = Executors.newFixedThreadPool(4); private final ExecutorService workers = Executors.newFixedThreadPool(4);
GaiaServer(Context context) throws IOException { 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"); File archive = new File(context.getCacheDir(), "gaia.zip");
// Atomic refresh on every process start; the APK is the source of truth. // Atomic refresh on every process start; the APK is the source of truth.
File temp = new File(context.getCacheDir(), "gaia.zip.tmp"); 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('.')); String app = host.substring(0, host.indexOf('.'));
if (app.equals("theme")) app = "default_theme"; 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); error(out, 404);
return; return;
} }
@ -109,6 +111,16 @@ final class GaiaServer implements Closeable {
return; return;
} }
if (path.endsWith("/")) path += "index.html"; 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; String key = path.startsWith("/_vulpes/") ? path.substring(1) : app + path;
ZipEntry entry = zip.getEntry(key); ZipEntry entry = zip.getEntry(key);
if (entry == null && path.startsWith("/shared/")) entry = zip.getEntry( if (entry == null && path.startsWith("/shared/")) entry = zip.getEntry(

View file

@ -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<String,JSONObject> 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);
}
}

View file

@ -45,7 +45,6 @@ public final class MainActivity extends Activity {
root.setOnApplyWindowInsetsListener((v, insets) -> { root.setOnApplyWindowInsetsListener((v, insets) -> {
if (Build.VERSION.SDK_INT >= 30) { if (Build.VERSION.SDK_INT >= 30) {
android.graphics.Insets bars = insets.getInsets( android.graphics.Insets bars = insets.getInsets(
WindowInsets.Type.systemBars() |
WindowInsets.Type.ime() | WindowInsets.Type.ime() |
WindowInsets.Type.displayCutout() WindowInsets.Type.displayCutout()
); );
@ -54,11 +53,12 @@ public final class MainActivity extends Activity {
insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetLeft(),
insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetTop(),
insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetRight(),
insets.getSystemWindowInsetBottom() insets.getSystemWindowInsetBottom() > dp(100) ? insets.getSystemWindowInsetBottom() : 0
); );
return insets; return insets;
}); });
setContentView(root); setContentView(root);
immersive();
// Respect Android's screen timeout; Gaia must not keep the phone awake indefinitely. // Respect Android's screen timeout; Gaia must not keep the phone awake indefinitely.
float brightness = app.getSharedPreferences("android", 0).getFloat("brightness", -1); float brightness = app.getSharedPreferences("android", 0).getFloat("brightness", -1);
if (brightness >= 0) { 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) { private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density); return Math.round(value * getResources().getDisplayMetrics().density);
} }
@ -274,7 +296,7 @@ public final class MainActivity extends Activity {
setHomeSurface(app.homeVisible); setHomeSurface(app.homeVisible);
} }
private void closeBrowser() { void closeBrowser() {
webPanel.setVisibility(View.GONE); webPanel.setVisibility(View.GONE);
gaiaView.setVisibility(View.VISIBLE); gaiaView.setVisibility(View.VISIBLE);
setHomeSurface(app.homeVisible); setHomeSurface(app.homeVisible);
@ -356,6 +378,7 @@ public final class MainActivity extends Activity {
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
resumed = true; resumed = true;
immersive();
app.activity = this; app.activity = this;
if (recoverGaia) { if (recoverGaia) {
recoverGaia = false; recoverGaia = false;

View file

@ -17,9 +17,32 @@ public final class VulpesApplication extends Application {
GeckoSession gaiaSession; GeckoSession gaiaSession;
AndroidServices services; AndroidServices services;
AndroidNotifications notifications; AndroidNotifications notifications;
boolean pendingScreenLock;
boolean homeVisible; boolean homeVisible;
boolean homeAtBottom; 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 { void prepare() throws IOException {
if (runtime != null) return; if (runtime != null) return;
server = new GaiaServer(this); server = new GaiaServer(this);
@ -58,6 +81,7 @@ public final class VulpesApplication extends Application {
@Override @Override
public void onConnect(WebExtension.Port p) { public void onConnect(WebExtension.Port p) {
port = p; port = p;
sendScreenLock();
if (activity != null) notifications.click(activity.getIntent()); if (activity != null) notifications.click(activity.getIntent());
p.setDelegate( p.setDelegate(
new WebExtension.PortDelegate() { new WebExtension.PortDelegate() {
@ -80,6 +104,18 @@ public final class VulpesApplication extends Application {
return GeckoResult.fromException(new SecurityException("EXTENSION_REQUIRED")); return GeckoResult.fromException(new SecurityException("EXTENSION_REQUIRED"));
} }
JSONObject json = (JSONObject) message; 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<Object> 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")) { if (json.optString("type").equals("homeScroll")) {
homeAtBottom = json.optBoolean("bottom"); homeAtBottom = json.optBoolean("bottom");
if (activity != null) activity.setHomeSurface(homeVisible); if (activity != null) activity.setHomeSurface(homeVisible);
@ -124,6 +160,7 @@ public final class VulpesApplication extends Application {
} }
void resumed() { void resumed() {
sendScreenLock();
if (port == null) return; if (port == null) return;
try { try {
port.postMessage(new JSONObject().put("type", "android-resume")); port.postMessage(new JSONObject().put("type", "android-resume"));

View file

@ -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 { authorizePlatform } from './services/platform.mjs';
import { SettingsService } from './services/settings.mjs'; import { SettingsService } from './services/settings.mjs';
import { DataStoreService } from './services/datastores.mjs'; import { DataStoreService } from './services/datastores.mjs';
@ -7,6 +9,9 @@ import { Messages } from './Messages.mjs';
import { Connections } from './Connections.mjs'; import { Connections } from './Connections.mjs';
import { Alarms } from './Alarms.mjs'; import { Alarms } from './Alarms.mjs';
const peers = new Set(); 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; let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage;
const identity = (peer) => peer.app; const identity = (peer) => peer.app;
function notify(peer, type, data) { function notify(peer, type, data) {
@ -26,7 +31,7 @@ function grants(app) {
function broadcast(type, data, target) { function broadcast(type, data, target) {
for (const peer of peers) { for (const peer of peers) {
if (target && peer.app.id !== target) continue; 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 ( if (
type === 'contactchange' && type === 'contactchange' &&
!peer.app.manifest.permissions?.contacts?.access?.includes('read') !peer.app.manifest.permissions?.contacts?.access?.includes('read')
@ -45,7 +50,7 @@ function broadcast(type, data, target) {
notify(peer, type, data); notify(peer, type, data);
} }
} }
function launch(target, entryPoint, path) { function launch(target, entryPoint, path, reload = false) {
path ||= path ||=
target.manifest.entry_points?.[entryPoint]?.launch_path || target.manifest.entry_points?.[entryPoint]?.launch_path ||
target.manifest.launch_path || target.manifest.launch_path ||
@ -53,7 +58,7 @@ function launch(target, entryPoint, path) {
const url = new URL(path, target.origin); const url = new URL(path, target.origin);
if (url.origin !== target.origin) throw Error('INVALID_URL'); if (url.origin !== target.origin) throw Error('INVALID_URL');
broadcast( broadcast(
'launch', reload ? 'activity-launch' : 'launch',
{ {
manifestURL: target.manifestURL, manifestURL: target.manifestURL,
origin: target.origin, origin: target.origin,
@ -66,6 +71,8 @@ function launch(target, entryPoint, path) {
} }
const ready = (async () => { const ready = (async () => {
apps = await (await fetch('registry.json')).json(); 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 defaults = await (await fetch('defaults.json')).json();
const saved = await browser.storage.local.get('settings'); const saved = await browser.storage.local.get('settings');
values = { ...defaults, ...saved.settings }; values = { ...defaults, ...saved.settings };
@ -82,6 +89,8 @@ const ready = (async () => {
values['wallpaper.image.valid'] = false; values['wallpaper.image.valid'] = false;
await browser.storage.local.set({ settings: values }); 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(); let writes = Promise.resolve();
settings = new SettingsService( settings = new SettingsService(
{ {
@ -123,6 +132,12 @@ const ready = (async () => {
// Java owns this port. Web pages have no access to native messaging. // Java owns this port. Web pages have no access to native messaging.
let native; let native;
let systemReady = false; let systemReady = false;
let pendingScreenLock = false;
function deliverScreenLock() {
if (!systemReady || !pendingScreenLock) return;
pendingScreenLock = false;
broadcast('screen-off', {}, 'system');
}
let pendingNotification; let pendingNotification;
function deliverNotification() { function deliverNotification() {
if (!systemReady || !pendingNotification) return; if (!systemReady || !pendingNotification) return;
@ -140,7 +155,15 @@ function connectNative() {
native.onMessage.addListener(async (message) => { native.onMessage.addListener(async (message) => {
await ready; await ready;
if (['home', 'holdhome'].includes(message.type)) broadcast(message.type, {}, 'system'); 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') { if (message.type === 'notification-click') {
pendingNotification = message.data; pendingNotification = message.data;
deliverNotification(); deliverNotification();
@ -206,6 +229,7 @@ async function handle(peer, request) {
if (method === 'android.systemReady') { if (method === 'android.systemReady') {
if (app.id !== 'system') throw Error('PERMISSION_DENIED'); if (app.id !== 'system') throw Error('PERMISSION_DENIED');
systemReady = true; systemReady = true;
deliverScreenLock();
deliverNotification(); deliverNotification();
return null; return null;
} }
@ -278,6 +302,7 @@ async function handle(peer, request) {
} }
if (!Object.keys(params.values).length) return null; if (!Object.keys(params.values).length) return null;
} }
if (method === 'locale.current') return values['language.current'] || 'en-US';
if (method === 'settings.snapshot') { if (method === 'settings.snapshot') {
if (!grants(app).includes('settings.read')) throw Error('PERMISSION_DENIED'); if (!grants(app).includes('settings.read')) throw Error('PERMISSION_DENIED');
return { ...values }; return { ...values };
@ -287,6 +312,14 @@ async function handle(peer, request) {
if (reply.error) throw Error(reply.error.code); if (reply.error) throw Error(reply.error.code);
return reply.result; 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') { if (method === 'apps.self') {
console.info('VULPES app ready', app.id); console.info('VULPES app ready', app.id);
return app; return app;
@ -298,6 +331,10 @@ async function handle(peer, request) {
if (!manage) throw Error('PERMISSION_DENIED'); if (!manage) throw Error('PERMISSION_DENIED');
return apps; 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') { if (method === 'apps.launch') {
const target = apps.find((a) => a.manifestURL === params.manifestURL); const target = apps.find((a) => a.manifestURL === params.manifestURL);
if (!target || (!manage && app.id !== target.id)) throw Error('PERMISSION_DENIED'); 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); connections.close(peer, params.id);
return null; 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 (method === 'activities.start') {
if (params.name === 'record' && params.data?.type === 'photos') { 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'); const camera = apps.find((a) => a.id === 'camera');
if (!camera) throw Error('ACTIVITY_NOT_SUPPORTED'); if (!camera) throw Error('ACTIVITY_NOT_SUPPORTED');
launch(camera); launch(camera);
@ -430,9 +471,21 @@ browser.runtime.onConnect.addListener((port) => {
peers.delete(peer); peers.delete(peer);
if (peer.app.id === 'system' && ![...peers].some((p) => p.app.id === 'system')) if (peer.app.id === 'system' && ![...peers].some((p) => p.app.id === 'system'))
systemReady = false; systemReady = false;
activities.cleanup(peer);
connections.cleanup(peer); connections.cleanup(peer);
alarms.cleanup(peer); alarms.cleanup(peer);
}) })
.catch(() => {}); .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;
});

View file

@ -1,6 +1,6 @@
/* Extension world: the browser provides origin/tab/frame identity, never Gaia. */ /* 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; const win = window.wrappedJSObject;
console.info('VULPES Android bridge', location.origin); console.info('VULPES Android bridge', location.origin);
let port; let port;

View file

@ -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"
}
]
}

View file

@ -0,0 +1 @@
installMarketplacePage(window,url=>browser.runtime.sendMessage({marketplaceDownload:url}).catch(console.error));

View file

@ -5,10 +5,10 @@ import { SettingsService } from '../../services/settings.mjs';
const context = vm.createContext({ window: {}, Blob, Uint8Array, btoa, atob }); const context = vm.createContext({ window: {}, Blob, Uint8Array, btoa, atob });
vm.runInContext( 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, 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 // A binary wallpaper larger than the original 1 MiB JSON ceiling, including
// every byte value. Exercise the actual service, JSON storage and observer path. // every byte value. Exercise the actual service, JSON storage and observer path.
const pixels = Uint8Array.from({ length: 1200000 }, (_, i) => i % 256); const pixels = Uint8Array.from({ length: 1200000 }, (_, i) => i % 256);

View file

@ -12,7 +12,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools")) 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() validate_assets()
OUT = ROOT / "android/app/src/main/assets" OUT = ROOT / "android/app/src/main/assets"
@ -39,7 +39,7 @@ for manifest in sorted(APPS.glob("*/manifest.webapp")):
id=name, id=name,
origin=origin, origin=origin,
manifestURL=origin + "/manifest.webapp", 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() info = build_info()
@ -126,7 +126,7 @@ if "--bridge-only" not in sys.argv or not (OUT / "gaia.zip").exists():
.read_text() .read_text()
) )
put("_vulpes/" + name, translate(text).encode()) 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-ui.js", (ROOT / "android/ui/android-ui.js").read_bytes())
put( put(
"_vulpes/android.css", "_vulpes/android.css",
@ -145,6 +145,7 @@ for name in ["Connections", "Contacts", "Messages"]:
) )
(ext / f"{name}.mjs").write_text(text) (ext / f"{name}.mjs").write_text(text)
shutil.copy2(ROOT / "host/Alarms.sys.mjs", ext / "Alarms.mjs") 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 / "release.json").write_text(json.dumps(release))
(ext / "registry.json").write_text(json.dumps(registry, ensure_ascii=False)) (ext / "registry.json").write_text(json.dumps(registry, ensure_ascii=False))
settings = json.loads(translate((ROOT / "assets/settings-defaults.json").read_text())) settings = json.loads(translate((ROOT / "assets/settings-defaults.json").read_text()))

View file

@ -19,7 +19,7 @@ const VCARD_MIME_TYPES = [
var ActivityHandler = { var ActivityHandler = {
_currentActivity: null, _currentActivity: null,
_launchedAsInlineActivity: (window.location.search == '?pick'), _launchedAsInlineActivity: new URLSearchParams(window.location.search).has('pick'),
mozContactParam: null, mozContactParam: null,
@ -124,6 +124,7 @@ var ActivityHandler = {
this.launch_activity(activity, 'add-parameters'); this.launch_activity(activity, 'add-parameters');
break; break;
case 'pick': case 'pick':
if (window.VulpesCompat) this._launchedAsInlineActivity = true;
if (!this._launchedAsInlineActivity) { if (!this._launchedAsInlineActivity) {
return; return;
} }
@ -356,15 +357,18 @@ var ActivityHandler = {
postNewSuccess: function ah_postNewSuccess(contact) { postNewSuccess: function ah_postNewSuccess(contact) {
this._currentActivity.postResult({ contact: contact }); this._currentActivity.postResult({ contact: contact });
this._currentActivity = null; this._currentActivity = null;
if (window.VulpesCompat) this._launchedAsInlineActivity = false;
}, },
postPickSuccess: function ah_postPickSuccess(result) { postPickSuccess: function ah_postPickSuccess(result) {
this._currentActivity.postResult(result); this._currentActivity.postResult(result);
this._currentActivity = null; this._currentActivity = null;
if (window.VulpesCompat) this._launchedAsInlineActivity = false;
}, },
postCancel: function ah_postCancel() { postCancel: function ah_postCancel() {
this._currentActivity.postError('canceled'); this._currentActivity.postError('canceled');
this._currentActivity = null; this._currentActivity = null;
if (window.VulpesCompat) this._launchedAsInlineActivity = false;
} }
}; };

View file

@ -655,7 +655,9 @@ var Contacts = (function() {
utils.PerformanceHelper.contentInteractive(); utils.PerformanceHelper.contentInteractive();
utils.PerformanceHelper.chromeInteractive(); utils.PerformanceHelper.chromeInteractive();
window.setTimeout(Contacts && Contacts.onLocalized); 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', LazyLoader.load(['/shared/js/contacts/import/utilities/misc.js',
'/shared/js/contacts/import/utilities/vcard_reader.js', '/shared/js/contacts/import/utilities/vcard_reader.js',
'/shared/js/contacts/import/utilities/vcard_parser.js'], '/shared/js/contacts/import/utilities/vcard_parser.js'],

View file

@ -1038,6 +1038,8 @@ contacts.Form = (function() {
// (Bug 1041455). // (Bug 1041455).
if (editingContact) { if (editingContact) {
Contacts.setCurrent(contact); Contacts.setCurrent(contact);
// The change event may arrive while the edit form is still visible.
Contacts.updateContactDetail(contact.id);
} }
} }
); );

View file

@ -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;} 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.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;} 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;i<iLen;i++){var child=children[i];if(child.order!==-1&&child.order<container.order){continue;} 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;i<iLen;i++){var child=children[i];if(child.order!==-1&&child.order<container.order){continue;}
this.icons.insertBefore(container,child);if(this.startupMetadata===null){this.iconAdded(container);} this.icons.insertBefore(container,child);if(this.startupMetadata===null){this.iconAdded(container);}
break;}} break;}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

View file

@ -0,0 +1,5 @@
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Marketplace</title><link rel="stylesheet" href="/shared/style/confirm.css"><link rel="stylesheet" href="marketplace.css"><script defer src="marketplace.js"></script></head><body>
<iframe id="catalogue" mozbrowser title="Marketplace"></iframe>
<section id="message" hidden><img src="icon.png" alt="" width="96" height="96"><p id="status" role="status"></p><button id="browse" type="button"></button><button id="open" type="button" hidden></button></section>
<form id="confirm" role="dialog" data-type="confirm" hidden><section><h1 id="title"></h1><p id="question"></p><p id="permissions"></p></section><menu><button id="cancel" type="button"></button><button id="install" type="submit" class="recommend"></button></menu></form>
</body></html>

View file

@ -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"
}
}
}

View file

@ -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}

View file

@ -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);
})();

View file

@ -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));} 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;} 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;} 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 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);} 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;} function domify(htmlText){var dummyDiv=document.createElement('div');dummyDiv.innerHTML=htmlText;var element=dummyDiv.firstElementChild;return element;}

View file

@ -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));} 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;} 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;} 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 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);} 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;} function domify(htmlText){var dummyDiv=document.createElement('div');dummyDiv.innerHTML=htmlText;var element=dummyDiv.firstElementChild;return element;}

View file

@ -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));} ;;'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;} 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;} 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;}}}}; 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;}}}};

File diff suppressed because one or more lines are too long

View file

@ -19,7 +19,7 @@ const VCARD_MIME_TYPES = [
var ActivityHandler = { var ActivityHandler = {
_currentActivity: null, _currentActivity: null,
_launchedAsInlineActivity: (window.location.search == '?pick'), _launchedAsInlineActivity: new URLSearchParams(window.location.search).has('pick'),
mozContactParam: null, mozContactParam: null,
@ -124,6 +124,7 @@ var ActivityHandler = {
this.launch_activity(activity, 'add-parameters'); this.launch_activity(activity, 'add-parameters');
break; break;
case 'pick': case 'pick':
if (window.VulpesCompat) this._launchedAsInlineActivity = true;
if (!this._launchedAsInlineActivity) { if (!this._launchedAsInlineActivity) {
return; return;
} }
@ -356,15 +357,18 @@ var ActivityHandler = {
postNewSuccess: function ah_postNewSuccess(contact) { postNewSuccess: function ah_postNewSuccess(contact) {
this._currentActivity.postResult({ contact: contact }); this._currentActivity.postResult({ contact: contact });
this._currentActivity = null; this._currentActivity = null;
if (window.VulpesCompat) this._launchedAsInlineActivity = false;
}, },
postPickSuccess: function ah_postPickSuccess(result) { postPickSuccess: function ah_postPickSuccess(result) {
this._currentActivity.postResult(result); this._currentActivity.postResult(result);
this._currentActivity = null; this._currentActivity = null;
if (window.VulpesCompat) this._launchedAsInlineActivity = false;
}, },
postCancel: function ah_postCancel() { postCancel: function ah_postCancel() {
this._currentActivity.postError('canceled'); this._currentActivity.postError('canceled');
this._currentActivity = null; this._currentActivity = null;
if (window.VulpesCompat) this._launchedAsInlineActivity = false;
} }
}; };

View file

@ -655,7 +655,9 @@ var Contacts = (function() {
utils.PerformanceHelper.contentInteractive(); utils.PerformanceHelper.contentInteractive();
utils.PerformanceHelper.chromeInteractive(); utils.PerformanceHelper.chromeInteractive();
window.setTimeout(Contacts && Contacts.onLocalized); 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', LazyLoader.load(['/shared/js/contacts/import/utilities/misc.js',
'/shared/js/contacts/import/utilities/vcard_reader.js', '/shared/js/contacts/import/utilities/vcard_reader.js',
'/shared/js/contacts/import/utilities/vcard_parser.js'], '/shared/js/contacts/import/utilities/vcard_parser.js'],

View file

@ -1038,6 +1038,8 @@ contacts.Form = (function() {
// (Bug 1041455). // (Bug 1041455).
if (editingContact) { if (editingContact) {
Contacts.setCurrent(contact); Contacts.setCurrent(contact);
// The change event may arrive while the edit form is still visible.
Contacts.updateContactDetail(contact.id);
} }
} }
); );

View file

@ -408,6 +408,10 @@
return; return;
} }
if (HIDDEN_ROLES.includes(manifest.role)) {
return;
}
// Do not add blacklisted apps // Do not add blacklisted apps
if (BLACKLIST.includes(app.origin)) { if (BLACKLIST.includes(app.origin)) {
return; return;

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

View file

@ -0,0 +1,5 @@
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Marketplace</title><link rel="stylesheet" href="/shared/style/confirm.css"><link rel="stylesheet" href="marketplace.css"><script defer src="marketplace.js"></script></head><body>
<iframe id="catalogue" mozbrowser title="Marketplace"></iframe>
<section id="message" hidden><img src="icon.png" alt="" width="96" height="96"><p id="status" role="status"></p><button id="browse" type="button"></button><button id="open" type="button" hidden></button></section>
<form id="confirm" role="dialog" data-type="confirm" hidden><section><h1 id="title"></h1><p id="question"></p><p id="permissions"></p></section><menu><button id="cancel" type="button"></button><button id="install" type="submit" class="recommend"></button></menu></form>
</body></html>

View file

@ -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"
}
}
}

View file

@ -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}

View file

@ -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);
})();

View file

@ -143,7 +143,7 @@ TonePlayer.prototype = {
_setExclusiveMode: function(exclusive) { _setExclusiveMode: function(exclusive) {
if (exclusive) { if (exclusive) {
if (!this._source) { if (!this._source) {
this._context = new AudioContext('ringer'); this._context = new AudioContext();
this._source = this._context.createMediaElementSource(this._player); this._source = this._context.createMediaElementSource(this._player);
this._source.connect(this._context.destination); this._source.connect(this._context.destination);
} }

View file

@ -43,7 +43,39 @@ define(function() {
return; 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'; 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 * Most browsers now scroll content into view taking CSS transforms into
@ -57,22 +89,6 @@ define(function() {
window.scrollTo(0, 0); 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();
}
});
}, },
/** /**

View file

@ -10,7 +10,9 @@
* application installation, crash reporter, and low storage notices. * application installation, crash reporter, and low storage notices.
* @class SystemBanner * @class SystemBanner
*/ */
function SystemBanner() {} function SystemBanner() {
this._generation = 0;
}
/** /**
* Amount of time before banner is automatically dismissed. * Amount of time before banner is automatically dismissed.
@ -77,10 +79,18 @@
* { label: l10nAttrs, callback: ..., dismiss: ... } * { label: l10nAttrs, callback: ..., dismiss: ... }
*/ */
show: function(messages, buttonParams) { show: function(messages, buttonParams) {
var generation = ++this._generation;
clearTimeout(this._dismissTimer);
if (this._cancelRemoval) {
this._cancelRemoval();
}
this._clicked = false; this._clicked = false;
this._showing = true; this._showing = true;
return this.getBanner().then(banner => { return this.getBanner().then(banner => {
if (generation !== this._generation || !this._showing) {
return;
}
// Clear the button and message containers. // Clear the button and message containers.
var messageContainer = banner.querySelector('.messages'); var messageContainer = banner.querySelector('.messages');
var buttonContainer = banner.querySelector('.buttons'); var buttonContainer = banner.querySelector('.buttons');
@ -110,13 +120,16 @@
// Hide banner and invoke button dismiss() on click or timeout. // Hide banner and invoke button dismiss() on click or timeout.
var hideBanner = () => { var hideBanner = () => {
if (generation !== this._generation || !this._showing) {
return;
}
this.hide(); this.hide();
if (!this._clicked) { if (!this._clicked) {
buttonParams && buttonParams.dismiss && buttonParams.dismiss(); buttonParams && buttonParams.dismiss && buttonParams.dismiss();
} }
}; };
banner.onclick = hideBanner; banner.onclick = hideBanner;
setTimeout(hideBanner, SystemBanner.TIMEOUT); this._dismissTimer = setTimeout(hideBanner, SystemBanner.TIMEOUT);
document.getElementById('screen').appendChild(banner); document.getElementById('screen').appendChild(banner);
banner.show(); banner.show();
@ -131,15 +144,37 @@
return; return;
} }
this._showing = false; this._showing = false;
clearTimeout(this._dismissTimer);
var generation = this._generation;
return this.getBanner().then(banner => { return this.getBanner().then(banner => {
banner.addEventListener('animationend', function removeBanner() { if (generation !== this._generation || this._showing) {
banner.removeEventListener('animationend', removeBanner); return;
// Only remove banner if we haven't called show() in the meantime. }
if (!this._showing) { // 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); 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(); banner.hide();
}); });
} }

View file

@ -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 {Input} from 'resource://vulpes/host/Input.sys.mjs';
import { ScreenPower } from 'resource://vulpes/services/screen-power.mjs'; import { ScreenPower } from 'resource://vulpes/services/screen-power.mjs';
import { Packages } from 'resource://vulpes/services/packages.mjs';
import { Activities, isGaiaActivity } from 'resource://vulpes/services/activities.mjs';
import { SettingsService } from 'resource://vulpes/services/settings.mjs'; import { SettingsService } from 'resource://vulpes/services/settings.mjs';
import { DataStoreService } from 'resource://vulpes/services/datastores.mjs'; import { DataStoreService } from 'resource://vulpes/services/datastores.mjs';
import { DataStoresIndexedDB } from 'resource://vulpes/adapters/datastores-indexeddb.mjs'; import { DataStoresIndexedDB } from 'resource://vulpes/adapters/datastores-indexeddb.mjs';
@ -29,6 +31,7 @@ export const Runtime = {
wakeLocks: new Map(), wakeLocks: new Map(),
idleListeners: new Map(), idleListeners: new Map(),
cleanup(actor) { cleanup(actor) {
this.activities?.cleanup(actor);
this.input?.cleanup(actor); this.input?.cleanup(actor);
this.platform?.nativeCamera.cleanup(actor); this.platform?.nativeCamera.cleanup(actor);
this.connections?.cleanup(actor); this.connections?.cleanup(actor);
@ -45,6 +48,22 @@ export const Runtime = {
this.input = new Input(this); this.input = new Input(this);
const root = Services.env.get('VULPES_WORKSPACE_ROOT'); const root = Services.env.get('VULPES_WORKSPACE_ROOT');
this.apps = await IOUtils.readJSON(PathUtils.join(root, 'assets', 'registry.json')); 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 // 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. // permission is scoped to their origins in this profile, never web pages.
for (const app of this.apps) for (const app of this.apps)
@ -91,6 +110,11 @@ export const Runtime = {
this.values['vulpes.gaia-restoration.v1'] = true; this.values['vulpes.gaia-restoration.v1'] = true;
} }
Services.prefs.setStringPref('intl.accept_languages', this.values['language.current']); 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.writeQueue = Promise.resolve();
this.settings = new SettingsService({ this.settings = new SettingsService({
get: async (key) => this.values[key], get: async (key) => this.values[key],
@ -115,7 +139,7 @@ export const Runtime = {
this.writeQueue = write.catch(() => {}); this.writeQueue = write.catch(() => {});
return write; return write;
}, },
}); }, {maxBytes:32 * 1024 * 1024});
const storage = new DataStoresIndexedDB(indexedDB, IDBKeyRange, () => const storage = new DataStoresIndexedDB(indexedDB, IDBKeyRange, () =>
Services.uuid.generateUUID().toString(), Services.uuid.generateUUID().toString(),
); );
@ -202,6 +226,16 @@ export const Runtime = {
this.actors.add(actor); this.actors.add(actor);
const { method, params } = request; const { method, params } = request;
if (typeof method !== 'string') return { error: { code: 'INVALID_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(method==='hardware.vibrate') {
if(app.id!=='system' || Services.env.get('VULPES_TUNDRA')!=='1') return {error:{code:'NOT_SUPPORTED'}}; if(app.id!=='system' || Services.env.get('VULPES_TUNDRA')!=='1') return {error:{code:'NOT_SUPPORTED'}};
const pattern=params?.pattern; const pattern=params?.pattern;
@ -267,10 +301,19 @@ export const Runtime = {
} }
} }
if (typeof method !== 'string') return { error: { code: 'INVALID_REQUEST' } }; 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 (method === 'activities.start') {
if (params?.name === 'record' && params.data?.type === 'photos') { if (params?.name === 'record' && params.data?.type === 'photos') {
// Gallery's Photo button opens the shared Gaia UI; it expects no result file. // Gallery and the lockscreen open Gaia Camera without expecting a file result.
if (app.id === 'gallery') { if (['gallery', 'system'].includes(app.id)) {
const camera = this.apps.find((a) => a.id === 'camera'); const camera = this.apps.find((a) => a.id === 'camera');
if (!camera) return { error: { code: 'ACTIVITY_NOT_SUPPORTED' } }; if (!camera) return { error: { code: 'ACTIVITY_NOT_SUPPORTED' } };
this.broadcast( this.broadcast(
@ -316,6 +359,7 @@ export const Runtime = {
return { error: { code: error.message || 'VIEW_ERROR' } }; return { error: { code: error.message || 'VIEW_ERROR' } };
} }
} }
if (method === 'locale.current') return { result: this.values['language.current'] || 'en-US' };
if (method === 'settings.snapshot') if (method === 'settings.snapshot')
return this.grants(app).includes('settings.read') return this.grants(app).includes('settings.read')
? { result: { ...this.values } } ? { result: { ...this.values } }
@ -441,6 +485,10 @@ export const Runtime = {
if (method === 'apps.self') return { result: app }; if (method === 'apps.self') return { result: app };
if (method === 'apps.list') if (method === 'apps.list')
return manage ? { result: this.apps } : { error: { code: 'PERMISSION_DENIED' } }; 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') { if (method === 'apps.launch') {
const target = this.apps.find((a) => a.manifestURL === params?.manifestURL); const target = this.apps.find((a) => a.manifestURL === params?.manifestURL);
if (!target || (!manage && app.id !== target.id)) if (!target || (!manage && app.id !== target.id))
@ -469,7 +517,7 @@ export const Runtime = {
try { try {
const app = this.identity(actor); const app = this.identity(actor);
if (!app || (target && app.id !== target)) continue; 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 ( if (
type === 'mediachange' && type === 'mediachange' &&
!app.manifest.permissions?.['device-storage:' + data.type]?.access?.includes('read') !app.manifest.permissions?.['device-storage:' + data.type]?.access?.includes('read')

View file

@ -15,12 +15,52 @@ export const Views = {
); );
Services.ppmm.sharedData.flush(); 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() { prune() {
for (const [id, entry] of this.entries) { for (const [id, entry] of this.entries) {
if (!BrowsingContext.get(id)?.parent) { if (!BrowsingContext.get(id)?.parent) {
this.remove(id, entry); this.remove(id, entry);
continue; 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. // A detached iframe's BrowsingContext can outlive its DOM element.
// Ask the trusted child actor about actual ownership, not just the BC tree. // Ask the trusted child actor about actual ownership, not just the BC tree.
if (entry.checking) continue; if (entry.checking) continue;
@ -123,7 +163,9 @@ export const Views = {
if (flags & Ci.nsIWebProgressListener.STATE_STOP) { if (flags & Ci.nsIWebProgressListener.STATE_STOP) {
emit('loadend', { backgroundColor: 'rgb(255, 255, 255)' }); emit('loadend', { backgroundColor: 'rgb(255, 255, 255)' });
emit('titlechange', browser.contentTitle || browser.currentURI.spec); 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() {}, onProgressChange() {},
@ -143,6 +185,8 @@ export const Views = {
this.entries.set(contextId, entry); this.entries.set(contextId, entry);
} }
entry.url = url; entry.url = url;
entry.errorPage=null;
entry.previousDocumentId=entry.browser.browsingContext?.currentWindowGlobal?.innerWindowId;
entry.browser.loadURI(uri, { entry.browser.loadURI(uri, {
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
}); });
@ -153,22 +197,8 @@ export const Views = {
if (operation === 'geometry') { if (operation === 'geometry') {
const { left, top, width, height, visible } = geometry || {}; const { left, top, width, height, visible } = geometry || {};
if (![left, top, width, height].every(Number.isFinite)) throw new Error('INVALID_GEOMETRY'); if (![left, top, width, height].every(Number.isFinite)) throw new Error('INVALID_GEOMETRY');
const shell = browser.ownerDocument.defaultView, entry.geometry={left,top,width,height,visible};
screen = shell.document.getElementById('screen'); await this.layout(entry);
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',
});
return null; return null;
} }
if (operation === 'back') { if (operation === 'back') {

View file

@ -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}));
}
}

View file

@ -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');
}
}

View file

@ -141,6 +141,27 @@ export class VulpesChild extends JSWindowActorChild {
} }
receiveMessage(message) { receiveMessage(message) {
if(message.name==='Vulpes:Edit') return this.edit(message.data); 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') { if (message.name === 'Vulpes:ViewAlive') {
return Array.from(this.document.querySelectorAll('iframe')).some( return Array.from(this.document.querySelectorAll('iframe')).some(
(frame) => frame.isConnected && frame.browsingContext?.id === message.data.contextId, (frame) => frame.isConnected && frame.browsingContext?.id === message.data.contextId,

View file

@ -196,3 +196,7 @@ html[data-vulpes-app='communications'] body {
/* Camera gestures must not magnify the application chrome. */ /* Camera gestures must not magnify the application chrome. */
html[data-vulpes-app='camera'], html[data-vulpes-app='camera'] body { touch-action:none; } 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; }

View file

@ -6,6 +6,26 @@
current = null, current = null,
generation = 0; generation = 0;
let cameraError = null; 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) { function recordError(error) {
cameraError = { name: error.name || 'Error', message: error.message || '' }; cameraError = { name: error.name || 'Error', message: error.message || '' };
console.warn('Vulpes Camera:', cameraError.name, cameraError.message); console.warn('Vulpes Camera:', cameraError.name, cameraError.message);
@ -87,7 +107,7 @@
Object.assign(event, detail); Object.assign(event, detail);
target.dispatchEvent(event); target.dispatchEvent(event);
} }
async function discover(stream) { async function discover(stream, name) {
if (stream.vulpesNative) { if (stream.vulpesNative) {
cameras = ['back', 'front']; cameras = ['back', 'front'];
return; return;
@ -104,12 +124,15 @@
const active = list.find((d) => d.deviceId === settings.deviceId) || list[0]; const active = list.find((d) => d.deviceId === settings.deviceId) || list[0];
const back = list.find((d) => /back|rear|environment/i.test(d.label)); 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 front = list.find((d) => /front|user|face/i.test(d.label));
const first = // Unlabelled webcams must not exchange identities every time we switch.
back || const previousBack = list.find(d => d.deviceId === devices.get('back'));
(settings.facingMode === 'user' && list.length > 1 ? list.find((d) => d !== active) : active); 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(); devices = new Map();
if (first) devices.set('back', first.deviceId); 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); if (second) devices.set('front', second.deviceId);
cameras = devices.size ? [...devices.keys()] : ['back']; cameras = devices.size ? [...devices.keys()] : ['back'];
} }
@ -129,13 +152,30 @@
await call({action:'stop'}); await call({action:'stop'});
throw new DOMException('Camera preview requires WebGL', 'NotSupportedError'); throw new DOMException('Camera preview requires WebGL', 'NotSupportedError');
} }
const timing = {frames:0, startedAt:performance.now(), conversionMs:0};
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));
shaders.forEach(value => gl.deleteShader(value));
if (buffer) gl.deleteBuffer(buffer);
if (program) gl.deleteProgram(program);
gl.getExtension('WEBGL_lose_context')?.loseContext();
});
return closing;
};
try {
function shader(type, source) { function shader(type, source) {
const value = gl.createShader(type); const value = gl.createShader(type);
shaders.push(value);
gl.shaderSource(value, source); gl.compileShader(value); gl.shaderSource(value, source); gl.compileShader(value);
if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw Error('Camera shader compilation failed'); if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw Error('Camera shader compilation failed');
return value; return value;
} }
const program = gl.createProgram(); program = gl.createProgram();
gl.attachShader(program, shader(gl.VERTEX_SHADER, ` gl.attachShader(program, shader(gl.VERTEX_SHADER, `
attribute vec2 position; varying vec2 coord; attribute vec2 position; varying vec2 coord;
void main() { gl_Position=vec4(position,0.,1.); coord=vec2(position.x*.5+.5,.5-position.y*.5); } void main() { gl_Position=vec4(position,0.,1.); coord=vec2(position.x*.5+.5,.5-position.y*.5); }
@ -157,31 +197,19 @@
gl.linkProgram(program); gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error('Camera shader link failed'); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error('Camera shader link failed');
gl.useProgram(program); gl.useProgram(program);
const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buffer); 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); gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);
const position=gl.getAttribLocation(program,'position'); const position=gl.getAttribLocation(program,'position');
gl.enableVertexAttribArray(position);gl.vertexAttribPointer(position,2,gl.FLOAT,false,0,0); gl.enableVertexAttribArray(position);gl.vertexAttribPointer(position,2,gl.FLOAT,false,0,0);
gl.uniform1f(gl.getUniformLocation(program,'rotation'),meta.orientation); gl.uniform1f(gl.getUniformLocation(program,'rotation'),meta.orientation);
const textures = ['luma','chroma'].map((name,index) => { ['luma','chroma'].forEach((name,index) => {
const texture=gl.createTexture();gl.activeTexture(gl.TEXTURE0+index);gl.bindTexture(gl.TEXTURE_2D,texture); 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_MIN_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_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_S,gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,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; gl.uniform1i(gl.getUniformLocation(program,name),index);
}); });
const timing = {frames:0, startedAt:performance.now(), conversionMs:0};
let stopped = false, timer, stream, sequence = -1, closing;
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);
gl.getExtension('WEBGL_lose_context')?.loseContext();
});
return closing;
};
const draw = async () => { const draw = async () => {
const frame = await call({action:'frame'}); const frame = await call({action:'frame'});
if (stopped || !frame.data || frame.sequence === sequence) return false; if (stopped || !frame.data || frame.sequence === sequence) return false;
@ -197,7 +225,6 @@
timing.frames++;timing.conversionMs+=performance.now()-start; timing.frames++;timing.conversionMs+=performance.now()-start;
return true; return true;
}; };
try {
const deadline = Date.now() + 6000; const deadline = Date.now() + 6000;
while (!(await draw())) { while (!(await draw())) {
if (Date.now() > deadline) throw new DOMException('No camera frames', 'NotReadableError'); if (Date.now() > deadline) throw new DOMException('No camera frames', 'NotReadableError');
@ -227,11 +254,19 @@
return stream; return stream;
} catch (error) { await stop().catch(() => {}); throw error; } } 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') if (config.mode && config.mode !== 'picture')
throw new DOMException('Video capture is not ported yet', 'NotSupportedError'); throw new DOMException('Video capture is not ported yet', 'NotSupportedError');
const ticket = ++generation; if (current) await releaseStream(current);
await current?.release?.(); await closing;
if (document.hidden || ticket !== generation)
throw new DOMException('Camera request superseded or hidden', 'AbortError');
const selected = devices.get(name); const selected = devices.get(name);
cameraError = null; cameraError = null;
@ -245,7 +280,7 @@
video: { video: {
...(selected ...(selected
? { deviceId: { exact: selected } } ? { deviceId: { exact: selected } }
: { facingMode: { ideal: 'environment' } }), : { facingMode: { ideal: name === 'front' ? 'user' : 'environment' } }),
width: { ideal: 1920 }, width: { ideal: 1920 },
height: { ideal: 1080 }, height: { ideal: 1080 },
}, },
@ -255,7 +290,7 @@
throw error; throw error;
} }
if (document.hidden || ticket !== generation) { if (document.hidden || ticket !== generation) {
stream.getTracks().forEach((t) => t.stop()); await releaseStream(stream);
const error = new DOMException('Camera hidden', 'AbortError'); const error = new DOMException('Camera hidden', 'AbortError');
recordError(error); recordError(error);
throw error; throw error;
@ -264,7 +299,7 @@
current = stream; current = stream;
let preview; let preview;
try { try {
await discover(stream); await discover(stream, name);
if (document.hidden || ticket !== generation) if (document.hidden || ticket !== generation)
throw new DOMException('Camera hidden', 'AbortError'); throw new DOMException('Camera hidden', 'AbortError');
@ -294,7 +329,7 @@
const native = stream.vulpesCameraMetadata; const native = stream.vulpesCameraMetadata;
const pictureSizes = native?.pictureSizes || [size]; const pictureSizes = native?.pictureSizes || [size];
let pictureSize = pictureSizes.reduce((a,b)=>a.width*a.height>b.width*b.height?a:b); 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 = () => ({ const configuration = () => ({
mode: 'picture', mode: 'picture',
previewSize: size, previewSize: size,
@ -378,19 +413,7 @@
emit(stream, 'previewstatechange', { newState: 'started' }); emit(stream, 'previewstatechange', { newState: 'started' });
}, },
release() { release() {
if (releasing) return releasing; return releaseStream(stream, preview);
// 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;
}, },
async startRecording() { async startRecording() {
throw new DOMException('Video unavailable', 'NotSupportedError'); throw new DOMException('Video unavailable', 'NotSupportedError');
@ -416,12 +439,7 @@
return { camera: stream, configuration: configuration() }; return { camera: stream, configuration: configuration() };
} catch (error) { } catch (error) {
recordError(error); recordError(error);
stream.getTracks().forEach((t) => t.stop()); await releaseStream(stream, preview).catch(recordError);
if (preview) {
preview.srcObject = null;
preview.remove();
}
if (current === stream) current = null;
throw error; throw error;
} }
} }
@ -433,8 +451,7 @@
// Keep that request valid; its completion still checks visibility. // Keep that request valid; its completion still checks visibility.
if (!current) return; if (!current) return;
++generation; ++generation;
if (current?.release) current.release().catch(recordError); releaseStream(current).catch(recordError);
else current?.getTracks().forEach((t) => t.stop());
}; };
addEventListener('pagehide', stop); addEventListener('pagehide', stop);
addEventListener('visibilitychange', () => { addEventListener('visibilitychange', () => {

View file

@ -21,7 +21,7 @@
addEventListener('error', (event) => addEventListener('error', (event) =>
record('error', [event.message, event.filename, event.lineno]), 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}); const battery = Object.assign(new EventTarget(), {level:NaN, charging:false, chargingTime:Infinity, dischargingTime:Infinity});
Object.defineProperty(navigator, 'battery', {value:battery}); Object.defineProperty(navigator, 'battery', {value:battery});
async function refreshBattery() { async function refreshBattery() {
@ -128,15 +128,16 @@
Object.defineProperty(navigator, 'mozPhoneNumberService', {value:{ Object.defineProperty(navigator, 'mozPhoneNumberService', {value:{
normalize: number => String(number || '').normalize('NFKC').replace(/[^0-9+*#]/g, '') normalize: number => String(number || '').normalize('NFKC').replace(/[^0-9+*#]/g, '')
}}); }});
const settingsCodec = window.VulpesAndroidSettingsCodec; const settingsCodec = window.VulpesSettingsCodec;
async function call(method, params) { async function call(method, params) {
if (!navigator.vulpesRequest) return Promise.reject(new Error('VULPES_HOST_UNAVAILABLE')); 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); params = await settingsCodec.encode(params);
return navigator.vulpesRequest({ version: 1, id: ++sequence, method, params }).then((reply) => { return navigator.vulpesRequest({ version: 1, id: ++sequence, method, params }).then((reply) => {
if (reply.error) throw new DOMException(reply.error.code, reply.error.code); if (reply.error) throw new DOMException(reply.error.code, reply.error.code);
return settingsCodec && 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) ? settingsCodec.decode(reply.result)
: reply.result; : reply.result;
}); });
@ -165,6 +166,16 @@
} }
return r; 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 observers = new Map();
const settings = new EventTarget(); const settings = new EventTarget();
settings.createLock = () => ({ settings.createLock = () => ({
@ -196,6 +207,7 @@
addEventListener('vulpes-service-event', (event) => { addEventListener('vulpes-service-event', (event) => {
const { type, data } = event.detail; const { type, data } = event.detail;
if (type === 'settingchange') { if (type === 'settingchange') {
if (data.key === 'language.current') setLanguage(data.value);
const change = { const change = {
settingName: data.key, settingName: data.key,
settingValue: settingsCodec ? settingsCodec.decode(data.value) : data.value, 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 === '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 === '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 === '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 })); if (type === 'activity-view') dispatchEvent(new CustomEvent('activity-view', { detail: data }));
}); });
function application(record) { function application(record) {
@ -215,7 +236,7 @@
const { id, ...publicRecord } = record; const { id, ...publicRecord } = record;
const app = Object.assign(new EventTarget(), publicRecord, { const app = Object.assign(new EventTarget(), publicRecord, {
installState: 'installed', installState: 'installed',
removable: false, removable: record.removable === true,
installTime: 0, installTime: 0,
updateTime: 0, updateTime: 0,
downloadAvailable: false, downloadAvailable: false,
@ -238,6 +259,14 @@
} }
const mgmt = new EventTarget(); const mgmt = new EventTarget();
mgmt.getAll = () => request(call('apps.list').then((apps) => apps.map(application))); 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.getNotInstalled = () => request(Promise.resolve([]));
mgmt.getIcon = async (app, size, entry) => { mgmt.getIcon = async (app, size, entry) => {
const manifest = app.manifest.entry_points?.[entry] || app.manifest; const manifest = app.manifest.entry_points?.[entry] || app.manifest;
@ -319,10 +348,19 @@
const handlers = new Map(); const handlers = new Map();
navigator.mozSetMessageHandler = (name, fn) => { navigator.mozSetMessageHandler = (name, fn) => {
handlers.set(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 === 'connection') call('iac.subscribe', {}).catch(console.error);
if (name === 'alarm') call('alarms.request', { operation: '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; navigator.mozSetMessageHandlerPromise = (promise) => promise;
window.VulpesCompat = { call, request, handlers }; window.VulpesCompat = { call, request, handlers };
window.MozActivity = function (source) { window.MozActivity = function (source) {
@ -657,13 +695,29 @@
Object.setPrototypeOf(GaiaElement.prototype, prototype); Object.setPrototypeOf(GaiaElement.prototype, prototype);
customElements.define(name, GaiaElement); customElements.define(name, GaiaElement);
registered.add(name); registered.add(name);
return GaiaElement; function LegacyGaiaElement() {
return initialize(nativeCreate(name));
}
LegacyGaiaElement.prototype = GaiaElement.prototype;
return LegacyGaiaElement;
}; };
document.createElement = function (name, options) { document.createElement = function (name, options) {
const element = nativeCreate(name, options); const element = nativeCreate(name, options);
if (name.toLowerCase() === 'iframe') wire(element); if (name.toLowerCase() === 'iframe') wire(element);
return registered.has(name) ? initialize(element) : 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 () { HTMLElement.prototype.createShadowRoot = function () {
const host = this, const host = this,
root = this.attachShadow({ mode: 'open' }); root = this.attachShadow({ mode: 'open' });

View file

@ -84,9 +84,15 @@
if (!event.target.closest('#messages-send-button')) return; if (!event.target.closest('#messages-send-button')) return;
event.preventDefault(); event.stopImmediatePropagation(); event.preventDefault(); event.stopImmediatePropagation();
// Android owns final confirmation and delivery. Do not fabricate a sent SMS. // 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', { VulpesCompat.call('platform.sms', {
number:window.ThreadUI?.recipients?.numbers?.join(';') || '', number:numbers.join(';'), body
body:window.Compose?.getText?.() || ''
}).catch(() => notice(unavailable())); }).catch(() => notice(unavailable()));
}, true); }, true);
} }

View file

@ -23,7 +23,9 @@
manager.connection = {status, network}; manager.connection = {status, network};
manager.connectionInformation = network ? {relSignalStrength:network.relSignalStrength} : null; manager.connectionInformation = network ? {relSignalStrength:network.relSignalStrength} : null;
if (changed) emit('statuschange', manager.connection); 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; let scanPending;
function scan() { function scan() {

14
host/marketplace-page.js Normal file
View file

@ -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);
});
}
}

41
host/settings-codec.js Normal file
View file

@ -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 });
})();

View file

@ -50,7 +50,12 @@ window.addEventListener('load', async () => {
}, },
allFrames: true, allFrames: true,
safeForUntrustedWebProcess: 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', { ChromeUtils.registerWindowActor('VulpesTouch', {
child: { child: {
@ -154,6 +159,7 @@ window.addEventListener('load', async () => {
home.setPointerCapture(event.pointerId); home.setPointerCapture(event.pointerId);
holdTimer = setTimeout(() => { holdTimer = setTimeout(() => {
held = true; held = true;
Views.hideAll();
Runtime.broadcast('holdhome', {}, 'system'); Runtime.broadcast('holdhome', {}, 'system');
}, 650); }, 650);
}); });
@ -162,12 +168,13 @@ window.addEventListener('load', async () => {
home.addEventListener('pointerup', cancel); home.addEventListener('pointerup', cancel);
home.addEventListener('pointercancel', cancel); home.addEventListener('pointercancel', cancel);
home.addEventListener('click', () => { home.addEventListener('click', () => {
if (!held) Runtime.broadcast('home', {}, 'system'); if (!held) {Views.hideAll();Runtime.broadcast('home', {}, 'system');}
held = false; held = false;
}); });
window.addEventListener('keydown', (event) => { window.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.code === 'Space') { if (event.ctrlKey && event.code === 'Space') {
event.preventDefault(); event.preventDefault();
Views.hideAll();
Runtime.broadcast('holdhome', {}, 'system'); Runtime.broadcast('holdhome', {}, 'system');
} }
}); });

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View file

@ -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));

View file

@ -1,15 +1,15 @@
{ {
"gaia": "2.7.0", "gaia": "2.7.0",
"channel": "preview", "channel": "preview",
"revision": 15, "revision": 17,
"androidVersionCode": 15, "androidVersionCode": 17,
"geckoview": "156.0.20260921121718", "geckoview": "156.0.20260921121718",
"engineAlignment": { "engineAlignment": {
"family": "156", "family": "156",
"patchParity": false, "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." "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": { "adapters": {
"desktop": "0.1.0-dev", "desktop": "0.1.0-dev",
"android": "0.1.0-dev" "android": "0.1.0-dev"

114
services/activities.mjs Normal file
View file

@ -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'));
}
}
}
}

77
services/packages.mjs Normal file
View file

@ -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;i<bytes.length;i+=8192)text+=String.fromCharCode(...bytes.subarray(i,i+8192));return btoa(text);}
export async function unpackPackage(bytes, source) {
packageURL(source);
if(bytes.length>MAX_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;i<count;i++) {
if(cursor+46>end || 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<Date.now())throw Error('INSTALL_EXPIRED');
await this.save(row.item);await this.changed();return row.item.id;}
}

View file

@ -1,6 +1,17 @@
// Test-only actor, registered by tools/inspect-host.py through Marionette. // Test-only actor, registered by tools/inspect-host.py through Marionette.
export class VulpesProbeChild extends JSWindowActorChild { export class VulpesProbeChild extends JSWindowActorChild {
async receiveMessage(message) { async receiveMessage(message) {
if (message.name === 'MouseClick') {
const {x,y}=message.data, win=this.contentWindow;
if (!Number.isFinite(x) || !Number.isFinite(y) || x<0 || y<0 ||
x>=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') { if (message.name === 'Tap') {
const {x,y}=message.data, win=this.contentWindow; const {x,y}=message.data, win=this.contentWindow;
if (!Number.isFinite(x) || !Number.isFinite(y) || x<0 || y<0 || if (!Number.isFinite(x) || !Number.isFinite(y) || x<0 || y<0 ||

83
tests/activities.test.mjs Normal file
View file

@ -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');

View file

@ -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);
});

View file

@ -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();
});

59
tests/contacts.test.mjs Normal file
View file

@ -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/);
});

View file

@ -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()

35
tests/packages.test.mjs Normal file
View file

@ -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','<h1>Test</h1>');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');

View file

@ -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');
});

View file

@ -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);
});

View file

@ -107,3 +107,27 @@ test('Native Wi-Fi acknowledges unchanged settings and completes delayed radio c
assert.equal(enabled,1); assert.equal(enabled,1);
assert.equal(navigator.mozWifiManager.enabled,true); 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);
});

View file

@ -1,11 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Reject stale Android payloads and inconsistent common release metadata.""" """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 from project import ROOT, release_info
p = argparse.ArgumentParser() 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() args = p.parse_args()
r = release_info() r = release_info()
apk = ROOT / (args.apk or f'android/dist/vulpes-os-preview-{r["version"]}.apk') 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) 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 = {} 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")) metadata = json.loads(archive.read("assets/build-info.json"))
packaged = json.loads(archive.read("assets/bridge/release.json")) packaged = json.loads(archive.read("assets/bridge/release.json"))
assert metadata["version"] == r["version"], metadata assert metadata["version"] == r["version"], metadata
@ -30,6 +39,7 @@ with zipfile.ZipFile(apk) as archive:
"platform-ui.js", "platform-ui.js",
"gaia-camera.js", "gaia-camera.js",
"gaia-compat.js", "gaia-compat.js",
"settings-codec.js",
"gaia-services.js", "gaia-services.js",
"gaia-styles.js", "gaia-styles.js",
"gaia-restoration.js", "gaia-restoration.js",
@ -65,6 +75,9 @@ with zipfile.ZipFile(apk) as archive:
("calendar", "js/bundle.js", "overrides"), ("calendar", "js/bundle.js", "overrides"),
("gallery", "js/metadata_scripts.js", "assets/webapps"), ("gallery", "js/metadata_scripts.js", "assets/webapps"),
("gallery", "js/ImageEditor.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()) expected = translate((ROOT / source / (app + ".gaiamobile.org") / path).read_text())
for old, new in [("-moz-calc(", "calc("), ("-moz-box-sizing:", "box-sizing:"), 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) actual = gaia.read(app + "/" + path)
assert actual == expected, "Stale application workflow: " + app + "/" + path assert actual == expected, "Stale application workflow: " + app + "/" + path
results[app + "/" + path] = hashlib.sha256(actual).hexdigest() 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("*"): for path in (ROOT / "overrides/search.gaiamobile.org/home").rglob("*"):
if path.is_file(): if path.is_file():
relative = path.relative_to(ROOT / "overrides/search.gaiamobile.org") relative = path.relative_to(ROOT / "overrides/search.gaiamobile.org")
assert gaia.read("search/" + relative.as_posix()) == path.read_bytes(), str(relative) assert gaia.read("search/" + relative.as_posix()) == path.read_bytes(), str(relative)
assert b"vulpes-local-home" in gaia.read("search/newtab.html") 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() 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 ( assert (
archive.read("assets/bridge/services/" + name) archive.read("assets/bridge/services/" + name)
== (ROOT / "services" / name).read_bytes() == (ROOT / "services" / name).read_bytes()
@ -99,8 +116,11 @@ report = {
"androidEngine": r["androidEngine"], "androidEngine": r["androidEngine"],
"exactEngineMatch": r["desktopEngine"] == r["androidEngine"], "exactEngineMatch": r["desktopEngine"] == r["androidEngine"],
"sharedFiles": results, "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.", "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)) print(json.dumps(report, indent=2))

72
tools/installed_apps.py Normal file
View file

@ -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

View file

@ -65,3 +65,12 @@ def release_info():
if not release["androidVersionCode"] > 4: if not release["androidVersionCode"] > 4:
raise ValueError("Android versionCode must preserve upgrades from Preview 4") raise ValueError("Android versionCode must preserve upgrades from Preview 4")
return release 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

View file

@ -3,15 +3,23 @@
from pathlib import Path from pathlib import Path
import re import re
ROOT=Path(__file__).resolve().parents[1] ROOT=Path(__file__).resolve().parents[1]
for panel, module in [('display','slider_handler'),('wifi','wifi_network_list')]: modules = [
name=f'panels/{panel}/{module}' ('panels/display/slider_handler', 'js/panels/display/panel.js'),
relative=f'js/panels/{panel}/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 original=ROOT/'assets/webapps/settings.gaiamobile.org'/relative
source=ROOT/'overrides/settings.gaiamobile.org'/f'js/{name}.js' 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() text=source.read_text()
dependencies=list(dict.fromkeys(re.findall(r"require\(['\"]([^'\"]+)['\"]\)",text))) dependencies=list(dict.fromkeys(re.findall(r"require\(['\"]([^'\"]+)['\"]\)",text)))
import json import json
if 'define(function(require)' in text:
text=text.replace('define(function(require)', 'define('+repr(name)+','+json.dumps(['require',*dependencies])+',function(require)',1) 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() bundle=original.read_text()
start=bundle.index("define('"+name+"'") start=bundle.index("define('"+name+"'")
end=bundle.find("define('",start+8) end=bundle.find("define('",start+8)

View file

@ -15,7 +15,9 @@ parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8765) parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--legacy-apps", action="store_true") parser.add_argument("--legacy-apps", action="store_true")
args = parser.parse_args() 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() validate_assets()
info = build_info() info = build_info()
@ -44,7 +46,7 @@ for manifest_path in manifests:
if not re.fullmatch("[a-z0-9_-]+", name): if not re.fullmatch("[a-z0-9_-]+", name):
continue continue
origin = f"http://{name}.localhost:{args.port}" 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() ORIGINS[urlsplit(origin).netloc] = manifest_path.parent.resolve()
REGISTRY.append( REGISTRY.append(
{ {
@ -56,7 +58,7 @@ for manifest_path in manifests:
) )
ORIGINS[f"theme.localhost:{args.port}"] = ORIGINS[f"default_theme.localhost:{args.port}"] ORIGINS[f"theme.localhost:{args.port}"] = ORIGINS[f"default_theme.localhost:{args.port}"]
(ROOT / "assets/registry.json").write_text( (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( (ROOT / "assets/settings-current-defaults.json").write_text(
translate((ROOT / "assets/settings-defaults.json").read_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): 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): 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()) root = ORIGINS.get(self.headers.get("Host", "").lower())
if root is None: if root is None:
self.send_error(421, "Unregistered application host") 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. # Only this non-privileged compatibility layer is exposed, never host modules.
if path in [ if path in [
"/_vulpes/" + name "/_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] file = ROOT / "host" / path.rsplit("/", 1)[1]
else: else:
@ -157,13 +197,13 @@ class Handler(BaseHTTPRequestHandler):
if file.suffix == ".html" and root in LEGACY_ROOTS: if file.suffix == ".html" and root in LEGACY_ROOTS:
data = data.replace( data = data.replace(
b"<head>", b"<head>",
b'<head><script src="/_vulpes/gaia-compat.js"></script><script src="/_vulpes/gaia-wifi.js"></script><script src="/_vulpes/gaia-input.js"></script><script src="/_vulpes/gaia-services.js"></script>', b'<head><script src="/_vulpes/settings-codec.js"></script><script src="/_vulpes/gaia-compat.js"></script><script src="/_vulpes/gaia-wifi.js"></script><script src="/_vulpes/gaia-input.js"></script><script src="/_vulpes/gaia-services.js"></script>',
1, 1,
) )
elif file.suffix == ".html" and not (root.name == "search.gaiamobile.org" and path.startswith("/home/")): elif file.suffix == ".html" and not (root.name == "search.gaiamobile.org" and path.startswith("/home/")):
data = data.replace( data = data.replace(
b"<head>", b"<head>",
b'<head><script src="/_vulpes/platform-config.js"></script><link rel="stylesheet" href="/_vulpes/desktop.css"><script src="/_vulpes/gaia-compat.js"></script><script src="/_vulpes/gaia-wifi.js"></script><script src="/_vulpes/gaia-input.js"></script><script src="/_vulpes/gaia-services.js"></script><script src="/_vulpes/gaia-styles.js"></script><script src="/_vulpes/gaia-restoration.js"></script><script src="/_vulpes/platform-ui.js"></script><script src="/_vulpes/gaia-camera.js"></script>', b'<head><script src="/_vulpes/platform-config.js"></script><link rel="stylesheet" href="/_vulpes/desktop.css"><script src="/_vulpes/settings-codec.js"></script><script src="/_vulpes/gaia-compat.js"></script><script src="/_vulpes/gaia-wifi.js"></script><script src="/_vulpes/gaia-input.js"></script><script src="/_vulpes/gaia-services.js"></script><script src="/_vulpes/gaia-styles.js"></script><script src="/_vulpes/gaia-restoration.js"></script><script src="/_vulpes/platform-ui.js"></script><script src="/_vulpes/gaia-camera.js"></script>',
1, 1,
) )
if ( if (

View file

@ -76,12 +76,13 @@ with Client(2857) as c:
"camera", "camera",
'return app.camera.mode==="picture" && app.camera.mozCamera.flashMode==="off";', 'return app.camera.mode==="picture" && app.camera.mozCamera.flashMode==="off";',
) )
# Native touch injection targets the camera frame even on high-DPI desktops. # Drive the desktop mouse through the normal touch simulator. Wait for
point = ev("camera", 'const r=document.querySelector(".js-capture").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};') # 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 && x<innerWidth && y<innerHeight && b.contains(document.elementFromPoint(x,y)) && {x,y};')
script = """const done=arguments[arguments.length-1]; script = """const done=arguments[arguments.length-1];
const root=Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('system').browsingContext; const root=Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('system').browsingContext;
function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith('http://camera.localhost:8765/'))return b;for(const child of b.children){const result=find(child);if(result)return result;}} function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith('http://camera.localhost:8765/'))return b;for(const child of b.children){const result=find(child);if(result)return result;}}
find(root).currentWindowGlobal.getActor('VulpesProbe').sendQuery('Tap',POINT).then(()=>done(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':[], assert c.call('WebDriver:ExecuteAsyncScript',{'script':script,'args':[],
'newSandbox':True,'sandbox':'system','scriptTimeout':10000})['value'] is True 'newSandbox':True,'sandbox':'system','scriptTimeout':10000})['value'] is True
photo = wait( photo = wait(

183
tools/test-contacts.py Normal file
View file

@ -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&&x<innerWidth&&y<innerHeight&&e.contains(document.elementFromPoint(x,y)))return {x,y};}return false;')
script = '''const done=arguments[arguments.length-1];
const root=Services.wm.getMostRecentWindow('vulpes:host').document.getElementById('system').browsingContext;
function find(b){if(b.currentWindowGlobal?.documentURI.spec.startsWith(PREFIX))return b;
for(const c of b.children){const found=find(c);if(found)return found;}}
find(root).currentWindowGlobal.getActor('VulpesProbe').sendQuery('MouseClick',POINT)
.then(()=>done(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.')

View file

@ -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))

View file

@ -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 (;;) {}
}

View file

@ -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

View file

@ -67,8 +67,10 @@ export async function run(win) {
.then(async response=>dump(`TUNDRA_HTTP ${response.status} ${(await response.text()).length}\n`)) .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')); .catch(error=>dump('TUNDRA_HTTP_ERROR '+error+' offline='+Services.io.offline+'\n'));
async function evaluate(app, code, message = 'Evaluate') { 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) { 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;} for(const child of context.children) {const found=find(child); if(found) return found;}
return null; return null;
} }
@ -92,9 +94,10 @@ export async function run(win) {
} }
async function tap(app, selector) { async function tap(app, selector) {
if(app==='keyboard') selector='.keyboard-type-container[data-active] '+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));`); const point=await wait(app, `for(const e of document.querySelectorAll(${JSON.stringify(selector)})) {
await sleep(600); const r=e.getBoundingClientRect(),x=r.x+r.width/2,y=r.y+r.height/2;
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};`); if(x>0 && y>0 && x<innerWidth && y<innerHeight && e.contains(document.elementFromPoint(x,y)))return {x,y};
}return false;`);
await evaluate(app,point,'Tap'); await evaluate(app,point,'Tap');
await sleep(350); await sleep(350);
} }
@ -229,241 +232,7 @@ export async function run(win) {
} }
// Fixed diagnostics can be requested again over the private maintenance SSH. // Fixed diagnostics can be requested again over the private maintenance SSH.
// Markers never contain scripts or arbitrary commands. // Markers never contain scripts or arbitrary commands.
async function requestedUiChecks() { async function requestedControls() {
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;
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;i<Math.min(smsReport.threads.count,5);i++) {
await evaluate('sms', `await Navigation.toPanel('thread',{id:window.__smsReviewThreads[${i}]});return true;`);
await sleep(1500);
smsReport.conversations.push(await evaluate('sms', `
const nodes=[...document.querySelectorAll('#messages-container .message')];
return {bubbles:nodes.length,validDates:nodes.every(e=>Number.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});
const controlsMarker = `${root}/logs/test-controls`; const controlsMarker = `${root}/logs/test-controls`;
if (await IOUtils.exists(controlsMarker)) { if (await IOUtils.exists(controlsMarker)) {
await IOUtils.remove(controlsMarker); await IOUtils.remove(controlsMarker);
@ -569,6 +338,392 @@ export async function run(win) {
controls.brightness=brightnessCheck; controls.brightness=brightnessCheck;
await IOUtils.writeJSON(`${root}/logs/controls.json`, controls); 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;i<Math.min(smsReport.threads.count,5);i++) {
await evaluate('sms', `await Navigation.toPanel('thread',{id:window.__smsReviewThreads[${i}]});return true;`);
await sleep(1500);
smsReport.conversations.push(await evaluate('sms', `
const nodes=[...document.querySelectorAll('#messages-container .message')];
return {bubbles:nodes.length,validDates:nodes.every(e=>Number.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.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', ` report.textFonts = await evaluate('homescreen', `
await document.fonts.ready; await document.fonts.ready;

View file

@ -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"
}

View file

@ -20,7 +20,7 @@ NETWORK_SERVICES = frozenset(('irsc_util', 'vendor.per_mgr', 'vendor.rmt_storage
'vendor.tftp_server', 'cnss-daemon', 'pd_mapper')) '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', 'vendor.audio-hal-2-0', 'vendor.camera-provider-2-4',
'camera_service', 'minimedia', 'minisf', 'miniaf')) 'camera_service', 'minimedia', 'minisf', 'miniaf'))

View file

@ -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()

View file

@ -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', '''<!DOCTYPE busconfig PUBLIC
"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig><policy user="root"><allow own="org.bluez"/><allow send_destination="org.bluez"/></policy></busconfig>
''')
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')

View file

@ -3,10 +3,13 @@
"""Configure isolated oFono/PulseAudio diagnostics; no call or SMS on startup.""" """Configure isolated oFono/PulseAudio diagnostics; no call or SMS on startup."""
from pathlib import Path from pathlib import Path
import shutil import shutil
import runpy
def prepare(root, write): def prepare(root, write):
write('etc/tundra/hardware-enabled', 'sargo\n') 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'), shutil.copy2(Path(__file__).with_name('modem-recovery.py'),
root/'etc/tundra/modem-recovery.py') root/'etc/tundra/modem-recovery.py')
write('etc/systemd/system/tundra-hal.service.d/modem-recovery.conf', '''[Service] write('etc/systemd/system/tundra-hal.service.d/modem-recovery.conf', '''[Service]

View file

@ -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

View file

@ -53,6 +53,18 @@ def main():
with image.open('rb') as f:sha=hashlib.file_digest(f,'sha256').hexdigest() with image.open('rb') as f:sha=hashlib.file_digest(f,'sha256').hexdigest()
if sha!='132d0e18c6a6a111293447e940267f35aa2b14fa00a872ef160b20dc2014d886':raise RuntimeError('Android reference changed') 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','loop,ro,noload',str(image),'/run/tundra/android-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/mmcblk0p70','/run/tundra/vendor-original')
run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p48','/mnt/vendor/persist') run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p48','/mnt/vendor/persist')
overlay('/run/tundra/android-original','/android','android') overlay('/run/tundra/android-original','/android','android')
@ -65,6 +77,9 @@ def main():
if count != 1:raise RuntimeError('Expected exactly one Sargo WLAN IPA setting') if count != 1:raise RuntimeError('Expected exactly one Sargo WLAN IPA setting')
config.write_text(text) # vendor overlay upper is tmpfs, never the partition config.write_text(text) # vendor overlay upper is tmpfs, never the partition
directory('/android/vendor/firmware_mnt') directory('/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') run('mount','-t','vfat','-o','ro','/dev/mmcblk0p21','/android/vendor/firmware_mnt')
report={} report={}
for p in sorted(Path('/android').rglob('*.rc')): for p in sorted(Path('/android').rglob('*.rc')):

View file

@ -50,7 +50,11 @@ class HardwareTests(unittest.TestCase):
self.assertIn('unmanaged-devices=*,except:interface-name:wlan0', config) self.assertIn('unmanaged-devices=*,except:interface-name:wlan0', config)
self.assertIn('auth-polkit=root-only', config) self.assertIn('auth-polkit=root-only', config)
self.assertIn('enabled=false', 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()) self.assertIn('VULPES_TUNDRA=1', (root/'etc/systemd/system/tundra-display.service').read_text())

View file

@ -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()

View file

@ -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('<QQ',offset,len(data))+data
class BackupTests(unittest.TestCase):
def test_holes_and_final_size_are_restored(self):
stream=b'TUNRAW1\0'+record(2,b'abc')+record(9,b'x')+record(12,b'')
target=io.BytesIO();receive(io.BytesIO(stream),target,12)
# BytesIO does not extend on truncate; actual disk files do.
self.assertEqual(target.getvalue(),b'\0\0abc\0\0\0\0x')
def test_empty_disk_has_a_required_trailer(self):
receive(io.BytesIO(b'TUNRAW1\0'+record(100,b'')),io.BytesIO(),100)
with self.assertRaises(ValueError):receive(io.BytesIO(b'TUNRAW1\0'),io.BytesIO(),100)
def test_overlapping_or_out_of_bounds_blocks_rejected(self):
for records in (record(2,b'abc')+record(4,b'bad'),record(8,b'too long')):
with self.assertRaises(ValueError):receive(io.BytesIO(b'TUNRAW1\0'+records),io.BytesIO(),10)
def test_wrong_trailer_and_trailing_bytes_rejected(self):
for stream in (b'TUNRAW1\0'+record(9,b''),b'TUNRAW1\0'+record(10,b'')+b'extra'):
with self.assertRaises(ValueError):receive(io.BytesIO(stream),io.BytesIO(),10)
def test_truncated_payload_rejected(self):
with self.assertRaises(ValueError):receive(io.BytesIO(b'TUNRAW1\0'+struct.pack('<QQ',0,5)+b'four'),io.BytesIO(),10)
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,91 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
TOOLS=Path(__file__).resolve().parents[1]/'tools'
sys.path.insert(0,str(TOOLS))
from standalone_volume import checked_volume
spec=importlib.util.spec_from_file_location('standalone_boot',TOOLS/'build-standalone-boot.py')
boot=importlib.util.module_from_spec(spec);spec.loader.exec_module(boot)
class StandaloneBootTests(unittest.TestCase):
def setUp(self):
self.volume={'schema':1,'device':'sargo','storage':'raw-userdata-ext4','image':'userdata.ext4',
'uuid':'12345678-1234-1234-1234-123456789abc','bytes':8*1024**3,
'imageSha256':'a'*64,'systemImageSha256':'b'*64,'filesystemCheckPassed':True}
self.init=b'''EXPECTED_STORAGE_HASH=old
# Use the inventoried filesystem
mount /dev/mapper/tundra-installed-ro /installed
echo TUNDRA_RESCUE_READY
exec /bin/sh /native-root.sh
'''
self.native=b'''SYSTEM_IMAGE=/installed/tundra/images/system.ext4
LOWER=/installed/home/droidian/vulpes-tundra-tests/runtime-reference-20260925/root
mkdir -p /newroot/reference/installed
mount --move /installed /newroot/reference/installed
'''
def test_direct_boot_has_no_legacy_storage_path(self):
init,native,mount=boot.storage_scripts(self.init,self.native,self.volume,False)
self.assertNotIn(b'EXPECTED_STORAGE_HASH',init)
self.assertNotIn(b'/dev/mapper',init)
self.assertNotIn(b'/installed',native)
self.assertNotIn(b'/home/droidian',native)
self.assertIn(b'/dev/mmcblk0p72',mount)
self.assertIn(b'VOLUME_OPTIONS=rw',mount)
self.assertNotIn(b'mkfs',mount)
self.assertIn(b'/newroot/reference/tundra-volume',native)
def test_container_boot_changes_only_volume_access(self):
init,native,mount=boot.storage_scripts(self.init,self.native,self.volume,True)
self.assertIn(b'/run/legacy-storage',init)
self.assertIn(b'VOLUME_OPTIONS=loop,rw',mount)
self.assertNotIn(b'/dev/mmcblk0p72',mount)
self.assertIn(b'/volume/tundra/images',native)
for script in (init,native,mount):
self.assertEqual(subprocess.run(['sh','-n'],input=script,capture_output=True).returncode,0)
def test_bundled_hardware_is_bound_read_only(self):
volume={**self.volume,'hardwareImages':{'vendor.img':{'sha256':'a'*64,'bytes':4096},'modem.img':{'sha256':'b'*64,'bytes':4096}}}
native=self.native+b'# Installed lower layers stay reachable\n'
_,script,_=boot.storage_scripts(self.init,native,volume,False)
self.assertIn(b'mount -o remount,bind,ro /newroot/reference/vendor.img',script)
self.assertIn(b'/newroot/etc/tundra/hardware-images.json',script)
self.assertEqual(subprocess.run(['sh','-n'],input=script,capture_output=True).returncode,0)
def test_wrong_volume_metadata_rejected(self):
with tempfile.TemporaryDirectory() as directory:
path=Path(directory)/'volume.json'
path.write_text(json.dumps(self.volume));checked_volume(path,False)
for key,value in [('device','bonito'),('image','../userdata.ext4'),('bytes',1),('uuid','$(reboot)'),('storage','lvm'),('filesystemCheckPassed',False)]:
path.write_text(json.dumps({**self.volume,key:value}))
with self.assertRaises(ValueError):checked_volume(path,False)
def test_boot_success_requires_current_gaia_on_raw_userdata(self):
spec=importlib.util.spec_from_file_location('success',TOOLS.parent/'runtime/mark-boot-success.py')
success=importlib.util.module_from_spec(spec);spec.loader.exec_module(success)
gaia={'bootId':'current','passed':True,'checks':{'home':True}}
mount={'source':'/dev/mmcblk0p72','fstype':'ext4'}
self.assertTrue(success.eligible('current',gaia,mount,{'androidboot.slot_suffix':'_a'},True))
self.assertFalse(success.eligible('new',gaia,mount,{'androidboot.slot_suffix':'_a'},True))
self.assertFalse(success.eligible('current',gaia,{'source':'/dev/loop1','fstype':'ext4'},{'androidboot.slot_suffix':'_a'},True))
self.assertFalse(success.eligible('current',gaia,mount,{'androidboot.slot_suffix':'_b'},True))
self.assertFalse(success.eligible('current',gaia,mount,{'androidboot.slot_suffix':'_a'},False))
def test_old_installer_refuses_standalone_image(self):
spec=importlib.util.spec_from_file_location('install',TOOLS/'install-sargo.py')
install=importlib.util.module_from_spec(spec);spec.loader.exec_module(install)
with tempfile.TemporaryDirectory() as directory:
path=Path(directory);(path/'build.json').write_text(json.dumps({'storageMode':'raw-userdata-ext4'}))
with self.assertRaisesRegex(ValueError,'standalone installer'):
install.checked_build(path)
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,80 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
from pathlib import Path
import struct
import sys
import tempfile
import unittest
from unittest.mock import patch
spec=importlib.util.spec_from_file_location('flash',Path(__file__).resolve().parents[1]/'tools/flash-standalone-sargo.py')
flash=importlib.util.module_from_spec(spec);spec.loader.exec_module(flash)
class FlashTests(unittest.TestCase):
def plan(self):
return {'userdataBytes':8*1024**3,'partitions':{name:{'bytes':4096} for name in flash.PARTITIONS}}
def values(self,**changes):
values={'product':'sargo','unlocked':'yes','current-slot':'b',
**{'partition-size:'+name:hex(64*1024**3 if name=='userdata' else 64*1024**2) for name in flash.PARTITIONS}}
values.update(changes);return values
def test_both_slots_accepted_for_full_install(self):
for slot in ('a','b'):
values=self.values(**{'current-slot':slot})
with patch.object(flash,'getvar',side_effect=lambda _,__,name:values[name]):
self.assertEqual(flash.preflight('fastboot','serial',self.plan())['current-slot'],slot)
def test_wrong_model_locked_or_small_partition_rejected(self):
for change in ({'product':'bonito'},{'unlocked':'no'},{'partition-size:userdata':'0x1000'}):
values=self.values(**change)
with patch.object(flash,'getvar',side_effect=lambda _,__,name:values[name]):
with self.assertRaises(ValueError):flash.preflight('fastboot','serial',self.plan())
def test_review_never_contacts_device(self):
with patch.object(sys,'argv',['flash']),patch.object(flash,'package',return_value=self.plan()),patch.object(flash.subprocess,'run') as run,patch.object(flash.subprocess,'check_output') as read:
flash.main();run.assert_not_called();read.assert_not_called()
def test_install_requires_explicit_userdata_replacement(self):
with patch.object(sys,'argv',['flash','--install']),patch.object(flash,'package',return_value=self.plan()),patch.object(flash.subprocess,'run') as run:
with self.assertRaises(SystemExit):flash.main()
run.assert_not_called()
def test_unqualified_package_requires_experimental_flag(self):
with patch.object(sys,'argv',['flash','--install','--erase-userdata']),patch.object(flash,'package',return_value=self.plan()),patch.object(flash.subprocess,'run') as run:
with self.assertRaises(SystemExit):flash.main()
run.assert_not_called()
def test_sparse_geometry(self):
with tempfile.TemporaryDirectory() as directory:
image=Path(directory)/'userdata.img'
image.write_bytes(struct.pack('<I4H4I',0xed26ff3a,1,0,28,12,4096,2097152,3,0))
self.assertEqual(flash.sparse_size(image),8*1024**3)
image.write_bytes(b'bad')
with self.assertRaises(ValueError):flash.sparse_size(image)
def test_incomplete_package_is_rejected_before_manifest_read(self):
with tempfile.TemporaryDirectory() as directory:
(Path(directory)/'.incomplete').touch()
with self.assertRaisesRegex(ValueError,'construction is incomplete'):
flash.package(directory)
def test_install_writes_only_planned_partitions_after_preflight(self):
with tempfile.TemporaryDirectory() as directory:
argv=['flash','--package',directory,'--install','--erase-userdata','--experimental']
with patch.object(sys,'argv',argv),patch.object(flash,'package',return_value=self.plan()), \
patch.object(flash.shutil,'which',return_value='fastboot'), \
patch.object(flash.subprocess,'check_output',return_value='test-device fastboot\n'), \
patch.object(flash,'preflight',return_value={'product':'sargo'}) as preflight, \
patch.object(flash.subprocess,'run') as run:
flash.main()
preflight.assert_called_once()
commands=[call.args[0][3:] for call in run.call_args_list]
self.assertEqual(commands[0],['erase','userdata'])
self.assertEqual([command[:2] for command in commands[1:-1]],
[['flash',partition] for partition in flash.PARTITIONS])
self.assertEqual(commands[-1],['set_active','a'])
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,69 @@
# SPDX-License-Identifier: MPL-2.0
import gzip
import hashlib
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('restore', Path(__file__).resolve().parents[1]/'tools/restore-standalone-sargo.py')
restore = importlib.util.module_from_spec(spec)
spec.loader.exec_module(restore)
class RestoreTests(unittest.TestCase):
def make_backup(self, folder):
payload = b'original userdata\0' * 100
sha = hashlib.sha256(payload).hexdigest()
data = {'archive': 'userdata-original.raw.gz', 'bytes': len(payload),
'sha256': sha, 'checked': True, 'sourceWasUnmounted': True}
(folder/data['archive']).write_bytes(gzip.compress(payload))
(folder/'userdata.json').write_text(json.dumps(data))
files = {}
for name in ('boot_a.img', 'dtbo_a.img', 'vbmeta_a.img'):
content = name.encode()
(folder/name).write_bytes(content)
files[name] = {'bytes': len(content), 'sha256': hashlib.sha256(content).hexdigest()}
manifest = {'device': 'sargo', 'activeSlot': 'a', 'files': files,
'userdataSha256': sha, 'userdataBytes': len(payload)}
(folder/'recovery.json').write_text(json.dumps(manifest))
return manifest
def test_review_verifies_backup_without_phone_commands(self):
with tempfile.TemporaryDirectory() as directory:
folder = Path(directory)
self.make_backup(folder)
with patch.object(sys, 'argv', ['restore', '--backup', directory]), \
patch.object(restore.subprocess, 'run') as run, \
patch.object(restore.subprocess, 'check_output') as read:
restore.main()
run.assert_not_called()
read.assert_not_called()
def test_unbound_userdata_is_rejected(self):
with tempfile.TemporaryDirectory() as directory:
folder = Path(directory)
manifest = self.make_backup(folder)
manifest['userdataSha256'] = '0' * 64
(folder/'recovery.json').write_text(json.dumps(manifest))
with patch.object(sys, 'argv', ['restore', '--backup', directory]), \
patch.object(restore.subprocess, 'run') as run:
with self.assertRaisesRegex(ValueError, 'not bound'):
restore.main()
run.assert_not_called()
def test_altered_userdata_is_rejected(self):
with tempfile.TemporaryDirectory() as directory:
folder = Path(directory)
self.make_backup(folder)
(folder/'userdata-original.raw.gz').write_bytes(gzip.compress(b'changed'))
with patch.object(sys, 'argv', ['restore', '--backup', directory]):
with self.assertRaisesRegex(ValueError, 'Storage backup changed'):
restore.main()
if __name__ == '__main__':
unittest.main()

View file

@ -184,9 +184,19 @@ def main():
buffer = io.BytesIO() buffer = io.BytesIO()
selected = [*sorted((ROOT.parent/'host').rglob('*')), selected = [*sorted((ROOT.parent/'host').rglob('*')),
*sorted((ROOT.parent/'services').rglob('*.mjs')), *sorted((ROOT.parent/'services').rglob('*.mjs')),
*sorted((ROOT.parent/'assets/webapps/marketplace.gaiamobile.org').rglob('*')),
ROOT.parent/'tools/installed_apps.py',
*sorted((ROOT.parent/'gaia/shared/elements/vulpes-fonts').rglob('*')), *sorted((ROOT.parent/'gaia/shared/elements/vulpes-fonts').rglob('*')),
*sorted((ROOT.parent/'overrides').rglob('*')), *sorted((ROOT.parent/'overrides').rglob('*')),
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js',
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js',
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/activities.js',
ROOT.parent/'assets/webapps/search.gaiamobile.org/manifest.webapp', ROOT.parent/'assets/webapps/search.gaiamobile.org/manifest.webapp',
ROOT.parent/'assets/webapps/homescreen.gaiamobile.org/gaia_build_defer_index.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/js/tone_player.js',
ROOT.parent/'tools/project.py',
ROOT.parent/'tools/serve-gaia.py', ROOT.parent/'tools/serve-gaia.py',
ROOT.parent/'release.json', ROOT.parent/'tests/ProbeChild.sys.mjs', ROOT.parent/'release.json', ROOT.parent/'tests/ProbeChild.sys.mjs',
ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py'] ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py']

View file

@ -0,0 +1,146 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Build a raw-userdata Tundra boot, or its nondestructive container test variant."""
import argparse
import json
import os
from pathlib import Path
import shlex
import shutil
import stat
import subprocess
import sys
import tempfile
from ramdisk_reference import read_newc
from standalone_volume import checked_volume
from tundra import ROOT, checked_source, cpio, digest
def storage_scripts(init, native, volume, trial):
start = init.index(b'# Use the inventoried filesystem')
end = init.index(b'echo TUNDRA_RESCUE_READY', start)
legacy = init[start:end]
# The container test keeps the old mount solely as a carrier for the image.
if trial:
legacy += b'mkdir -p /run/legacy-storage\nmount --move /installed /run/legacy-storage\n'
else:
legacy = b'# Standalone boot does not open the previous operating system.\n'
init = init[:start]+legacy+init[end:]
init = init.replace(b'exec /bin/sh /native-root.sh', b'exec /bin/sh /standalone-mount.sh')
native = native.replace(b'LOWER=/installed/home/droidian/vulpes-tundra-tests/runtime-reference-20260925/root',
b': "${SYSTEM_IMAGE:?A bundled system image is required}"\nLOWER=/run/tundra-system')
native = native.replace(b'/installed', b'/volume')
native = native.replace(b'/reference/volume', b'/reference/tundra-volume')
if volume.get('hardwareImages'):
lines = ['mkdir -p /newroot/reference']
for name in ('vendor.img', 'modem.img'):
lines += ['test -s /volume/tundra/hardware/'+name,
': > /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()

View file

@ -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()

View file

@ -128,7 +128,7 @@ def main():
ignore=shutil.ignore_patterns('__pycache__', 'test', 'tests')) ignore=shutil.ignore_patterns('__pycache__', 'test', 'tests'))
for name in ('tools', 'tests', 'logs', 'profiles'): for name in ('tools', 'tests', 'logs', 'profiles'):
(product/name).mkdir(exist_ok=True) (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/'tools'/name, product/'tools'/name)
shutil.copy2(PROJECT/'tests/ProbeChild.sys.mjs', product/'tests/ProbeChild.sys.mjs') shutil.copy2(PROJECT/'tests/ProbeChild.sys.mjs', product/'tests/ProbeChild.sys.mjs')
shutil.copy2(PROJECT/'release.json', product/'release.json') shutil.copy2(PROJECT/'release.json', product/'release.json')

View file

@ -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('<I4H4I',header)
if magic!=0xed26ff3a or major!=1 or file_header!=28 or chunk_header!=12 or block_size!=4096 or not chunks:
raise ValueError('Expected an Android sparse ext4 image')
return block_size*blocks
def package(folder):
folder=Path(folder).resolve()
if (folder/'.incomplete').exists():raise ValueError('Package construction is incomplete')
value=json.loads((folder/'package.json').read_text())
if value.get('schema')!=1 or value.get('device')!='sargo' or value.get('storage')!='raw-userdata-ext4':
raise ValueError('Wrong package model or storage format')
if set(value.get('partitions',{}))!=set(PARTITIONS):raise ValueError('Unexpected partition write plan')
for partition,record in value['partitions'].items():
if record.get('file')!=NAMES[partition]:raise ValueError('Unexpected image filename')
path=folder/record['file']
if path.is_symlink() or path.stat().st_size!=record['bytes'] or digest(path)!=record['sha256']:
raise ValueError('Image checksum or size mismatch: '+partition)
if sparse_size(folder/'userdata.img')!=value['userdataBytes']:raise ValueError('Sparse userdata geometry changed')
return value
def getvar(fastboot,serial,name):
result=subprocess.run([fastboot,'-s',serial,'getvar',name],capture_output=True,text=True,check=True,timeout=15)
for line in (result.stdout+'\n'+result.stderr).splitlines():
match=re.fullmatch(r'(?:\(bootloader\)\s*)?'+re.escape(name)+r':\s*(.+)',line.strip())
if match:return match[1].strip()
raise ValueError('Missing Fastboot variable: '+name)
def preflight(fastboot,serial,value):
info={name:getvar(fastboot,serial,name) for name in ('product','unlocked','current-slot')}
if info['product']!='sargo' or info['unlocked']!='yes' or info['current-slot'] not in ('a','b'):
raise ValueError('Requires an unlocked Pixel 3a (sargo), not Pixel 3a XL')
for partition in PARTITIONS:
size=int(getvar(fastboot,serial,'partition-size:'+partition),16)
needed=value['userdataBytes'] if partition=='userdata' else value['partitions'][partition]['bytes']
if needed>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()

View file

@ -23,6 +23,8 @@ def load(name, path):
def checked_build(folder): def checked_build(folder):
folder=Path(folder).resolve() folder=Path(folder).resolve()
build=json.loads((folder/'build.json').read_text()) 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') if build.get('localDebugger'):raise ValueError('Diagnostic debugger image cannot be flashed')
qualification=json.loads((folder/'qualification.json').read_text()) qualification=json.loads((folder/'qualification.json').read_text())
if (not qualification.get('passed') or not qualification.get('privateInstallReady') if (not qualification.get('passed') or not qualification.get('privateInstallReady')

View file

@ -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()

View file

@ -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).free<data['bytes']+1024**3:raise ValueError('Not enough host space to decompress recovery image')
partial=folder/'userdata-restore.partial'
with gzip.open(archive,'rb') as source,partial.open('xb') as target:
while block:=source.read(4*1024**2):
if not block.strip(b'\0'):target.seek(len(block),1)
else:target.write(block)
target.truncate(data['bytes'])
if digest(partial)!=data['sha256']:raise ValueError('Decompressed backup changed')
partial.rename(raw)
subprocess.run([fastboot,'-s',serial,'erase','userdata'],check=True,timeout=300)
subprocess.run([fastboot,'-s',serial,'-S','256M','flash','userdata',str(raw)],check=True,timeout=7200)
for name in ('dtbo_a.img','vbmeta_a.img','boot_a.img'):
subprocess.run([fastboot,'-s',serial,'flash',name.removesuffix('.img'),str(folder/name)],check=True,timeout=120)
subprocess.run([fastboot,'-s',serial,'set_active','a'],check=True,timeout=30)
print('Original userdata and boot restored. Select Start; bootloader remains unlocked.')
if __name__=='__main__':main()

View file

@ -0,0 +1,25 @@
# SPDX-License-Identifier: MPL-2.0
"""Decode the read-only TUNRAW1 stream; reject truncation and overlapping blocks."""
import struct
def exact(stream, length):
data=bytearray()
while len(data)<length:
block=stream.read(length-len(data))
if not block:raise ValueError('Truncated backup stream')
data.extend(block)
return bytes(data)
def receive(stream, target, expected_size):
if exact(stream,8)!=b'TUNRAW1\0':raise ValueError('Unexpected backup protocol')
end=0
while True:
offset,length=struct.unpack('<QQ',exact(stream,16))
if not length:
if offset!=expected_size or stream.read(1):raise ValueError('Backup size or trailer mismatch')
target.truncate(expected_size)
return
if offset<end or length>65536 or offset+length>expected_size:raise ValueError('Invalid backup extent')
target.seek(offset);target.write(exact(stream,length));end=offset+length

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