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

View file

@ -14,11 +14,13 @@ import java.util.zip.*;
final class GaiaServer implements Closeable {
static final int PORT = 18765;
final InstalledApps installed;
private final ZipFile zip;
private final ServerSocket socket;
private final ExecutorService workers = Executors.newFixedThreadPool(4);
GaiaServer(Context context) throws IOException {
try {installed=new InstalledApps(context);}catch(Exception e){throw new IOException(e);}
File archive = new File(context.getCacheDir(), "gaia.zip");
// Atomic refresh on every process start; the APK is the source of truth.
File temp = new File(context.getCacheDir(), "gaia.zip.tmp");
@ -94,7 +96,7 @@ final class GaiaServer implements Closeable {
}
String app = host.substring(0, host.indexOf('.'));
if (app.equals("theme")) app = "default_theme";
if (zip.getEntry(app + "/manifest.webapp") == null) {
if (!InstalledApps.valid(app) && zip.getEntry(app + "/manifest.webapp") == null) {
error(out, 404);
return;
}
@ -109,6 +111,16 @@ final class GaiaServer implements Closeable {
return;
}
if (path.endsWith("/")) path += "index.html";
if (InstalledApps.valid(app)) {
byte[] body=installed.read(app,path.substring(1));
if(body==null){error(out,404);return;}
String ext=path.substring(path.lastIndexOf('.')+1).toLowerCase(Locale.ROOT);
String mime=MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
if(ext.equals("js"))mime="text/javascript";
if(ext.equals("webapp"))mime="application/json";
if(mime==null)mime="application/octet-stream";
out.write(("HTTP/1.1 200 OK\r\nContent-Type: "+mime+"\r\nContent-Length: "+body.length+"\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\nCache-Control: no-store\r\nAccess-Control-Allow-Origin: *\r\nContent-Security-Policy: object-src 'none'; frame-ancestors http://*.localhost:"+PORT+"\r\n\r\n").getBytes(StandardCharsets.US_ASCII));out.write(body);return;
}
String key = path.startsWith("/_vulpes/") ? path.substring(1) : app + path;
ZipEntry entry = zip.getEntry(key);
if (entry == null && path.startsWith("/shared/")) entry = zip.getEntry(

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,8 +45,7 @@ public final class MainActivity extends Activity {
root.setOnApplyWindowInsetsListener((v, insets) -> {
if (Build.VERSION.SDK_INT >= 30) {
android.graphics.Insets bars = insets.getInsets(
WindowInsets.Type.systemBars() |
WindowInsets.Type.ime() |
WindowInsets.Type.ime() |
WindowInsets.Type.displayCutout()
);
v.setPadding(bars.left, bars.top, bars.right, bars.bottom);
@ -54,11 +53,12 @@ public final class MainActivity extends Activity {
insets.getSystemWindowInsetLeft(),
insets.getSystemWindowInsetTop(),
insets.getSystemWindowInsetRight(),
insets.getSystemWindowInsetBottom()
insets.getSystemWindowInsetBottom() > dp(100) ? insets.getSystemWindowInsetBottom() : 0
);
return insets;
});
setContentView(root);
immersive();
// Respect Android's screen timeout; Gaia must not keep the phone awake indefinitely.
float brightness = app.getSharedPreferences("android", 0).getFloat("brightness", -1);
if (brightness >= 0) {
@ -166,6 +166,28 @@ public final class MainActivity extends Activity {
}
}
private void immersive() {
if (Build.VERSION.SDK_INT >= 30) {
getWindow().setDecorFitsSystemWindows(false);
WindowInsetsController controller = getWindow().getInsetsController();
if (controller != null) {
controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
controller.hide(WindowInsets.Type.systemBars());
}
} else {
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN |
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
}
}
@Override
public void onWindowFocusChanged(boolean focused) {
super.onWindowFocusChanged(focused);
if (focused) immersive();
}
private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density);
}
@ -274,7 +296,7 @@ public final class MainActivity extends Activity {
setHomeSurface(app.homeVisible);
}
private void closeBrowser() {
void closeBrowser() {
webPanel.setVisibility(View.GONE);
gaiaView.setVisibility(View.VISIBLE);
setHomeSurface(app.homeVisible);
@ -356,6 +378,7 @@ public final class MainActivity extends Activity {
public void onResume() {
super.onResume();
resumed = true;
immersive();
app.activity = this;
if (recoverGaia) {
recoverGaia = false;

View file

@ -17,9 +17,32 @@ public final class VulpesApplication extends Application {
GeckoSession gaiaSession;
AndroidServices services;
AndroidNotifications notifications;
boolean pendingScreenLock;
boolean homeVisible;
boolean homeAtBottom;
@Override
public void onCreate() {
super.onCreate();
android.content.BroadcastReceiver receiver = new android.content.BroadcastReceiver() {
@Override public void onReceive(android.content.Context context, android.content.Intent intent) {
pendingScreenLock = true;
sendScreenLock();
}
};
android.content.IntentFilter filter = new android.content.IntentFilter(android.content.Intent.ACTION_SCREEN_OFF);
if (android.os.Build.VERSION.SDK_INT >= 33) registerReceiver(receiver,filter,RECEIVER_NOT_EXPORTED);
else registerReceiver(receiver,filter);
}
void sendScreenLock() {
if (port == null || !pendingScreenLock) return;
try {
port.postMessage(new JSONObject().put("type","android-screen-off"));
pendingScreenLock = false;
} catch (Exception error) {Log.w("Vulpes","Screen lock pending",error);}
}
void prepare() throws IOException {
if (runtime != null) return;
server = new GaiaServer(this);
@ -58,6 +81,7 @@ public final class VulpesApplication extends Application {
@Override
public void onConnect(WebExtension.Port p) {
port = p;
sendScreenLock();
if (activity != null) notifications.click(activity.getIntent());
p.setDelegate(
new WebExtension.PortDelegate() {
@ -80,6 +104,18 @@ public final class VulpesApplication extends Application {
return GeckoResult.fromException(new SecurityException("EXTENSION_REQUIRED"));
}
JSONObject json = (JSONObject) message;
if (json.optString("type").equals("marketplace.return") && activity!=null) {activity.runOnUiThread(()->activity.closeBrowser());return GeckoResult.fromValue(null);}
if (json.optString("type").startsWith("packages.")) {
GeckoResult<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")) {
homeAtBottom = json.optBoolean("bottom");
if (activity != null) activity.setHomeSurface(homeVisible);
@ -124,6 +160,7 @@ public final class VulpesApplication extends Application {
}
void resumed() {
sendScreenLock();
if (port == null) return;
try {
port.postMessage(new JSONObject().put("type", "android-resume"));

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 { SettingsService } from './services/settings.mjs';
import { DataStoreService } from './services/datastores.mjs';
@ -7,6 +9,9 @@ import { Messages } from './Messages.mjs';
import { Connections } from './Connections.mjs';
import { Alarms } from './Alarms.mjs';
const peers = new Set();
let activities, packages;
const nativePackages=(operation,data={})=>browser.runtime.sendNativeMessage('vulpes',{type:'packages.'+operation,...data}).then(JSON.parse);
async function refreshInstalled(){const installed=await nativePackages('list');apps.splice(0,apps.length,...apps.filter(a=>!a.installed),...installed);}
let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage;
const identity = (peer) => peer.app;
function notify(peer, type, data) {
@ -26,7 +31,7 @@ function grants(app) {
function broadcast(type, data, target) {
for (const peer of peers) {
if (target && peer.app.id !== target) continue;
if (type === 'settingchange' && !grants(peer.app).includes('settings.read')) continue;
if (type === 'settingchange' && data.key !== 'language.current' && !grants(peer.app).includes('settings.read')) continue;
if (
type === 'contactchange' &&
!peer.app.manifest.permissions?.contacts?.access?.includes('read')
@ -45,7 +50,7 @@ function broadcast(type, data, target) {
notify(peer, type, data);
}
}
function launch(target, entryPoint, path) {
function launch(target, entryPoint, path, reload = false) {
path ||=
target.manifest.entry_points?.[entryPoint]?.launch_path ||
target.manifest.launch_path ||
@ -53,7 +58,7 @@ function launch(target, entryPoint, path) {
const url = new URL(path, target.origin);
if (url.origin !== target.origin) throw Error('INVALID_URL');
broadcast(
'launch',
reload ? 'activity-launch' : 'launch',
{
manifestURL: target.manifestURL,
origin: target.origin,
@ -66,6 +71,8 @@ function launch(target, entryPoint, path) {
}
const ready = (async () => {
apps = await (await fetch('registry.json')).json();
await refreshInstalled();
packages=new Packages({save:item=>nativePackages('save',{item}),changed:refreshInstalled});
const defaults = await (await fetch('defaults.json')).json();
const saved = await browser.storage.local.get('settings');
values = { ...defaults, ...saved.settings };
@ -82,6 +89,8 @@ const ready = (async () => {
values['wallpaper.image.valid'] = false;
await browser.storage.local.set({ settings: values });
}
activities = new Activities({identity,apps:()=>apps,notify,uuid:()=>crypto.randomUUID(),
launch:(target,path,{reload=false,entryPoint}={})=>launch(target,entryPoint,path,reload)});
let writes = Promise.resolve();
settings = new SettingsService(
{
@ -123,6 +132,12 @@ const ready = (async () => {
// Java owns this port. Web pages have no access to native messaging.
let native;
let systemReady = false;
let pendingScreenLock = false;
function deliverScreenLock() {
if (!systemReady || !pendingScreenLock) return;
pendingScreenLock = false;
broadcast('screen-off', {}, 'system');
}
let pendingNotification;
function deliverNotification() {
if (!systemReady || !pendingNotification) return;
@ -140,7 +155,15 @@ function connectNative() {
native.onMessage.addListener(async (message) => {
await ready;
if (['home', 'holdhome'].includes(message.type)) broadcast(message.type, {}, 'system');
if (message.type === 'android-resume') await refreshAndroid().catch(console.warn);
if (message.type === 'android-screen-off') {
pendingScreenLock = true;
deliverScreenLock();
}
if (message.type === 'android-resume') {
deliverScreenLock();
broadcast('screen-on', {}, 'system');
await refreshAndroid().catch(console.warn);
}
if (message.type === 'notification-click') {
pendingNotification = message.data;
deliverNotification();
@ -206,6 +229,7 @@ async function handle(peer, request) {
if (method === 'android.systemReady') {
if (app.id !== 'system') throw Error('PERMISSION_DENIED');
systemReady = true;
deliverScreenLock();
deliverNotification();
return null;
}
@ -278,6 +302,7 @@ async function handle(peer, request) {
}
if (!Object.keys(params.values).length) return null;
}
if (method === 'locale.current') return values['language.current'] || 'en-US';
if (method === 'settings.snapshot') {
if (!grants(app).includes('settings.read')) throw Error('PERMISSION_DENIED');
return { ...values };
@ -287,6 +312,14 @@ async function handle(peer, request) {
if (reply.error) throw Error(reply.error.code);
return reply.result;
}
if(method.startsWith('marketplace.')) {
if(app.id!=='marketplace')throw Error('PERMISSION_DENIED');
if(method==='marketplace.poll'){const url=marketplaceDownload;marketplaceDownload=null;return url;}
if(method==='marketplace.cancel'){packages.cancel();return true;}
if(method==='marketplace.prepare')return packages.prepare(params.url);
if(method==='marketplace.install'){const id=await packages.install(params.token);const installed=apps.find(a=>a.id===id);broadcast('apps-installed',installed);return installed;}
throw Error('METHOD_NOT_SUPPORTED');
}
if (method === 'apps.self') {
console.info('VULPES app ready', app.id);
return app;
@ -298,6 +331,10 @@ async function handle(peer, request) {
if (!manage) throw Error('PERMISSION_DENIED');
return apps;
}
if(method==='apps.uninstall') {
const installed=apps.find(a=>a.id===params.id && a.installed);if(!manage || !installed)throw Error('PERMISSION_DENIED');
await nativePackages('remove',{id:params.id});await refreshInstalled();broadcast('apps-uninstalled',installed);return true;
}
if (method === 'apps.launch') {
const target = apps.find((a) => a.manifestURL === params.manifestURL);
if (!target || (!manage && app.id !== target.id)) throw Error('PERMISSION_DENIED');
@ -319,9 +356,13 @@ async function handle(peer, request) {
connections.close(peer, params.id);
return null;
}
if (method === 'activities.cancel') return activities.cancel(peer);
if (method === 'activities.subscribe') return activities.subscribe(peer);
if (method === 'activities.finish') return activities.finish(peer,params);
if (method === 'activities.start' && isGaiaActivity(params)) return activities.start(peer,params);
if (method === 'activities.start') {
if (params.name === 'record' && params.data?.type === 'photos') {
if (app.id === 'gallery') {
if (['gallery', 'system'].includes(app.id)) {
const camera = apps.find((a) => a.id === 'camera');
if (!camera) throw Error('ACTIVITY_NOT_SUPPORTED');
launch(camera);
@ -430,9 +471,21 @@ browser.runtime.onConnect.addListener((port) => {
peers.delete(peer);
if (peer.app.id === 'system' && ![...peers].some((p) => p.app.id === 'system'))
systemReady = false;
activities.cleanup(peer);
connections.cleanup(peer);
alarms.cleanup(peer);
})
.catch(() => {});
});
});
let marketplaceDownload=null;
browser.runtime.onMessage.addListener(async (message,sender)=>{
if(!message.marketplaceDownload)return;
const origin=new URL(sender.url);
if(origin.origin!=='https://vulpes-os.org' || !origin.pathname.startsWith('/marketplace/'))throw Error('PERMISSION_DENIED');
await ready;marketplaceDownload=packageURL(message.marketplaceDownload);
const app=apps.find(a=>a.id==='marketplace');launch(app);
await browser.runtime.sendNativeMessage('vulpes',{type:'marketplace.return'});
return true;
});

View file

@ -1,6 +1,6 @@
/* Extension world: the browser provides origin/tab/frame identity, never Gaia. */
(() => {
if (location.port !== '18765') return;
if (location.port !== '18765' || location.hostname.startsWith('user-')) return;
const win = window.wrappedJSObject;
console.info('VULPES Android bridge', location.origin);
let port;

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 });
vm.runInContext(
await readFile(new URL('../ui/settings-codec.js', import.meta.url), 'utf8'),
await readFile(new URL('../../host/settings-codec.js', import.meta.url), 'utf8'),
context,
);
const { encode, decode } = context.window.VulpesAndroidSettingsCodec;
const { encode, decode } = context.window.VulpesSettingsCodec;
// A binary wallpaper larger than the original 1 MiB JSON ceiling, including
// every byte value. Exercise the actual service, JSON storage and observer path.
const pixels = Uint8Array.from({ length: 1200000 }, (_, i) => i % 256);

View file

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