Release Vulpes OS 2.7-preview.24
This commit is contained in:
parent
49572b6b45
commit
97ef99f827
135 changed files with 7717 additions and 2524 deletions
|
|
@ -3,7 +3,14 @@ import {Subprocess} from 'resource://gre/modules/Subprocess.sys.mjs';
|
|||
import {setTimeout, clearTimeout} from 'resource://gre/modules/Timer.sys.mjs';
|
||||
|
||||
export class NativeCamera {
|
||||
constructor() { this.session = null; this.queue = Promise.resolve(); this.closing = Promise.resolve(); this.nextSession = 0; }
|
||||
constructor(device = '') {
|
||||
this.device = device;
|
||||
this.previewSize = device === 'beatles' ? {width:320,height:240} : {width:640,height:480};
|
||||
this.session = null;
|
||||
this.queue = Promise.resolve();
|
||||
this.closing = Promise.resolve();
|
||||
this.nextSession = 0;
|
||||
}
|
||||
|
||||
request(actor, params) {
|
||||
const work = this.queue.then(() => this.perform(actor, params));
|
||||
|
|
@ -24,7 +31,8 @@ export class NativeCamera {
|
|||
if(length>65536) throw Error('CAMERA_INVALID_FORMAT');
|
||||
return pipe.readJSON(length);
|
||||
}
|
||||
if(length!==0 && length!==640*480*3/2) throw Error('CAMERA_INVALID_FRAME');
|
||||
const expected = this.session.width * this.session.height * 3 / 2;
|
||||
if(length!==0 && length!==expected) throw Error('CAMERA_INVALID_FRAME');
|
||||
return {sequence,data:length ? await pipe.read(length) : null};
|
||||
})(),
|
||||
new Promise((_, reject) => { timer=setTimeout(()=>reject(Error('CAMERA_TIMEOUT')),15000); }),
|
||||
|
|
@ -40,15 +48,28 @@ export class NativeCamera {
|
|||
if (params.action === 'start') {
|
||||
if (this.session && this.session.actor !== actor) throw Error('CAMERA_BUSY');
|
||||
await this.close();
|
||||
const process = await Subprocess.call({command:'/usr/bin/python3',
|
||||
arguments:['-u', '/etc/tundra/camera-preview.py', '--camera', params.camera === 'front' ? '1' : '0'],
|
||||
if (this.device === 'beatles' && params.camera === 'front') throw Error('CAMERA_UNAVAILABLE');
|
||||
const beatles = this.device === 'beatles';
|
||||
const process = await Subprocess.call({command:beatles?'/usr/bin/python3.11':'/usr/bin/python3',
|
||||
arguments:beatles
|
||||
? ['-u','/opt/vulpes/tundra/runtime/beatles-camera-client.py']
|
||||
: ['-u', '/etc/tundra/camera-preview.py', '--camera', params.camera === 'front' ? '1' : '0',
|
||||
'--width',String(this.previewSize.width),'--height',String(this.previewSize.height)],
|
||||
stderr:'ignore'});
|
||||
this.session = {actor, process, id: ++this.nextSession};
|
||||
try {
|
||||
const metadata = await this.packet(this.session, true);
|
||||
if (!metadata.ready || metadata.format !== 'nv21' || metadata.width !== 640 || metadata.height !== 480)
|
||||
if (!metadata.ready || metadata.format !== 'nv21' ||
|
||||
metadata.width !== this.previewSize.width || metadata.height !== this.previewSize.height)
|
||||
throw Error('CAMERA_INVALID_FORMAT');
|
||||
return {...metadata, session: this.session.id};
|
||||
this.session.width = metadata.width;
|
||||
this.session.height = metadata.height;
|
||||
return {...metadata, session: this.session.id,
|
||||
// The 2720 Flip has 400 MB of RAM and copies each frame through
|
||||
// CameraService, Gecko IPC and a software canvas. Polling at the
|
||||
// sensor's nominal rate exhausts its zram; keep its preview bounded.
|
||||
frameRate: beatles ? 5 : 30,
|
||||
cameras: beatles ? ['back'] : ['back','front']};
|
||||
} catch (error) { this.close(); throw error; }
|
||||
}
|
||||
if (!this.session || this.session.actor !== actor || this.session.id !== params.session)
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export class Platform {
|
|||
this.notifications = new Map();
|
||||
this.nextId = 1;
|
||||
this.cameraWindow = null;
|
||||
this.nativeCamera = new NativeCamera();
|
||||
this.nativeCamera = new NativeCamera(Services.env.get('VULPES_DEVICE') || '');
|
||||
this.picking = false;
|
||||
}
|
||||
async init() {
|
||||
|
|
@ -242,6 +242,7 @@ export class Platform {
|
|||
throw Error('NOT_SUPPORTED');
|
||||
}
|
||||
if (Services.env.get('VULPES_TUNDRA_STATUS') === '1') {
|
||||
if (operation === 'cameraPreview') return this.nativeCamera.request(actor, params);
|
||||
if (operation === 'wifi') return tundraWifi(params);
|
||||
const radio = Services.env.get('VULPES_TUNDRA_RADIO') === '1';
|
||||
if (radio && operation === 'radioPower') return tundraRadio('radioPower',params);
|
||||
|
|
@ -255,7 +256,7 @@ export class Platform {
|
|||
adapter:{version:'0.1.0-dev',device:'beatles'}, tundra:'0.1.0-dev',
|
||||
version:this.runtime.release.version, gaia:this.runtime.release.gaia,
|
||||
gecko:Services.appinfo.platformVersion, hardwareStatus:true,
|
||||
radioStatus:true, wifiControl:Services.env.get('VULPES_TUNDRA_WIFI') === '1', camera:false, media:true, contacts:true,
|
||||
radioStatus:true, wifiControl:Services.env.get('VULPES_TUNDRA_WIFI') === '1', camera:Services.env.get('VULPES_TUNDRA_CAMERA') === '1', media:true, contacts:true,
|
||||
notifications:true, dial:radio, callControl:radio, sms:radio, mms:false, callAudio:radio, alarms:false,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -281,6 +281,16 @@ export const Runtime = {
|
|||
this.actors.add(actor);
|
||||
const { method, params } = request;
|
||||
if (typeof method !== 'string') return { error: { code: 'INVALID_REQUEST' } };
|
||||
if (method === 'system.screenshot') {
|
||||
if (app.id !== 'system') return { error: { code: 'PERMISSION_DENIED' } };
|
||||
try { return { result: await Views.captureSystem(actor) }; }
|
||||
catch (error) { return { error: { code: error.message || 'SCREENSHOT_FAILED' } }; }
|
||||
}
|
||||
if (method === 'apps.close') {
|
||||
if (app.id !== 'costcontrol') return { error: { code: 'PERMISSION_DENIED' } };
|
||||
this.broadcast('home', {}, 'system');
|
||||
return { result: true };
|
||||
}
|
||||
if(method.startsWith('marketplace.')) {
|
||||
if(app.id!=='marketplace')return {error:{code:'PERMISSION_DENIED'}};
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export async function auditTickets({win,evaluate,wait,tap,sleep,root}) {
|
|||
await sleep(600);
|
||||
};
|
||||
win.focus();win.document.getElementById('system').focus();
|
||||
await step('unlock',()=>ev('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;'));
|
||||
await step('unlock',()=>ev('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))lockScreen.unlock(true);return !Service.query("locked");'));
|
||||
await step('cameraOpen',async()=>{
|
||||
await launch('camera');
|
||||
await wait('camera','return !!app.camera.mozCamera && !app.camera.isBusy;',15000);
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ export function exchange(path, params, limit=65536, timeout=20000) {
|
|||
const bytes=new TextEncoder().encode(JSON.stringify(params)+'\n');
|
||||
if (bytes.length>1228800) return Promise.reject(Error('REQUEST_TOO_LARGE'));
|
||||
return new Promise((resolve,reject)=>{
|
||||
let transport,input,output,reader,timer,finished=false,received='',offset=0;
|
||||
let transport,input,output,reader,pump,timer,finished=false,received='',offset=0;
|
||||
const finish=(error,value)=>{
|
||||
if(finished)return;finished=true;clearTimeout(timer);
|
||||
try{pump?.cancel(Cr.NS_BINDING_ABORTED);}catch(_){}
|
||||
try{input?.close();}catch(_){}try{output?.close();}catch(_){}
|
||||
try{transport?.close(Cr.NS_BINDING_ABORTED);}catch(_){}
|
||||
if(error)reject(error);else resolve(value);
|
||||
|
|
@ -35,20 +36,31 @@ export function exchange(path, params, limit=65536, timeout=20000) {
|
|||
output=transport.openOutputStream(0,0,0).QueryInterface(Ci.nsIAsyncOutputStream);
|
||||
reader=Cc['@mozilla.org/scriptableinputstream;1'].createInstance(Ci.nsIScriptableInputStream);
|
||||
reader.init(input);
|
||||
const read={onInputStreamReady(){
|
||||
if(finished)return;
|
||||
try{
|
||||
const count=input.available();
|
||||
if(!count)throw Error('BROKER_CLOSED');
|
||||
if(received.length+count>limit)throw Error('BROKER_REPLY_TOO_LARGE');
|
||||
received+=reader.readBytes(count);
|
||||
const read={
|
||||
onStartRequest() {},
|
||||
onDataAvailable(_request,stream,_offset,count){
|
||||
if(finished)return;
|
||||
try{
|
||||
if(received.length+count>limit)throw Error('BROKER_REPLY_TOO_LARGE');
|
||||
received+=reader.readBytes(count);
|
||||
const end=received.indexOf('\n');
|
||||
if(end>=0){
|
||||
const data=Uint8Array.from(received.slice(0,end),c=>c.charCodeAt(0));
|
||||
finish(null,JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(data)));
|
||||
}
|
||||
}catch(error){finish(error);}
|
||||
},
|
||||
onStopRequest(_request,status){
|
||||
if(finished)return;
|
||||
const end=received.indexOf('\n');
|
||||
if(end>=0){
|
||||
const data=Uint8Array.from(received.slice(0,end),c=>c.charCodeAt(0));
|
||||
finish(null,JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(data)));
|
||||
}else input.asyncWait(read,0,0,Services.tm.currentThread);
|
||||
}catch(error){finish(error);}
|
||||
}};
|
||||
try{
|
||||
const data=Uint8Array.from(received.slice(0,end),c=>c.charCodeAt(0));
|
||||
finish(null,JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(data)));
|
||||
}catch(error){finish(error);}
|
||||
}else finish(Error(status===Cr.NS_OK?'BROKER_CLOSED':'BROKER_READ_FAILED'));
|
||||
}
|
||||
};
|
||||
const write={onOutputStreamReady(){
|
||||
if(finished)return;
|
||||
try{
|
||||
|
|
@ -57,7 +69,13 @@ export function exchange(path, params, limit=65536, timeout=20000) {
|
|||
if(offset<bytes.length)output.asyncWait(write,0,0,Services.tm.currentThread);
|
||||
}catch(error){finish(error);}
|
||||
}};
|
||||
input.asyncWait(read,0,0,Services.tm.currentThread);
|
||||
pump=Cc['@mozilla.org/network/input-stream-pump;1'].createInstance(Ci.nsIInputStreamPump);
|
||||
// Gecko removed the stream position/length parameters. Its current
|
||||
// signature is (stream, segmentSize, segmentCount, closeWhenDone,
|
||||
// mainThreadTarget), so passing the legacy six arguments puts an
|
||||
// integer where the optional event target is expected.
|
||||
pump.init(input,0,0,false);
|
||||
pump.asyncRead(read);
|
||||
output.asyncWait(write,0,0,Services.tm.currentThread);
|
||||
}catch(error){finish(error);}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,28 @@
|
|||
// real top-level browser, never by removing CSP or frame-ancestors protection.
|
||||
export const Views = {
|
||||
entries: new Map(),
|
||||
async captureSystem(actor) {
|
||||
const context = actor.browsingContext;
|
||||
const uri = context?.currentWindowGlobal?.documentURI;
|
||||
if (!context || context.parent || uri?.scheme !== 'http' || uri?.host !== 'system.localhost' ||
|
||||
uri?.filePath !== '/index.html') {
|
||||
throw new Error('SCREENSHOT_NOT_AVAILABLE');
|
||||
}
|
||||
const image = await context.currentWindowGlobal.drawSnapshot(null, 1, 'white');
|
||||
const doc = context.top.embedderElement?.ownerDocument;
|
||||
if (!doc) {
|
||||
image.close();
|
||||
throw new Error('SCREENSHOT_NOT_AVAILABLE');
|
||||
}
|
||||
const canvas = doc.createElementNS('http://www.w3.org/1999/xhtml', 'canvas');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
canvas.getContext('2d').drawImage(image, 0, 0);
|
||||
image.close();
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('SCREENSHOT_FAILED')), 'image/png');
|
||||
});
|
||||
},
|
||||
syncContext(entry) {
|
||||
const context=entry.browser.browsingContext;
|
||||
if (!context || context.id===entry.topContextId) return;
|
||||
|
|
|
|||
|
|
@ -5,14 +5,7 @@ import {InputEditor} from 'resource://vulpes/services/input-editor.mjs';
|
|||
export class KeypadChild extends JSWindowActorChild {
|
||||
receiveMessage(message) {
|
||||
if (message.name === 'Vulpes:FocusBrowserToolbar') {
|
||||
if (this.document.location.hostname !== 'system.localhost') return;
|
||||
const bar = [...this.document.querySelectorAll('.chrome .urlbar')].find(element => {
|
||||
if (!element.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && element.contains(this.document.elementFromPoint(
|
||||
rect.x + rect.width / 2, rect.y + rect.height / 2));
|
||||
});
|
||||
if (bar) this.select(bar);
|
||||
this.focusBrowserToolbar();
|
||||
return;
|
||||
}
|
||||
if (message.name !== 'Vulpes:Navigate') return;
|
||||
|
|
@ -37,6 +30,13 @@ export class KeypadChild extends JSWindowActorChild {
|
|||
this.hashStartReturnMode=this.composer.returnMode;
|
||||
}
|
||||
const target = document.activeElement;
|
||||
// The collapsed Gaia rocketbar can leave its search input focused after
|
||||
// dismissing address entry. On keypad devices, Up should still reopen the
|
||||
// browser toolbar instead of moving the caret in that hidden input.
|
||||
if (key === 'ArrowUp' && document.location.hostname === 'system.localhost' &&
|
||||
target?.id === 'rocketbar-input' && !document.querySelector('#rocketbar')?.classList.contains('active')) {
|
||||
if (this.focusBrowserToolbar()) return;
|
||||
}
|
||||
const editing = !target?.readOnly && (target?.isContentEditable || target?.localName === 'textarea' ||
|
||||
(target?.localName === 'input' && ['text','search','email','url','tel','password','number'].includes(target.type)));
|
||||
const modal = [...document.querySelectorAll('[aria-modal="true"],[data-type="confirm"],[data-type="action"]')]
|
||||
|
|
@ -172,6 +172,18 @@ export class KeypadChild extends JSWindowActorChild {
|
|||
}
|
||||
this.select(elements[index]);
|
||||
}
|
||||
focusBrowserToolbar() {
|
||||
if (this.document.location.hostname !== 'system.localhost') return false;
|
||||
const bar = [...this.document.querySelectorAll('.chrome .urlbar')].find(element => {
|
||||
if (!element.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && element.contains(this.document.elementFromPoint(
|
||||
rect.x + rect.width / 2, rect.y + rect.height / 2));
|
||||
});
|
||||
if (!bar) return false;
|
||||
this.select(bar);
|
||||
return true;
|
||||
}
|
||||
select(element) {
|
||||
const document = this.document;
|
||||
if (!this.style) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
// Local Vulpes bootstrap, loaded only for the isolated host launcher.
|
||||
try {
|
||||
if (Services.env.get('VULPES_DEVICE') === 'sargo') {
|
||||
// Keep a pinch gesture from scaling the phone UI as a whole.
|
||||
pref('apz.allow_zooming', false);
|
||||
}
|
||||
if (Services.env.get('VULPES_HOST_ROOT')) {
|
||||
var root = Components.classes['@mozilla.org/file/local;1'].createInstance(Components.interfaces.nsIFile);
|
||||
root.initWithPath(Services.env.get('VULPES_HOST_ROOT'));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
// The Gaia camera UI consumes mozCameras. Only live MediaStream capabilities are exposed here.
|
||||
(() => {
|
||||
if (location.hostname !== 'camera.localhost') return;
|
||||
const videoMime = typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus') ? 'video/webm;codecs=vp8,opus' : null;
|
||||
const supportedMime = candidates => typeof MediaRecorder !== 'undefined' &&
|
||||
typeof MediaRecorder.isTypeSupported === 'function' ?
|
||||
candidates.find(type => MediaRecorder.isTypeSupported(type)) || null : null;
|
||||
const videoOnlyMime = supportedMime([
|
||||
'video/webm;codecs=vp8', 'video/webm;codecs=vp9', 'video/webm'
|
||||
]);
|
||||
const videoAudioMime = supportedMime([
|
||||
'video/webm;codecs=vp8,opus', 'video/webm;codecs=vp9,opus'
|
||||
]);
|
||||
const nativeCaptureAvailable = window.VulpesPlatform !== 'tundra' ||
|
||||
typeof HTMLCanvasElement.prototype.captureStream === 'function';
|
||||
const videoMime = nativeCaptureAvailable ? videoOnlyMime : null;
|
||||
window.VulpesVideoExtension = videoMime ? 'webm' : null;
|
||||
let cameras = ['back'],
|
||||
devices = new Map(),
|
||||
|
|
@ -143,7 +154,7 @@
|
|||
}
|
||||
async function discover(stream, name) {
|
||||
if (stream.vulpesNative) {
|
||||
cameras = ['back', 'front'];
|
||||
cameras = stream.vulpesCameraMetadata.cameras || ['back', 'front'];
|
||||
return;
|
||||
}
|
||||
// Device enumeration may wait indefinitely for focus in embedded desktop contexts.
|
||||
|
|
@ -178,19 +189,26 @@
|
|||
const call = (params) => VulpesCompat.call('platform.cameraPreview', {...params, session});
|
||||
const meta = await call({action:'start', camera:name === 'front' ? 'front' : 'back'});
|
||||
session = meta.session;
|
||||
const frameRate = meta.frameRate || 30;
|
||||
const canvas = nativeCanvas || (nativeCanvas = document.createElement('canvas'));
|
||||
const sideways = meta.orientation % 180 !== 0;
|
||||
const width = sideways ? meta.height : meta.width;
|
||||
const height = sideways ? meta.width : meta.height;
|
||||
if (canvas.width !== width) canvas.width = width;
|
||||
if (canvas.height !== height) canvas.height = height;
|
||||
// Convert NV21 in a shader instead of blocking Gaia's UI with one JS loop
|
||||
// per pixel. The two textures contain Y and interleaved V/U respectively.
|
||||
// Prefer the GPU converter, but older feature-phone runtimes may expose no
|
||||
// WebGL context at all. Their 320x240 preview is small enough for 2D canvas.
|
||||
const gl = canvas.getContext('webgl', {alpha:false, antialias:false, preserveDrawingBuffer:true});
|
||||
if (!gl) {
|
||||
const context2d = gl ? null : canvas.getContext('2d', {alpha:false});
|
||||
if (!gl && !context2d) {
|
||||
await call({action:'stop'});
|
||||
throw new DOMException('Camera preview requires WebGL', 'NotSupportedError');
|
||||
throw new DOMException('Camera preview has no supported canvas renderer', 'NotSupportedError');
|
||||
}
|
||||
const rawCanvas = context2d ? document.createElement('canvas') : null;
|
||||
if (rawCanvas) { rawCanvas.width=meta.width; rawCanvas.height=meta.height; }
|
||||
const rawContext = rawCanvas?.getContext('2d', {alpha:false});
|
||||
const imageData = rawContext?.createImageData(meta.width,meta.height);
|
||||
const pixels = meta.width * meta.height;
|
||||
const timing = {frames:0, startedAt:performance.now(), conversionMs:0, transferMs:0, sensorFrames:0};
|
||||
let stopped = false, timer, stream, wakeLock, sequence = -1, closing, program, buffer;
|
||||
const textures = [], shaders = [];
|
||||
|
|
@ -200,12 +218,14 @@
|
|||
wakeLock?.unlock(); wakeLock = null;
|
||||
closing = call({action:'stop'}).finally(() => {
|
||||
if (stream) stream.vulpesClosed = true;
|
||||
textures.forEach(texture => gl.deleteTexture(texture));
|
||||
shaders.forEach(value => gl.deleteShader(value));
|
||||
if (buffer) gl.deleteBuffer(buffer);
|
||||
if (program) gl.deleteProgram(program);
|
||||
// The next lens reuses this context after its old stream has stopped.
|
||||
gl.clearColor(0,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
if (gl) {
|
||||
textures.forEach(texture => gl.deleteTexture(texture));
|
||||
shaders.forEach(value => gl.deleteShader(value));
|
||||
if (buffer) gl.deleteBuffer(buffer);
|
||||
if (program) gl.deleteProgram(program);
|
||||
// The next lens reuses this context after its old stream has stopped.
|
||||
gl.clearColor(0,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
} else context2d.clearRect(0,0,canvas.width,canvas.height);
|
||||
});
|
||||
return closing;
|
||||
};
|
||||
|
|
@ -217,6 +237,7 @@
|
|||
if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw Error('Camera shader compilation failed');
|
||||
return value;
|
||||
}
|
||||
if (gl) {
|
||||
program = gl.createProgram();
|
||||
gl.attachShader(program, shader(gl.VERTEX_SHADER, `
|
||||
attribute vec2 position; varying vec2 coord;
|
||||
|
|
@ -253,11 +274,11 @@
|
|||
gl.uniform1i(gl.getUniformLocation(program,name),index);
|
||||
});
|
||||
gl.viewport(0,0,canvas.width,canvas.height);
|
||||
const pixels = meta.width * meta.height;
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE,meta.width,meta.height,0,gl.LUMINANCE,gl.UNSIGNED_BYTE,null);
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE_ALPHA,meta.width/2,meta.height/2,0,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,null);
|
||||
}
|
||||
const draw = async () => {
|
||||
const requested = performance.now();
|
||||
const frame = await call({action:'frame'});
|
||||
|
|
@ -267,11 +288,32 @@
|
|||
if (data.length !== w * h * 3 / 2) throw Error('Invalid native camera frame');
|
||||
sequence = frame.sequence; timing.sensorFrames = sequence;
|
||||
const start=performance.now();
|
||||
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,textures[0]);
|
||||
gl.texSubImage2D(gl.TEXTURE_2D,0,0,0,w,h,gl.LUMINANCE,gl.UNSIGNED_BYTE,data.subarray(0,pixels));
|
||||
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,textures[1]);
|
||||
gl.texSubImage2D(gl.TEXTURE_2D,0,0,0,w/2,h/2,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,data.subarray(pixels));
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
|
||||
if (gl) {
|
||||
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,textures[0]);
|
||||
gl.texSubImage2D(gl.TEXTURE_2D,0,0,0,w,h,gl.LUMINANCE,gl.UNSIGNED_BYTE,data.subarray(0,pixels));
|
||||
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,textures[1]);
|
||||
gl.texSubImage2D(gl.TEXTURE_2D,0,0,0,w/2,h/2,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,data.subarray(pixels));
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
|
||||
} else {
|
||||
const rgba=imageData.data;
|
||||
for(let y=0;y<h;y++) {
|
||||
const row=y*w,uv=pixels+(y>>1)*w;
|
||||
for(let x=0;x<w;x++) {
|
||||
const yy=Math.max(0,data[row+x]-16),pair=uv+(x&~1);
|
||||
const v=data[pair]-128,u=data[pair+1]-128,c=298*yy,offset=(row+x)*4;
|
||||
rgba[offset]=Math.max(0,Math.min(255,(c+409*v+128)>>8));
|
||||
rgba[offset+1]=Math.max(0,Math.min(255,(c-100*u-208*v+128)>>8));
|
||||
rgba[offset+2]=Math.max(0,Math.min(255,(c+516*u+128)>>8));
|
||||
rgba[offset+3]=255;
|
||||
}
|
||||
}
|
||||
rawContext.putImageData(imageData,0,0);
|
||||
context2d.save();
|
||||
if(meta.orientation===90){context2d.translate(canvas.width,0);context2d.rotate(Math.PI/2);}
|
||||
else if(meta.orientation===180){context2d.translate(canvas.width,canvas.height);context2d.rotate(Math.PI);}
|
||||
else if(meta.orientation===270){context2d.translate(0,canvas.height);context2d.rotate(-Math.PI/2);}
|
||||
context2d.drawImage(rawCanvas,0,0);context2d.restore();
|
||||
}
|
||||
timing.frames++;timing.conversionMs+=performance.now()-start;
|
||||
return true;
|
||||
};
|
||||
|
|
@ -302,9 +344,9 @@
|
|||
if (!stopped) { recordError(error); await stop().catch(recordError); }
|
||||
return;
|
||||
}
|
||||
if (!stopped) timer = setTimeout(next, Math.max(0, 1000/30-(performance.now()-started)));
|
||||
if (!stopped) timer = setTimeout(next, Math.max(0, 1000/frameRate-(performance.now()-started)));
|
||||
};
|
||||
timer = setTimeout(next, 1000/30);
|
||||
timer = setTimeout(next, 1000/frameRate);
|
||||
return stream;
|
||||
} catch (error) { await stop().catch(() => {}); throw error; }
|
||||
}
|
||||
|
|
@ -408,7 +450,8 @@
|
|||
let pictureSize = pictureSizes.reduce((a,b)=>a.width*a.height>b.width*b.height?a:b);
|
||||
let flashMode = 'off', flashUpdate = Promise.resolve();
|
||||
let focusArea = null, zoom = 1, mode = config.mode || 'picture', recording, startingRecording = false;
|
||||
const profile = {containerFormat:'webm',video:{width:size.width,height:size.height,codec:'vp8',bitrate:1500000,framerate:30},audio:{codec:'opus',samplerate:48000,channels:1,bitrate:64000}};
|
||||
const frameRate = native?.frameRate || 30;
|
||||
const profile = {containerFormat:'webm',video:{width:size.width,height:size.height,codec:videoMime?.includes('vp9')?'vp9':'vp8',bitrate:frameRate < 10 ? 500000 : 1500000,framerate:frameRate},audio:{codec:'opus',samplerate:48000,channels:1,bitrate:64000}};
|
||||
const configuration = () => ({
|
||||
mode,
|
||||
previewSize: size,
|
||||
|
|
@ -500,16 +543,55 @@
|
|||
if(!videoMime)throw new DOMException('Video unavailable','NotSupportedError');
|
||||
if(recording || startingRecording)throw new DOMException('Recording in progress','InvalidStateError');
|
||||
startingRecording=true;
|
||||
let audio, captured;
|
||||
let audio = null, captured, copyTimer, recordingCanvas, recordingContext;
|
||||
try {
|
||||
audio=await navigator.mediaDevices.getUserMedia({audio:true,video:false});
|
||||
// Video capture can still be useful when audio capture is unavailable.
|
||||
// Camera track failures remain fatal; only the optional audio track falls back.
|
||||
try {
|
||||
audio=await navigator.mediaDevices.getUserMedia({audio:true,video:false});
|
||||
} catch(error) {
|
||||
// Tundra may expose the camera through its HAL while Gecko has no
|
||||
// microphone capture backend. Audio is optional: that must not
|
||||
// prevent the video encoder from starting.
|
||||
console.info('Vulpes Camera: microphone unavailable; recording video without audio', error.name || error);
|
||||
}
|
||||
if(current!==stream || document.hidden)throw new DOMException('Camera closed','AbortError');
|
||||
// Only recording captures the native canvas. The preview keeps its
|
||||
// direct WebGL surface and the capture track is stopped before a switch.
|
||||
captured=native ? stream.vulpesPreviewCanvas.captureStream(30) : new MediaStream(stream.getVideoTracks().map(t=>t.clone()));
|
||||
for(const track of audio.getAudioTracks())captured.addTrack(track);
|
||||
const recorder=new MediaRecorder(captured,{mimeType:videoMime,videoBitsPerSecond:1500000,audioBitsPerSecond:64000});
|
||||
const chunks=[];let bytes=0,failed=false,finish;
|
||||
if(native) {
|
||||
// Gecko 156 on the Flip does not emit MediaRecorder data from a
|
||||
// WebGL captureStream. Copy the already-rendered sensor preview
|
||||
// into a small 2D canvas at the device's bounded frame rate.
|
||||
const source=stream.vulpesPreviewCanvas;
|
||||
recordingCanvas=document.createElement('canvas');
|
||||
recordingCanvas.width=source.width;recordingCanvas.height=source.height;
|
||||
recordingContext=recordingCanvas.getContext('2d',{alpha:false});
|
||||
if(!recordingContext)throw new DOMException('Video canvas unavailable','NotSupportedError');
|
||||
captured=recordingCanvas.captureStream(frameRate);
|
||||
} else captured=new MediaStream(stream.getVideoTracks().map(t=>t.clone()));
|
||||
for(const track of audio?.getAudioTracks() || [])captured.addTrack(track);
|
||||
const recorderMime=audio && videoAudioMime ? videoAudioMime : videoOnlyMime;
|
||||
if (audio && !videoAudioMime) {
|
||||
audio.getTracks().forEach(track => track.stop());
|
||||
audio = null;
|
||||
}
|
||||
const recorderOptions={mimeType:recorderMime,
|
||||
videoBitsPerSecond:profile.video.bitrate,
|
||||
...(audio ? {audioBitsPerSecond:profile.audio.bitrate} : {})};
|
||||
let recorder;
|
||||
try {
|
||||
recorder=new MediaRecorder(captured,recorderOptions);
|
||||
} catch(error) {
|
||||
if(!audio || !videoAudioMime)throw error;
|
||||
// Some encoders report Opus support but fail to initialize it
|
||||
// for a live device stream. Retry video-only instead of losing
|
||||
// the entire recording.
|
||||
audio.getTracks().forEach(track=>track.stop());audio=null;
|
||||
captured.getAudioTracks().forEach(track=>captured.removeTrack(track));
|
||||
recorder=new MediaRecorder(captured,{mimeType:videoOnlyMime,
|
||||
videoBitsPerSecond:profile.video.bitrate});
|
||||
}
|
||||
const chunks=[];let bytes=0,failed=false,finish,sizeLimitTimer;
|
||||
const done=new Promise(resolve=>{finish=resolve;});
|
||||
const cap=Math.min(options.maxFileSizeBytes || Infinity,32*1024*1024);
|
||||
recorder.ondataavailable=event=>{
|
||||
|
|
@ -521,9 +603,11 @@
|
|||
};
|
||||
recorder.onerror=()=>{failed=true;emit(stream,'recorderstatechange',{newState:'MediaRecorderFailed'});};
|
||||
recorder.onstop=async()=>{
|
||||
clearTimeout(sizeLimitTimer);
|
||||
try {
|
||||
if(failed || !bytes)throw Error('Empty or failed recording');
|
||||
const blob=new Blob(chunks,{type:'video/webm'});
|
||||
const blob=new Blob(chunks,{type:recorder.mimeType || recorderMime || 'video/webm'});
|
||||
if(blob.size>cap)throw new Error('Video exceeds the available storage limit');
|
||||
await storage.addNamed(blob,filepath);
|
||||
const canvas=document.createElement('canvas');
|
||||
const source=native ? stream.vulpesPreviewCanvas : preview;
|
||||
|
|
@ -536,16 +620,44 @@
|
|||
} catch(error) {
|
||||
recordError(error);emit(stream,'recorderstatechange',{newState:'MediaRecorderFailed'});
|
||||
} finally {
|
||||
clearTimeout(copyTimer);
|
||||
captured.getTracks().forEach(t=>t.stop());
|
||||
recordingCanvas?.remove();
|
||||
chunks.length=0;recording=null;finish();
|
||||
emit(stream,'recorderstatechange',{newState:'Stopped'});
|
||||
}
|
||||
};
|
||||
recording={recorder,done};
|
||||
recorder.start(500);
|
||||
// Gecko 156's ARM/feature-phone media recorder can enter the
|
||||
// inactive state without firing dataavailable/onstop when a
|
||||
// timeslice is supplied. Collect the final chunk on stop instead.
|
||||
recorder.start();
|
||||
if(recordingCanvas) {
|
||||
const track=captured.getVideoTracks()[0];
|
||||
const copyFrame=()=>{
|
||||
if(recorder.state!=='recording')return;
|
||||
try {recordingContext.drawImage(stream.vulpesPreviewCanvas,0,0,recordingCanvas.width,recordingCanvas.height);track.requestFrame?.();}
|
||||
catch(error) {recordError(error);}
|
||||
if(recorder.state==='recording')copyTimer=setTimeout(copyFrame,Math.max(40,1000/frameRate));
|
||||
};
|
||||
recordingContext.drawImage(stream.vulpesPreviewCanvas,0,0,recordingCanvas.width,recordingCanvas.height);
|
||||
track.requestFrame?.();
|
||||
copyTimer=setTimeout(copyFrame,Math.max(40,1000/frameRate));
|
||||
}
|
||||
if(Number.isFinite(cap)) {
|
||||
const bitrate=Math.max(1,profile.video.bitrate||500000);
|
||||
sizeLimitTimer=setTimeout(()=>{
|
||||
if(recorder.state==='recording') {
|
||||
emit(stream,'recorderstatechange',{newState:'FileSizeLimitReached'});
|
||||
recorder.stop();
|
||||
}
|
||||
},Math.max(1000,Math.floor(cap*8/bitrate*1000*.95)));
|
||||
}
|
||||
emit(stream,'recorderstatechange',{newState:'Started'});
|
||||
} catch(error) {
|
||||
clearTimeout(copyTimer);
|
||||
captured?.getTracks().forEach(t=>t.stop());audio?.getTracks().forEach(t=>t.stop());
|
||||
recordingCanvas?.remove();
|
||||
recording=null;throw error;
|
||||
} finally {startingRecording=false;}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -30,9 +30,14 @@
|
|||
const next = state.battery;
|
||||
if (next && Number.isFinite(next.level) && next.level >= 0 && next.level <= 1) {
|
||||
const oldLevel = battery.level, oldCharging = battery.charging;
|
||||
const oldChargingTime = battery.chargingTime, oldDischargingTime = battery.dischargingTime;
|
||||
battery.level = next.level; battery.charging = !!next.charging;
|
||||
battery.chargingTime = Number.isFinite(next.chargingTime) && next.chargingTime > 0 ? next.chargingTime : Infinity;
|
||||
battery.dischargingTime = Number.isFinite(next.dischargingTime) && next.dischargingTime > 0 ? next.dischargingTime : Infinity;
|
||||
if (oldLevel !== battery.level) battery.dispatchEvent(new Event('levelchange'));
|
||||
if (oldCharging !== battery.charging) battery.dispatchEvent(new Event('chargingchange'));
|
||||
if (oldChargingTime !== battery.chargingTime) battery.dispatchEvent(new Event('chargingtimechange'));
|
||||
if (oldDischargingTime !== battery.dischargingTime) battery.dispatchEvent(new Event('dischargingtimechange'));
|
||||
}
|
||||
} catch (_) { /* An unknown battery is not an empty battery. */ }
|
||||
}
|
||||
|
|
@ -278,6 +283,12 @@
|
|||
if (location.hostname==='system.localhost') addEventListener('mozContentEvent', event=>{
|
||||
if (event.detail?.type==='activity-choice')
|
||||
call('activities.select',{id:event.detail.id,value:event.detail.value}).catch(console.error);
|
||||
if (event.detail?.type==='take-screenshot')
|
||||
call('system.screenshot',{}).then(file=>dispatchEvent(new CustomEvent('mozChromeEvent',{
|
||||
detail:{type:'take-screenshot-success',file}
|
||||
}))).catch(error=>dispatchEvent(new CustomEvent('mozChromeEvent',{
|
||||
detail:{type:'take-screenshot-error',error:error.message || 'SCREENSHOT_FAILED'}
|
||||
})));
|
||||
});
|
||||
function application(record) {
|
||||
// DOMApplication never exposed the registry's internal id. Gaia uses `id`
|
||||
|
|
@ -412,6 +423,12 @@
|
|||
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 };
|
||||
// Cost Control calls window.close() after its SIM-ready error dialog.
|
||||
// Desktop and Android use Gaia's in-app task switcher, so close the app
|
||||
// through the privileged host bridge instead of leaving the dialog stuck.
|
||||
if (location.hostname === 'costcontrol.localhost') {
|
||||
window.close = () => call('apps.close', {}).catch(console.error);
|
||||
}
|
||||
window.MozActivity = function (source) {
|
||||
return request(call('activities.start', source));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,6 +43,18 @@
|
|||
padding: 1rem 1.5rem;
|
||||
transform: none;
|
||||
}
|
||||
#display #vulpes-layout-row {
|
||||
display: block !important;
|
||||
min-height: 10rem;
|
||||
height: auto !important;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
#vulpes-layout-row > p {
|
||||
height: auto;
|
||||
line-height: 2.4rem;
|
||||
padding: 1rem 1.5rem;
|
||||
transform: none;
|
||||
}
|
||||
:root[data-vulpes-theme="dark"][data-vulpes-app="settings"] h2 {
|
||||
background-color: var(--vulpes-surface) !important;
|
||||
}
|
||||
|
|
@ -155,6 +167,12 @@ html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] #apps-panel > .
|
|||
padding-top: 2px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
html[data-vulpes-app="homescreen"] gaia-grid#icons {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html[data-vulpes-app="homescreen"] body.edit-mode gaia-grid#icons {
|
||||
padding-bottom: calc(5rem + var(--software-home-button-height, 5rem));
|
||||
}
|
||||
|
||||
/* A single applications panel; saved pinned pages remain available in classic mode. */
|
||||
html[data-vulpes-layout="compact"][data-vulpes-app="homescreen"] :is(#page-indicator-header, #pages-panel, #shadow) {
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@
|
|||
}
|
||||
// Settings follows delayed power changes. Adapters with live association
|
||||
// state also refresh System, so reconnects update the status-bar icon.
|
||||
if(location.hostname.startsWith('settings.') || state.state) {
|
||||
if(location.hostname.startsWith('settings.') || location.hostname.startsWith('system.')) {
|
||||
let pending=false;
|
||||
const refresh=setInterval(async()=>{
|
||||
if((location.hostname.startsWith('settings.') && document.hidden) || pending) return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue