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

@ -1,6 +1,8 @@
import { setInterval } from 'resource://gre/modules/Timer.sys.mjs';
import { setInterval, setTimeout, clearTimeout } from 'resource://gre/modules/Timer.sys.mjs';
import {Input} from 'resource://vulpes/host/Input.sys.mjs';
import { ScreenPower } from 'resource://vulpes/services/screen-power.mjs';
import { Packages } from 'resource://vulpes/services/packages.mjs';
import { Activities, isGaiaActivity } from 'resource://vulpes/services/activities.mjs';
import { SettingsService } from 'resource://vulpes/services/settings.mjs';
import { DataStoreService } from 'resource://vulpes/services/datastores.mjs';
import { DataStoresIndexedDB } from 'resource://vulpes/adapters/datastores-indexeddb.mjs';
@ -29,6 +31,7 @@ export const Runtime = {
wakeLocks: new Map(),
idleListeners: new Map(),
cleanup(actor) {
this.activities?.cleanup(actor);
this.input?.cleanup(actor);
this.platform?.nativeCamera.cleanup(actor);
this.connections?.cleanup(actor);
@ -45,6 +48,22 @@ export const Runtime = {
this.input = new Input(this);
const root = Services.env.get('VULPES_WORKSPACE_ROOT');
this.apps = await IOUtils.readJSON(PathUtils.join(root, 'assets', 'registry.json'));
const storeURL = 'http://system.localhost:8765/_vulpes/packages/';
const store = async (operation, item) => {
const token = await IOUtils.readUTF8(PathUtils.join(root,'profiles','installed-apps','.token'));
const response = await fetch(storeURL+operation,{method:'POST',headers:{'X-Vulpes-Install':token,'Content-Type':'application/json'},body:JSON.stringify(item)});
if(!response.ok)throw Error('PACKAGE_STORAGE_FAILED');
return response.json();
};
this.refreshInstalled = async () => {
const response=await fetch(storeURL+'list');if(!response.ok)throw Error('PACKAGE_STORAGE_FAILED');
const installed=await response.json();
this.apps.splice(0,this.apps.length,...this.apps.filter(a=>!a.installed),...installed);
return installed;
};
this.packages = new Packages({schedule:setTimeout,unschedule:clearTimeout,save:item=>store('save',item),changed:()=>this.refreshInstalled(),uuid:()=>Services.uuid.generateUUID().toString()});
this.removePackage = async id => {const app=this.apps.find(a=>a.id===id && a.installed);if(!app)throw Error('NOT_REMOVABLE');
await store('remove',{id});await this.refreshInstalled();this.broadcast('apps-uninstalled',app);};
// Installed Gaia audio apps must be able to ring when an alarm fires. This
// permission is scoped to their origins in this profile, never web pages.
for (const app of this.apps)
@ -91,6 +110,11 @@ export const Runtime = {
this.values['vulpes.gaia-restoration.v1'] = true;
}
Services.prefs.setStringPref('intl.accept_languages', this.values['language.current']);
this.activities = new Activities({identity:actor=>this.identity(actor),apps:()=>this.apps,
uuid:()=>Services.uuid.generateUUID().toString(),
notify:(actor,type,data)=>actor.sendAsyncMessage('Vulpes:Event',{type,data}),
launch:(target,path,{reload=false,entryPoint}={})=>this.broadcast(reload?'activity-launch':'launch',{manifestURL:target.manifestURL,origin:target.origin,
entryPoint,url:new URL(path || target.manifest.launch_path || '/index.html',target.origin).href,timestamp:Date.now()},'system')});
this.writeQueue = Promise.resolve();
this.settings = new SettingsService({
get: async (key) => this.values[key],
@ -115,7 +139,7 @@ export const Runtime = {
this.writeQueue = write.catch(() => {});
return write;
},
});
}, {maxBytes:32 * 1024 * 1024});
const storage = new DataStoresIndexedDB(indexedDB, IDBKeyRange, () =>
Services.uuid.generateUUID().toString(),
);
@ -202,6 +226,16 @@ export const Runtime = {
this.actors.add(actor);
const { method, params } = request;
if (typeof method !== 'string') return { error: { code: 'INVALID_REQUEST' } };
if(method.startsWith('marketplace.')) {
if(app.id!=='marketplace')return {error:{code:'PERMISSION_DENIED'}};
try {
if(method==='marketplace.poll'){const url=this.marketplaceDownload;this.marketplaceDownload=null;return {result:url||null};}
if(method==='marketplace.cancel'){this.packages.cancel();return {result:true};}
if(method==='marketplace.prepare')return {result:await this.packages.prepare(params.url)};
if(method==='marketplace.install') {const id=await this.packages.install(params.token);const installed=this.apps.find(a=>a.id===id);this.broadcast('apps-installed',installed);return {result:installed};}
} catch(error) {return {error:{code:error.message}};}
return {error:{code:'METHOD_NOT_SUPPORTED'}};
}
if(method==='hardware.vibrate') {
if(app.id!=='system' || Services.env.get('VULPES_TUNDRA')!=='1') return {error:{code:'NOT_SUPPORTED'}};
const pattern=params?.pattern;
@ -267,10 +301,19 @@ export const Runtime = {
}
}
if (typeof method !== 'string') return { error: { code: 'INVALID_REQUEST' } };
if (['activities.subscribe','activities.finish','activities.cancel'].includes(method) || (method === 'activities.start' && isGaiaActivity(params))) {
try {
const result = method === 'activities.cancel' ? this.activities.cancel(actor)
: method === 'activities.subscribe' ? this.activities.subscribe(actor)
: method === 'activities.finish' ? this.activities.finish(actor,params)
: await this.activities.start(actor,params);
return {result:result ?? null};
} catch(error) {return {error:{code:error.message}};}
}
if (method === 'activities.start') {
if (params?.name === 'record' && params.data?.type === 'photos') {
// Gallery's Photo button opens the shared Gaia UI; it expects no result file.
if (app.id === 'gallery') {
// Gallery and the lockscreen open Gaia Camera without expecting a file result.
if (['gallery', 'system'].includes(app.id)) {
const camera = this.apps.find((a) => a.id === 'camera');
if (!camera) return { error: { code: 'ACTIVITY_NOT_SUPPORTED' } };
this.broadcast(
@ -316,6 +359,7 @@ export const Runtime = {
return { error: { code: error.message || 'VIEW_ERROR' } };
}
}
if (method === 'locale.current') return { result: this.values['language.current'] || 'en-US' };
if (method === 'settings.snapshot')
return this.grants(app).includes('settings.read')
? { result: { ...this.values } }
@ -441,6 +485,10 @@ export const Runtime = {
if (method === 'apps.self') return { result: app };
if (method === 'apps.list')
return manage ? { result: this.apps } : { error: { code: 'PERMISSION_DENIED' } };
if(method==='apps.uninstall') {
if(!manage)return {error:{code:'PERMISSION_DENIED'}};
try {await this.removePackage(params.id);return {result:true};}catch(error){return {error:{code:error.message}};}
}
if (method === 'apps.launch') {
const target = this.apps.find((a) => a.manifestURL === params?.manifestURL);
if (!target || (!manage && app.id !== target.id))
@ -469,7 +517,7 @@ export const Runtime = {
try {
const app = this.identity(actor);
if (!app || (target && app.id !== target)) continue;
if (type === 'settingchange' && !this.grants(app).includes('settings.read')) continue;
if (type === 'settingchange' && data.key !== 'language.current' && !this.grants(app).includes('settings.read')) continue;
if (
type === 'mediachange' &&
!app.manifest.permissions?.['device-storage:' + data.type]?.access?.includes('read')

View file

@ -15,12 +15,52 @@ export const Views = {
);
Services.ppmm.sharedData.flush();
},
hideAll() {
for (const entry of this.entries.values()) entry.browser.style.visibility='hidden';
},
async layout(entry) {
const geometry=entry.geometry;
if (!geometry) return;
let {left,top,width,height,visible}=geometry;
let right=left+width,bottom=top+height;
try {
// A child document can remain visible while its owning Gaia app is hidden.
// Check every embedding frame in its own process, up to the system app.
for (let child=entry.actor.browsingContext;child.parent;child=child.parent) {
const state=await child.parent.currentWindowGlobal.getActor('Vulpes').sendQuery(
'Vulpes:ViewState',{contextId:child.id});
visible=visible && state.visible;
if (!visible) break;
left=Math.max(left,state.left);top=Math.max(top,state.top);
right=Math.min(right,state.right);bottom=Math.min(bottom,state.bottom);
}
} catch (_) {visible=false;}
if (entry.geometry!==geometry || !entry.browser.isConnected) return;
const shell=entry.browser.ownerDocument.defaultView;
const bounds=shell.document.getElementById('screen').getBoundingClientRect();
const x=shell.mozInnerScreenX+bounds.left,y=shell.mozInnerScreenY+bounds.top;
left=Math.max(x,left);top=Math.max(y,top);
right=Math.min(x+bounds.width,right);bottom=Math.min(y+bounds.height,bottom);
Object.assign(entry.browser.style,{left:(left-x)+'px',top:(top-y)+'px',
width:Math.max(0,right-left)+'px',height:Math.max(0,bottom-top)+'px',
visibility:visible && right>left && bottom>top?'visible':'hidden'});
},
prune() {
for (const [id, entry] of this.entries) {
if (!BrowsingContext.get(id)?.parent) {
this.remove(id, entry);
continue;
}
this.layout(entry);
const document=entry.browser.browsingContext?.currentWindowGlobal;
const page=document?.documentURI.spec || '';
// A retry keeps the previous error document until the next navigation commits.
if (document?.innerWindowId!==entry.previousDocumentId &&
/^about:(neterror|certerror|tabcrashed)/.test(page) && entry.errorPage!==page) {
entry.errorPage=page;
try {entry.actor.sendAsyncMessage('Vulpes:Event',{type:'view',
data:{contextId:id,type:'error',detail:{type:'other'}}});} catch (_) {}
}
// A detached iframe's BrowsingContext can outlive its DOM element.
// Ask the trusted child actor about actual ownership, not just the BC tree.
if (entry.checking) continue;
@ -123,7 +163,9 @@ export const Views = {
if (flags & Ci.nsIWebProgressListener.STATE_STOP) {
emit('loadend', { backgroundColor: 'rgb(255, 255, 255)' });
emit('titlechange', browser.contentTitle || browser.currentURI.spec);
if (!Components.isSuccessCode(status)) emit('error', { type: 'other', status });
// Replacing a navigation cancels the previous request normally.
if (!Components.isSuccessCode(status) && status !== Cr.NS_BINDING_ABORTED)
emit('error', { type: 'other', status });
}
},
onProgressChange() {},
@ -143,6 +185,8 @@ export const Views = {
this.entries.set(contextId, entry);
}
entry.url = url;
entry.errorPage=null;
entry.previousDocumentId=entry.browser.browsingContext?.currentWindowGlobal?.innerWindowId;
entry.browser.loadURI(uri, {
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
});
@ -153,22 +197,8 @@ export const Views = {
if (operation === 'geometry') {
const { left, top, width, height, visible } = geometry || {};
if (![left, top, width, height].every(Number.isFinite)) throw new Error('INVALID_GEOMETRY');
const shell = browser.ownerDocument.defaultView,
screen = shell.document.getElementById('screen');
const bounds = screen.getBoundingClientRect();
const x = left - shell.mozInnerScreenX - bounds.left;
const y = top - shell.mozInnerScreenY - bounds.top;
const right = Math.min(480, x + width),
bottom = Math.min(800, y + height);
const clippedX = Math.max(0, x),
clippedY = Math.max(0, y);
Object.assign(browser.style, {
left: clippedX + 'px',
top: clippedY + 'px',
width: Math.max(0, right - clippedX) + 'px',
height: Math.max(0, bottom - clippedY) + 'px',
visibility: visible && right > clippedX && bottom > clippedY ? 'visible' : 'hidden',
});
entry.geometry={left,top,width,height,visible};
await this.layout(entry);
return null;
}
if (operation === 'back') {

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) {
if(message.name==='Vulpes:Edit') return this.edit(message.data);
if (message.name === 'Vulpes:ViewState') {
const frame = Array.from(this.document.querySelectorAll('iframe')).find(
f => f.browsingContext?.id === message.data.contextId);
if (!frame?.isConnected) return {visible:false};
const win = this.contentWindow, rect = frame.getBoundingClientRect();
const visible = !this.document.hidden && frame.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}) &&
[[.5,.5],[.05,.05],[.95,.05],[.05,.95],[.95,.95]].every(([x,y]) =>
this.document.elementFromPoint(rect.left+rect.width*x,rect.top+rect.height*y)===frame);
let top = Math.max(0,rect.top);
// Gaia's status bar belongs above all app content, including native web views.
if (this.document.location.hostname === 'system.localhost') {
const bar=this.document.getElementById('statusbar');
if (bar?.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) {
const r=bar.getBoundingClientRect();
if (r.width>0 && r.bottom>top && r.top<=top) top=r.bottom;
}
}
return {visible,left:Math.max(0,rect.left)+win.mozInnerScreenX,
top:top+win.mozInnerScreenY,right:Math.min(win.innerWidth,rect.right)+win.mozInnerScreenX,
bottom:Math.min(win.innerHeight,rect.bottom)+win.mozInnerScreenY};
}
if (message.name === 'Vulpes:ViewAlive') {
return Array.from(this.document.querySelectorAll('iframe')).some(
(frame) => frame.isConnected && frame.browsingContext?.id === message.data.contextId,

View file

@ -196,3 +196,7 @@ html[data-vulpes-app='communications'] body {
/* Camera gestures must not magnify the application chrome. */
html[data-vulpes-app='camera'], html[data-vulpes-app='camera'] body { touch-action:none; }
/* Homescreen pages must settle at their boundary after a drag (Forgejo #3). */
html[data-vulpes-app="homescreen"] #panels { scroll-snap-type:x mandatory; }
html[data-vulpes-app="homescreen"] #panels > * { scroll-snap-align:start; }

View file

@ -6,6 +6,26 @@
current = null,
generation = 0;
let cameraError = null;
let opening = Promise.resolve(), closing = Promise.resolve();
const releases = new WeakMap();
function releaseStream(stream, preview) {
if (releases.has(stream)) return releases.get(stream);
if (current === stream) current = null;
const release = Promise.resolve().then(async () => {
for (const video of document.querySelectorAll('video')) {
if (video.srcObject === stream) {
video.pause(); video.srcObject = null;
if ('vulpesCapture' in video.dataset) video.remove();
}
}
preview?.remove();
try { await stream.vulpesCameraClose?.(); }
finally { stream.getTracks().forEach(track => track.stop()); }
});
releases.set(stream, release);
closing = release.catch(recordError);
return release;
}
function recordError(error) {
cameraError = { name: error.name || 'Error', message: error.message || '' };
console.warn('Vulpes Camera:', cameraError.name, cameraError.message);
@ -87,7 +107,7 @@
Object.assign(event, detail);
target.dispatchEvent(event);
}
async function discover(stream) {
async function discover(stream, name) {
if (stream.vulpesNative) {
cameras = ['back', 'front'];
return;
@ -104,12 +124,15 @@
const active = list.find((d) => d.deviceId === settings.deviceId) || list[0];
const back = list.find((d) => /back|rear|environment/i.test(d.label));
const front = list.find((d) => /front|user|face/i.test(d.label));
const first =
back ||
(settings.facingMode === 'user' && list.length > 1 ? list.find((d) => d !== active) : active);
// Unlabelled webcams must not exchange identities every time we switch.
const previousBack = list.find(d => d.deviceId === devices.get('back'));
const previousFront = list.find(d => d.deviceId === devices.get('front'));
const first = back || previousBack ||
((settings.facingMode === 'user' || name === 'front') && list.length > 1
? list.find(d => d !== active) : active);
devices = new Map();
if (first) devices.set('back', first.deviceId);
const second = front && front !== first ? front : list.find((d) => d !== first);
const second = [front, previousFront].find(d => d && d !== first) || list.find(d => d !== first);
if (second) devices.set('front', second.deviceId);
cameras = devices.size ? [...devices.keys()] : ['back'];
}
@ -129,75 +152,79 @@
await call({action:'stop'});
throw new DOMException('Camera preview requires WebGL', 'NotSupportedError');
}
function shader(type, source) {
const value = gl.createShader(type);
gl.shaderSource(value, source); gl.compileShader(value);
if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw Error('Camera shader compilation failed');
return value;
}
const program = gl.createProgram();
gl.attachShader(program, shader(gl.VERTEX_SHADER, `
attribute vec2 position; varying vec2 coord;
void main() { gl_Position=vec4(position,0.,1.); coord=vec2(position.x*.5+.5,.5-position.y*.5); }
`));
gl.attachShader(program, shader(gl.FRAGMENT_SHADER, `
precision mediump float; varying vec2 coord;
uniform sampler2D luma; uniform sampler2D chroma; uniform float rotation;
void main() {
vec2 p=coord;
if(rotation==90.) p=vec2(coord.y,1.-coord.x);
else if(rotation==270.) p=vec2(1.-coord.y,coord.x);
else if(rotation==180.) p=1.-coord;
float y=1.16438356*(texture2D(luma,p).r-16./255.);
vec4 vu=texture2D(chroma,p);
float v=vu.r-128./255.; float u=vu.a-128./255.;
gl_FragColor=vec4(y+1.59602678*v,y-.39176229*u-.81296764*v,y+2.01723214*u,1.);
}
`));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error('Camera shader link failed');
gl.useProgram(program);
const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buffer);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);
const position=gl.getAttribLocation(program,'position');
gl.enableVertexAttribArray(position);gl.vertexAttribPointer(position,2,gl.FLOAT,false,0,0);
gl.uniform1f(gl.getUniformLocation(program,'rotation'),meta.orientation);
const textures = ['luma','chroma'].map((name,index) => {
const texture=gl.createTexture();gl.activeTexture(gl.TEXTURE0+index);gl.bindTexture(gl.TEXTURE_2D,texture);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
gl.uniform1i(gl.getUniformLocation(program,name),index);return texture;
});
const timing = {frames:0, startedAt:performance.now(), conversionMs:0};
let stopped = false, timer, stream, sequence = -1, closing;
let stopped = false, timer, stream, sequence = -1, closing, program, buffer;
const textures = [], shaders = [];
const stop = () => {
if (closing) return closing;
stopped = true; clearTimeout(timer);
closing = call({action:'stop'}).finally(() => {
textures.forEach(texture => gl.deleteTexture(texture));
gl.deleteBuffer(buffer); gl.deleteProgram(program);
shaders.forEach(value => gl.deleteShader(value));
if (buffer) gl.deleteBuffer(buffer);
if (program) gl.deleteProgram(program);
gl.getExtension('WEBGL_lose_context')?.loseContext();
});
return closing;
};
const draw = async () => {
const frame = await call({action:'frame'});
if (stopped || !frame.data || frame.sequence === sequence) return false;
const data = new Uint8Array(frame.data), w = meta.width, h = meta.height;
if (data.length !== w * h * 3 / 2) throw Error('Invalid native camera frame');
sequence = frame.sequence;
const start=performance.now();
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,textures[0]);
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE,w,h,0,gl.LUMINANCE,gl.UNSIGNED_BYTE,data.subarray(0,w*h));
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,textures[1]);
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE_ALPHA,w/2,h/2,0,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,data.subarray(w*h));
gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
timing.frames++;timing.conversionMs+=performance.now()-start;
return true;
};
try {
function shader(type, source) {
const value = gl.createShader(type);
shaders.push(value);
gl.shaderSource(value, source); gl.compileShader(value);
if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw Error('Camera shader compilation failed');
return value;
}
program = gl.createProgram();
gl.attachShader(program, shader(gl.VERTEX_SHADER, `
attribute vec2 position; varying vec2 coord;
void main() { gl_Position=vec4(position,0.,1.); coord=vec2(position.x*.5+.5,.5-position.y*.5); }
`));
gl.attachShader(program, shader(gl.FRAGMENT_SHADER, `
precision mediump float; varying vec2 coord;
uniform sampler2D luma; uniform sampler2D chroma; uniform float rotation;
void main() {
vec2 p=coord;
if(rotation==90.) p=vec2(coord.y,1.-coord.x);
else if(rotation==270.) p=vec2(1.-coord.y,coord.x);
else if(rotation==180.) p=1.-coord;
float y=1.16438356*(texture2D(luma,p).r-16./255.);
vec4 vu=texture2D(chroma,p);
float v=vu.r-128./255.; float u=vu.a-128./255.;
gl_FragColor=vec4(y+1.59602678*v,y-.39176229*u-.81296764*v,y+2.01723214*u,1.);
}
`));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error('Camera shader link failed');
gl.useProgram(program);
buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buffer);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);
const position=gl.getAttribLocation(program,'position');
gl.enableVertexAttribArray(position);gl.vertexAttribPointer(position,2,gl.FLOAT,false,0,0);
gl.uniform1f(gl.getUniformLocation(program,'rotation'),meta.orientation);
['luma','chroma'].forEach((name,index) => {
const texture=gl.createTexture();textures.push(texture);gl.activeTexture(gl.TEXTURE0+index);gl.bindTexture(gl.TEXTURE_2D,texture);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
gl.uniform1i(gl.getUniformLocation(program,name),index);
});
const draw = async () => {
const frame = await call({action:'frame'});
if (stopped || !frame.data || frame.sequence === sequence) return false;
const data = new Uint8Array(frame.data), w = meta.width, h = meta.height;
if (data.length !== w * h * 3 / 2) throw Error('Invalid native camera frame');
sequence = frame.sequence;
const start=performance.now();
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,textures[0]);
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE,w,h,0,gl.LUMINANCE,gl.UNSIGNED_BYTE,data.subarray(0,w*h));
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,textures[1]);
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE_ALPHA,w/2,h/2,0,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,data.subarray(w*h));
gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
timing.frames++;timing.conversionMs+=performance.now()-start;
return true;
};
const deadline = Date.now() + 6000;
while (!(await draw())) {
if (Date.now() > deadline) throw new DOMException('No camera frames', 'NotReadableError');
@ -227,11 +254,19 @@
return stream;
} catch (error) { await stop().catch(() => {}); throw error; }
}
async function getCamera(name, config = {}) {
function getCamera(name, config = {}) {
const ticket = ++generation;
const request = opening.then(() => openCamera(name, config, ticket));
opening = request.catch(() => {});
return request;
}
async function openCamera(name, config, ticket) {
if (config.mode && config.mode !== 'picture')
throw new DOMException('Video capture is not ported yet', 'NotSupportedError');
const ticket = ++generation;
await current?.release?.();
if (current) await releaseStream(current);
await closing;
if (document.hidden || ticket !== generation)
throw new DOMException('Camera request superseded or hidden', 'AbortError');
const selected = devices.get(name);
cameraError = null;
@ -245,7 +280,7 @@
video: {
...(selected
? { deviceId: { exact: selected } }
: { facingMode: { ideal: 'environment' } }),
: { facingMode: { ideal: name === 'front' ? 'user' : 'environment' } }),
width: { ideal: 1920 },
height: { ideal: 1080 },
},
@ -255,7 +290,7 @@
throw error;
}
if (document.hidden || ticket !== generation) {
stream.getTracks().forEach((t) => t.stop());
await releaseStream(stream);
const error = new DOMException('Camera hidden', 'AbortError');
recordError(error);
throw error;
@ -264,7 +299,7 @@
current = stream;
let preview;
try {
await discover(stream);
await discover(stream, name);
if (document.hidden || ticket !== generation)
throw new DOMException('Camera hidden', 'AbortError');
@ -294,7 +329,7 @@
const native = stream.vulpesCameraMetadata;
const pictureSizes = native?.pictureSizes || [size];
let pictureSize = pictureSizes.reduce((a,b)=>a.width*a.height>b.width*b.height?a:b);
let focusArea = null, zoom = 1, releasing;
let focusArea = null, zoom = 1;
const configuration = () => ({
mode: 'picture',
previewSize: size,
@ -378,19 +413,7 @@
emit(stream, 'previewstatechange', { newState: 'started' });
},
release() {
if (releasing) return releasing;
// Detach first: visibility changes and Gaia may release the same stream
// again while its hardware worker is still shutting down.
if (current === stream) current = null;
releasing = Promise.resolve().then(async () => {
for (const video of document.querySelectorAll('video')) {
if (video.srcObject === stream) { video.pause(); video.srcObject = null; }
}
preview.remove();
await stream.vulpesCameraClose?.();
stream.getTracks().forEach(t => t.stop());
});
return releasing;
return releaseStream(stream, preview);
},
async startRecording() {
throw new DOMException('Video unavailable', 'NotSupportedError');
@ -416,12 +439,7 @@
return { camera: stream, configuration: configuration() };
} catch (error) {
recordError(error);
stream.getTracks().forEach((t) => t.stop());
if (preview) {
preview.srcObject = null;
preview.remove();
}
if (current === stream) current = null;
await releaseStream(stream, preview).catch(recordError);
throw error;
}
}
@ -433,8 +451,7 @@
// Keep that request valid; its completion still checks visibility.
if (!current) return;
++generation;
if (current?.release) current.release().catch(recordError);
else current?.getTracks().forEach((t) => t.stop());
releaseStream(current).catch(recordError);
};
addEventListener('pagehide', stop);
addEventListener('visibilitychange', () => {

View file

@ -21,7 +21,7 @@
addEventListener('error', (event) =>
record('error', [event.message, event.filename, event.lineno]),
);
// Desktop transports Blobs directly; Android installs a JSON-compatible codec.
// Binary settings use the same persistence format on every host.
const battery = Object.assign(new EventTarget(), {level:NaN, charging:false, chargingTime:Infinity, dischargingTime:Infinity});
Object.defineProperty(navigator, 'battery', {value:battery});
async function refreshBattery() {
@ -128,15 +128,16 @@
Object.defineProperty(navigator, 'mozPhoneNumberService', {value:{
normalize: number => String(number || '').normalize('NFKC').replace(/[^0-9+*#]/g, '')
}});
const settingsCodec = window.VulpesAndroidSettingsCodec;
const settingsCodec = window.VulpesSettingsCodec;
async function call(method, params) {
if (!navigator.vulpesRequest) return Promise.reject(new Error('VULPES_HOST_UNAVAILABLE'));
if (settingsCodec && ['settings.set', 'media.request'].includes(method))
if (settingsCodec && (method === 'settings.set' ||
(window.VulpesPlatform === 'android' && ['media.request', 'activities.finish', 'contacts.request'].includes(method))))
params = await settingsCodec.encode(params);
return navigator.vulpesRequest({ version: 1, id: ++sequence, method, params }).then((reply) => {
if (reply.error) throw new DOMException(reply.error.code, reply.error.code);
return settingsCodec &&
['settings.get', 'settings.snapshot', 'media.request'].includes(method)
['settings.get', 'settings.snapshot', 'media.request', 'activities.start', 'contacts.request'].includes(method)
? settingsCodec.decode(reply.result)
: reply.result;
});
@ -165,6 +166,16 @@
}
return r;
}
let language = navigator.language;
function setLanguage(value) {
if (typeof value !== 'string' || !/^[a-z]{2,3}(?:-[a-zA-Z0-9]{2,8})*$/.test(value)) return;
const changed = language !== value;
language = value;
if (changed) dispatchEvent(new Event('languagechange'));
}
Object.defineProperty(navigator, 'language', {get:() => language});
Object.defineProperty(navigator, 'languages', {get:() => Object.freeze([language])});
call('locale.current').then(setLanguage).catch(() => {});
const observers = new Map();
const settings = new EventTarget();
settings.createLock = () => ({
@ -196,6 +207,7 @@
addEventListener('vulpes-service-event', (event) => {
const { type, data } = event.detail;
if (type === 'settingchange') {
if (data.key === 'language.current') setLanguage(data.value);
const change = {
settingName: data.key,
settingValue: settingsCodec ? settingsCodec.decode(data.value) : data.value,
@ -205,8 +217,17 @@
}
if (type === 'callscreen-closed') document.querySelector('iframe[name="call_screen"]')?.dispatchEvent(new CustomEvent('mozbrowserclose',{bubbles:true}));
if (type === 'hardware-key') dispatchEvent(new CustomEvent('softwareButtonEvent', {detail:data}));
if (type === 'activity-launch') dispatchEvent(new CustomEvent('open-app', {detail:{...data,showApp:true,onlyShowApp:false}}));
if (type === 'launch') dispatchEvent(new CustomEvent('webapps-launch', { detail: data }));
if (type === 'home' || type === 'holdhome') dispatchEvent(new CustomEvent(type));
if (type === 'screen-off') {
dispatchEvent(new CustomEvent('secure-killapps'));
window.Service?.request('lock').catch(console.error);
}
if (type === 'screen-on') window.ScreenManager?.turnScreenOn(true);
if (type === 'home' || type === 'holdhome') {
call('activities.cancel', {}).catch(console.error);
dispatchEvent(new CustomEvent(type));
}
if (type === 'activity-view') dispatchEvent(new CustomEvent('activity-view', { detail: data }));
});
function application(record) {
@ -215,7 +236,7 @@
const { id, ...publicRecord } = record;
const app = Object.assign(new EventTarget(), publicRecord, {
installState: 'installed',
removable: false,
removable: record.removable === true,
installTime: 0,
updateTime: 0,
downloadAvailable: false,
@ -238,6 +259,14 @@
}
const mgmt = new EventTarget();
mgmt.getAll = () => request(call('apps.list').then((apps) => apps.map(application)));
mgmt.uninstall = app => request(call('apps.uninstall',{id:new URL(app.origin).hostname.split('.')[0]}));
addEventListener('vulpes-service-event',event=>{
const {type,data}=event.detail;
if(!['apps-installed','apps-uninstalled'].includes(type))return;
const name=type==='apps-installed'?'install':'uninstall';
const change=new Event(name);Object.defineProperty(change,'application',{value:application(data)});
mgmt.dispatchEvent(change);if(typeof mgmt['on'+name]==='function')mgmt['on'+name](change);
});
mgmt.getNotInstalled = () => request(Promise.resolve([]));
mgmt.getIcon = async (app, size, entry) => {
const manifest = app.manifest.entry_points?.[entry] || app.manifest;
@ -319,10 +348,19 @@
const handlers = new Map();
navigator.mozSetMessageHandler = (name, fn) => {
handlers.set(name, fn);
if (name === 'activity') call('activities.subscribe', {}).catch(console.error);
if (name === 'connection') call('iac.subscribe', {}).catch(console.error);
if (name === 'alarm') call('alarms.request', { operation: 'subscribe' }).catch(console.error);
};
navigator.mozHasPendingMessage = () => false;
addEventListener('vulpes-service-event', ({detail}) => {
if (detail.type !== 'activity') return;
const {id,source} = detail.data;
setTimeout(() => handlers.get('activity')?.({source,
postResult: result => call('activities.finish',{id,result}).catch(console.error),
postError: error => call('activities.finish',{id,error:String(error)}).catch(console.error)
}),0);
});
navigator.mozHasPendingMessage = name => name === 'activity' && (new URLSearchParams(location.search).has('activity') || /\/(pick|share)\.html$/.test(location.pathname));
navigator.mozSetMessageHandlerPromise = (promise) => promise;
window.VulpesCompat = { call, request, handlers };
window.MozActivity = function (source) {
@ -657,13 +695,29 @@
Object.setPrototypeOf(GaiaElement.prototype, prototype);
customElements.define(name, GaiaElement);
registered.add(name);
return GaiaElement;
function LegacyGaiaElement() {
return initialize(nativeCreate(name));
}
LegacyGaiaElement.prototype = GaiaElement.prototype;
return LegacyGaiaElement;
};
document.createElement = function (name, options) {
const element = nativeCreate(name, options);
if (name.toLowerCase() === 'iframe') wire(element);
return registered.has(name) ? initialize(element) : element;
};
// v0 created callbacks ran for detached fragments too. Gaia reads component
// state immediately after parsing its templates, before attaching them.
const innerHTML = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
Object.defineProperty(Element.prototype, 'innerHTML', {
...innerHTML,
set(value) {
innerHTML.set.call(this, value);
for (const element of this.querySelectorAll('*')) {
if (registered.has(element.localName)) initialize(element);
}
},
});
HTMLElement.prototype.createShadowRoot = function () {
const host = this,
root = this.attachShadow({ mode: 'open' });

View file

@ -84,9 +84,15 @@
if (!event.target.closest('#messages-send-button')) return;
event.preventDefault(); event.stopImmediatePropagation();
// Android owns final confirmation and delivery. Do not fabricate a sent SMS.
const view = window.ConversationView;
view?.assimilateRecipients();
const numbers = window.Navigation?.isCurrentPanel('composer') ?
view?.recipients?.numbers : view?.activeThread?.participants;
const body = window.Compose?.getText?.() || '';
if (!numbers?.length || !body) return;
if (window.Compose.type !== 'sms') { notice(unavailable()); return; }
VulpesCompat.call('platform.sms', {
number:window.ThreadUI?.recipients?.numbers?.join(';') || '',
body:window.Compose?.getText?.() || ''
number:numbers.join(';'), body
}).catch(() => notice(unavailable()));
}, true);
}

View file

@ -23,7 +23,9 @@
manager.connection = {status, network};
manager.connectionInformation = network ? {relSignalStrength:network.relSignalStrength} : null;
if (changed) emit('statuschange', manager.connection);
emit('connectioninfoupdate', manager.connectionInformation || {});
// Gaia's signal listeners require the associated network. A scan while
// disconnected must not announce connection information for a missing AP.
if (network) emit('connectioninfoupdate', {network, ...manager.connectionInformation});
}
let scanPending;
function scan() {

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,
safeForUntrustedWebProcess: true,
matches: Runtime.apps.map((app) => 'http://' + Services.io.newURI(app.origin).host + '/*'),
matches: Runtime.apps.filter(app=>!app.installed).map((app) => 'http://' + Services.io.newURI(app.origin).host + '/*'),
});
ChromeUtils.registerWindowActor('VulpesMarketplace', {
parent:{esModuleURI:'resource://vulpes/host/actors/MarketplaceParent.sys.mjs'},
child:{esModuleURI:'resource://vulpes/host/actors/MarketplaceChild.sys.mjs',events:{DOMContentLoaded:{capture:true}}},
allFrames:true,safeForUntrustedWebProcess:true,matches:['https://vulpes-os.org/marketplace/*']
});
ChromeUtils.registerWindowActor('VulpesTouch', {
child: {
@ -154,6 +159,7 @@ window.addEventListener('load', async () => {
home.setPointerCapture(event.pointerId);
holdTimer = setTimeout(() => {
held = true;
Views.hideAll();
Runtime.broadcast('holdhome', {}, 'system');
}, 650);
});
@ -162,12 +168,13 @@ window.addEventListener('load', async () => {
home.addEventListener('pointerup', cancel);
home.addEventListener('pointercancel', cancel);
home.addEventListener('click', () => {
if (!held) Runtime.broadcast('home', {}, 'system');
if (!held) {Views.hideAll();Runtime.broadcast('home', {}, 'system');}
held = false;
});
window.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.code === 'Space') {
event.preventDefault();
Views.hideAll();
Runtime.broadcast('holdhome', {}, 'system');
}
});