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:
parent
d04824ec89
commit
0041cd9660
101 changed files with 3883 additions and 335 deletions
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
7
host/actors/MarketplaceChild.sys.mjs
Normal file
7
host/actors/MarketplaceChild.sys.mjs
Normal 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}));
|
||||
}
|
||||
}
|
||||
13
host/actors/MarketplaceParent.sys.mjs
Normal file
13
host/actors/MarketplaceParent.sys.mjs
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
14
host/marketplace-page.js
Normal 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
41
host/settings-codec.js
Normal 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 });
|
||||
})();
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue