Release Preview 20 shared desktop, Android and Sargo updates

Restore Gaia settings and media workflows, extend native network adapters, and align the APK payload. Generate French/English changelogs and website entries from the simplified JSON source.

Validation: shared JS and Tundra unit tests; desktop workflows and persistence; signed APK payload parity; Sargo RAM and installed startup with boot readback. Android runtime qualification is recorded separately.
This commit is contained in:
OverSu 2026-10-13 00:19:45 +02:00
commit 1311bed5e9
170 changed files with 19698 additions and 2495 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
<p align="center">Firefox OS fork · Open source</p> <p align="center">Firefox OS fork · Open source</p>
<p align="center"> <p align="center">
<a href="https://nnsprod.com/git/oversu/VulpesOS/releases"><img src="https://img.shields.io/badge/Vulpes-2.7%20Preview%2017-0755be?style=flat-square" alt="Vulpes: 2.7 Preview 17"></a> <a href="https://nnsprod.com/git/oversu/VulpesOS/releases"><img src="https://img.shields.io/badge/Vulpes-2.7%20Preview%2020-0755be?style=flat-square" alt="Vulpes: 2.7 Preview 20"></a>
<img src="https://img.shields.io/badge/Gaia-2.7-0755be?style=flat-square" alt="Gaia: 2.7"> <img src="https://img.shields.io/badge/Gaia-2.7-0755be?style=flat-square" alt="Gaia: 2.7">
<img src="https://img.shields.io/badge/Gecko-156.0.1-0099cc?style=flat-square" alt="Gecko: 156.0.1"> <img src="https://img.shields.io/badge/Gecko-156.0.1-0099cc?style=flat-square" alt="Gecko: 156.0.1">
<img src="https://img.shields.io/badge/Tundra-0.1.0-0099cc?style=flat-square" alt="Tundra: 0.1.0"> <img src="https://img.shields.io/badge/Tundra-0.1.0-0099cc?style=flat-square" alt="Tundra: 0.1.0">
@ -38,6 +38,12 @@ Je développe Vulpes principalement seul, sous le nom d’OverSu, avec l’aide
Sur Pixel 3a, **Tundra démarre directement Gecko et Gaia** et assure leur accès au matériel. Droidian a servi d’environnement de test au début du projet : il n’est pas inclus dans Vulpes OS et Tundra ne repose pas dessus. Sur Pixel 3a, **Tundra démarre directement Gecko et Gaia** et assure leur accès au matériel. Droidian a servi d’environnement de test au début du projet : il n’est pas inclus dans Vulpes OS et Tundra ne repose pas dessus.
### Preview 20 · 13 octobre 2026
[Télécharger l’APK](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.20). Les corrections communes concernent les paramètres Gaia, l’enregistrement vidéo, les contacts et les médias. Les adaptations réseau natives sont incluses dans les sources. Le [changelog](CHANGELOG.md) précise les vérifications et les limites restantes.
PC/Sargo utilisent Gecko **156.0.1** ; Android utilise GeckoView **156.0**. Android 8 / API 26 minimum déclaré, sans validation de tous les appareils. Appels et SMS ouvrent les applications Android. L’APK est un build de développement signé, avec débogage via ADB autorisé.
### Linux ### Linux
Prérequis : Linux x86_64, Git, Python 3 et les bibliothèques système nécessaires à Firefox. Prérequis : Linux x86_64, Git, Python 3 et les bibliothèques système nécessaires à Firefox.

View file

@ -4,7 +4,7 @@
<p align="center">Firefox OS fork · Open source</p> <p align="center">Firefox OS fork · Open source</p>
<p align="center"> <p align="center">
<a href="https://nnsprod.com/git/oversu/VulpesOS/releases"><img src="https://img.shields.io/badge/Vulpes-2.7%20Preview%2017-0755be?style=flat-square" alt="Vulpes: 2.7 Preview 17"></a> <a href="https://nnsprod.com/git/oversu/VulpesOS/releases"><img src="https://img.shields.io/badge/Vulpes-2.7%20Preview%2020-0755be?style=flat-square" alt="Vulpes: 2.7 Preview 20"></a>
<img src="https://img.shields.io/badge/Gaia-2.7-0755be?style=flat-square" alt="Gaia: 2.7"> <img src="https://img.shields.io/badge/Gaia-2.7-0755be?style=flat-square" alt="Gaia: 2.7">
<img src="https://img.shields.io/badge/Gecko-156.0.1-0099cc?style=flat-square" alt="Gecko: 156.0.1"> <img src="https://img.shields.io/badge/Gecko-156.0.1-0099cc?style=flat-square" alt="Gecko: 156.0.1">
<img src="https://img.shields.io/badge/Tundra-0.1.0-0099cc?style=flat-square" alt="Tundra: 0.1.0"> <img src="https://img.shields.io/badge/Tundra-0.1.0-0099cc?style=flat-square" alt="Tundra: 0.1.0">
@ -38,6 +38,12 @@ I’m OverSu, and I develop Vulpes mostly on my own, with help from people who t
On the Pixel 3a, **Tundra boots Gecko and Gaia directly** and provides access to the hardware. Droidian was used as a testing environment early in development. It is not included in Vulpes OS, and Tundra is not based on it. On the Pixel 3a, **Tundra boots Gecko and Gaia directly** and provides access to the hardware. Droidian was used as a testing environment early in development. It is not included in Vulpes OS, and Tundra is not based on it.
### Preview 20 · 13 October 2026
[Download the APK](https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.20). Shared updates cover Gaia settings, camera recording, contacts and media. Native network changes are included in the sources. See the [changelog](CHANGELOG.en.md) for checks and remaining limitations.
Desktop/Sargo use Gecko **156.0.1**; Android uses GeckoView **156.0**. Android 8 / API 26 minimum is declared; not every device is tested. Calls and SMS open Android applications. The APK is a signed development build, with debugging through authorized ADB.
### Linux ### Linux
You’ll need Linux x86_64, Git, Python 3 and the system libraries required by Firefox. You’ll need Linux x86_64, Git, Python 3 and the system libraries required by Firefox.

View file

@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" /> <uses-feature android:name="android.hardware.camera.any" android:required="false" />
<uses-permission android:name="com.android.alarm.permission.SET_ALARM" /> <uses-permission android:name="com.android.alarm.permission.SET_ALARM" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View file

@ -102,6 +102,18 @@ final class AndroidServices {
); );
result.complete(null); result.complete(null);
break; break;
case "android.storage":
io.execute(() -> {
try {
File data = new File(app.getApplicationInfo().dataDir);
File mediaRoot = new File(app.getFilesDir(), "media");
JSONObject stats = new JSONObject().put("free", data.getUsableSpace())
.put("apps", storageBytes(data, mediaRoot)).put("sdcard", storageBytes(mediaRoot, null))
.put("scope", "vulpes-profile");
main.post(() -> result.complete(stats));
} catch (Exception e) { fail(result, e); }
});
break;
case "android.media": case "android.media":
JSONObject params = p; JSONObject params = p;
io.execute(() -> { io.execute(() -> {
@ -122,6 +134,15 @@ final class AndroidServices {
return result; return result;
} }
private static long storageBytes(File file, File skip) throws IOException {
if (file.equals(skip) || !file.exists() || !file.getCanonicalFile().equals(file.getAbsoluteFile())) return 0;
if (file.isFile()) return file.length();
long size = 0;
File[] children = file.listFiles();
if (children != null) for (File child : children) size += storageBytes(child, skip);
return size;
}
private void fail(GeckoResult<Object> result, Exception e) { private void fail(GeckoResult<Object> result, Exception e) {
main.post(() -> { main.post(() -> {
try { try {

View file

@ -550,12 +550,17 @@ public final class MainActivity extends Activity {
"http".equals(origin.getScheme()) && "http".equals(origin.getScheme()) &&
"camera.localhost".equals(origin.getHost()) && "camera.localhost".equals(origin.getHost()) &&
origin.getPort() == 18765; origin.getPort() == 18765;
if (!camera || (audio != null && audio.length > 0) || video == null || video.length == 0) { boolean hasVideo = video != null && video.length > 0;
boolean hasAudio = audio != null && audio.length > 0;
if (!camera || (!hasVideo && !hasAudio)) {
callback.reject(); callback.reject();
return; return;
} }
permission(new String[] { android.Manifest.permission.CAMERA }, granted -> { java.util.ArrayList<String> requested = new java.util.ArrayList<>();
if (granted) callback.grant(video[0], null); if (hasVideo) requested.add(android.Manifest.permission.CAMERA);
if (hasAudio) requested.add(android.Manifest.permission.RECORD_AUDIO);
permission(requested.toArray(new String[0]), granted -> {
if (granted) callback.grant(hasVideo ? video[0] : null, hasAudio ? audio[0] : null);
else callback.reject(); else callback.reject();
}); });
} }

View file

@ -1,3 +1,4 @@
import {Input} from './services/input.mjs';
import { Packages, packageURL } from './services/packages.mjs'; import { Packages, packageURL } from './services/packages.mjs';
import { Activities, isGaiaActivity } from './services/activities.mjs'; import { Activities, isGaiaActivity } from './services/activities.mjs';
import { authorizePlatform } from './services/platform.mjs'; import { authorizePlatform } from './services/platform.mjs';
@ -10,6 +11,8 @@ import { Connections } from './Connections.mjs';
import { Alarms } from './Alarms.mjs'; import { Alarms } from './Alarms.mjs';
const peers = new Set(); const peers = new Set();
let activities, packages; let activities, packages;
const input = new Input({identity:peer=>peer.app,broadcast});
let editSequence=0;
const nativePackages=(operation,data={})=>browser.runtime.sendNativeMessage('vulpes',{type:'packages.'+operation,...data}).then(JSON.parse); const nativePackages=(operation,data={})=>browser.runtime.sendNativeMessage('vulpes',{type:'packages.'+operation,...data}).then(JSON.parse);
async function refreshInstalled(){const installed=await nativePackages('list');apps.splice(0,apps.length,...apps.filter(a=>!a.installed),...installed);} async function refreshInstalled(){const installed=await nativePackages('list');apps.splice(0,apps.length,...apps.filter(a=>!a.installed),...installed);}
let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage; let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage;
@ -31,7 +34,7 @@ function grants(app) {
function broadcast(type, data, target) { function broadcast(type, data, target) {
for (const peer of peers) { for (const peer of peers) {
if (target && peer.app.id !== target) continue; if (target && peer.app.id !== target) continue;
if (type === 'settingchange' && data.key !== 'language.current' && !grants(peer.app).includes('settings.read')) continue; if (type === 'settingchange' && !['language.current', 'vulpes.theme'].includes(data.key) && !grants(peer.app).includes('settings.read')) continue;
if ( if (
type === 'contactchange' && type === 'contactchange' &&
!peer.app.manifest.permissions?.contacts?.access?.includes('read') !peer.app.manifest.permissions?.contacts?.access?.includes('read')
@ -90,6 +93,7 @@ const ready = (async () => {
await browser.storage.local.set({ settings: values }); await browser.storage.local.set({ settings: values });
} }
activities = new Activities({identity,apps:()=>apps,notify,uuid:()=>crypto.randomUUID(), activities = new Activities({identity,apps:()=>apps,notify,uuid:()=>crypto.randomUUID(),
choose:data=>broadcast('activity-choice',data,'system'),
launch:(target,path,{reload=false,entryPoint}={})=>launch(target,entryPoint,path,reload)}); launch:(target,path,{reload=false,entryPoint}={})=>launch(target,entryPoint,path,reload)});
let writes = Promise.resolve(); let writes = Promise.resolve();
settings = new SettingsService( settings = new SettingsService(
@ -100,7 +104,12 @@ const ready = (async () => {
const next = { ...values, ...Object.fromEntries(entries) }; const next = { ...values, ...Object.fromEntries(entries) };
await browser.storage.local.set({ settings: next }); await browser.storage.local.set({ settings: next });
values = next; values = next;
for (const [key, value] of entries) broadcast('settingchange', { key, value }); for (const [key, value] of entries) {
broadcast('settingchange', { key, value });
if(key==='keyboard.gaia.enabled') {
input.hide();broadcast('input-mode',{enabled:value===true});
}
}
}); });
writes = write.catch(() => {}); writes = write.catch(() => {});
return write; return write;
@ -302,6 +311,9 @@ async function handle(peer, request) {
} }
if (!Object.keys(params.values).length) return null; if (!Object.keys(params.values).length) return null;
} }
if (method === 'input.config') return values['keyboard.gaia.enabled'] === true;
if (method === 'input.request') return input.request(peer,params);
if (method === 'appearance.current') return values['vulpes.theme'] || 'light';
if (method === 'locale.current') return values['language.current'] || 'en-US'; if (method === 'locale.current') return values['language.current'] || 'en-US';
if (method === 'settings.snapshot') { if (method === 'settings.snapshot') {
if (!grants(app).includes('settings.read')) throw Error('PERMISSION_DENIED'); if (!grants(app).includes('settings.read')) throw Error('PERMISSION_DENIED');
@ -357,6 +369,7 @@ async function handle(peer, request) {
return null; return null;
} }
if (method === 'activities.cancel') return activities.cancel(peer); if (method === 'activities.cancel') return activities.cancel(peer);
if (method === 'activities.select') return activities.select(peer,params);
if (method === 'activities.subscribe') return activities.subscribe(peer); if (method === 'activities.subscribe') return activities.subscribe(peer);
if (method === 'activities.finish') return activities.finish(peer,params); if (method === 'activities.finish') return activities.finish(peer,params);
if (method === 'activities.start' && isGaiaActivity(params)) return activities.start(peer,params); if (method === 'activities.start' && isGaiaActivity(params)) return activities.start(peer,params);
@ -412,6 +425,7 @@ async function handle(peer, request) {
return null; return null;
} }
if (method === 'desktop.wakelock') return null; // Android Activity owns the visible screen. if (method === 'desktop.wakelock') return null; // Android Activity owns the visible screen.
if (method === 'storage.stats') {if(app.id!=='settings')throw Error('PERMISSION_DENIED');return android('storage',{});}
if (method === 'media.request') return media(app, params); if (method === 'media.request') return media(app, params);
throw Error('METHOD_NOT_SUPPORTED'); throw Error('METHOD_NOT_SUPPORTED');
} }
@ -449,6 +463,15 @@ browser.runtime.onConnect.addListener((port) => {
app, app,
manager: { documentURI: { spec: url.href } }, manager: { documentURI: { spec: url.href } },
sendAsyncMessage: (_, event) => notify(peer, event.type, event.data), sendAsyncMessage: (_, event) => notify(peer, event.type, event.data),
edits: new Map(),
sendQuery(_, params) {
const id=++editSequence;
return new Promise((resolve,reject)=>{
const timer=setTimeout(()=>{peer.edits.delete(id);reject(Error('INPUT_CONTEXT_EXPIRED'));},5000);
peer.edits.set(id,{resolve,reject,timer});
port.postMessage({edit:{id,params}});
});
},
}; };
peers.add(peer); peers.add(peer);
}); });
@ -456,6 +479,16 @@ browser.runtime.onConnect.addListener((port) => {
let reply; let reply;
try { try {
await attached; await attached;
if(Object.hasOwn(message,'inputFocus')) {
if(values['keyboard.gaia.enabled']===true) input.changed(peer,message.inputFocus);
return;
}
if(Object.hasOwn(message,'editReply')) {
const edit=peer.edits.get(message.editReply);
if(edit) {clearTimeout(edit.timer);peer.edits.delete(message.editReply);
if(message.error)edit.reject(Error(message.error));else edit.resolve(message.result);}
return;
}
reply = { result: await handle(peer, message.request) }; reply = { result: await handle(peer, message.request) };
} catch (e) { } catch (e) {
reply = { error: { code: e.message || 'HOST_ERROR' } }; reply = { error: { code: e.message || 'HOST_ERROR' } };
@ -469,6 +502,9 @@ browser.runtime.onConnect.addListener((port) => {
attached attached
.then(() => { .then(() => {
peers.delete(peer); peers.delete(peer);
input.cleanup(peer);
for(const edit of peer.edits.values()) {clearTimeout(edit.timer);edit.reject(Error('INPUT_CONTEXT_EXPIRED'));}
peer.edits.clear();
if (peer.app.id === 'system' && ![...peers].some((p) => p.app.id === 'system')) if (peer.app.id === 'system' && ![...peers].some((p) => p.app.id === 'system'))
systemReady = false; systemReady = false;
activities.cleanup(peer); activities.cleanup(peer);

View file

@ -25,7 +25,23 @@
}); });
} }
function onMessage(message) { function onMessage(message) {
if (message.edit) {
try {
const result=editor.edit(message.edit.params);
result.inputMode=originalModes.get(editor.inputElement) ||
(editor.inputElement.classList.contains('recipient')?'tel':'');
port.postMessage({editReply:message.edit.id, result});
}
catch (error) { port.postMessage({editReply:message.edit.id, error:String(error.message)}); }
return;
}
if (message.event) { if (message.event) {
if(message.event.type==='input-mode') {
gaiaKeyboard=message.event.data.enabled===true;
if(!gaiaKeyboard) restoreMode();
win.VulpesNativeInput=gaiaKeyboard;
win.dispatchEvent(new window.Event('vulpes-input-configured'));
}
win.dispatchEvent( win.dispatchEvent(
new window.CustomEvent('vulpes-service-event', { detail: cloneInto(message.event, win) }), new window.CustomEvent('vulpes-service-event', { detail: cloneInto(message.event, win) }),
); );
@ -50,6 +66,45 @@
}, 1000); }, 1000);
} }
connect(); connect();
const editor = new InputEditor(window);
let gaiaKeyboard = false;
const originalModes = new WeakMap();
function restoreMode() {
const field = editor.inputElement;
if (!field || !originalModes.has(field)) return;
const value = originalModes.get(field);
if (value === null) field.removeAttribute('inputmode');
else field.setAttribute('inputmode', value);
originalModes.delete(field);
}
request({version:1,id:0,method:'input.config',params:{}}).then(reply => {
gaiaKeyboard = reply.result === true;
win.VulpesNativeInput = gaiaKeyboard;
win.dispatchEvent(new window.Event('vulpes-input-configured'));
}).catch(error => console.error('Gaia input configuration:', error));
for (const type of ['focusin', 'pointerdown', 'click']) addEventListener(type, event => {
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return;
let element = event.target;
if (element?.isContentEditable) element=element.closest('[contenteditable=true]') || element;
const editable = element?.isContentEditable ||
(['input','textarea'].includes(element?.localName) &&
!['button','submit','range','checkbox','radio','date','time','datetime-local','file'].includes(element.type));
if (!editable || element.disabled || element.readOnly) {
if (type === 'click' && !element?.closest?.('[data-vulpes-keep-input]')) port.postMessage({inputFocus:null});
return;
}
if (element !== editor.inputElement) restoreMode();
editor.focus(element);
if (!originalModes.has(element)) originalModes.set(element, element.getAttribute('inputmode'));
// Suppress Android's IME only in this editor; external web pages keep it.
element.setAttribute('inputmode', 'none');
const state=editor.state();
state.inputMode=originalModes.get(element) || (element.classList.contains('recipient')?'tel':'');
port.postMessage({inputFocus:state});
}, true);
addEventListener('visibilitychange', () => {
if (document.hidden && gaiaKeyboard) { restoreMode(); port.postMessage({inputFocus:null}); }
});
addEventListener('unload', () => clearTimeout(reconnect)); addEventListener('unload', () => clearTimeout(reconnect));
exportFunction(request, win.navigator, { defineAs: 'vulpesRequest' }); exportFunction(request, win.navigator, { defineAs: 'vulpesRequest' });
const frames = new Map(); const frames = new Map();

View file

@ -25,6 +25,7 @@
"http://*.localhost/*" "http://*.localhost/*"
], ],
"js": [ "js": [
"input-editor.js",
"content.js" "content.js"
], ],
"all_frames": true, "all_frames": true,

View file

@ -125,11 +125,11 @@ result["camera_resumes_from_android_background"] = wait(
"camera", "camera",
'!document.hidden && !!app.camera.mozCamera && app.camera.mozCamera!==__streamBefore && app.camera.mozCamera.getVideoTracks().every(t=>t.readyState==="live") && !app.camera.isBusy', '!document.hidden && !!app.camera.mozCamera && app.camera.mozCamera!==__streamBefore && app.camera.mozCamera.getVideoTracks().every(t=>t.readyState==="live") && !app.camera.isBusy',
) )
result["unavailable_controls"] = ev( result["capability_controls"] = ev(
"camera", "camera",
'[".js-flash",".mode-switch"].every(s=>document.querySelector(s).getAttribute("aria-disabled")==="true")', 'document.querySelector(".js-flash").getAttribute("aria-disabled")==="true" && (document.querySelector(".mode-switch").getAttribute("aria-disabled")!=="true") === !!Object.keys(app.camera.mozCamera.capabilities.recorderProfiles).length',
) )
assert result["unavailable_controls"] assert result["capability_controls"]
result["errors"] = ev( result["errors"] = ev(
"camera", '__vulpesDiagnostics.filter(d=>d.kind==="error" || d.kind==="rejection")' "camera", '__vulpesDiagnostics.filter(d=>d.kind==="error" || d.kind==="rejection")'
) )

View file

@ -12,7 +12,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools")) sys.path.insert(0, str(ROOT / "tools"))
from project import build_info, validate_assets, release_info, application_manifest from project import SOURCE_OVERRIDES, build_info, validate_assets, release_info, application_manifest
validate_assets() validate_assets()
OUT = ROOT / "android/app/src/main/assets" OUT = ROOT / "android/app/src/main/assets"
@ -46,17 +46,8 @@ info = build_info()
release = release_info() release = release_info()
revision = info["revision"] revision = info["revision"]
commit_time = str(info["timestamp"]) commit_time = str(info["timestamp"])
source_overrides = {
"system": [
"js/launcher.js",
"js/gaia_scheduler.js",
"js/keyboard_manager.js",
"js/core.js",
"js/rocketbar.js",
]
}
textsuffix = {".html", ".js", ".css", ".json", ".webapp", ".properties", ".svg"} textsuffix = {".html", ".js", ".css", ".json", ".webapp", ".properties", ".svg"}
head = b'<head><script src="/_vulpes/platform-config.js"></script><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"><link rel="stylesheet" href="/_vulpes/desktop.css"><link rel="stylesheet" href="/_vulpes/android.css"><script src="/_vulpes/settings-codec.js"></script><script src="/_vulpes/gaia-compat.js"></script><script src="/_vulpes/gaia-wifi.js"></script><script src="/_vulpes/gaia-services.js"></script><script src="/_vulpes/gaia-styles.js"></script><script src="/_vulpes/android-ui.js"></script><script src="/_vulpes/gaia-restoration.js"></script><script src="/_vulpes/platform-ui.js"></script><script src="/_vulpes/gaia-camera.js"></script>' head = b'<head><script src="/_vulpes/platform-config.js"></script><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"><link rel="stylesheet" href="/_vulpes/desktop.css"><link rel="stylesheet" href="/_vulpes/android.css"><script src="/_vulpes/settings-codec.js"></script><script src="/_vulpes/gaia-compat.js"></script><script src="/_vulpes/gaia-wifi.js"></script><script src="/_vulpes/gaia-mobile-settings.js"></script><script src="/_vulpes/gaia-bluetooth.js"></script><script src="/_vulpes/gaia-input.js"></script><script src="/_vulpes/gaia-services.js"></script><script src="/_vulpes/gaia-styles.js"></script><link rel="stylesheet" href="/_vulpes/gaia-theme.css"><script src="/_vulpes/gaia-theme.js"></script><script src="/_vulpes/gaia-select.js"></script><script src="/_vulpes/android-ui.js"></script><script src="/_vulpes/gaia-restoration.js"></script><script src="/_vulpes/platform-ui.js"></script><script src="/_vulpes/gaia-camera.js"></script>'
if "--bridge-only" not in sys.argv or not (OUT / "gaia.zip").exists(): if "--bridge-only" not in sys.argv or not (OUT / "gaia.zip").exists():
with zipfile.ZipFile(OUT / "gaia.zip", "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z: with zipfile.ZipFile(OUT / "gaia.zip", "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z:
hashes = {} hashes = {}
@ -82,7 +73,7 @@ if "--bridge-only" not in sys.argv or not (OUT / "gaia.zip").exists():
if p.is_file() and p.name != "manifest.webapp" if p.is_file() and p.name != "manifest.webapp"
} }
) )
for name in source_overrides.get(app["id"], []): for name in SOURCE_OVERRIDES.get(app["id"], []):
files[name] = ROOT / "gaia/apps" / app["id"] / name files[name] = ROOT / "gaia/apps" / app["id"] / name
for name, p in files.items(): for name, p in files.items():
data = p.read_bytes() data = p.read_bytes()
@ -120,7 +111,7 @@ if "--bridge-only" not in sys.argv or not (OUT / "gaia.zip").exists():
data = translate(data.decode("utf8")).encode() data = translate(data.decode("utf8")).encode()
put("_shared/" + p.relative_to(ROOT / "gaia/shared").as_posix(), data) put("_shared/" + p.relative_to(ROOT / "gaia/shared").as_posix(), data)
put("_vulpes/platform-config.js", b'window.VulpesPlatform="android";') put("_vulpes/platform-config.js", b'window.VulpesPlatform="android";')
for name in ["gaia-compat.js", "gaia-services.js", "gaia-styles.js", "gaia-restoration.js", "gaia-wifi.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]: for name in ["gaia-compat.js", "gaia-input.js", "gaia-services.js", "gaia-styles.js", "gaia-theme.js", "gaia-select.js", "gaia-mobile-settings.js", "gaia-theme.css", "gaia-restoration.js", "gaia-wifi.js", "gaia-bluetooth.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]:
text = ( text = (
(ROOT / "host" / name) (ROOT / "host" / name)
.read_text() .read_text()
@ -145,6 +136,7 @@ for name in ["Connections", "Contacts", "Messages"]:
) )
(ext / f"{name}.mjs").write_text(text) (ext / f"{name}.mjs").write_text(text)
shutil.copy2(ROOT / "host/Alarms.sys.mjs", ext / "Alarms.mjs") shutil.copy2(ROOT / "host/Alarms.sys.mjs", ext / "Alarms.mjs")
(ext / "input-editor.js").write_text((ROOT / "services/input-editor.mjs").read_text().replace('export class InputEditor', 'class InputEditor'))
shutil.copy2(ROOT / "host/marketplace-page.js", ext / "marketplace-page.js") shutil.copy2(ROOT / "host/marketplace-page.js", ext / "marketplace-page.js")
(ext / "release.json").write_text(json.dumps(release)) (ext / "release.json").write_text(json.dumps(release))
(ext / "registry.json").write_text(json.dumps(registry, ensure_ascii=False)) (ext / "registry.json").write_text(json.dumps(registry, ensure_ascii=False))

View file

@ -5447,6 +5447,9 @@ define('views/viewfinder', [
if (!preview) { if (!preview) {
return; return;
} }
// Recompute when the embedded app becomes visible or rotates.
this.getSize();
if (!this.width || !this.height) { return; }
var aspect; var aspect;
if (sensorAngle % 180 === 0) { if (sensorAngle % 180 === 0) {
this.container = { this.container = {
@ -5697,6 +5700,7 @@ define('controllers/viewfinder', [
this.views.viewfinder.set('grid', 'off'); this.views.viewfinder.set('grid', 'off');
}; };
ViewfinderController.prototype.bindEvents = function () { ViewfinderController.prototype.bindEvents = function () {
window.addEventListener('resize', this.configurePreview);
this.views.viewfinder.on('fadedin', this.app.firer('viewfinder:visible')); this.views.viewfinder.on('fadedin', this.app.firer('viewfinder:visible'));
this.views.viewfinder.on('fadedout', this.app.firer('viewfinder:hidden')); this.views.viewfinder.on('fadedout', this.app.firer('viewfinder:hidden'));
this.views.viewfinder.on('click', this.app.firer('viewfinder:click')); this.views.viewfinder.on('click', this.app.firer('viewfinder:click'));

View file

@ -1,7 +1,7 @@
define('modules/navigator/mozPermissionSettings',[],function(){'use strict';return window.navigator.mozPermissionSettings;});define('panels/app_permissions_list/app_permissions_list',['require','modules/settings_service','shared/manifest_helper','modules/apps_cache','modules/navigator/mozApps','modules/navigator/mozPermissionSettings'],function(require){'use strict';var SettingsService=require('modules/settings_service');var ManifestHelper=require('shared/manifest_helper');var AppsCache=require('modules/apps_cache');var mozApps=require('modules/navigator/mozApps');var mozPerms=require('modules/navigator/mozPermissionSettings');var PermissionsList=function pl(){this._listRoot=null;this._permissionsTable=null;this._permissionTableHaveProcessed=false;this._apps=null;this._enabled=false;};PermissionsList.prototype={init:function pl_init(listRoot){this._listRoot=listRoot;this._boundOnAppChoose=this._onAppChoose.bind(this);this._boundOnApplicationInstall=this._onApplicationInstall.bind(this);this._boundOnApplicationUninstall=this._onApplicationUninstall.bind(this);},set enabled(value){if(value!==this._enabled){this._enabled=value;if(this._enabled){this._bindEvents();}else{this._unbindEvents();}}},_bindEvents:function pl__bindEvents(){this._listRoot.addEventListener('click',this._boundOnAppChoose);AppsCache.addEventListener('oninstall',this._boundOnApplicationInstall);AppsCache.addEventListener('onuninstall',this._boundOnApplicationUninstall);},_unbindEvents:function pl__unbindEvents(){this._listRoot.removeEventListener('click',this._boundOnAppChoose);AppsCache.removeEventListener('oninstall',this._boundOnApplicationInstall);AppsCache.removeEventListener('onuninstall',this._boundOnApplicationUninstall);},setPermissionsTable:function pl_setPermissionsTable(permissionTable){this._permissionTable=permissionTable;},refresh:function pl_refresh(){var self=this;this._apps=[];if(this._permissionTableHaveProcessed){return this.loadApps();}else{return mozApps.getSelf().then(function(app){return self._initExplicitPermissionsTable(app);}).then(function(){return self.loadApps();});}},_onAppChoose:function pl__onAppChoose(evt){if(evt.target.dataset&&evt.target.dataset.appIndex){SettingsService.navigate('appPermissions-details',{app:this._apps[evt.target.dataset.appIndex],permissionsTable:this._permissionTable});}},_onApplicationInstall:function pl__onApplicationInstall(evt){var app=evt.application;this._apps.push(app);this._sortApps();this.renderList();},_onApplicationUninstall:function pl__onApplicationUninstall(evt){var app;var appIndex;this._apps.some(function findApp(anApp,index){if(anApp.origin===evt.application.origin){app=anApp;appIndex=index;return true;} define('modules/navigator/mozPermissionSettings',[],function(){'use strict';return window.navigator.mozPermissionSettings;});define('panels/app_permissions_list/app_permissions_list',['require','modules/settings_service','shared/manifest_helper','modules/apps_cache','modules/navigator/mozApps','modules/navigator/mozPermissionSettings'],function(require){'use strict';var SettingsService=require('modules/settings_service');var ManifestHelper=require('shared/manifest_helper');var AppsCache=require('modules/apps_cache');var mozApps=require('modules/navigator/mozApps');var mozPerms=require('modules/navigator/mozPermissionSettings');var PermissionsList=function pl(){this._listRoot=null;this._permissionsTable=null;this._permissionTableHaveProcessed=false;this._apps=null;this._enabled=false;};PermissionsList.prototype={init:function pl_init(listRoot){this._listRoot=listRoot;this._boundOnAppChoose=this._onAppChoose.bind(this);this._boundOnApplicationInstall=this._onApplicationInstall.bind(this);this._boundOnApplicationUninstall=this._onApplicationUninstall.bind(this);},set enabled(value){if(value!==this._enabled){this._enabled=value;if(this._enabled){this._bindEvents();}else{this._unbindEvents();}}},_bindEvents:function pl__bindEvents(){this._listRoot.addEventListener('click',this._boundOnAppChoose);AppsCache.addEventListener('oninstall',this._boundOnApplicationInstall);AppsCache.addEventListener('onuninstall',this._boundOnApplicationUninstall);},_unbindEvents:function pl__unbindEvents(){this._listRoot.removeEventListener('click',this._boundOnAppChoose);AppsCache.removeEventListener('oninstall',this._boundOnApplicationInstall);AppsCache.removeEventListener('onuninstall',this._boundOnApplicationUninstall);},setPermissionsTable:function pl_setPermissionsTable(permissionTable){this._permissionTable=permissionTable;},refresh:function pl_refresh(){var self=this;this._apps=[];if(this._permissionTableHaveProcessed){return this.loadApps();}else{return mozApps.getSelf().then(function(app){return self._initExplicitPermissionsTable(app);}).then(function(){return self.loadApps();});}},_onAppChoose:function pl__onAppChoose(evt){var target=evt.target.closest('[data-app-index]');if(target&&this._listRoot.contains(target)){SettingsService.navigate('appPermissions-details',{app:this._apps[target.dataset.appIndex],permissionsTable:this._permissionTable});}},_onApplicationInstall:function pl__onApplicationInstall(evt){var app=evt.application;this._apps.push(app);this._sortApps();this.renderList();},_onApplicationUninstall:function pl__onApplicationUninstall(evt){var app;var appIndex;this._apps.some(function findApp(anApp,index){if(anApp.origin===evt.application.origin){app=anApp;appIndex=index;return true;}
return false;});if(!app){return;} return false;});if(!app){return;}
SettingsService.navigate('appPermissions');this._apps.splice(appIndex,1);this.renderList();},_sortApps:function pl__sortApps(){this._apps.sort(function alphabeticalSort(app,otherApp){var manifest=new ManifestHelper(app.manifest?app.manifest:app.updateManifest);var otherManifest=new ManifestHelper(otherApp.manifest?otherApp.manifest:otherApp.updateManifest);return manifest.name>otherManifest.name;});},_genAppItemTemplate:function pl__genAppItemTemplate(itemData){var icon=document.createElement('img');var item=document.createElement('li');var link=document.createElement('a');var span=document.createElement('span');span.textContent=itemData.name;span.dir='auto';icon.src=itemData.iconSrc;link.dataset.appIndex=itemData.index;link.href='#';link.classList.add('menu-item');link.appendChild(icon);link.appendChild(span);item.appendChild(link);return item;},renderList:function pl_renderList(){this._listRoot.innerHTML='';var listFragment=document.createDocumentFragment();this._apps.forEach(function appIterator(app,index){var manifest=new ManifestHelper(app.manifest?app.manifest:app.updateManifest);var li=this._genAppItemTemplate({name:manifest.displayName,index:index,iconSrc:this._getBestIconURL(app,manifest.icons)});listFragment.appendChild(li);}.bind(this));this._listRoot.appendChild(listFragment);},_initExplicitPermissionsTable:function pl__initExplicitPermissionsTable(app){this._currentApp=app;var table=this._permissionTable;table.explicitCertifiedPermissions=[];table.plainPermissions.forEach(function plainPermIterator(perm){if(this._isExplicitPerm(perm)){table.explicitCertifiedPermissions.push({explicitPermission:perm,permission:perm});}}.bind(this));table.composedPermissions.forEach(function permIterator(perm){table.accessModes.forEach(function modeIterator(mode){var composedPerm=perm+'-'+mode;if(this._isExplicitPerm(composedPerm)){table.explicitCertifiedPermissions.push({explicitPermission:composedPerm,permission:perm});}}.bind(this));}.bind(this));this._permissionTableHaveProcessed=true;},_isExplicitPerm:function pl_isExplicitPerm(perm){return mozPerms.isExplicit(perm,this._currentApp.manifestURL,this._currentApp.origin,false);},loadApps:function pl_loadApps(){var self=this;return AppsCache.apps().then(function(apps){self._loadApps(apps);});},_loadApps:function pl__loadApps(apps){var table=this._permissionTable;apps.forEach(function(app){var manifest=app.manifest?app.manifest:app.updateManifest;if(manifest.type!='certified'){this._apps.push(app);return;} SettingsService.navigate('appPermissions');this._apps.splice(appIndex,1);this.renderList();},_sortApps:function pl__sortApps(){this._apps.sort(function alphabeticalSort(app,otherApp){var manifest=new ManifestHelper(app.manifest?app.manifest:app.updateManifest);var otherManifest=new ManifestHelper(otherApp.manifest?otherApp.manifest:otherApp.updateManifest);return manifest.name>otherManifest.name;});},_genAppItemTemplate:function pl__genAppItemTemplate(itemData){var icon=document.createElement('img');var item=document.createElement('li');var link=document.createElement('a');var span=document.createElement('span');span.textContent=itemData.name;span.dir='auto';icon.src=itemData.iconSrc;link.dataset.appIndex=itemData.index;link.href='#';link.classList.add('menu-item');link.appendChild(icon);link.appendChild(span);item.appendChild(link);return item;},renderList:function pl_renderList(){this._listRoot.innerHTML='';var listFragment=document.createDocumentFragment();this._apps.forEach(function appIterator(app,index){var manifest=new ManifestHelper(app.manifest?app.manifest:app.updateManifest);var li=this._genAppItemTemplate({name:manifest.displayName,index:index,iconSrc:this._getBestIconURL(app,manifest.icons)});listFragment.appendChild(li);}.bind(this));this._listRoot.appendChild(listFragment);},_initExplicitPermissionsTable:function pl__initExplicitPermissionsTable(app){this._currentApp=app;var table=this._permissionTable;table.explicitCertifiedPermissions=[];table.plainPermissions.forEach(function plainPermIterator(perm){if(this._isExplicitPerm(perm)){table.explicitCertifiedPermissions.push({explicitPermission:perm,permission:perm});}}.bind(this));table.composedPermissions.forEach(function permIterator(perm){table.accessModes.forEach(function modeIterator(mode){var composedPerm=perm+'-'+mode;if(this._isExplicitPerm(composedPerm)){table.explicitCertifiedPermissions.push({explicitPermission:composedPerm,permission:perm});}}.bind(this));}.bind(this));this._permissionTableHaveProcessed=true;},_isExplicitPerm:function pl_isExplicitPerm(perm){return mozPerms&&mozPerms.isExplicit(perm,this._currentApp.manifestURL,this._currentApp.origin,false);},loadApps:function pl_loadApps(){var self=this;return AppsCache.apps().then(function(apps){self._loadApps(apps);});},_loadApps:function pl__loadApps(apps){var table=this._permissionTable;apps.forEach(function(app){var manifest=app.manifest?app.manifest:app.updateManifest;if(manifest.type!='certified'){this._apps.push(app);return;}
var display=table.explicitCertifiedPermissions.some(function iterator(perm){var permInfo=mozPerms.get(perm.explicitPermission,app.manifestURL,app.origin,false);return permInfo!='unknown';}.bind(this));if(display){this._apps.push(app);}}.bind(this));this._sortApps();this.renderList();},_getBestIconURL:function pl__getBestIconURL(app,icons){if(!icons||!Object.keys(icons).length){return'../style/images/default.png';} var display=table.explicitCertifiedPermissions.some(function iterator(perm){var permInfo=mozPerms.get(perm.explicitPermission,app.manifestURL,app.origin,false);return permInfo!='unknown';}.bind(this));if(display){this._apps.push(app);}}.bind(this));this._sortApps();this.renderList();},_getBestIconURL:function pl__getBestIconURL(app,icons){if(!icons||!Object.keys(icons).length){return'../style/images/default.png';}
var preferredIconSize=30*(window.devicePixelRatio||1);var preferredSize=Number.MAX_VALUE;var max=0;for(var size in icons){size=parseInt(size,10);if(size>max){max=size;} var preferredIconSize=30*(window.devicePixelRatio||1);var preferredSize=Number.MAX_VALUE;var max=0;for(var size in icons){size=parseInt(size,10);if(size>max){max=size;}
if(size>=preferredIconSize&&size<preferredSize){preferredSize=size;}} if(size>=preferredIconSize&&size<preferredSize){preferredSize=size;}}

View file

@ -1,11 +1,17 @@
define('panels/screen_lock_passcode/panel',['require','modules/settings_utils','modules/dialog_panel','shared/passcode_helper'],function(require){'use strict';var SettingsUtils=require('modules/settings_utils');var DialogPanel=require('modules/dialog_panel');var PasscodeHelper=require('shared/passcode_helper');return function ctor_screenlockPasscode(){var mozSettings=window.navigator.mozSettings;return DialogPanel({_elements:{},_mode:'create',_leftApp:false,_passcodeBuffer:'',_checkingLength:{'create':8,'new':8,'edit':4,'confirm':4,'confirmLock':4},_headerL10nIdMapping:{'create':'new-passcode','edit':'current-passcode','new':'new-passcode','confirm':'enter-passcode','confirmLock':'enter-passcode'},_submitButtonL10nIdMapping:{'new':'change','create':'create','edit':'','confirm':'','confirmLock':''},onInit:function(panel){this._elements.panel=panel;this._elements.gaiaHeader=panel.querySelector('gaia-header');this._elements.header=panel.querySelector('gaia-header h1');this._elements.passcodeInput=panel.querySelector('.passcode-input');this._elements.passcodeDigits=panel.querySelectorAll('.passcode-digit');this._elements.passcodeContainer=panel.querySelector('.passcode-container');this._elements.submitButton=panel.querySelector('button[type="submit"]');this._elements.submitButtonText=this._elements.submitButton.querySelector('span');this._bindEvents();},_bindEvents:function(){this._elements.passcodeInput.onkeypress=this._onPasscodeInputKeypress.bind(this);this._elements.passcodeContainer.onclick=this._onPasscodeContainerClick.bind(this);},_onPasscodeContainerClick:function(evt){evt.preventDefault();this._elements.passcodeInput.focus();},_onPasscodeInputKeypress:function(evt){var keyCode=evt.keyCode;if(keyCode!==0&&keyCode!==8){return;} define('panels/screen_lock_passcode/panel',['require','modules/settings_utils','modules/dialog_panel','shared/passcode_helper'],function(require){'use strict';var SettingsUtils=require('modules/settings_utils');var DialogPanel=require('modules/dialog_panel');var PasscodeHelper=require('shared/passcode_helper');return function ctor_screenlockPasscode(){var mozSettings=window.navigator.mozSettings;return DialogPanel({_elements:{},_mode:'create',_leftApp:false,_passcodeBuffer:'',_checkingLength:{'create':8,'new':8,'edit':4,'confirm':4,'confirmLock':4},_headerL10nIdMapping:{'create':'new-passcode','edit':'current-passcode','new':'new-passcode','confirm':'enter-passcode','confirmLock':'enter-passcode'},_submitButtonL10nIdMapping:{'new':'change','create':'create','edit':'','confirm':'','confirmLock':''},onInit:function(panel){this._elements.panel=panel;this._elements.gaiaHeader=panel.querySelector('gaia-header');this._elements.header=panel.querySelector('gaia-header h1');this._elements.passcodeInput=panel.querySelector('.passcode-input');this._elements.passcodeDigits=panel.querySelectorAll('.passcode-digit');this._elements.passcodeContainer=panel.querySelector('.passcode-container');this._elements.submitButton=panel.querySelector('button[type="submit"]');this._elements.submitButtonText=this._elements.submitButton.querySelector('span');this._bindEvents();},_bindEvents:function(){this._elements.passcodeInput.type='password';this._elements.passcodeInput.inputMode='numeric';this._elements.passcodeInput.maxLength=8;this._elements.passcodeInput.oninput=this._onPasscodeInput.bind(this);this._elements.passcodeContainer.onclick=this._onPasscodeContainerClick.bind(this);},_onPasscodeContainerClick:function(evt){evt.preventDefault();this._elements.passcodeInput.focus();},_onPasscodeInput: function() {
evt.preventDefault();if(this._passcodeBuffer===''){this._hideErrorMessage();} // Android IMEs commit text without legacy keypress/charCode events.
var code=evt.charCode;if(code!==0&&(code<0x30||code>0x39)){return;} var value = this._elements.passcodeInput.value.replace(/[^0-9]/g, '').slice(0, 8);
var key=String.fromCharCode(code);if(evt.charCode===0){if(this._passcodeBuffer.length>0){this._passcodeBuffer=this._getPasscodeBuffer('all');if(this._elements.panel.dataset.passcodeStatus==='success'){this._resetPasscodeStatus();}}}else if(this._passcodeBuffer.length<8){this._passcodeBuffer+=key;} this._hideErrorMessage();
this._updatePassCodeUI();this._processPasscode();},_updateHeaderAndButtonUI:function(){var headerL10nId=this._headerL10nIdMapping[this._mode];var buttonL10nId=this._submitButtonL10nIdMapping[this._mode];if(headerL10nId){this._elements.header.setAttribute('data-l10n-id',headerL10nId);} if (this._elements.panel.dataset.passcodeStatus === 'success') {
this._resetPasscodeStatus();
}
this._passcodeBuffer = value;
this._updatePassCodeUI();
this._processPasscode();
},_updateHeaderAndButtonUI:function(){var headerL10nId=this._headerL10nIdMapping[this._mode];var buttonL10nId=this._submitButtonL10nIdMapping[this._mode];if(headerL10nId){this._elements.header.setAttribute('data-l10n-id',headerL10nId);}
if(buttonL10nId===''){this._elements.submitButton.disabled=true;this._elements.submitButtonText.removeAttribute('data-l10n-id');this._elements.submitButtonText.textContent='';}else if(buttonL10nId){this._elements.submitButton.disabled=false;this._elements.submitButtonText.setAttribute('data-l10n-id',buttonL10nId);} if(buttonL10nId===''){this._elements.submitButton.disabled=true;this._elements.submitButtonText.removeAttribute('data-l10n-id');this._elements.submitButtonText.textContent='';}else if(buttonL10nId){this._elements.submitButton.disabled=false;this._elements.submitButtonText.setAttribute('data-l10n-id',buttonL10nId);}
SettingsUtils.runHeaderFontFit(this._elements.gaiaHeader);},_updatePassCodeUI:function(){for(var i=0;i<8;i++){if(i<this._passcodeBuffer.length){this._elements.passcodeDigits[i].dataset.dot=true;}else{delete this._elements.passcodeDigits[i].dataset.dot;}}},_processPasscode:function(){if(this._passcodeBuffer.length===this._checkingLength[this._mode]){switch(this._mode){case'create':case'new':var originalPasscode=this._getPasscodeBuffer('original');var confirmPasscode=this._getPasscodeBuffer('confirm');if(originalPasscode!==confirmPasscode){this._passcodeBuffer='';this._updatePassCodeUI();this._showErrorMessage();}else{this._showSuccessPasscodeStatus();} SettingsUtils.runHeaderFontFit(this._elements.gaiaHeader);},_updatePassCodeUI:function(){this._elements.passcodeInput.value=this._passcodeBuffer;for(var i=0;i<8;i++){if(i<this._passcodeBuffer.length){this._elements.passcodeDigits[i].dataset.dot=true;}else{delete this._elements.passcodeDigits[i].dataset.dot;}}},_processPasscode:function(){if(this._passcodeBuffer.length===this._checkingLength[this._mode]){switch(this._mode){case'create':case'new':var originalPasscode=this._getPasscodeBuffer('original');var confirmPasscode=this._getPasscodeBuffer('confirm');if(originalPasscode!==confirmPasscode){this._passcodeBuffer='';this._updatePassCodeUI();this._showErrorMessage();}else{this._showSuccessPasscodeStatus();}
break;case'confirm':this._checkPasscode().then((result)=>{if(result){mozSettings.createLock().set({'lockscreen.passcode-lock.enabled':false}).then(()=>{this._showSuccessPasscodeStatus();this.cancel();}).catch((error)=>{console.log(error);});}else{this._passcodeBuffer='';this._updatePassCodeUI();}});break;case'confirmLock':this._checkPasscode().then((result)=>{if(result){mozSettings.createLock().set({'lockscreen.enabled':false,'lockscreen.passcode-lock.enabled':false}).then(()=>{this._showSuccessPasscodeStatus();this.cancel();}).catch((error)=>{console.log(error);});}else{this._passcodeBuffer='';this._updatePassCodeUI();}});break;case'edit':this._checkPasscode().then((result)=>{if(result){this._passcodeBuffer='';this._updatePassCodeUI();this._showDialogInMode('new');}else{this._passcodeBuffer='';this._updatePassCodeUI();}});break;}}},_showDialogInMode:function(mode){this._mode=mode;this._elements.panel.dataset.mode=mode;this._hideErrorMessage();this._updatePassCodeUI();this._updateHeaderAndButtonUI();},onSubmit:function(){if(this._elements.panel.dataset.passcodeStatus!=='success'){this._showErrorMessage();this._elements.passcodeInput.focus();return Promise.reject();}else{var originalPasscode=this._getPasscodeBuffer('original');return mozSettings.createLock().set({'lockscreen.passcode-lock.enabled':true}).then(()=>{return PasscodeHelper.set(originalPasscode);});}},onCancel:function(){this._elements.passcodeInput.blur();},onBeforeShow:function(panel,options){this._mode=options.mode;if(!this._leftApp){this._showDialogInMode(this._mode);} break;case'confirm':this._checkPasscode().then((result)=>{if(result){mozSettings.createLock().set({'lockscreen.passcode-lock.enabled':false}).then(()=>{this._showSuccessPasscodeStatus();this.cancel();}).catch((error)=>{console.log(error);});}else{this._passcodeBuffer='';this._updatePassCodeUI();}});break;case'confirmLock':this._checkPasscode().then((result)=>{if(result){mozSettings.createLock().set({'lockscreen.enabled':false,'lockscreen.passcode-lock.enabled':false}).then(()=>{this._showSuccessPasscodeStatus();this.cancel();}).catch((error)=>{console.log(error);});}else{this._passcodeBuffer='';this._updatePassCodeUI();}});break;case'edit':this._checkPasscode().then((result)=>{if(result){this._passcodeBuffer='';this._updatePassCodeUI();this._showDialogInMode('new');}else{this._passcodeBuffer='';this._updatePassCodeUI();}});break;}}},_showDialogInMode:function(mode){this._mode=mode;this._elements.panel.dataset.mode=mode;this._hideErrorMessage();this._updatePassCodeUI();this._updateHeaderAndButtonUI();},onSubmit:function(){if(this._elements.panel.dataset.passcodeStatus!=='success'){this._showErrorMessage();this._elements.passcodeInput.focus();return Promise.reject();}else{var originalPasscode=this._getPasscodeBuffer('original');return mozSettings.createLock().set({'lockscreen.passcode-lock.enabled':true}).then(()=>{return PasscodeHelper.set(originalPasscode);});}},onCancel:function(){this._elements.passcodeInput.blur();},onBeforeShow:function(panel,options){this._mode=options.mode;if(!this._leftApp){this._showDialogInMode(this._mode);}
this._leftApp=false;},onShow:function(){this._elements.passcodeInput.focus();},onHide:function(){this._leftApp=document.hidden;if(!this._leftApp){this._passcodeBuffer='';}},_checkPasscode:function(){return PasscodeHelper.check(this._passcodeBuffer).then((result)=>{if(result){this._hideErrorMessage();}else{this._showErrorMessage();} this._leftApp=false;},onShow:function(){this._elements.passcodeInput.focus();},onHide:function(){this._leftApp=document.hidden;if(!this._leftApp){this._passcodeBuffer='';}},_checkPasscode:function(){return PasscodeHelper.check(this._passcodeBuffer).then((result)=>{if(result){this._hideErrorMessage();}else{this._showErrorMessage();}
return result;}).catch(()=>{this._showErrorMessage();});},_showErrorMessage:function(){this._elements.panel.dataset.passcodeStatus='error';},_hideErrorMessage:function(){this._elements.panel.dataset.passcodeStatus='';},_resetPasscodeStatus:function(){this._elements.panel.dataset.passcodeStatus='';},_showSuccessPasscodeStatus:function(){this._elements.panel.dataset.passcodeStatus='success';},_getPasscodeBuffer:function(part){if(part==='original'){return this._passcodeBuffer.substring(0,4);}else if(part==='confirm'){return this._passcodeBuffer.substring(4,8);}else if(part==='all'){return this._passcodeBuffer.substring(0,this._passcodeBuffer.length-1);}else{console.log('You are using _getPasscodeBuffer wrongly, '+'please check your argument again - '+part);}}});};}); return result;}).catch(()=>{this._showErrorMessage();});},_showErrorMessage:function(){this._elements.panel.dataset.passcodeStatus='error';},_hideErrorMessage:function(){this._elements.panel.dataset.passcodeStatus='';},_resetPasscodeStatus:function(){this._elements.panel.dataset.passcodeStatus='';},_showSuccessPasscodeStatus:function(){this._elements.panel.dataset.passcodeStatus='success';},_getPasscodeBuffer:function(part){if(part==='original'){return this._passcodeBuffer.substring(0,4);}else if(part==='confirm'){return this._passcodeBuffer.substring(4,8);}else if(part==='all'){return this._passcodeBuffer.substring(0,this._passcodeBuffer.length-1);}else{console.log('You are using _getPasscodeBuffer wrongly, '+'please check your argument again - '+part);}}});};});

View file

@ -399,7 +399,7 @@ function iteratorSV(value){this.applicationSource.addPreviouslyInstalledSvApp(va
loadTable(DB_SV_APP_STORE_NAME,'indexSV',iteratorSV.bind(this));loadTable(DB_ITEM_STORE,'index',iterator,finish.bind(this));function addIfUnique(item){if(!cached[item.identifier]){cached[item.identifier]=true;this._allItems.push(item);}} loadTable(DB_SV_APP_STORE_NAME,'indexSV',iteratorSV.bind(this));loadTable(DB_ITEM_STORE,'index',iterator,finish.bind(this));function addIfUnique(item){if(!cached[item.identifier]){cached[item.identifier]=true;this._allItems.push(item);}}
function finish(){for(var i=0,iLen=collected.length;i<iLen;i++){var thisItem=collected[i];if(thisItem.type==='app'){var itemObj=this.applicationSource.mapToApp(thisItem);addIfUnique.call(this,itemObj);}else if(thisItem.type==='divider'){var divider=new GaiaGrid.Divider(thisItem);this._allItems.push(divider);}else if(thisItem.type==='bookmark'){var bookmark=new GaiaGrid.Bookmark(thisItem);addIfUnique.call(this,bookmark);}} function finish(){for(var i=0,iLen=collected.length;i<iLen;i++){var thisItem=collected[i];if(thisItem.type==='app'){var itemObj=this.applicationSource.mapToApp(thisItem);addIfUnique.call(this,itemObj);}else if(thisItem.type==='divider'){var divider=new GaiaGrid.Divider(thisItem);this._allItems.push(divider);}else if(thisItem.type==='bookmark'){var bookmark=new GaiaGrid.Bookmark(thisItem);addIfUnique.call(this,bookmark);}}
this.notifyReady();if(callback&&typeof callback==='function'){callback();}}},synchronize:function(){this.sources.forEach(function eachSource(source){source.synchronize();});},initSources:function(callback){var pending=this.sources.length;var allEntries=[];var self=this;var current=0;function handleSource(){var source=self.sources[current];current++;source.populate(next);} this.notifyReady();if(callback&&typeof callback==='function'){callback();}}},synchronize:function(){this.sources.forEach(function eachSource(source){source.synchronize();});},initSources:function(callback){var pending=this.sources.length;var allEntries=[];var self=this;var current=0;function handleSource(){var source=self.sources[current];current++;source.populate(next);}
handleSource();function next(entries){allEntries=allEntries.concat(entries);if(!(--pending)){callback(allEntries);}else{handleSource();}}},populate:function(callback){this.initSources(function(entries){this.deferredSave(entries,callback);}.bind(this));},notifyReady:function(){this.ready=true;dispatchEvent(new CustomEvent('databaseready'));},getNextPosition:function(){var nextPosition=this.nextPosition;this.nextPosition++;return nextPosition;},handleIAC:function(connectionRequest){if(connectionRequest.keyword!=='verticalhome-migrate'){return;} handleSource();function next(entries){allEntries=allEntries.concat(entries);if(!(--pending)){callback(allEntries);}else{handleSource();}}},populate:function(callback){this.initSources(function(entries){this.save(entries,callback);}.bind(this));},notifyReady:function(){this.ready=true;dispatchEvent(new CustomEvent('databaseready'));},getNextPosition:function(){var nextPosition=this.nextPosition;this.nextPosition++;return nextPosition;},handleIAC:function(connectionRequest){if(connectionRequest.keyword!=='verticalhome-migrate'){return;}
var port=connectionRequest.port;port.onmessage=(event)=>{if(event.data.action!=='migrate'){console.error('Unhandled migration action requested:',event.data.action);return;} var port=connectionRequest.port;port.onmessage=(event)=>{if(event.data.action!=='migrate'){console.error('Unhandled migration action requested:',event.data.action);return;}
this.migrate(event.data.manifestURL);};port.start();},migrate:function(manifestURL){this.migratingTo=manifestURL;console.info('verticalhome migrating to '+this.migratingTo);if(this.migrating){return;} this.migrate(event.data.manifestURL);};port.start();},migrate:function(manifestURL){this.migratingTo=manifestURL;console.info('verticalhome migrating to '+this.migratingTo);if(this.migrating){return;}
this.migrating=true;(datastore?Promise.resolve():navigator.getDataStores(DATASTORE_NAME).then(stores=>{datastore=stores[0];return Promise.resolve();},(e)=>{return Promise.reject(e);})).then(()=>{this.all(entries=>{this.saveMigrationData(entries,'detail',true).then(()=>{this.migrating=false;window.navigator.mozSettings.createLock().set({'homescreen.manifestURL':this.migratingTo});navigator.getDataStores(PREFS_STORE).then(stores=>{if(stores.length<1){console.error('Error opening verticalhome prefs datastore');return;} this.migrating=true;(datastore?Promise.resolve():navigator.getDataStores(DATASTORE_NAME).then(stores=>{datastore=stores[0];return Promise.resolve();},(e)=>{return Promise.reject(e);})).then(()=>{this.all(entries=>{this.saveMigrationData(entries,'detail',true).then(()=>{this.migrating=false;window.navigator.mozSettings.createLock().set({'homescreen.manifestURL':this.migratingTo});navigator.getDataStores(PREFS_STORE).then(stores=>{if(stores.length<1){console.error('Error opening verticalhome prefs datastore');return;}

View file

@ -70,3 +70,6 @@ html[dir="rtl"] .wallpaper {
margin: 0 0.5rem; margin: 0 0.5rem;
} }
/* Keep preview tiles aligned when source wallpapers have different ratios. */
#wallpapers .wallpaper { aspect-ratio: 2 / 3; object-fit: cover; display: block; }

File diff suppressed because one or more lines are too long

View file

@ -272,7 +272,7 @@ ActivityController.prototype.onActivityConfirmed = function(newMedia) {
// Video // Video
if (newMedia.isVideo) { if (newMedia.isVideo) {
media.type = 'video/3gpp'; media.type = media.blob.type || 'video/3gpp';
media.poster = newMedia.poster.blob; media.poster = newMedia.poster.blob;
activity.postResult(media); activity.postResult(media);
this.app.clearSpinner(); this.app.clearSpinner();

View file

@ -107,7 +107,7 @@ OverlayController.prototype.updateOverlay = function(type, enabled, reason) {
return; return;
} }
var closable = self.activity.pick && type !== 'request-fail'; var closable = self.activity.pick || reason === 'request-fail';
var view = new OverlayView({ var view = new OverlayView({
type: reason, type: reason,
closable: closable, closable: closable,
@ -118,6 +118,10 @@ OverlayController.prototype.updateOverlay = function(type, enabled, reason) {
overlay.view = view.appendTo(document.body) overlay.view = view.appendTo(document.body)
.on('click:close-btn', function() { .on('click:close-btn', function() {
if (reason === 'request-fail' && !self.activity.pick) {
self.updateOverlay(type, false);
return;
}
self.app.emit('activitycanceled'); self.app.emit('activitycanceled');
}); });

View file

@ -147,7 +147,7 @@ StorageController.prototype.storeVideo = function(video) {
var self = this; var self = this;
// Add the poster image to the image storage // Add the poster image to the image storage
poster.filepath = video.filepath.replace('.3gp', '.jpg'); poster.filepath = video.filepath.replace(/\.(?:3gp|mp4|webm)$/, '.jpg');
video.isVideo = true; video.isVideo = true;
this.storage.addPicture( this.storage.addPicture(

View file

@ -102,6 +102,7 @@ ViewfinderController.prototype.hideGrid = function() {
* @private * @private
*/ */
ViewfinderController.prototype.bindEvents = function() { ViewfinderController.prototype.bindEvents = function() {
window.addEventListener('resize', this.configurePreview);
// View // View
this.views.viewfinder.on('fadedin', this.app.firer('viewfinder:visible')); this.views.viewfinder.on('fadedin', this.app.firer('viewfinder:visible'));

View file

@ -25,7 +25,7 @@ var dcfConfig = {
seq: null, seq: null,
postFix: 'MZLLA', postFix: 'MZLLA',
prefix: {video: 'VID_', image: 'IMG_'}, prefix: {video: 'VID_', image: 'IMG_'},
ext: {video: '3gp', image: 'jpg'} ext: {video: window.VulpesVideoExtension || '3gp', image: 'jpg'}
}; };
exports.init = function() { exports.init = function() {

View file

@ -252,7 +252,7 @@ Storage.prototype.checkFilepath = function(filepath) {
// image and get the video filepath by changing '.jpg' to '.3gp' // image and get the video filepath by changing '.jpg' to '.3gp'
if (filepath.indexOf('VID') != -1 && if (filepath.indexOf('VID') != -1 &&
filepath.lastIndexOf('.jpg') === filepath.length - 4) { filepath.lastIndexOf('.jpg') === filepath.length - 4) {
filepath = filepath.replace('.jpg', '.3gp'); filepath = filepath.replace('.jpg', '.' + (window.VulpesVideoExtension || '3gp'));
} }
return filepath; return filepath;
@ -380,7 +380,7 @@ Storage.prototype.deletePicture = function(filepath, done) {
* @param {String} filepath * @param {String} filepath
*/ */
Storage.prototype.deleteVideo = function(filepath) { Storage.prototype.deleteVideo = function(filepath) {
var poster = filepath.replace('.3gp', '.jpg'); var poster = filepath.replace(/\.(?:3gp|mp4|webm)$/, '.jpg');
this.video.delete(filepath).onerror = function(e) { this.video.delete(filepath).onerror = function(e) {
console.warn( console.warn(

View file

@ -205,6 +205,9 @@ module.exports = View.extend({
*/ */
updatePreview: function(preview, sensorAngle, mirrored) { updatePreview: function(preview, sensorAngle, mirrored) {
if (!preview) { return; } if (!preview) { return; }
// An embedded app can initialize before its window is visible.
this.getSize();
if (!this.width || !this.height) { return; }
var aspect; var aspect;
// Invert dimensions if the camera's `sensorAngle` is // Invert dimensions if the camera's `sensorAngle` is

View file

@ -25,6 +25,19 @@ var TelephonyHelper = (function() {
return Promise.reject(); return Promise.reject();
} }
// The platform adapter owns network checks on Tundra and Android.
if (window.VulpesPlatformDial) {
return window.VulpesPlatformDial(sanitizedNumber.split(',')[0]).then(function(obj) {
if (window.TelephonyCall && obj instanceof TelephonyCall) {
installHandlers(obj, sanitizedNumber, false, cardIndex,
oncall, onconnected, ondisconnected);
} else if (oncall) {
oncall();
}
return obj;
});
}
var conn = navigator.mozMobileConnections && var conn = navigator.mozMobileConnections &&
navigator.mozMobileConnections[cardIndex]; navigator.mozMobileConnections[cardIndex];
@ -170,11 +183,13 @@ var TelephonyHelper = (function() {
call.addEventListener('connected', onconnected); call.addEventListener('connected', onconnected);
call.addEventListener('disconnected', function handleDisconnect(evt) { call.addEventListener('disconnected', function handleDisconnect(evt) {
loadTelephonyMessages(function() { if (evt.call.disconnectedReason) {
TelephonyMessages.handleDisconnect( loadTelephonyMessages(function() {
evt.call.disconnectedReason, number, emergencyOnly TelephonyMessages.handleDisconnect(
); evt.call.disconnectedReason, number, emergencyOnly
}); );
});
}
ondisconnected && ondisconnected(); ondisconnected && ondisconnected();
}); });
} }

View file

@ -651,25 +651,30 @@ var metadataParser = (function() {
function videoMetadataParser(file, metadataCallback, errorCallback) { function videoMetadataParser(file, metadataCallback, errorCallback) {
var metadata = {}; var metadata = {};
var videofilename = file.name.replace('.jpg', '.3gp'); var extensions = ['webm', '3gp', 'mp4'];
metadata.video = videofilename; function findVideo() {
var videofilename = file.name.replace(/\.jpg$/, '.' + extensions.shift());
var getreq = videostorage.get(videofilename); var getreq = videostorage.get(videofilename);
getreq.onerror = function() { getreq.onerror = function() {
errorCallback('cannot get video file: ' + videofilename); if (extensions.length) findVideo();
}; else errorCallback('cannot get video file: ' + videofilename);
getreq.onsuccess = function() { };
var videofile = getreq.result; getreq.onsuccess = function() {
getVideoRotation(videofile, function(rotation) { metadata.video = videofilename;
if (typeof rotation === 'number') { if (/\.webm$/.test(videofilename)) {
metadata.rotation = rotation; metadata.rotation = 0;
getVideoThumbnailAndSize(); getVideoThumbnailAndSize();
return;
} }
else if (typeof rotation === 'string') { getVideoRotation(getreq.result, function(rotation) {
errorCallback('Video rotation:', rotation); if (typeof rotation === 'number') {
} metadata.rotation = rotation;
}); getVideoThumbnailAndSize();
}; } else errorCallback('Video rotation: ' + rotation);
});
};
}
findVideo();
function getVideoThumbnailAndSize() { function getVideoThumbnailAndSize() {
var url = URL.createObjectURL(file); var url = URL.createObjectURL(file);

View file

@ -139,10 +139,6 @@ define(function(require) {
*/ */
DateTime.prototype._init = function() { DateTime.prototype._init = function() {
this._mozTime = window.navigator.mozTime; this._mozTime = window.navigator.mozTime;
if (!this._mozTime) {
console.error('Could not get window.navigator.mozTime');
return;
}
this._boundSetTimeAutoEnabled = function(event) { this._boundSetTimeAutoEnabled = function(event) {
this._clockAutoEnabled = event.settingValue; this._clockAutoEnabled = event.settingValue;
@ -320,9 +316,7 @@ define(function(require) {
switch (type) { switch (type) {
case 'date': case 'date':
tokens = value.split('-'); tokens = value.split('-');
newDate.setYear(tokens[0]); newDate.setFullYear(Number(tokens[0]), Number(tokens[1]) - 1, Number(tokens[2]));
newDate.setMonth(tokens[1] - 1);
newDate.setDate(tokens[2]);
break; break;
case 'time': case 'time':
@ -331,7 +325,8 @@ define(function(require) {
newDate.setMinutes(parseInt(tokens[1])); newDate.setMinutes(parseInt(tokens[1]));
break; break;
} }
this._mozTime.set(newDate); if (!this._mozTime) return;
return this._mozTime.set(newDate);
}; };
/** /**

View file

@ -99,37 +99,35 @@ define(function(require) {
_transit: function dm__transit(method, dialog, options) { _transit: function dm__transit(method, dialog, options) {
var promise = new Promise(function(resolve) { var promise = new Promise(function(resolve) {
var panel = dialog.panel; var panel = dialog.panel;
var completed = false;
panel.addEventListener('transitionend', function paintWait(evt) { var fallback;
if ((method === 'close' || method === 'open') && function finish(event) {
evt.propertyName === 'visibility') { if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
// After transition, we have to `hide` the panel, otherwise if (completed) return;
// the panel would still exist on the layer and would block completed = true;
// the scrolling event. clearTimeout(fallback);
if (method === 'close') { panel.removeEventListener('transitionend', finish);
panel.hidden = true; panel.removeEventListener('transitioncancel', finish);
} if (method === 'close') panel.hidden = true;
panel.removeEventListener('transitionend', paintWait); resolve();
resolve();
}
});
// Before transition, we have to `show` the panel, otherwise
// the panel before applying transition class.
if (method === 'open') {
panel.hidden = false;
} }
if (method === 'open') panel.hidden = false;
// We need to apply class later otherwise Gecko can't apply
// this transition and 150ms is an approximate number after doing
// several rounds of manual tests.
setTimeout(function() { setTimeout(function() {
if (method === 'open') { panel.addEventListener('transitionend', finish);
// Let's unhide the panel first panel.addEventListener('transitioncancel', finish);
panel.classList.add('current'); panel.classList.toggle('current', method === 'open');
} else { var style = window.getComputedStyle(panel);
panel.classList.remove('current'); function milliseconds(value) {
return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
} }
var durations = style.transitionDuration.split(',').map(milliseconds);
var delays = style.transitionDelay.split(',').map(milliseconds);
var duration = Math.max.apply(Math, durations.map(function(value, index) {
return value + delays[index % delays.length];
}));
// Reduced motion, display changes or interrupted transitions may
// omit the visibility event. Always release the dialog queue.
fallback = setTimeout(finish, Math.max(0, duration) + 100);
}, 150); }, 150);
}); });
return promise; return promise;

View file

@ -190,29 +190,30 @@ define(function(require) {
if ('#' + panelId === currentPanel) { if ('#' + panelId === currentPanel) {
defer.reject('You are showing the same panel #' + panelId); defer.reject('You are showing the same panel #' + panelId);
} else { } else {
options.onWrapSubmit = function() { var opening;
DialogManager.close(dialog, 'submit', options) var closing = false;
.then(function(result) { function close(type) {
defer.resolve({ if (closing) return;
type: 'submit', closing = true;
value: result // A tap during the opening animation must not run hide/show
}); // concurrently, or a queued dialog can inherit stale form values.
Promise.resolve(opening).then(function() {
return DialogManager.close(dialog, type, options);
}).then(function(result) {
self._navigating = false; self._navigating = false;
defer.resolve({type: type, value: result});
self._execPendingRequest(); self._execPendingRequest();
}, function(error) {
// Panel validation rejects to keep the form open. Allow a
// correction or cancellation without rejecting the show promise.
closing = false;
dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
detail: error
}));
}); });
}; }
options.onWrapSubmit = function() { close('submit'); };
options.onWrapCancel = function() { options.onWrapCancel = function() { close('cancel'); };
DialogManager.close(dialog, 'cancel', options)
.then(function(result) {
defer.resolve({
type: 'cancel',
value: result
});
self._navigating = false;
self._execPendingRequest();
});
};
switch (options.type) { switch (options.type) {
case 'alert': case 'alert':
@ -229,7 +230,17 @@ define(function(require) {
break; break;
} }
this._navigating = true; this._navigating = true;
DialogManager.open(dialog, options); opening = DialogManager.open(dialog, options);
opening.catch(function(error) {
// A missing host API must not lock every later Settings dialog.
dialogDOM.classList.remove('current');
dialogDOM.hidden = true;
var overlay = document.querySelector('.settings-dialog-overlay');
if (overlay) overlay.hidden = true;
self._navigating = false;
defer.reject(error);
self._execPendingRequest();
});
} }
} }

View file

@ -246,6 +246,17 @@ define(function(require) {
// directly // directly
Settings._currentPanel = '#' + panelId; Settings._currentPanel = '#' + panelId;
callback(); callback();
}).catch(function(error) {
console.error('Settings panel ' + panelId + ':', error);
newPanelElement.classList.remove('current');
if (currentPanelElement) currentPanelElement.classList.add('current');
// Release the navigation lock even when a hardware query fails.
callback(error);
if (window.VulpesGaiaUI) {
window.VulpesGaiaUI.notice(document.documentElement.lang.indexOf('fr') === 0 ?
'Ce panneau n’a pas pu être chargé. Vous pouvez revenir aux autres paramètres.' :
'This panel could not be loaded. You can return to the other settings.');
}
}); });
}); });
}); });

View file

@ -103,9 +103,10 @@ define(function(require) {
* @access public * @access public
*/ */
_onAppChoose: function pl__onAppChoose(evt) { _onAppChoose: function pl__onAppChoose(evt) {
if (evt.target.dataset && evt.target.dataset.appIndex) { var target = evt.target.closest('[data-app-index]');
if (target && this._listRoot.contains(target)) {
SettingsService.navigate('appPermissions-details', { SettingsService.navigate('appPermissions-details', {
app: this._apps[evt.target.dataset.appIndex], app: this._apps[target.dataset.appIndex],
permissionsTable: this._permissionTable permissionsTable: this._permissionTable
}); });
} }
@ -262,7 +263,7 @@ define(function(require) {
* @return {Bool} * @return {Bool}
*/ */
_isExplicitPerm: function pl_isExplicitPerm(perm) { _isExplicitPerm: function pl_isExplicitPerm(perm) {
return mozPerms.isExplicit(perm, this._currentApp.manifestURL, return mozPerms && mozPerms.isExplicit(perm, this._currentApp.manifestURL,
this._currentApp.origin, false); this._currentApp.origin, false);
}, },

View file

@ -21,7 +21,7 @@ define(function(require) {
* refer to the JSON File Data. * refer to the JSON File Data.
*/ */
this._callSupportInfo = null; this._callSupportInfo = null;
var url = 'http://support.mozilla.org/products/firefox-os'; var url = 'https://vulpes-os.org/';
this._elements.userGuide.addEventListener('click', () => { this._elements.userGuide.addEventListener('click', () => {
this._elements.userGuide.blur(); this._elements.userGuide.blur();
var activity = new window.MozActivity({ var activity = new window.MozActivity({
@ -55,7 +55,10 @@ define(function(require) {
.then(function loadSupportInfo(data) { .then(function loadSupportInfo(data) {
this._supportInfo = data; this._supportInfo = data;
callback(this._supportInfo); callback(this._supportInfo);
}.bind(this)); }.bind(this)).catch(function(error) {
console.warn('Support information:', error);
callback({onlinesupport: {title: 'Vulpes OS', href: 'https://vulpes-os.org/'}, callsupport: []});
});
}, },
/** /**
* create support url items * create support url items
@ -103,7 +106,7 @@ define(function(require) {
_renderSupportInfo: function support_renderSupportInfo(result) { _renderSupportInfo: function support_renderSupportInfo(result) {
var onlineSupportInfo = null; var onlineSupportInfo = null;
var onlineSupportTitle = result['support.onlinesupport.title']; var onlineSupportTitle = result['support.onlinesupport.title'];
if (onlineSupportTitle !== '') { if (typeof onlineSupportTitle === 'string' && onlineSupportTitle !== '') {
onlineSupportInfo = { title: onlineSupportTitle }; onlineSupportInfo = { title: onlineSupportTitle };
onlineSupportInfo.href = result['support.onlinesupport.href']; onlineSupportInfo.href = result['support.onlinesupport.href'];
this._setOnlineSupportInfo(onlineSupportInfo); this._setOnlineSupportInfo(onlineSupportInfo);
@ -114,7 +117,7 @@ define(function(require) {
// Check to see if we have a title for the first support number. // Check to see if we have a title for the first support number.
var callSupport1Title = result['support.callsupport1.title']; var callSupport1Title = result['support.callsupport1.title'];
// If we have a title we'll go ahead and load the href for it too. // If we have a title we'll go ahead and load the href for it too.
if (callSupport1Title !== '') { if (typeof callSupport1Title === 'string' && callSupport1Title !== '') {
this._callSupportInfo = [ this._callSupportInfo = [
{ {
'title': callSupport1Title, 'title': callSupport1Title,
@ -125,7 +128,7 @@ define(function(require) {
// Now check to see if we have a title for the second // Now check to see if we have a title for the second
// support number. If we do, we'll load it's href as well. // support number. If we do, we'll load it's href as well.
var callSupport2Title = result['support.callsupport2.title']; var callSupport2Title = result['support.callsupport2.title'];
if (callSupport2Title !== '') { if (typeof callSupport2Title === 'string' && callSupport2Title !== '') {
this._callSupportInfo.push({ this._callSupportInfo.push({
'title': callSupport2Title, 'title': callSupport2Title,
'href': result['support.callsupport2.href'] 'href': result['support.callsupport2.href']

View file

@ -66,8 +66,11 @@ define(function(require) {
}, },
_bindEvents: function() { _bindEvents: function() {
this._elements.passcodeInput.onkeypress = this._elements.passcodeInput.type = 'password';
this._onPasscodeInputKeypress.bind(this); this._elements.passcodeInput.inputMode = 'numeric';
this._elements.passcodeInput.maxLength = 8;
this._elements.passcodeInput.oninput =
this._onPasscodeInput.bind(this);
this._elements.passcodeContainer.onclick = this._elements.passcodeContainer.onclick =
this._onPasscodeContainerClick.bind(this); this._onPasscodeContainerClick.bind(this);
}, },
@ -77,37 +80,14 @@ define(function(require) {
this._elements.passcodeInput.focus(); this._elements.passcodeInput.focus();
}, },
_onPasscodeInputKeypress: function(evt) { _onPasscodeInput: function() {
// key code for key strokes from the keypad are 0 (numbers) and 8 // Android IMEs commit text without legacy keypress/charCode events.
// (backspace). Filter out the events that are not from the keypad. var value = this._elements.passcodeInput.value.replace(/[^0-9]/g, '').slice(0, 8);
var keyCode = evt.keyCode; this._hideErrorMessage();
if (keyCode !== 0 && keyCode !== 8) { if (this._elements.panel.dataset.passcodeStatus === 'success') {
return; this._resetPasscodeStatus();
} }
this._passcodeBuffer = value;
evt.preventDefault();
if (this._passcodeBuffer === '') {
this._hideErrorMessage();
}
var code = evt.charCode;
if (code !== 0 && (code < 0x30 || code > 0x39)) {
return;
}
var key = String.fromCharCode(code);
if (evt.charCode === 0) {
if (this._passcodeBuffer.length > 0) {
this._passcodeBuffer = this._getPasscodeBuffer('all');
if (this._elements.panel.dataset.passcodeStatus === 'success') {
this._resetPasscodeStatus();
}
}
} else if (this._passcodeBuffer.length < 8) {
this._passcodeBuffer += key;
}
this._updatePassCodeUI(); this._updatePassCodeUI();
this._processPasscode(); this._processPasscode();
}, },
@ -134,6 +114,7 @@ define(function(require) {
}, },
_updatePassCodeUI: function() { _updatePassCodeUI: function() {
this._elements.passcodeInput.value = this._passcodeBuffer;
for (var i = 0; i < 8; i++) { for (var i = 0; i < 8; i++) {
if (i < this._passcodeBuffer.length) { if (i < this._passcodeBuffer.length) {
this._elements.passcodeDigits[i].dataset.dot = true; this._elements.passcodeDigits[i].dataset.dot = true;

View file

@ -304,7 +304,7 @@ define(function(require) {
*/ */
_updateSimSettingsUI: function scm__updateSimSettingsUI() { _updateSimSettingsUI: function scm__updateSimSettingsUI() {
var firstCardInfo = this._simcards[0].getInfo(); var firstCardInfo = this._simcards[0].getInfo();
var secondCardInfo = this._simcards[1].getInfo(); var secondCardInfo = (this._simcards[1] ? this._simcards[1].getInfo() : {absent: true});
var hidden = firstCardInfo.absent && secondCardInfo.absent || var hidden = firstCardInfo.absent && secondCardInfo.absent ||
this._isAirplaneMode; this._isAirplaneMode;
@ -322,7 +322,7 @@ define(function(require) {
*/ */
_updateSelectOptionsUI: function scm__updateSelectOptionsUI() { _updateSelectOptionsUI: function scm__updateSelectOptionsUI() {
var firstCardInfo = this._simcards[0].getInfo(); var firstCardInfo = this._simcards[0].getInfo();
var secondCardInfo = this._simcards[1].getInfo(); var secondCardInfo = (this._simcards[1] ? this._simcards[1].getInfo() : {absent: true});
// two cards all are not absent, we have to update separately // two cards all are not absent, we have to update separately
if (!firstCardInfo.absent && !secondCardInfo.absent) { if (!firstCardInfo.absent && !secondCardInfo.absent) {

View file

@ -41,7 +41,8 @@ define(function(require) {
var info = wifiManager.connectionInformation || {}; var info = wifiManager.connectionInformation || {};
elements.ip.textContent = info.ipAddress || ''; elements.ip.textContent = info.ipAddress || '';
document.l10n.setAttributes(elements.speed, document.l10n.setAttributes(elements.speed,
'linkSpeedMbs', { linkSpeed: info.linkSpeed }); Number.isFinite(info.linkSpeed) ? 'linkSpeedMbs' : 'unavailable',
Number.isFinite(info.linkSpeed) ? { linkSpeed: info.linkSpeed } : {});
} }
}); });
}; };

View file

@ -439,7 +439,8 @@
*/ */
populate: function(callback) { populate: function(callback) {
this.initSources(function(entries) { this.initSources(function(entries) {
this.deferredSave(entries, callback); // The first render waits for this write; deferring it waits for that render.
this.save(entries, callback);
}.bind(this)); }.bind(this));
}, },

View file

@ -102,7 +102,7 @@ var currentLayoutMode;
var pendingUpdateTitleText = false; var pendingUpdateTitleText = false;
// Videos recorded by our own camera have filenames of this form // Videos recorded by our own camera have filenames of this form
var FROMCAMERA = /DCIM\/\d{3}MZLLA\/VID_\d{4}\.3gp$/; var FROMCAMERA = /DCIM\/\d{3}MZLLA\/VID_\d{4}\.(?:3gp|mp4|webm)$/;
// We have a single instance of the loading checker because it is used // We have a single instance of the loading checker because it is used
// across functions // across functions
@ -664,7 +664,7 @@ function deleteFile(filename) {
// If we're deleting a video file recorded by our camera, // If we're deleting a video file recorded by our camera,
// we also need to delete the poster image associated with // we also need to delete the poster image associated with
// that video. // that video.
var postername = filename.replace('.3gp', '.jpg'); var postername = filename.replace(/\.(?:3gp|mp4|webm)$/, '.jpg');
navigator.getDeviceStorage('pictures'). delete(postername); navigator.getDeviceStorage('pictures'). delete(postername);
} }

View file

@ -70,3 +70,6 @@ html[dir="rtl"] .wallpaper {
margin: 0 0.5rem; margin: 0 0.5rem;
} }
/* Keep preview tiles aligned when source wallpapers have different ratios. */
#wallpapers .wallpaper { aspect-ratio: 2 / 3; object-fit: cover; display: block; }

View file

@ -113,6 +113,10 @@ var DownloadHelper = (function() {
*/ */
function getBlob(download) { function getBlob(download) {
var req = new Request(); var req = new Request();
if (download.vulpesDownload) {
download.getBlob().then(function(blob) { req.done(blob); }, function(error) { req.failed(error); });
return req;
}
var storage = getVolume(download.storageName); var storage = getVolume(download.storageName);
var storeAvailableReq = storage.available(); var storeAvailableReq = storage.available();
@ -394,6 +398,10 @@ var DownloadHelper = (function() {
*/ */
function remove(download) { function remove(download) {
var req = new Request(); var req = new Request();
if (download.vulpesDownload) {
navigator.mozDownloadManager.remove(download).then(function() { req.done(download); }, function(error) { req.failed(error); });
return req;
}
var incompleteDownload = download.state !== 'succeeded'; var incompleteDownload = download.state !== 'succeeded';
// If is not done, use download manager to remove it, // If is not done, use download manager to remove it,
// otherwise, deal with the datastore. // otherwise, deal with the datastore.

30
host/Downloads.sys.mjs Normal file
View file

@ -0,0 +1,30 @@
// SPDX-License-Identifier: MPL-2.0
// Download identities belong to Gecko; paths supplied by content are never used.
export class GaiaDownloads {
constructor() {this.ids=new WeakMap();this.items=new Map();}
async request(app, params={}) {
if(!['settings','system'].includes(app.id))throw Error('PERMISSION_DENIED');
if(!['list','pause','resume','remove','blob'].includes(params.operation))throw Error('INVALID_VALUE');
const {Downloads}=ChromeUtils.importESModule('resource://gre/modules/Downloads.sys.mjs');
const list=await Downloads.getList(Downloads.PUBLIC);
const entries=await list.getAll();
this.items.clear();
const rows=entries.map(item=>{
if(!this.ids.has(item))this.ids.set(item,Services.uuid.generateUUID().toString());
const id=this.ids.get(item);this.items.set(id,item);
return {id,url:item.source.url,path:item.target.path,startTime:+item.startTime,
contentType:item.contentType||'application/octet-stream',currentBytes:item.currentBytes,totalBytes:item.totalBytes,
state:item.succeeded?'succeeded':item.stopped?'stopped':'downloading'};
});
if(params.operation==='list')return rows;
const item=this.items.get(params.id);if(!item)throw Error('DOWNLOAD_NOT_FOUND');
if(params.operation==='blob') {
if(!item.succeeded)throw Error('DOWNLOAD_NOT_COMPLETE');
return File.createFromFileName(item.target.path);
}
if(params.operation==='pause')await item.cancel();
if(params.operation==='resume')await item.start();
if(params.operation==='remove') {await item.cancel();await item.finalize(true);if(item.succeeded)await IOUtils.remove(item.target.path,{ignoreAbsent:true});await list.remove(item);}
return true;
}
}

View file

@ -1,36 +1 @@
// Only the bundled keyboard can edit the field reported by a content actor. export {Input} from 'resource://vulpes/services/input.mjs';
export class Input {
constructor(runtime) { this.runtime=runtime; this.focus=null; this.sequence=0; }
changed(actor, state) {
const app=this.runtime.identity(actor);
if (!app || app.id==='keyboard') return;
if (!state) { if(this.focus?.actor===actor) this.hide(); return; }
this.focus={actor,state:{...state,id:++this.sequence}};
this.runtime.broadcast('input-focus',this.focus.state,'system');
this.runtime.broadcast('input-focus',this.focus.state,'keyboard');
}
hide() {
this.focus=null;
this.runtime.broadcast('input-focus',null,'system');
this.runtime.broadcast('input-focus',null,'keyboard');
}
cleanup(actor) { if(this.focus?.actor===actor) this.hide(); }
async request(actor, p) {
const app=this.runtime.identity(actor);
if (!app || !['keyboard','system'].includes(app.id)) throw Error('PERMISSION_DENIED');
if (p.operation==='hide') { this.hide();return null; }
if(app.id!=='keyboard') throw Error('PERMISSION_DENIED');
if(p.operation==='state') return this.focus?.state || null;
if(p.operation==='height') {
if(!Number.isFinite(p.height) || p.height<0 || p.height>600) throw Error('INVALID_VALUE');
this.runtime.broadcast('input-height',{height:p.height},'system');return null;
}
if(!this.focus || p.id!==this.focus.state.id) throw Error('INPUT_CONTEXT_EXPIRED');
const context=this.focus;
const result=await context.actor.sendQuery('Vulpes:Edit',p);
// An application can close or focus another field while the edit is pending.
if(this.focus!==context) throw Error('INPUT_CONTEXT_EXPIRED');
Object.assign(context.state,result);
return context.state;
}
}

View file

@ -1,10 +1,11 @@
// Desktop message store. Radio operations fail explicitly; no synthetic SMS. // Desktop message store. Radio operations fail explicitly; no synthetic SMS.
export class Messages { export class Messages {
constructor(storage, radio = null, notify = () => {}, radioStatus = null) { constructor(storage, radio = null, notify = () => {}, radioStatus = null, mms = null) {
this.storage = storage; this.storage = storage;
this.radio = radio; this.radio = radio;
this.notify = notify; this.notify = notify;
this.radioStatus = radioStatus; this.radioStatus = radioStatus;
this.mms = mms;
this.queue = Promise.resolve(); this.queue = Promise.resolve();
} }
receive(incoming) { receive(incoming) {
@ -14,7 +15,8 @@ export class Messages {
if (existing) return existing; if (existing) return existing;
const id=Math.max(Date.now(),...records.map(m=>Number(m.id)+1 || 0)); const id=Math.max(Date.now(),...records.map(m=>Number(m.id)+1 || 0));
const record={id,threadId:records.find(m=>(m.sender||m.receiver)===incoming.sender)?.threadId || id+1, const record={id,threadId:records.find(m=>(m.sender||m.receiver)===incoming.sender)?.threadId || id+1,
receipt:incoming.receipt,type:'sms',sender:incoming.sender,receiver:null, receipt:incoming.receipt,type:incoming.type || 'sms',sender:incoming.sender,receiver:null,
...(incoming.type==='mms' ? {receivers:incoming.receivers || [],subject:incoming.subject || '',smil:incoming.smil || '',attachments:incoming.attachments || [],deliveryInfo:[]} : {}),
body:incoming.body,timestamp:incoming.timestamp,delivery:'received',read:false}; body:incoming.body,timestamp:incoming.timestamp,delivery:'received',read:false};
records.push(record); records.push(record);
await this.storage.run('vulpes-private-messages','put',[records,'records']); await this.storage.run('vulpes-private-messages','put',[records,'records']);
@ -24,14 +26,16 @@ export class Messages {
return work; return work;
} }
refreshOutgoing() { refreshOutgoing() {
if (!this.radioStatus) return Promise.resolve(); if (!this.radioStatus && !this.mms) return Promise.resolve();
const work=this.queue.then(async()=>{ const work=this.queue.then(async()=>{
const records=(await this.storage.run('vulpes-private-messages','get',['records'])).value || []; const records=(await this.storage.run('vulpes-private-messages','get',['records'])).value || [];
for (const message of records.filter(m=>m.delivery==='sending' && m.radioPath)) { for (const message of records.filter(m=>m.delivery==='sending' && m.radioPath)) {
const result=await this.radioStatus(message.radioPath); const result=await (message.type==='mms' ? this.mms.status(message.radioPath) : this.radioStatus(message.radioPath));
if (!['sent','failed'].includes(result.state)) continue; if (!['sent','failed'].includes(result.state)) continue;
message.delivery=result.state==='sent'?'sent':'error'; message.delivery=result.state==='sent'?'sent':'error';
message.deliveryStatus=result.state==='sent'?'not-applicable':'error'; message.deliveryStatus=result.state==='sent'?'not-applicable':'error';
if(result.error) message.error=result.error;
if(message.type==='mms') message.deliveryInfo=message.receivers.map(receiver=>({receiver,deliveryStatus:message.deliveryStatus,readStatus:'not-applicable'}));
await this.storage.run('vulpes-private-messages','put',[records,'records']); await this.storage.run('vulpes-private-messages','put',[records,'records']);
this.notify(result.state,structuredClone(message)); this.notify(result.state,structuredClone(message));
} }
@ -104,7 +108,34 @@ export class Messages {
this.notify('sent', structuredClone(record)); this.notify('sent', structuredClone(record));
return record; return record;
} }
if (['send', 'sendMMS', 'retrieveMMS', 'getSmscAddress', 'setSmscAddress'].includes(operation)) if (operation === 'sendMMS' && this.mms) {
const payload=args[0];
this.mms.validate(payload);
const receivers=payload.receivers.slice();
const participants=receivers.slice().sort().join(',');
const existing=records.find(m=>(m.type==='mms' && m.delivery!=='received' ? m.receivers.slice().sort().join(',') : m.receiver || m.sender)===participants);
const record={id:nextId++,threadId:existing?.threadId || nextId++,type:'mms',sender:null,
receivers,subject:payload.subject || '',smil:payload.smil || '',attachments:payload.attachments || [],body:payload.subject || '',
timestamp:Date.now(),delivery:'sending',deliveryStatus:'pending',read:true,
deliveryInfo:receivers.map(receiver=>({receiver,deliveryStatus:'pending',readStatus:'not-applicable'}))};
records.push(record);
await this.storage.run('vulpes-private-messages','put',[records,'records']);
this.notify('sending',structuredClone(record));
try {
const submitted=await this.mms.send(payload);
record.radioPath=submitted.path;
await this.storage.run('vulpes-private-messages','put',[records,'records']);
return record;
} catch(error) {
record.delivery='error';record.deliveryStatus='error';record.error=error.message;
record.deliveryInfo=receivers.map(receiver=>({receiver,deliveryStatus:'error',readStatus:'not-applicable'}));
await this.storage.run('vulpes-private-messages','put',[records,'records']);
this.notify('failed',structuredClone(record));throw error;
}
}
if (['sendMMS', 'retrieveMMS'].includes(operation))
throw new Error('NotSupportedError');
if (['send', 'getSmscAddress', 'setSmscAddress'].includes(operation))
throw new Error('RadioDisabledError'); throw new Error('RadioDisabledError');
if (operation === 'getThreads') { if (operation === 'getThreads') {
const threads = new Map(); const threads = new Map();
@ -115,7 +146,7 @@ export class Messages {
id: message.threadId, id: message.threadId,
body: message.body, body: message.body,
timestamp: message.timestamp, timestamp: message.timestamp,
participants: [message.sender || message.receiver], participants: message.delivery==='received' ? [message.sender] : message.type==='mms' ? message.receivers : [message.receiver],
unreadCount: records.filter((m) => m.threadId === message.threadId && !m.read).length, unreadCount: records.filter((m) => m.threadId === message.threadId && !m.read).length,
lastMessageType: message.type, lastMessageType: message.type,
}); });

View file

@ -17,8 +17,9 @@ export class NativeCamera {
return await Promise.race([ return await Promise.race([
(async () => { (async () => {
const pipe=session.process.stdout; const pipe=session.process.stdout;
const sequence=metadata ? null : await pipe.readUint32(); const header=new DataView(await pipe.read(metadata ? 4 : 8));
const length=await pipe.readUint32(); const sequence=metadata ? null : header.getUint32(0,true);
const length=header.getUint32(metadata ? 0 : 4,true);
if(metadata) { if(metadata) {
if(length>65536) throw Error('CAMERA_INVALID_FORMAT'); if(length>65536) throw Error('CAMERA_INVALID_FORMAT');
return pipe.readJSON(length); return pipe.readJSON(length);

View file

@ -1,9 +1,12 @@
import {receivedMms} from 'resource://vulpes/services/mms.mjs';
import { authorizePlatform } from 'resource://vulpes/services/platform.mjs'; import { authorizePlatform } from 'resource://vulpes/services/platform.mjs';
import { import {
tundraCapabilities, tundraCapabilities,
tundraBluetooth,
tundraCallAudio, tundraCallAudio,
tundraHardware, tundraHardware,
tundraRadio, tundraRadio,
tundraMms,
tundraSnapshot, tundraSnapshot,
tundraWifi, tundraWifi,
} from 'resource://vulpes/host/Tundra.sys.mjs'; } from 'resource://vulpes/host/Tundra.sys.mjs';
@ -48,6 +51,25 @@ export class Platform {
} }
async init() { async init() {
this.radioQueue = Promise.resolve(); this.radioQueue = Promise.resolve();
if (Services.env.get('VULPES_TUNDRA') === '1') {
let shown = null;
const pollBluetooth = async () => {
try {
const state = await tundraBluetooth({operation:'status'});
const pending = state.pairing?.[0];
const app = this.runtime.apps.find(a=>a.id==='bluetooth');
if (pending && app && pending.id!==shown) {
shown=pending.id;
this.runtime.broadcast('launch',{manifestURL:app.manifestURL,origin:app.origin,
url:app.origin+'/onpair.html',timestamp:Date.now()},'system');
}
if (!pending) shown=null;
} catch (_) {}
setTimeout(pollBluetooth,1500);
};
pollBluetooth();
}
this.notificationPath = PathUtils.join(PathUtils.profileDir,'vulpes-notifications.json'); this.notificationPath = PathUtils.join(PathUtils.profileDir,'vulpes-notifications.json');
let notifications = []; let notifications = [];
try { notifications = await IOUtils.readJSON(this.notificationPath); } catch (_) {} try { notifications = await IOUtils.readJSON(this.notificationPath); } catch (_) {}
@ -80,6 +102,16 @@ export class Platform {
await tundraRadio('ackMessages',{ids:[row.receipt]}); await tundraRadio('ackMessages',{ids:[row.receipt]});
} }
} catch(error) {console.error('SMS inbox:',error.message);} } catch(error) {console.error('SMS inbox:',error.message);}
try {
const rows=await tundraMms('incoming');
const app=this.runtime.apps.find(a=>a.id==='sms');
for(const row of rows) {
const message=await this.runtime.messages.receive(receivedMms(row));
await this.gaiaNotification(app,{operation:'show',title:message.sender,body:message.body || 'MMS',tag:'sms-'+message.id,data:{threadId:message.threadId}});
this.runtime.broadcast('sms-received',message,'sms');
await tundraMms('ack',{ids:[row.receipt]});
}
} catch(error) {console.error('MMS inbox:',error.message);}
setTimeout(inbox,2000); setTimeout(inbox,2000);
}; };
inbox(); inbox();
@ -136,48 +168,39 @@ export class Platform {
!request.secure || !request.secure ||
request.sharingScreen || request.sharingScreen ||
request.sharingAudio || request.sharingAudio ||
request.audioInputDevices?.length || !(request.videoInputDevices?.length || request.audioInputDevices?.length)
!request.videoInputDevices?.length
) { ) {
deny(); deny();
return; return;
} }
const principal = global.documentPrincipal; const principal = global.documentPrincipal;
let permission = Services.perms.testExactPermissionFromPrincipal(principal, 'camera'); const video = request.videoInputDevices?.[0], audio = request.audioInputDevices?.[0];
if (permission === Ci.nsIPermissionManager.UNKNOWN_ACTION) { for (const [device, kind, message] of [
const allowed = Services.prompt.confirm( [video, 'camera', 'Autoriser Photo à utiliser la caméra ? / Allow Camera to use your camera?'],
browser.ownerDocument.defaultView, [audio, 'microphone', 'Autoriser Photo à enregistrer le son ? / Allow Camera to record audio?']
'Vulpes — Photo', ]) {
'Autoriser Photo à utiliser la webcam ? / Allow Camera to use your webcam?', if (!device) continue;
); let permission = Services.perms.testExactPermissionFromPrincipal(principal, kind);
permission = allowed if (permission === Ci.nsIPermissionManager.UNKNOWN_ACTION) {
? Ci.nsIPermissionManager.ALLOW_ACTION permission = Services.prompt.confirm(browser.ownerDocument.defaultView, 'Vulpes — Photo', message)
: Ci.nsIPermissionManager.DENY_ACTION; ? Ci.nsIPermissionManager.ALLOW_ACTION : Ci.nsIPermissionManager.DENY_ACTION;
Services.perms.addFromPrincipal(principal, 'camera', permission); Services.perms.addFromPrincipal(principal, kind, permission);
}
if (permission !== Ci.nsIPermissionManager.ALLOW_ACTION) { deny(); return; }
} }
if (permission !== Ci.nsIPermissionManager.ALLOW_ACTION) { if (!(await actor.checkOSPermission(!!video, !!audio, false))) { deny(); return; }
deny();
return;
}
if (!(await actor.checkOSPermission(true, false, false))) {
deny();
return;
}
// Gecko consumes this one-shot grant when allocating the real device.
// Use its top-level principal, as Firefox's WebRTC permission handler does.
const topPrincipal = browser.browsingContext.currentWindowGlobal.documentPrincipal; const topPrincipal = browser.browsingContext.currentWindowGlobal.documentPrincipal;
Services.perms.addFromPrincipal( const devices = [];
topPrincipal, for (const [device, kind] of [[video, 'MediaManagerVideo'], [audio, 'MediaManagerAudio']]) {
'MediaManagerVideo', if (!device) continue;
Ci.nsIPermissionManager.ALLOW_ACTION, Services.perms.addFromPrincipal(topPrincipal, kind, Ci.nsIPermissionManager.ALLOW_ACTION, Ci.nsIPermissionManager.EXPIRE_SESSION);
Ci.nsIPermissionManager.EXPIRE_SESSION, actor.activateDevicePerm(request.windowID, device.mediaSource, device.rawId);
); devices.push(device.deviceIndex);
const device = request.videoInputDevices[0]; }
actor.activateDevicePerm(request.windowID, device.mediaSource, device.rawId);
actor.sendAsyncMessage('webrtc:Allow', { actor.sendAsyncMessage('webrtc:Allow', {
callID: request.callID, callID: request.callID,
windowID: request.windowID, windowID: request.windowID,
devices: [request.videoInputDevices[0].deviceIndex], devices,
}); });
} }
async request(actor, app, operation, params) { async request(actor, app, operation, params) {
@ -193,6 +216,9 @@ export class Platform {
if (operation === 'snapshot') return tundraSnapshot(); if (operation === 'snapshot') return tundraSnapshot();
if (operation === 'power') return tundraHardware({operation:'power', action:params.action}); if (operation === 'power') return tundraHardware({operation:'power', action:params.action});
if (operation === 'wifi') return tundraWifi(params); if (operation === 'wifi') return tundraWifi(params);
if (operation === 'deviceSettings') return tundraRadio('deviceSettings', params);
if (operation === 'mobileSettings') return tundraRadio('mobileSettings', params);
if (operation === 'bluetooth') return tundraBluetooth(params);
if (['callHistory','ackCallHistory'].includes(operation)) return this.radio(operation, params); if (['callHistory','ackCallHistory'].includes(operation)) return this.radio(operation, params);
if (operation === 'dial') return this.radio('dial', params); if (operation === 'dial') return this.radio('dial', params);
if (operation === 'hangup') return tundraRadio('hangup', params); if (operation === 'hangup') return tundraRadio('hangup', params);

View file

@ -1,3 +1,4 @@
import {mmsPayload} from 'resource://vulpes/services/mms.mjs';
import { setInterval, setTimeout, clearTimeout } from 'resource://gre/modules/Timer.sys.mjs'; import { setInterval, setTimeout, clearTimeout } from 'resource://gre/modules/Timer.sys.mjs';
import {Input} from 'resource://vulpes/host/Input.sys.mjs'; import {Input} from 'resource://vulpes/host/Input.sys.mjs';
import { ScreenPower } from 'resource://vulpes/services/screen-power.mjs'; import { ScreenPower } from 'resource://vulpes/services/screen-power.mjs';
@ -13,7 +14,7 @@ import { Messages } from 'resource://vulpes/host/Messages.sys.mjs';
import { Alarms } from 'resource://vulpes/host/Alarms.sys.mjs'; import { Alarms } from 'resource://vulpes/host/Alarms.sys.mjs';
import { Platform } from 'resource://vulpes/host/Platform.sys.mjs'; import { Platform } from 'resource://vulpes/host/Platform.sys.mjs';
import { Media } from 'resource://vulpes/host/Media.sys.mjs'; import { Media } from 'resource://vulpes/host/Media.sys.mjs';
import { tundraCallAudio, tundraHardware, tundraRadio } from 'resource://vulpes/host/Tundra.sys.mjs'; import { tundraCallAudio, tundraHardware, tundraRadio, tundraMms } from 'resource://vulpes/host/Tundra.sys.mjs';
Cu.importGlobalProperties(['indexedDB']); Cu.importGlobalProperties(['indexedDB']);
// This is the only privileged owner of application identity and settings data. // This is the only privileged owner of application identity and settings data.
@ -111,6 +112,7 @@ export const Runtime = {
} }
Services.prefs.setStringPref('intl.accept_languages', this.values['language.current']); Services.prefs.setStringPref('intl.accept_languages', this.values['language.current']);
this.activities = new Activities({identity:actor=>this.identity(actor),apps:()=>this.apps, this.activities = new Activities({identity:actor=>this.identity(actor),apps:()=>this.apps,
choose:data=>this.broadcast('activity-choice',data,'system'),
uuid:()=>Services.uuid.generateUUID().toString(), uuid:()=>Services.uuid.generateUUID().toString(),
notify:(actor,type,data)=>actor.sendAsyncMessage('Vulpes:Event',{type,data}), 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, launch:(target,path,{reload=false,entryPoint}={})=>this.broadcast(reload?'activity-launch':'launch',{manifestURL:target.manifestURL,origin:target.origin,
@ -123,6 +125,19 @@ export const Runtime = {
const values = { ...this.values, ...Object.fromEntries(entries) }; const values = { ...this.values, ...Object.fromEntries(entries) };
await IOUtils.writeJSON(path, values, { tmpPath: path + '.tmp' }); await IOUtils.writeJSON(path, values, { tmpPath: path + '.tmp' });
this.values = values; this.values = values;
const prefMap={
'ui.click_hold_context_menus.delay':'ui.click_hold_context_menus.delay',
'accessibility.monoaudio.enable':'accessibility.monoaudio.enable',
'geolocation.enabled':'geo.enabled',
'privacy.donottrackheader.enabled':'privacy.donottrackheader.enabled',
'debug.paint-flashing.enabled':'nglayout.debug.paint_flashing',
'apz.overscroll.enabled':'apz.overscroll.enabled'
};
for(const [key,value] of entries) {
const pref=prefMap[key];if(!pref)continue;
if(typeof value==='boolean')Services.prefs.setBoolPref(pref,value);
else if(key==='ui.click_hold_context_menus.delay' && [400,800,1600].includes(value))Services.prefs.setIntPref(pref,value);
}
if (entries.some(([key]) => key === 'language.current')) if (entries.some(([key]) => key === 'language.current'))
Services.prefs.setStringPref('intl.accept_languages', values['language.current']); Services.prefs.setStringPref('intl.accept_languages', values['language.current']);
if (Services.env.get('VULPES_TUNDRA') === '1') { if (Services.env.get('VULPES_TUNDRA') === '1') {
@ -156,6 +171,8 @@ export const Runtime = {
: null, : null,
(type, message) => this.broadcast('sms-' + type, message, 'sms'), (type, message) => this.broadcast('sms-' + type, message, 'sms'),
path => tundraRadio('messageState', {path}), path => tundraRadio('messageState', {path}),
Services.env.get('VULPES_TUNDRA_RADIO') === '1' ? {validate:mmsPayload,
send:message=>tundraMms('send',mmsPayload(message)), status:path=>tundraMms('state',{path})} : null,
); );
this.media = new Media((data) => this.broadcast('mediachange', data)); this.media = new Media((data) => this.broadcast('mediachange', data));
this.platform = new Platform(this); this.platform = new Platform(this);
@ -301,9 +318,10 @@ export const Runtime = {
} }
} }
if (typeof method !== 'string') return { error: { code: 'INVALID_REQUEST' } }; if (typeof method !== 'string') return { error: { code: 'INVALID_REQUEST' } };
if (['activities.subscribe','activities.finish','activities.cancel'].includes(method) || (method === 'activities.start' && isGaiaActivity(params))) { if (['activities.subscribe','activities.finish','activities.cancel','activities.select'].includes(method) || (method === 'activities.start' && isGaiaActivity(params))) {
try { try {
const result = method === 'activities.cancel' ? this.activities.cancel(actor) const result = method === 'activities.select' ? this.activities.select(actor,params)
: method === 'activities.cancel' ? this.activities.cancel(actor)
: method === 'activities.subscribe' ? this.activities.subscribe(actor) : method === 'activities.subscribe' ? this.activities.subscribe(actor)
: method === 'activities.finish' ? this.activities.finish(actor,params) : method === 'activities.finish' ? this.activities.finish(actor,params)
: await this.activities.start(actor,params); : await this.activities.start(actor,params);
@ -359,11 +377,28 @@ export const Runtime = {
return { error: { code: error.message || 'VIEW_ERROR' } }; return { error: { code: error.message || 'VIEW_ERROR' } };
} }
} }
if (method === 'storage.stats') {
try {const {storageStats}=ChromeUtils.importESModule('resource://vulpes/host/StorageStats.sys.mjs');return {result:await storageStats(app)};}
catch(error) {return {error:{code:error.message || 'STORAGE_ERROR'}};}
}
if (method === 'downloads.request') {
try {
const {GaiaDownloads}=ChromeUtils.importESModule('resource://vulpes/host/Downloads.sys.mjs');
this.downloads ||= new GaiaDownloads();
return {result:await this.downloads.request(app,params)};
} catch(error) {return {error:{code:error.message || 'DOWNLOAD_ERROR'}};}
}
if (method === 'appearance.current') return { result: this.values['vulpes.theme'] || 'light' };
if (method === 'locale.current') return { result: this.values['language.current'] || 'en-US' }; if (method === 'locale.current') return { result: this.values['language.current'] || 'en-US' };
if (method === 'settings.snapshot') if (method === 'settings.snapshot')
return this.grants(app).includes('settings.read') return this.grants(app).includes('settings.read')
? { result: { ...this.values } } ? { result: { ...this.values } }
: { error: { code: 'PERMISSION_DENIED' } }; : { error: { code: 'PERMISSION_DENIED' } };
if (method === 'settings.clockChanged') {
if(app.id !== 'settings' || Services.env.get('VULPES_TUNDRA') !== '1') return {error:{code:'PERMISSION_DENIED'}};
Services.obs.notifyObservers(null, 'system-timezone-changed');
return {result:true};
}
if (method.startsWith('settings.')) if (method.startsWith('settings.'))
return this.settings.bindSession(app.id, this.grants(app))(request); return this.settings.bindSession(app.id, this.grants(app))(request);
if (method === 'desktop.idle') { if (method === 'desktop.idle') {
@ -517,7 +552,7 @@ export const Runtime = {
try { try {
const app = this.identity(actor); const app = this.identity(actor);
if (!app || (target && app.id !== target)) continue; if (!app || (target && app.id !== target)) continue;
if (type === 'settingchange' && data.key !== 'language.current' && !this.grants(app).includes('settings.read')) continue; if (type === 'settingchange' && !['language.current', 'vulpes.theme'].includes(data.key) && !this.grants(app).includes('settings.read')) continue;
if ( if (
type === 'mediachange' && type === 'mediachange' &&
!app.manifest.permissions?.['device-storage:' + data.type]?.access?.includes('read') !app.manifest.permissions?.['device-storage:' + data.type]?.access?.includes('read')

133
host/SettingsAudit.sys.mjs Normal file
View file

@ -0,0 +1,133 @@
// SPDX-License-Identifier: MPL-2.0
// Settings navigation and bounded readback tests, enabled by a private marker.
export async function auditSettings({evaluate,wait,sleep,save}) {
const context=new Set(['wifi-status','wifi-auth','wifi-joinHidden','wifi-wps','wifi-enterCertificateNickname','call-cf-not-reachable-settings','call-cf-no-reply-settings','call-cf-mobile-busy-settings','call-cf-unconditional-settings','call-voiceMailSettings','hotspot-wifiSettings','screenLock-passcode','apn-editor','apn-list','appPermissions-details','addon-details','homescreen-details','frame','simpin-dialog','call-fdnList-add','call-barring-passcode-change','settings-alert-dialog','settings-confirm-dialog','settings-prompt-dialog','ums-partial-warning','template-messaging','full-developer-mode-warning','full-developer-mode-final-warning']);
await evaluate('system','ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;');
await wait('settings','return document.readyState==="complete" && typeof require==="function";');
const ids=await evaluate('settings','return Array.from(document.querySelectorAll("section[id]"),e=>e.id);');
const report={scope:'navigation only; no destructive actions, SIM PIN attempts or subscriptions',panels:{}};
for(const id of ids) {
if(context.has(id)){report.panels[id]={contextRequired:true};continue;}
try {
await evaluate('settings',`window.__vulpesDiagnostics=[];require(['modules/settings_service'],s=>s.navigate(${JSON.stringify(id)},${JSON.stringify(id==='messaging-details'?{cardIndex:0}:{})}));return true;`);
await wait('settings',`return document.querySelector('section.current')?.id===${JSON.stringify(id)};`,25000);
// Slow carrier queries continue after the panel appears. Wait for those
// controls before leaving, rather than flooding the radio worker while
// sweeping every panel in a few milliseconds.
if(id==='call')await wait('settings',"return !document.querySelector('#ril-callerId').disabled;",30000);
if(id==='call-forwarding')await wait('settings',"return !!document.querySelector('#call-forwarding .callForwardingUnconditional[aria-disabled=false]');",30000);
await sleep(450);
report.panels[id]=await evaluate('settings',`const p=document.getElementById(${JSON.stringify(id)});return {opened:true,title:p.querySelector('h1')?.textContent,unavailable:!!p.querySelector('[data-vulpes-unavailable]'),links:Array.from(p.querySelectorAll('a[href^="#"]'),e=>e.getAttribute('href')),controls:Array.from(p.querySelectorAll('input,select,gaia-switch,gaia-checkbox'),e=>({name:e.name||e.id||e.getAttribute('name'),disabled:!!e.disabled,hidden:!e.checkVisibility()})),errors:window.__vulpesDiagnostics};`);
} catch(error) {
report.panels[id]={opened:false,error:String(error),errors:await evaluate('settings','return window.__vulpesDiagnostics;')};
await evaluate('settings','location.reload();return true;');await sleep(1500);
await wait('settings','return document.readyState==="complete" && typeof require==="function";');
}
await save(report);
}
report.dialogs={};
for(const [id,method,options] of [
['settings-confirm-dialog','confirm',{}],['settings-prompt-dialog','prompt',{}],['settings-alert-dialog','alert',{}],
['wifi-auth','show',{network:{ssid:'Audit — not connected',security:['WPA-PSK']},security:'WPA-PSK',sl:3}],
['wifi-status','show',{network:{ssid:'Audit — not connected',security:[]},security:'',sl:3}],
['wifi-joinHidden','show',{}],['screenLock-passcode','show',{mode:'create'}]
]) {
try {
await evaluate('settings',`window.__dialogResult=null;window.__vulpesDiagnostics=[];require(['modules/dialog_service'],d=>d.${method}(${JSON.stringify(method==='show'?id:'cancel')},${JSON.stringify(options)}).then(r=>window.__dialogResult=r,e=>window.__dialogResult={error:String(e)}));return true;`);
await wait('settings',`return document.getElementById(${JSON.stringify(id)}).classList.contains('current');`,3000);
await evaluate('settings',`const p=document.getElementById(${JSON.stringify(id)});const button=p.querySelector('button[type=reset]')||p.querySelector('button[type=submit]');if(button)button.click();else p.querySelector('gaia-header')?.dispatchEvent(new CustomEvent('action'));return true;`);
await wait('settings',`return !!window.__dialogResult;`,3000);
report.dialogs[id]=await evaluate('settings',`return {result:window.__dialogResult,errors:window.__vulpesDiagnostics,closed:!document.getElementById(${JSON.stringify(id)}).classList.contains('current')};`);
} catch(error) {
report.dialogs[id]={error:String(error),errors:await evaluate('settings','return window.__vulpesDiagnostics;')};
await evaluate('settings','location.reload();return true;');await sleep(1500);
await wait('settings','return document.readyState==="complete" && typeof require==="function";');
}
await save(report);
}
report.effects={};
for(const [name,code] of Object.entries({
storage:'return await VulpesCompat.call("storage.stats",{});',
modemReadback:'const read=await VulpesCompat.call("platform.mobileSettings",{action:"network"});if(typeof read.enabled!=="boolean")throw Error("Unknown data state");const after=await VulpesCompat.call("platform.mobileSettings",{action:"dataEnabled",enabled:read.enabled});return {same:read.enabled===after.enabled,registered:after.status,technology:after.technology};',
clockReadback:'const read=await VulpesCompat.call("platform.deviceSettings",{action:"clock"});const after=await VulpesCompat.call("platform.deviceSettings",{action:"setTimezone",timezone:read.timezone});return {same:read.timezone===after.timezone,timezone:after.timezone,automaticAvailable:after.automaticAvailable};'
})) {
try {report.effects[name]=await evaluate('settings',code);}
catch(error){report.effects[name]={error:String(error)};}
}
report.networkUi={};
try {
await evaluate('settings',"window.__vulpesDiagnostics=[];require(['modules/settings_service'],s=>s.navigate('operator-settings'));return true;");
await wait('settings',"return document.querySelector('#operator-settings.current select:not([disabled])')?.options.length>0;",7000);
report.networkUi.radio=await evaluate('settings',"const s=document.querySelector('#operator-settings select');s.dispatchEvent(new Event('change',{bubbles:true}));return {mode:s.value,choices:Array.from(s.options,o=>o.value)};");
await wait('settings',"return !document.querySelector('#operator-settings select').disabled;",7000);
report.networkUi.radio.same=await evaluate('settings',`return document.querySelector('#operator-settings select').value===${JSON.stringify(report.networkUi.radio.mode)};`);
for(const type of ['default','mms']) {
await evaluate('settings',`require(['modules/settings_service'],s=>s.navigate('apn-settings'));return true;`);
await wait('settings',"return !!document.querySelector('#apn-settings.current [data-apn-type]');",4000);
await evaluate('settings',`document.querySelector('#apn-settings [data-apn-type=${type}]').click();return true;`);
await wait('settings',"return !!document.querySelector('#apn-list.current label.name');",7000);
await evaluate('settings',"document.querySelector('#apn-list label.name').dispatchEvent(new MouseEvent('mouseup',{bubbles:true}));return true;");
await wait('settings',"return !!document.querySelector('#apn-editor.current .apn:not([disabled])');",7000);
report.networkUi[type]=await evaluate('settings',"const p=document.querySelector('#apn-editor');return {opened:true,apnPopulated:!!p.querySelector('.apn').value,passwordBlank:p.querySelector('.password').value==='',protocol:p.querySelector('.protocol').value,authentication:p.querySelector('.authtype').value,mmscEditable:!p.querySelector('.mmsc').disabled};");
// Submit the current APN unchanged, through Gaia. Never change operator
// credentials, activate a bearer or expose any APN/user/password in logs.
await evaluate('settings',"const p=document.querySelector('#apn-editor');p.querySelector('.apn').dispatchEvent(new Event('input',{bubbles:true}));p.querySelector('.ok').click();return true;");
await wait('settings',"return !!document.querySelector('#apn-list.current label.name');",7000);
report.networkUi[type].savedUnchanged=true;
}
report.networkUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');
} catch(error) {report.networkUi.error=String(error);report.networkUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');}
report.supplementaryUi={};
try {
await evaluate('settings',"window.__vulpesDiagnostics=[];require(['modules/settings_service'],s=>s.navigate('call'));return true;");
await wait('settings',"return !!document.querySelector('#call.current #ril-callerId:not([disabled])');",30000);
report.supplementaryUi.calls=await evaluate('settings',"return {waitingEnabled:!document.querySelector('#menuItem-callWaiting gaia-switch').disabled,callerEnabled:!document.querySelector('#ril-callerId').disabled};");
await evaluate('settings',"document.querySelector('#ril-callerId').dispatchEvent(new Event('change',{bubbles:true}));return true;");
await wait('settings',"return !document.querySelector('#ril-callerId').disabled;",50000);
report.supplementaryUi.callerSavedUnchanged=true;
await evaluate('settings',"require(['modules/settings_service'],s=>s.navigate('call-forwarding'));return true;");
await wait('settings',"return !!document.querySelector('#call-forwarding.current .callForwardingUnconditional[aria-disabled=false]');",30000);
report.supplementaryUi.dialogs={};
for(const [selector,id] of [
['.callForwardingUnconditional','call-cf-unconditional-settings'],['.callForwardingMobileBusy','call-cf-mobile-busy-settings'],
['.callForwardingNoReply','call-cf-no-reply-settings'],['.callForwardingNotReachable','call-cf-not-reachable-settings']
]) {
await evaluate('settings',`document.querySelector('#call-forwarding ${selector}').click();return true;`);
await wait('settings',`return document.getElementById('${id}').classList.contains('current');`,4000);
await evaluate('settings',`document.querySelector('#${id} button[type=reset]').click();return true;`);
await wait('settings',"return document.getElementById('call-forwarding').classList.contains('current');",4000);
report.supplementaryUi.dialogs[id]={opened:true,cancelled:true};
}
report.supplementaryUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');
} catch(error) {report.supplementaryUi.error=String(error);report.supplementaryUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');}
report.hotspotUi={};
try {
await evaluate('settings',"window.__vulpesDiagnostics=[];require(['modules/settings_service'],s=>s.navigate('hotspot'));return true;");
await wait('settings',"return !!document.querySelector('#hotspot.current #tethering-wifi-enabled:not([disabled])');",10000);
await evaluate('settings',"document.querySelector('#hotspot-settings-section a').click();return true;");
await wait('settings',"return document.getElementById('hotspot-wifiSettings').classList.contains('current');",4000);
report.hotspotUi=await evaluate('settings',"const p=document.getElementById('hotspot-wifiSettings');return {opened:true,securityChoices:p.querySelector('select').options.length,passwordMasked:p.querySelector('input[name=password]').type==='password'};");
await evaluate('settings',"document.querySelector('#hotspot-wifiSettings button[type=reset]').click();return true;");
await wait('settings',"return document.getElementById('hotspot').classList.contains('current') && !document.getElementById('hotspot-wifiSettings').classList.contains('current') && !document.querySelector('#tethering-wifi-enabled').disabled;",4000);
report.hotspotUi.cancelled=true;
const unconfigured=await evaluate('settings',"const s=await VulpesCompat.call('platform.wifi',{operation:'hotspotStatus'});return !s.configured && !s.enabled;");
if(unconfigured) {
await evaluate('settings',"document.querySelector('#hotspot-settings-section a').click();return true;");
await wait('settings',"return document.getElementById('hotspot-wifiSettings').classList.contains('current');",4000);
await evaluate('settings',"const p=document.getElementById('hotspot-wifiSettings');p.querySelector('input[type=text]').value='Vulpes-qualification';p.querySelector('select').value='wpa2-psk';p.querySelector('input[name=password]').value=crypto.randomUUID();p.querySelector('button[type=submit]').click();return true;");
await wait('settings',"return !!document.querySelector('#hotspot.current #tethering-wifi-enabled:not([disabled])') && !document.getElementById('hotspot-wifiSettings').classList.contains('current');",10000);
report.hotspotUi.configuredByAudit=true;
for(const enabled of [true,false]) {
await evaluate('settings',`const c=document.querySelector('#tethering-wifi-enabled');c.checked=${enabled};c.dispatchEvent(new Event('change',{bubbles:true}));return true;`);
await wait('settings',`const c=document.querySelector('#tethering-wifi-enabled');return !c.disabled && c.checked===${enabled};`,90000);
const actual=await evaluate('settings',"return (await VulpesCompat.call('platform.wifi',{operation:'hotspotStatus'})).enabled;");
if(actual!==enabled)throw Error('Hotspot UI/readback mismatch');
report.hotspotUi[enabled?'started':'stopped']=true;
}
}
report.hotspotUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');
} catch(error) {report.hotspotUi.error=String(error);report.hotspotUi.errors=await evaluate('settings','return window.__vulpesDiagnostics;');report.hotspotUi.formState=await evaluate('settings',"const p=document.getElementById('hotspot-wifiSettings');return {current:p.classList.contains('current'),message:p.querySelector('[role=status]')?.textContent,submitDisabled:p.querySelector('button[type=submit]')?.disabled};");}
report.scope='panel navigation plus storage read and idempotent data/timezone/radio/APN write/readback; temporary hotspot start/stop when initially unconfigured; no destructive actions, PIN attempts or subscriptions';
await evaluate('settings','require(["modules/settings_service"],s=>s.navigate("root"));return true;');
return report;
}

View file

@ -0,0 +1,56 @@
// Private qualification entry point, invoked only by a test marker/profile.
export async function auditFollowup({evaluate,wait,sleep,save,tap}) {
const report={checks:{},errors:{},mediaRetained:false};
const launch=async app=>{
await evaluate('system',`ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://${app}.localhost:8765/manifest.webapp'});return true;`);
await wait(app,'return document.readyState==="complete" && !document.hidden;');
await wait('system',`return Service.query('getActiveApp')?.origin==='http://${app}.localhost:8765';`);
};
await wait('system',`return document.body?.getAttribute('ready-state')==='fullyLoaded';`,30000);
await sleep(1000);
try {
await launch('settings');await wait('settings','return typeof require==="function";');
await evaluate('settings',`const s=await new Promise(r=>require(['modules/settings_service'],r));await s.navigate('dateTime');return true;`);
await sleep(800);
// Test the actual Gaia picker without changing the user's system clock.
await evaluate('settings',`if(!navigator.mozTime){window.__testClockInstalled=true;Object.defineProperty(navigator,'mozTime',{value:{},configurable:true});}window.__dateModule=await new Promise(r=>require(['modules/date_time'],r));window.__oldMozTime=__dateModule._mozTime;window.__pickedTime=null;__dateModule._mozTime={set:d=>{window.__pickedTime=+d;return Promise.resolve();}};document.querySelector('#dateTime .time-manual').classList.remove('disabled');for(const e of document.querySelectorAll('#dateTime input'))e.disabled=false;return true;`);
for(const type of ['date','time']) {
await evaluate('settings',`const e=document.querySelector('#dateTime .${type}-picker');e.disabled=false;e.click();return true;`);
await wait('system',`const p=Service.query('getActiveApp')?.valueSelector;return !!(p?.isShown && p._${type}Picker);`);
report.checks[type+'Picker']=await evaluate('system',`const p=Service.query('getActiveApp').valueSelector;const e=p.elements.${type==='date'?'spinDate':'time'}PickerPopup;return {visible:e.checkVisibility(),height:e.getBoundingClientRect().height};`);
await evaluate('system',`Service.query('getActiveApp').valueSelector.confirm();return true;`);
await wait('settings','return Number.isFinite(window.__pickedTime);');
report.checks[type+'Commit']=true;
await evaluate('settings','window.__pickedTime=null;return true;');
}
await evaluate('settings',`__dateModule._mozTime=__oldMozTime;const s=await new Promise(r=>require(['modules/settings_service'],r));await s.navigate('keyboard');return true;`);
await launch('keyboard');
await wait('keyboard','return !!document.querySelector("#general gaia-header");');
await evaluate('keyboard',`document.querySelector('#general gaia-header').dispatchEvent(new CustomEvent('action'));return true;`);
await wait('system',`return Service.query('getActiveApp')?.origin==='http://settings.localhost:8765';`);
report.checks.keyboardBack=true;
} catch(error){report.errors.settings=String(error);}
finally {
await evaluate('settings','if(window.__dateModule)__dateModule._mozTime=window.__oldMozTime;if(window.__testClockInstalled)delete navigator.mozTime;return true;').catch(()=>{});
}
await save(report);
try {
await launch('camera');
await wait('camera','return !!window.app?.camera?.mozCamera && !app.camera.isBusy;');
await evaluate('camera',`window.__vulpesDiagnostics=[];window.__lastVideo=null;app.once('newmedia',v=>{window.__lastVideo=v;});app.settings.mode.select('video');return true;`);
await wait('camera','return app.camera.mode==="video" && !app.camera.isBusy;');
await tap('camera','.js-capture');
await wait('camera','return app.camera.get("recording")==="started";');
await sleep(3000);
await tap('camera','.js-capture');
await wait('camera','return !!window.__lastVideo?.blob;',20000);
report.checks.video=await evaluate('camera',`const v=window.__lastVideo;const el=document.createElement('video');el.muted=true;el.src=URL.createObjectURL(v.blob);try{await new Promise((ok,fail)=>{el.onloadeddata=ok;el.onerror=()=>fail(Error('Recorded video cannot be decoded'));});return {bytes:v.blob.size,mime:v.blob.type,path:v.filepath,width:el.videoWidth,height:el.videoHeight,poster:v.poster.blob.size};}finally{URL.revokeObjectURL(el.src);el.remove();}`);
report.errors.camera=await evaluate('camera','return __vulpesDiagnostics.filter(x=>x.kind==="error" || x.kind==="rejection");');
} catch(error){report.errors.video=String(error);report.errors.camera=await evaluate('camera','return window.__vulpesDiagnostics;').catch(()=>[]);}
finally {
await evaluate('camera',`await app.camera.mozCamera?.vulpesFinishRecording?.();if(window.__lastVideo){await navigator.getDeviceStorage('videos').delete(__lastVideo.filepath);await navigator.getDeviceStorage('pictures').delete(__lastVideo.poster.filepath);}app.settings.mode.select('picture');return true;`).catch(()=>{});
await evaluate('system',"dispatchEvent(new Event('home'));return true;").catch(()=>{});
}
report.passed=!Object.values(report.errors).some(x=>Array.isArray(x)?x.length:!!x) && !!report.checks.video;
await save(report);return report;
}

28
host/StorageStats.sys.mjs Normal file
View file

@ -0,0 +1,28 @@
// SPDX-License-Identifier: MPL-2.0
// Only byte counts for Vulpes-owned storage cross the application boundary.
let pending, cached;
export async function storageStats(app) {
if(app.id!=='settings')throw Error('PERMISSION_DENIED');
if(cached && Date.now()-cached.at<5000)return cached.value;
if(pending)return pending;
pending=(async()=>{
async function bytes(path,skip='') {
if(path===skip || !await IOUtils.exists(path))return 0;
const file=Cc['@mozilla.org/file/local;1'].createInstance(Ci.nsIFile);file.initWithPath(path);
if(file.isSymlink())return 0;
const info=await IOUtils.stat(path);
if(info.type==='regular')return info.size;
if(info.type!=='directory')return 0;
let total=0;
for(const child of await IOUtils.getChildren(path)) {
try {total+=await bytes(child,skip);} catch(error) {if(error.name!=='NotFoundError')throw error;}
}
return total;
}
const profile=PathUtils.profileDir,media=PathUtils.join(profile,'vulpes-media');
const file=Cc['@mozilla.org/file/local;1'].createInstance(Ci.nsIFile);file.initWithPath(profile);
const value={free:file.diskSpaceAvailable,apps:await bytes(profile,media),sdcard:await bytes(media),scope:'vulpes-profile'};
cached={at:Date.now(),value};return value;
})().finally(()=>{pending=null;});
return pending;
}

View file

@ -42,6 +42,7 @@ export function tundraCapabilities(release) {
dial: radio, dial: radio,
callControl: radio, callControl: radio,
sms: radio, sms: radio,
mms: radio,
alarms: false, alarms: false,
hardwareStatus: true, hardwareStatus: true,
wifiControl: Services.env?.get('VULPES_TUNDRA_WIFI') === '1', wifiControl: Services.env?.get('VULPES_TUNDRA_WIFI') === '1',
@ -50,7 +51,7 @@ export function tundraCapabilities(release) {
export async function tundraRadio(operation, params = {}) { export async function tundraRadio(operation, params = {}) {
if (Services.env.get('VULPES_TUNDRA_RADIO') !== '1') throw Error('RADIO_UNAVAILABLE'); if (Services.env.get('VULPES_TUNDRA_RADIO') !== '1') throw Error('RADIO_UNAVAILABLE');
if (!['status', 'calls', 'dial', 'hangup', 'answer', 'sms', 'incomingMessages', 'ackMessages', 'messageState'].includes(operation)) throw Error('RADIO_INVALID_REQUEST'); if (!['deviceSettings', 'mobileSettings', 'status', 'calls', 'dial', 'hangup', 'answer', 'sms', 'incomingMessages', 'ackMessages', 'messageState'].includes(operation)) throw Error('RADIO_INVALID_REQUEST');
const { Subprocess } = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs'); const { Subprocess } = ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process = await Subprocess.call({ const process = await Subprocess.call({
command: '/usr/bin/python3', command: '/usr/bin/python3',
@ -78,8 +79,8 @@ export async function tundraRadio(operation, params = {}) {
throw Error('RADIO_INVALID_REPLY'); throw Error('RADIO_INVALID_REPLY');
} }
if (reply?.ok === false) { if (reply?.ok === false) {
const errors = ['RADIO_NOT_REGISTERED', 'RADIO_BUSY', 'RADIO_NOT_READY', const errors = ['RADIO_NOT_REGISTERED', 'RADIO_BUSY', 'RADIO_NOT_READY', 'RADIO_TIMEOUT',
'RADIO_REQUEST_FAILED', 'INVALID_NUMBER', 'INVALID_SMS', 'INVALID_CALL']; 'RADIO_REQUEST_FAILED', 'RADIO_READBACK_FAILED', 'INVALID_REQUEST', 'APN_IN_USE', 'APN_NOT_FOUND', 'APN_WRITE_FAILED', 'APN_ROLLBACK_FAILED', 'CLOCK_REQUEST_FAILED', 'CLOCK_UNAVAILABLE', 'INVALID_NUMBER', 'INVALID_SMS', 'INVALID_CALL'];
throw Error(errors.includes(reply.error) ? reply.error : 'RADIO_REQUEST_FAILED'); throw Error(errors.includes(reply.error) ? reply.error : 'RADIO_REQUEST_FAILED');
} }
if (reply?.ok !== true) throw Error('RADIO_INVALID_REPLY'); if (reply?.ok !== true) throw Error('RADIO_INVALID_REPLY');
@ -92,11 +93,11 @@ const WIFI_SCAN_CACHE_MS = 5000;
export async function tundraWifi(params) { export async function tundraWifi(params) {
if (Services.env.get('VULPES_TUNDRA_WIFI') !== '1') throw Error('NOT_SUPPORTED'); if (Services.env.get('VULPES_TUNDRA_WIFI') !== '1') throw Error('NOT_SUPPORTED');
if (!['scan', 'connect', 'disconnect', 'status', 'setEnabled', 'known', 'forget'].includes(params.operation)) if (!['scan', 'connect', 'connectHidden', 'disconnect', 'status', 'setEnabled', 'known', 'forget','hotspotStatus','hotspotSave','hotspotPower'].includes(params.operation))
throw Error('WIFI_INVALID_REQUEST'); throw Error('WIFI_INVALID_REQUEST');
if (params.operation !== 'scan') { if (params.operation !== 'scan') {
try { return await wifiCommand(params); } try { return await wifiCommand(params); }
finally { wifiScanCache = null; } finally { if (['connect','connectHidden','disconnect','forget','setEnabled','hotspotPower'].includes(params.operation)) wifiScanCache = null; }
} }
// System and Settings share a broker: at most one Python scan client exists. // System and Settings share a broker: at most one Python scan client exists.
if (wifiScanPending) return wifiScanPending; if (wifiScanPending) return wifiScanPending;
@ -176,3 +177,33 @@ export async function tundraCallAudio(params) {
if (reply.error) throw Error(reply.error); if (reply.error) throw Error(reply.error);
return reply.result; return reply.result;
} }
// Separate socket and bounded payload so MMS traffic cannot block call control.
export async function tundraMms(operation, params={}) {
if(Services.env.get('VULPES_TUNDRA_RADIO')!=='1') throw Error('NotSupportedError');
if(!['send','state','incoming','ack'].includes(operation)) throw Error('INVALID_REQUEST');
const {Subprocess}=ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process=await Subprocess.call({command:'/usr/bin/python3',arguments:['/etc/tundra/mms-control.py','--client'],stderr:'ignore'});
await process.stdin.write(JSON.stringify({version:1,operation,...params}));await process.stdin.close();
let output='';for(;;){const chunk=await process.stdout.readString();if(!chunk)break;output+=chunk;if(output.length>1228800){process.kill();throw Error('MMS_TOO_LARGE');}}
const {exitCode}=await process.wait();if(exitCode)throw Error('MMS_UNAVAILABLE');
const reply=JSON.parse(output);if(reply.ok!==true)throw Error(reply.error || 'MMS_UNAVAILABLE');return reply.result;
}
// A persistent daemon owns BlueZ discovery and pending pairing requests.
export async function tundraBluetooth(params) {
if (Services.env.get('VULPES_TUNDRA') !== '1') throw Error('NotSupportedError');
const {Subprocess}=ChromeUtils.importESModule('resource://gre/modules/Subprocess.sys.mjs');
const process=await Subprocess.call({command:'/usr/bin/python3',
arguments:['/etc/tundra/bluetooth-control.py','--client'],stderr:'ignore'});
await process.stdin.write(JSON.stringify({...params,version:1}));await process.stdin.close();
let output='';
for (;;) {
const chunk=await process.stdout.readString();if(!chunk)break;
output+=chunk;if(output.length>65536){process.kill();throw Error('BLUETOOTH_INVALID_REPLY');}
}
const {exitCode}=await process.wait();if(exitCode)throw Error('BLUETOOTH_UNAVAILABLE');
const reply=JSON.parse(output);if(reply.error)throw Error(reply.error);
return reply.result;
}

View file

@ -1,16 +1,20 @@
import {InputEditor} from 'resource://vulpes/services/input-editor.mjs';
export class VulpesChild extends JSWindowActorChild { export class VulpesChild extends JSWindowActorChild {
handleEvent(event) { handleEvent(event) {
if(['focusin','pointerdown'].includes(event.type)) { if(['focusin','pointerdown','click'].includes(event.type)) {
if(this.editing) return; if(this._editor?.editing) return;
if(!Services.cpmm.sharedData.get('vulpes:native-input')) return; if(!Services.cpmm.sharedData.get('vulpes:native-input')) return;
let element=event.target; let element=event.target;
if(element?.isContentEditable) element=element.closest('[contenteditable=true]') || element; if(element?.isContentEditable) element=element.closest('[contenteditable=true]') || element;
const editable=element?.isContentEditable || (['input','textarea'].includes(element?.localName) && !['button','submit','range','checkbox','radio','date','time','datetime-local','file'].includes(element.type)); const editable=element?.isContentEditable || (['input','textarea'].includes(element?.localName) && !['button','submit','range','checkbox','radio','date','time','datetime-local','file'].includes(element.type));
if(!editable || element.disabled || element.readOnly) { if(!editable || element.disabled || element.readOnly) {
if(event.type==='pointerdown') this.sendAsyncMessage('Vulpes:InputFocus',null); // Dismissing on pointerdown moves buttons before pointerup and loses
// the click. Composer actions may also explicitly retain the editor.
if(event.type==='click' && !element?.closest?.('[data-vulpes-keep-input]'))
this.sendAsyncMessage('Vulpes:InputFocus',null);
return; return;
} }
this.inputElement=element; this.editor.focus(element);
this.sendAsyncMessage('Vulpes:InputFocus',this.inputState()); this.sendAsyncMessage('Vulpes:InputFocus',this.inputState());
return; return;
} }
@ -76,69 +80,9 @@ export class VulpesChild extends JSWindowActorChild {
{ defineAs: 'vulpesFrameForContext' }, { defineAs: 'vulpesFrameForContext' },
); );
} }
inputState() { get editor() { return this._editor ||= new InputEditor(this.contentWindow); }
const e=this.inputElement; inputState() { return this.editor.state(); }
const text=e.isContentEditable ? e.textContent : e.value; edit(params) { return this.editor.edit(params); }
const selection=this.contentWindow.getSelection();
let start=e.selectionStart ?? text.length, end=e.selectionEnd ?? start;
if(e.isContentEditable && selection.rangeCount && e.contains(selection.anchorNode)) {
const range=this.document.createRange();range.selectNodeContents(e);
range.setEnd(selection.anchorNode,selection.anchorOffset);start=range.toString().length;
range.setEnd(selection.focusNode,selection.focusOffset);end=range.toString().length;
[start,end]=[Math.min(start,end),Math.max(start,end)];
}
return {type:e.isContentEditable?'contenteditable':e.localName,inputType:e.type || (e.classList.contains('recipient')?'tel':'text'),
inputMode:e.inputMode || (e.classList.contains('recipient')?'tel':''),
text:e.type==='password'?'':text,selectionStart:start,selectionEnd:end};
}
edit(p) {
const e=this.inputElement,win=this.contentWindow;
if(!e?.isConnected || this.document.hidden) throw Error('INPUT_CONTEXT_EXPIRED');
this.editing=true;
try {e.focus();} finally {this.editing=false;}
// A touch can focus a new contenteditable while Gecko retains the previous
// field's selection. execCommand follows that selection, not activeElement.
if(e.isContentEditable) {
const selection=win.getSelection();
if(!e.contains(selection.anchorNode) || !e.contains(selection.focusNode)) {
const range=this.document.createRange();
range.selectNodeContents(e);range.collapse(false);
selection.removeAllRanges();selection.addRange(range);
}
}
if(p.operation==='key') {
let key=typeof p.key==='object'?p.key.key:p.key;
if(key===8 || key==='Backspace') this.document.execCommand('delete');
else if(key===13 || key==='Enter') {
const options={key:'Enter',code:'Enter',keyCode:13,charCode:13,which:13,bubbles:true,cancelable:true};
const accepted=e.dispatchEvent(new win.KeyboardEvent('keydown',options)) && e.dispatchEvent(new win.KeyboardEvent('keypress',options));
if(accepted && (e.isContentEditable || e.localName==='textarea')) this.document.execCommand('insertLineBreak');
e.dispatchEvent(new win.KeyboardEvent('keyup',options));
} else {
const text=typeof key==='string'?key:String.fromCodePoint(p.charCode || key);
if(text.length>16) throw Error('INVALID_VALUE');
this.document.execCommand('insertText',false,text);
}
} else if(['selection','replace'].includes(p.operation)) {
const state=this.inputState();
const start=p.operation==='selection'?p.start:state.selectionStart+(p.offset||0);
const length=p.length||0;
if(!Number.isInteger(start)||!Number.isInteger(length)||start<0||length<0||start+length>(e.value ?? e.textContent).length) throw Error('INVALID_VALUE');
if(e.isContentEditable) {
const walker=this.document.createTreeWalker(e,win.NodeFilter.SHOW_TEXT);
const points=[];let offset=0,node;
while((node=walker.nextNode())) {points.push({node,start:offset,end:offset+node.length});offset+=node.length;}
const point=index=>{const part=points.find(p=>index<=p.end);return part?[part.node,index-part.start]:[e,0];};
const range=this.document.createRange();range.setStart(...point(start));range.setEnd(...point(start+length));
win.getSelection().removeAllRanges();win.getSelection().addRange(range);
} else e.setSelectionRange(start,start+length);
if(p.operation==='replace') {
if(typeof p.text!=='string'||p.text.length>4096) throw Error('INVALID_VALUE');
this.document.execCommand('insertText',false,p.text);
}
} else throw Error('METHOD_NOT_SUPPORTED');
return this.inputState();
}
receiveMessage(message) { receiveMessage(message) {
if(message.name==='Vulpes:Edit') return this.edit(message.data); if(message.name==='Vulpes:Edit') return this.edit(message.data);
if (message.name === 'Vulpes:ViewState') { if (message.name === 'Vulpes:ViewState') {
@ -146,9 +90,16 @@ export class VulpesChild extends JSWindowActorChild {
f => f.browsingContext?.id === message.data.contextId); f => f.browsingContext?.id === message.data.contextId);
if (!frame?.isConnected) return {visible:false}; if (!frame?.isConnected) return {visible:false};
const win = this.contentWindow, rect = frame.getBoundingClientRect(); const win = this.contentWindow, rect = frame.getBoundingClientRect();
// App chrome (the status bar and browser controls) legitimately covers
// the corners of a mozbrowser iframe. Requiring every corner to hit the
// iframe hid the external browser completely, leaving a white page for
// sites such as YouTube. The centre plus one side sample is sufficient
// to prove that the content viewport is visible.
const samples = [[.5,.5],[.05,.5],[.95,.5],[.5,.05],[.5,.95]];
const hits = samples.reduce((count,[x,y]) => count +
(this.document.elementFromPoint(rect.left+rect.width*x,rect.top+rect.height*y)===frame ? 1 : 0), 0);
const visible = !this.document.hidden && frame.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}) && const visible = !this.document.hidden && frame.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}) &&
[[.5,.5],[.05,.05],[.95,.05],[.05,.95],[.95,.95]].every(([x,y]) => hits >= 2;
this.document.elementFromPoint(rect.left+rect.width*x,rect.top+rect.height*y)===frame);
let top = Math.max(0,rect.top); let top = Math.max(0,rect.top);
// Gaia's status bar belongs above all app content, including native web views. // Gaia's status bar belongs above all app content, including native web views.
if (this.document.location.hostname === 'system.localhost') { if (this.document.location.hostname === 'system.localhost') {

View file

@ -146,6 +146,12 @@ h1, h2, h3, h4, h5, h6, #statusbar-time,
/* Gaia owns edge gestures; avoid a second host overscroll effect. */ /* Gaia owns edge gestures; avoid a second host overscroll effect. */
html[data-vulpes-app] { overscroll-behavior:none; } html[data-vulpes-app] { overscroll-behavior:none; }
/* padding-box is no longer supported. Content-box made the full-width
section wrap below the dialog's spacer, hiding its title and message. */
form[role='dialog'][data-type='confirm'] > section {
box-sizing: border-box;
}
/* Native selects now paint their own arrow; the old Gecko arrow-hiding offset /* Native selects now paint their own arrow; the old Gecko arrow-hiding offset
clips Fira descenders on current Gecko. */ clips Fira descenders on current Gecko. */
@ -200,3 +206,47 @@ html[data-vulpes-app='camera'], html[data-vulpes-app='camera'] body { touch-acti
/* Homescreen pages must settle at their boundary after a drag (Forgejo #3). */ /* 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-type:x mandatory; }
html[data-vulpes-app="homescreen"] #panels > * { scroll-snap-align:start; } html[data-vulpes-app="homescreen"] #panels > * { scroll-snap-align:start; }
/* System gestures are handled by Gaia, not by the page's pan/zoom handler.
App frames keep their own scrolling; the notification list scrolls vertically. */
html[data-vulpes-app='system'],
html[data-vulpes-app='system'] body {
overscroll-behavior: none;
}
html[data-vulpes-app='system'] #lockscreen {
touch-action: none;
}
html[data-vulpes-app='system'] #utility-tray {
touch-action: pan-y;
overscroll-behavior: contain;
}
/* Keep the original Gaia value selector inside the application's viewport. */
html[data-vulpes-app] #vulpes-value-selector {
position: fixed; inset: 0; z-index: 2147483000; margin: 0;
font-family: FiraSans, sans-serif; box-sizing: border-box;
}
html[data-vulpes-app] #vulpes-value-selector > section { box-sizing: border-box; }
html[data-vulpes-app] #vulpes-value-selector [role=option][aria-disabled=true] { opacity: .45; }
html[data-vulpes-app] #vulpes-value-selector-layer {
position: fixed; inset: 0; z-index: 2147483000;
}
html[data-vulpes-app] :is(.button,.bb-button) select.vulpes-select-label-owner {
opacity: 0;
}
html[data-vulpes-app] .vulpes-select-label {
position: absolute;
inset: 0 3.5rem 0 1.1rem;
display: flex;
align-items: center;
pointer-events: none;
font: italic 1.7rem/1.5 FiraSans, sans-serif;
white-space: nowrap;
overflow: hidden;
color: var(--text-color, #333);
text-shadow: none;
}
html[dir=rtl][data-vulpes-app] .vulpes-select-label { inset: 0 1.1rem 0 3.5rem; }
html[data-vulpes-theme=dark] .vulpes-select-label { color: #eee; }

126
host/gaia-bluetooth.js Normal file
View file

@ -0,0 +1,126 @@
// Gaia Bluetooth v2, backed by the host's permission-checked BlueZ broker.
(() => {
const app = location.hostname.split('.')[0];
if (!(window.VulpesPlatform === 'tundra' || window.VulpesTestBluetooth) ||
!['system', 'settings', 'bluetooth'].includes(app)) return;
const {call} = VulpesCompat;
const manager = Object.assign(new EventTarget(), {defaultAdapter:null});
const adapter = Object.assign(new EventTarget(), {
state:'disabled', address:'', name:'', discoverable:false, discovering:false,
pairingReqs:new EventTarget(),
});
const devices = new Map();
let discovery = null, seen = new Set(), pending = false, timer, closed = false;
let currentPairing = null, initialized = false;
const emit = (target, type, values={}) => {
const event = Object.assign(new Event(type), values);
target.dispatchEvent(event); target['on'+type]?.(event);
};
function change(target, fields) {
const attrs = Object.keys(fields).filter(k=>JSON.stringify(target[k])!==JSON.stringify(fields[k]));
Object.assign(target, fields);
if (attrs.length) emit(target, 'attributechanged', {attrs});
}
function apply(state) {
if (!state?.available) return;
for (const row of state.devices) {
let device = devices.get(row.address);
const oldPaired = device?.paired;
if (!device) {
device = new EventTarget(); devices.set(row.address, device);
}
change(device, {...row, connectionStatus:row.connected?'connected':'disconnected'});
if (oldPaired !== undefined && oldPaired !== row.paired)
emit(adapter, row.paired?'devicepaired':'deviceunpaired', {device,address:row.address});
if (discovery && state.discovering && !seen.has(row.address)) {
seen.add(row.address); emit(discovery, 'devicefound', {device});
}
}
for (const [address, device] of devices) {
if (!state.devices.some(d=>d.address===address)) {
if (device.paired) emit(adapter,'deviceunpaired',{address,device});
devices.delete(address);
}
}
change(adapter, {state:state.enabled?'enabled':'disabled',address:state.address,
name:state.name,discoverable:state.discoverable,discovering:state.discovering});
if (!manager.defaultAdapter) {
manager.defaultAdapter=adapter; emit(manager,'attributechanged',{attrs:['defaultAdapter']});
}
currentPairing = state.pairing?.[0] || null;
if (app==='bluetooth' && location.pathname==='/onpair.html') showPairing();
}
async function command(operation, params={}) {
const value=await call('platform.bluetooth',{operation,...params});
if (value?.available) apply(value);
return value;
}
async function refresh() {
if (pending || closed) return;
pending=true;
try {apply(await command('status'));}
catch(error) {console.warn('Bluetooth:',error.message);}
finally {pending=false;}
}
adapter.enable=()=>command('power',{enabled:true});
adapter.disable=()=>command('power',{enabled:false});
adapter.setName=name=>command('rename',{name});
adapter.setDiscoverable=enabled=>command('discoverable',{enabled});
adapter.startDiscovery=async()=>{
await command('scan');
discovery=new EventTarget(); seen=new Set();
const handle=discovery;
// Gaia installs its discovery listeners after this promise resolves.
setTimeout(()=>refresh(),0);
return handle;
};
adapter.stopDiscovery=async()=>{await command('stopScan');discovery=null;};
adapter.getPairedDevices=()=>[...devices.values()].filter(d=>d.paired);
adapter.getConnectedDevices=async profile=>{
await refresh();
const uuid=Number(profile).toString(16).padStart(8,'0')+'-0000-1000-8000-00805f9b34fb';
return [...devices.values()].filter(d=>d.connected && d.uuids.includes(uuid));
};
for (const op of ['pair','unpair','connect','disconnect'])
adapter[op]=async value=>{
await command(op,{address:typeof value==='string'?value:value.address});
await refresh();
};
const unsupported=()=>Promise.reject(new DOMException('Bluetooth profile is unavailable','NotSupportedError'));
for (const name of ['sendFile','stopSendingFile','confirmReceivingFile','connectSco','disconnectSco',
'sendMediaMetaData','sendMediaPlayStatus']) adapter[name]=unsupported;
adapter.isScoConnected=()=>Promise.resolve(false);
manager.getAdapters=()=>Promise.resolve(manager.defaultAdapter?[adapter]:[]);
Object.defineProperty(navigator,'mozBluetooth',{value:manager});
let shown = null;
function showPairing() {
if (!initialized || !window.Pairview) return;
const item=currentPairing;
if (!item) {if(shown) window.close();return;}
if (shown===item.id) return;
shown=item.id;
const reply=(accept,code)=>command('reply',{id:item.id,accept,code});
Pairview.init(item.method==='enterpasskey'?'enterpincode':item.method,{
deviceName:item.deviceName,handle:{passkey:item.passkey,
accept:()=>reply(true),reject:()=>reply(false),setPinCode:code=>reply(true,code)},
});
}
addEventListener('DOMContentLoaded',async()=>{
initialized=true;
await refresh(); showPairing();
if(app==='system') {
navigator.mozSettings.addObserver('bluetooth.enabled',event=>{
const enabled=event.settingValue===true;
if((adapter.state==='enabled')!==enabled)
command('power',{enabled}).catch(()=>call('settings.set',{values:{'bluetooth.enabled':adapter.state==='enabled'}}));
});
await call('settings.set',{values:{'bluetooth.enabled':adapter.state==='enabled'}});
}
timer=setInterval(()=>{if(app==='system'||!document.hidden||discovery)refresh();},1500);
},{once:true});
addEventListener('pagehide',()=>{
closed=true;clearInterval(timer);
if(discovery)command('stopScan').catch(()=>{});
},{once:true});
})();

View file

@ -1,6 +1,8 @@
// The Gaia camera UI consumes mozCameras. Only live MediaStream capabilities are exposed here. // The Gaia camera UI consumes mozCameras. Only live MediaStream capabilities are exposed here.
(() => { (() => {
if (location.hostname !== 'camera.localhost') return; if (location.hostname !== 'camera.localhost') return;
const videoMime = typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus') ? 'video/webm;codecs=vp8,opus' : null;
window.VulpesVideoExtension = videoMime ? 'webm' : null;
let cameras = ['back'], let cameras = ['back'],
devices = new Map(), devices = new Map(),
current = null, current = null,
@ -8,11 +10,22 @@
let cameraError = null; let cameraError = null;
let opening = Promise.resolve(), closing = Promise.resolve(); let opening = Promise.resolve(), closing = Promise.resolve();
const releases = new WeakMap(); const releases = new WeakMap();
const nativeViews = new WeakMap();
function releaseStream(stream, preview) { function releaseStream(stream, preview) {
if (releases.has(stream)) return releases.get(stream); if (releases.has(stream)) return releases.get(stream);
if (current === stream) current = null; if (current === stream) current = null;
const release = Promise.resolve().then(async () => { const release = Promise.resolve().then(async () => {
await stream.vulpesFinishRecording?.();
emit(stream, 'previewstatechange', {newState:'stopped'});
// Stop the capture producer before detaching its consumers. Gecko can
// otherwise block in srcObject=null while a GPU-backed frame is in use.
stream.getTracks().forEach(track => track.stop());
for (const video of document.querySelectorAll('video')) { for (const video of document.querySelectorAll('video')) {
if (nativeViews.get(video) === stream) {
nativeViews.delete(video);
stream.vulpesPreviewCanvas.remove();
video.style.display = '';
}
if (video.srcObject === stream) { if (video.srcObject === stream) {
video.pause(); video.srcObject = null; video.pause(); video.srcObject = null;
if ('vulpesCapture' in video.dataset) video.remove(); if ('vulpesCapture' in video.dataset) video.remove();
@ -96,12 +109,33 @@
Object.defineProperty(HTMLMediaElement.prototype, 'mozSrcObject', { Object.defineProperty(HTMLMediaElement.prototype, 'mozSrcObject', {
configurable: true, configurable: true,
get() { get() {
return nativeSource.get.call(this); return nativeViews.get(this) || nativeSource.get.call(this);
}, },
set(stream) { set(stream) {
nativeSource.set.call(this, stream); const old = nativeViews.get(this);
if (old) {
old.vulpesPreviewCanvas.remove();
nativeViews.delete(this);
this.style.display = '';
}
if (stream?.vulpesPreviewCanvas) {
// The HAL already delivers NV21 frames. Render them directly in Gaia's
// viewfinder instead of capturing the WebGL canvas into a second GPU
// video pipeline, which stalls on repeated switches on Sargo.
nativeViews.set(this, stream);
const canvas = stream.vulpesPreviewCanvas;
canvas.className = this.className;
canvas.dataset.vulpesNativePreview = '';
canvas.style.cssText = 'display:block;width:100%;height:100%;pointer-events:none';
this.style.display = 'none';
this.parentNode.append(canvas);
} else nativeSource.set.call(this, stream);
}, },
}); });
const playVideo = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function() {
return nativeViews.has(this) ? Promise.resolve() : playVideo.call(this);
};
function emit(target, type, detail = {}) { function emit(target, type, detail = {}) {
const event = new Event(type); const event = new Event(type);
Object.assign(event, detail); Object.assign(event, detail);
@ -136,15 +170,20 @@
if (second) devices.set('front', second.deviceId); if (second) devices.set('front', second.deviceId);
cameras = devices.size ? [...devices.keys()] : ['back']; cameras = devices.size ? [...devices.keys()] : ['back'];
} }
// Keep one graphics context per app. Losing/recreating it at every lens switch
// can exhaust the mobile driver while captured video frames are still in use.
let nativeCanvas = null;
async function nativeStream(name) { async function nativeStream(name) {
let session; let session;
const call = (params) => VulpesCompat.call('platform.cameraPreview', {...params, session}); const call = (params) => VulpesCompat.call('platform.cameraPreview', {...params, session});
const meta = await call({action:'start', camera:name === 'front' ? 'front' : 'back'}); const meta = await call({action:'start', camera:name === 'front' ? 'front' : 'back'});
session = meta.session; session = meta.session;
const canvas = document.createElement('canvas'); const canvas = nativeCanvas || (nativeCanvas = document.createElement('canvas'));
const sideways = meta.orientation % 180 !== 0; const sideways = meta.orientation % 180 !== 0;
canvas.width = sideways ? meta.height : meta.width; const width = sideways ? meta.height : meta.width;
canvas.height = sideways ? meta.width : meta.height; 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 // 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. // per pixel. The two textures contain Y and interleaved V/U respectively.
const gl = canvas.getContext('webgl', {alpha:false, antialias:false, preserveDrawingBuffer:true}); const gl = canvas.getContext('webgl', {alpha:false, antialias:false, preserveDrawingBuffer:true});
@ -152,18 +191,21 @@
await call({action:'stop'}); await call({action:'stop'});
throw new DOMException('Camera preview requires WebGL', 'NotSupportedError'); throw new DOMException('Camera preview requires WebGL', 'NotSupportedError');
} }
const timing = {frames:0, startedAt:performance.now(), conversionMs:0}; const timing = {frames:0, startedAt:performance.now(), conversionMs:0, transferMs:0, sensorFrames:0};
let stopped = false, timer, stream, sequence = -1, closing, program, buffer; let stopped = false, timer, stream, wakeLock, sequence = -1, closing, program, buffer;
const textures = [], shaders = []; const textures = [], shaders = [];
const stop = () => { const stop = () => {
if (closing) return closing; if (closing) return closing;
stopped = true; clearTimeout(timer); stopped = true; clearTimeout(timer);
wakeLock?.unlock(); wakeLock = null;
closing = call({action:'stop'}).finally(() => { closing = call({action:'stop'}).finally(() => {
if (stream) stream.vulpesClosed = true;
textures.forEach(texture => gl.deleteTexture(texture)); textures.forEach(texture => gl.deleteTexture(texture));
shaders.forEach(value => gl.deleteShader(value)); shaders.forEach(value => gl.deleteShader(value));
if (buffer) gl.deleteBuffer(buffer); if (buffer) gl.deleteBuffer(buffer);
if (program) gl.deleteProgram(program); if (program) gl.deleteProgram(program);
gl.getExtension('WEBGL_lose_context')?.loseContext(); // The next lens reuses this context after its old stream has stopped.
gl.clearColor(0,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT);
}); });
return closing; return closing;
}; };
@ -210,17 +252,25 @@
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
gl.uniform1i(gl.getUniformLocation(program,name),index); 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 draw = async () => {
const requested = performance.now();
const frame = await call({action:'frame'}); const frame = await call({action:'frame'});
timing.transferMs += performance.now()-requested;
if (stopped || !frame.data || frame.sequence === sequence) return false; if (stopped || !frame.data || frame.sequence === sequence) return false;
const data = new Uint8Array(frame.data), w = meta.width, h = meta.height; 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'); if (data.length !== w * h * 3 / 2) throw Error('Invalid native camera frame');
sequence = frame.sequence; sequence = frame.sequence; timing.sensorFrames = sequence;
const start=performance.now(); const start=performance.now();
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,textures[0]); 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.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.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.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); gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
timing.frames++;timing.conversionMs+=performance.now()-start; timing.frames++;timing.conversionMs+=performance.now()-start;
return true; return true;
@ -230,7 +280,11 @@
if (Date.now() > deadline) throw new DOMException('No camera frames', 'NotReadableError'); if (Date.now() > deadline) throw new DOMException('No camera frames', 'NotReadableError');
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise(resolve => setTimeout(resolve, 50));
} }
stream = canvas.captureStream(24); stream = new MediaStream();
stream.vulpesPreviewCanvas = canvas;
stream.vulpesClosed = false;
// A canvas has no video element's implicit screen wake lock.
wakeLock = navigator.requestWakeLock?.('screen');
stream.vulpesTiming = timing; stream.vulpesTiming = timing;
stream.vulpesNative = true; stream.vulpesNative = true;
stream.vulpesCameraMetadata = meta; stream.vulpesCameraMetadata = meta;
@ -245,15 +299,28 @@
const started = performance.now(); const started = performance.now();
try { await draw(); } try { await draw(); }
catch (error) { catch (error) {
if (!stopped) { recordError(error); stream.getTracks().forEach(track => track.stop()); } if (!stopped) { recordError(error); await stop().catch(recordError); }
return; return;
} }
if (!stopped) timer = setTimeout(next, Math.max(0, 1000/24-(performance.now()-started))); if (!stopped) timer = setTimeout(next, Math.max(0, 1000/30-(performance.now()-started)));
}; };
timer = setTimeout(next, 1000/24); timer = setTimeout(next, 1000/30);
return stream; return stream;
} catch (error) { await stop().catch(() => {}); throw error; } } catch (error) { await stop().catch(() => {}); throw error; }
} }
async function captureVideo(constraints, ticket, platform) {
// track.stop() returns before some Android camera drivers finish closing.
// Retry the same sensor, never replace a refused/missing camera silently.
for (let attempt = 0; ; attempt++) {
if (document.hidden || ticket !== generation)
throw new DOMException('Camera request superseded or hidden', 'AbortError');
try { return await navigator.mediaDevices.getUserMedia(constraints); }
catch (error) {
if (platform !== 'android' || error.name !== 'NotReadableError' || attempt >= 2) throw error;
await new Promise(resolve => setTimeout(resolve, 200 * (attempt + 1)));
}
}
}
function getCamera(name, config = {}) { function getCamera(name, config = {}) {
const ticket = ++generation; const ticket = ++generation;
const request = opening.then(() => openCamera(name, config, ticket)); const request = opening.then(() => openCamera(name, config, ticket));
@ -275,7 +342,7 @@
const platform = await VulpesCompat.call('platform.capabilities', {}); const platform = await VulpesCompat.call('platform.capabilities', {});
if (platform.platform === 'tundra' && !platform.camera) if (platform.platform === 'tundra' && !platform.camera)
throw new DOMException('Native camera adapter unavailable', 'NotSupportedError'); throw new DOMException('Native camera adapter unavailable', 'NotSupportedError');
stream = platform.platform === 'tundra' ? await nativeStream(name) : await navigator.mediaDevices.getUserMedia({ stream = platform.platform === 'tundra' ? await nativeStream(name) : await captureVideo({
audio: false, audio: false,
video: { video: {
...(selected ...(selected
@ -284,7 +351,7 @@
width: { ideal: 1920 }, width: { ideal: 1920 },
height: { ideal: 1080 }, height: { ideal: 1080 },
}, },
}); }, ticket, platform.platform);
} catch (error) { } catch (error) {
recordError(error); recordError(error);
throw error; throw error;
@ -304,44 +371,55 @@
throw new DOMException('Camera hidden', 'AbortError'); throw new DOMException('Camera hidden', 'AbortError');
const track = stream.getVideoTracks()[0]; const track = stream.getVideoTracks()[0];
const settings = track.getSettings(); const native = stream.vulpesCameraMetadata;
const size = { width: settings.width, height: settings.height }; let size;
preview = document.createElement('video'); if (native) {
preview.muted = true; // Preview dimensions and still photos come directly from the HAL.
preview.playsInline = true; // The native canvas needs no hidden capture video.
preview.srcObject = stream; const sideways = native.orientation % 180 !== 0;
preview.dataset.vulpesCapture = ''; size = {width:sideways ? native.height : native.width,
preview.style.cssText = 'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none'; height:sideways ? native.width : native.height};
document.body.append(preview); } else {
let previewTimeout; preview = document.createElement('video');
await Promise.race([ preview.muted = true;
preview.play(), preview.playsInline = true;
new Promise((_, reject) => { preview.srcObject = stream;
previewTimeout = setTimeout( preview.dataset.vulpesCapture = '';
() => reject(new DOMException('Camera preview timed out', 'NotReadableError')), preview.style.cssText = 'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none';
10000, document.body.append(preview);
); let previewTimeout;
}), await Promise.race([
]).finally(() => clearTimeout(previewTimeout)); preview.play(),
new Promise((_, reject) => {
previewTimeout = setTimeout(
() => reject(new DOMException('Camera preview timed out', 'NotReadableError')),
10000,
);
}),
]).finally(() => clearTimeout(previewTimeout));
const settings = stream.getVideoTracks()[0].getSettings();
size = {width:preview.videoWidth || settings.width,
height:preview.videoHeight || settings.height};
}
if (document.hidden || ticket !== generation) if (document.hidden || ticket !== generation)
throw new DOMException('Camera hidden', 'AbortError'); throw new DOMException('Camera hidden', 'AbortError');
const native = stream.vulpesCameraMetadata;
const pictureSizes = native?.pictureSizes || [size]; const pictureSizes = native?.pictureSizes || [size];
let pictureSize = pictureSizes.reduce((a,b)=>a.width*a.height>b.width*b.height?a:b); let pictureSize = pictureSizes.reduce((a,b)=>a.width*a.height>b.width*b.height?a:b);
let focusArea = null, zoom = 1; let focusArea = null, zoom = 1, 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 configuration = () => ({ const configuration = () => ({
mode: 'picture', mode,
previewSize: size, previewSize: size,
pictureSize, pictureSize,
recorderProfile: null, recorderProfile: videoMime ? 'webm' : null,
}); });
Object.assign(stream, { Object.assign(stream, {
capabilities: { capabilities: {
pictureSizes, pictureSizes,
previewSizes: [size], previewSizes: [size],
thumbnailSizes: [], thumbnailSizes: [],
recorderProfiles: {}, recorderProfiles: videoMime ? {webm:profile} : {},
flashModes: ['off'], flashModes: ['off'],
focusModes: native ? (native.focusModes.includes('auto') ? ['auto','infinity'] : ['fixed']) : ['fixed'], focusModes: native ? (native.focusModes.includes('auto') ? ['auto','infinity'] : ['fixed']) : ['fixed'],
sceneModes: ['auto'], sceneModes: ['auto'],
@ -359,8 +437,9 @@
zoom: 1, zoom: 1,
async setConfiguration(value) { async setConfiguration(value) {
if (value.pictureSize) stream.setPictureSize(value.pictureSize); if (value.pictureSize) stream.setPictureSize(value.pictureSize);
if (value.mode && value.mode !== 'picture') if (value.mode && !['picture',...(videoMime ? ['video'] : [])].includes(value.mode))
throw new DOMException('Video unavailable', 'NotSupportedError'); throw new DOMException('Video unavailable', 'NotSupportedError');
if(value.mode)mode=value.mode;
queueMicrotask(() => emit(stream, 'previewstatechange', { newState: 'started' })); queueMicrotask(() => emit(stream, 'previewstatechange', { newState: 'started' }));
return configuration(); return configuration();
}, },
@ -415,10 +494,66 @@
release() { release() {
return releaseStream(stream, preview); return releaseStream(stream, preview);
}, },
async startRecording() { async startRecording(options, storage, filepath) {
throw new DOMException('Video unavailable', 'NotSupportedError'); if(!videoMime)throw new DOMException('Video unavailable','NotSupportedError');
if(recording || startingRecording)throw new DOMException('Recording in progress','InvalidStateError');
startingRecording=true;
let audio, captured;
try {
audio=await navigator.mediaDevices.getUserMedia({audio:true,video:false});
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;
const done=new Promise(resolve=>{finish=resolve;});
const cap=Math.min(options.maxFileSizeBytes || Infinity,32*1024*1024);
recorder.ondataavailable=event=>{
if(event.data.size) {chunks.push(event.data);bytes+=event.data.size;}
if(bytes>=cap && recorder.state==='recording') {
emit(stream,'recorderstatechange',{newState:'FileSizeLimitReached'});
recorder.stop();
}
};
recorder.onerror=()=>{failed=true;emit(stream,'recorderstatechange',{newState:'MediaRecorderFailed'});};
recorder.onstop=async()=>{
try {
if(failed || !bytes)throw Error('Empty or failed recording');
const blob=new Blob(chunks,{type:'video/webm'});
await storage.addNamed(blob,filepath);
const canvas=document.createElement('canvas');
const source=native ? stream.vulpesPreviewCanvas : preview;
canvas.width=native ? source.width : preview.videoWidth;
canvas.height=native ? source.height : preview.videoHeight;
canvas.getContext('2d').drawImage(source,0,0,canvas.width,canvas.height);
const poster=await new Promise(resolve=>canvas.toBlob(resolve,'image/jpeg',0.9));
if(!poster)throw Error('Video poster failed');
emit(stream,'poster',{data:poster});
} catch(error) {
recordError(error);emit(stream,'recorderstatechange',{newState:'MediaRecorderFailed'});
} finally {
captured.getTracks().forEach(t=>t.stop());
chunks.length=0;recording=null;finish();
emit(stream,'recorderstatechange',{newState:'Stopped'});
}
};
recording={recorder,done};
recorder.start(500);
emit(stream,'recorderstatechange',{newState:'Started'});
} catch(error) {
captured?.getTracks().forEach(t=>t.stop());audio?.getTracks().forEach(t=>t.stop());
recording=null;throw error;
} finally {startingRecording=false;}
},
stopRecording() {
if(recording?.recorder.state==='recording')recording.recorder.stop();
},
async vulpesFinishRecording() {
if(!recording)return;
const done=recording.done;stream.stopRecording();await done;
}, },
stopRecording() {},
}); });
if (native) Object.defineProperty(stream,'zoom',{ if (native) Object.defineProperty(stream,'zoom',{
configurable:true, get:()=>zoom, configurable:true, get:()=>zoom,
@ -433,7 +568,7 @@
}); });
dispatchEvent( dispatchEvent(
new CustomEvent('vulpes-camera-capabilities', { new CustomEvent('vulpes-camera-capabilities', {
detail: { cameras: [...cameras], flash: false, video: false }, detail: { cameras: [...cameras], flash: false, video: !!videoMime },
}), }),
); );
return { camera: stream, configuration: configuration() }; return { camera: stream, configuration: configuration() };
@ -479,7 +614,7 @@
'Only one camera available', 'Only one camera available',
'toggle-camera-rear', 'toggle-camera-rear',
], ],
['.mode-switch', true, 'Vidéo pas encore disponible', 'Video is not available yet', null], ['.mode-switch', !videoMime, 'Vidéo pas encore disponible', 'Video is not available yet', null],
]; ];
const fr = (document.documentElement.lang || navigator.language).startsWith('fr'); const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
for (const [selector, disabled, french, english, icon] of controls) { for (const [selector, disabled, french, english, icon] of controls) {
@ -489,7 +624,7 @@
e.setAttribute('aria-disabled', String(disabled)); e.setAttribute('aria-disabled', String(disabled));
if (!disabled) { if (!disabled) {
e.removeAttribute('title'); e.removeAttribute('title');
e.setAttribute('aria-label', fr ? 'Changer de caméra' : 'Switch camera'); if(selector==='.js-camera')e.setAttribute('aria-label', fr ? 'Changer de caméra' : 'Switch camera');
} }
if (disabled) { if (disabled) {
e.removeAttribute('data-l10n-id'); e.removeAttribute('data-l10n-id');

View file

@ -67,7 +67,25 @@
const unsupported = () => Promise.reject(new DOMException('Not available on this adapter', 'NotSupportedError')); const unsupported = () => Promise.reject(new DOMException('Not available on this adapter', 'NotSupportedError'));
telephony.conferenceGroup = Object.assign(new EventTarget(), {calls:[], state:'', add:unsupported, remove:unsupported, hangUp:unsupported}); telephony.conferenceGroup = Object.assign(new EventTarget(), {calls:[], state:'', add:unsupported, remove:unsupported, hangUp:unsupported});
telephony.ready = Promise.resolve(); telephony.ready = Promise.resolve();
telephony.dial = number => call('platform.dial',{number}); class TelephonyCall extends EventTarget {}
Object.defineProperty(window,'TelephonyCall',{value:TelephonyCall});
function makeCall(item) {
return Object.assign(new TelephonyCall(),{path:item.path,id:{number:item.number},serviceId:0,
state:({active:'connected',waiting:'incoming'})[item.state] || item.state,
emergency:false,group:null,switchable:false,mergeable:false,
answer:()=>call('platform.answer',{path:item.path}),hangUp:()=>call('platform.hangup',{path:item.path}),
hold:unsupported,resume:unsupported});
}
telephony.dial = async number => {
const result = await call('platform.dial',{number});
let current = telephony.calls.find(c=>c.path===result.path);
if (!current) {
current = makeCall({path:result.path,number,state:'dialing'});
telephony.calls.push(current);
emit(telephony,'callschanged');
}
return current;
};
telephony.dialEmergency = unsupported; telephony.dialEmergency = unsupported;
telephony.sendTones = unsupported; telephony.sendTones = unsupported;
telephony.startTone = unsupported; telephony.startTone = unsupported;
@ -94,14 +112,17 @@
const state = ({active:'connected',waiting:'incoming'})[item.state] || item.state; const state = ({active:'connected',waiting:'incoming'})[item.state] || item.state;
if (!c) { if (!c) {
changed = true; changed = true;
c = Object.assign(new EventTarget(),{path:item.path,id:{number:item.number}, serviceId:0, c = makeCall(item);
state, emergency:false, group:null, switchable:false, mergeable:false,
answer:()=>call('platform.answer',{path:item.path}), hangUp:()=>call('platform.hangup',{path:item.path}),
hold:unsupported,resume:unsupported});
} else if (c.state !== state) { c.state=state;changes.push(c); } } else if (c.state !== state) { c.state=state;changes.push(c); }
return c; return c;
}); });
for (const c of old) if (!telephony.calls.includes(c)) {c.state='disconnected';changes.push(c);changed=true;} for (const c of old) if (!telephony.calls.includes(c)) {
c.state='disconnected';
// The broker currently supplies no modem disconnect cause. Avoid
// inventing an error when a call simply disappears after hang-up.
c.disconnectedReason=null;
changes.push(c);changed=true;
}
for (const c of changes) {emit(c,'statechange',{call:c});emit(c,c.state,{call:c});} for (const c of changes) {emit(c,'statechange',{call:c});emit(c,c.state,{call:c});}
if (changed) { if (changed) {
if (location.hostname === 'system.localhost' && live.some(c => ['incoming','waiting'].includes(c.state))) window.Service?.request('turnScreenOn'); if (location.hostname === 'system.localhost' && live.some(c => ['incoming','waiting'].includes(c.state))) window.Service?.request('turnScreenOn');
@ -131,13 +152,13 @@
const settingsCodec = window.VulpesSettingsCodec; const settingsCodec = window.VulpesSettingsCodec;
async function call(method, params) { async function call(method, params) {
if (!navigator.vulpesRequest) return Promise.reject(new Error('VULPES_HOST_UNAVAILABLE')); if (!navigator.vulpesRequest) return Promise.reject(new Error('VULPES_HOST_UNAVAILABLE'));
if (settingsCodec && (method === 'settings.set' || if (settingsCodec && (method === 'settings.set' || method === 'messages.request' ||
(window.VulpesPlatform === 'android' && ['media.request', 'activities.finish', 'contacts.request'].includes(method)))) (window.VulpesPlatform === 'android' && ['media.request', 'activities.finish', 'contacts.request'].includes(method))))
params = await settingsCodec.encode(params); params = await settingsCodec.encode(params);
return navigator.vulpesRequest({ version: 1, id: ++sequence, method, params }).then((reply) => { return navigator.vulpesRequest({ version: 1, id: ++sequence, method, params }).then((reply) => {
if (reply.error) throw new DOMException(reply.error.code, reply.error.code); if (reply.error) throw new DOMException(reply.error.code, reply.error.code);
return settingsCodec && return settingsCodec &&
['settings.get', 'settings.snapshot', 'media.request', 'activities.start', 'contacts.request'].includes(method) ['messages.request', 'settings.get', 'settings.snapshot', 'media.request', 'activities.start', 'contacts.request'].includes(method)
? settingsCodec.decode(reply.result) ? settingsCodec.decode(reply.result)
: reply.result; : reply.result;
}); });
@ -217,11 +238,15 @@
} }
if (type === 'callscreen-closed') document.querySelector('iframe[name="call_screen"]')?.dispatchEvent(new CustomEvent('mozbrowserclose',{bubbles:true})); if (type === 'callscreen-closed') document.querySelector('iframe[name="call_screen"]')?.dispatchEvent(new CustomEvent('mozbrowserclose',{bubbles:true}));
if (type === 'hardware-key') dispatchEvent(new CustomEvent('softwareButtonEvent', {detail:data})); if (type === 'hardware-key') dispatchEvent(new CustomEvent('softwareButtonEvent', {detail:data}));
if (type === 'activity-choice') dispatchEvent(new CustomEvent('mozChromeEvent', {detail:{...data,type:'activity-choice'}}));
if (type === 'activity-launch') dispatchEvent(new CustomEvent('open-app', {detail:{...data,showApp:true,onlyShowApp:false}})); if (type === 'activity-launch') dispatchEvent(new CustomEvent('open-app', {detail:{...data,showApp:true,onlyShowApp:false}}));
if (type === 'launch') dispatchEvent(new CustomEvent('webapps-launch', { detail: data })); if (type === 'launch') dispatchEvent(new CustomEvent('webapps-launch', { detail: data }));
if (type === 'screen-off') { if (type === 'screen-off') {
dispatchEvent(new CustomEvent('secure-killapps')); settings.createLock().get('lockscreen.enabled').then(values => {
window.Service?.request('lock').catch(console.error); if (values['lockscreen.enabled'] === false) return;
dispatchEvent(new CustomEvent('secure-killapps'));
window.Service?.request('lock').catch(console.error);
}).catch(console.error);
} }
if (type === 'screen-on') window.ScreenManager?.turnScreenOn(true); if (type === 'screen-on') window.ScreenManager?.turnScreenOn(true);
if (type === 'home' || type === 'holdhome') { if (type === 'home' || type === 'holdhome') {
@ -230,6 +255,10 @@
} }
if (type === 'activity-view') dispatchEvent(new CustomEvent('activity-view', { detail: data })); if (type === 'activity-view') dispatchEvent(new CustomEvent('activity-view', { detail: data }));
}); });
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);
});
function application(record) { function application(record) {
// DOMApplication never exposed the registry's internal id. Gaia uses `id` // DOMApplication never exposed the registry's internal id. Gaia uses `id`
// to distinguish a bookmark from an installed application. // to distinguish a bookmark from an installed application.
@ -520,7 +549,7 @@
frames.add(frame); frames.add(frame);
// Set delegation before navigation; a MutationObserver runs too late on Android. // Set delegation before navigation; a MutationObserver runs too late on Android.
if (location.hostname === 'system.localhost') if (location.hostname === 'system.localhost')
frame.setAttribute('allow', 'camera http://camera.localhost:' + location.port); frame.setAttribute('allow', 'camera http://camera.localhost:' + location.port + '; microphone http://camera.localhost:' + location.port);
frame.addEventListener('load', () => { frame.addEventListener('load', () => {
if (!frame.hasAttribute('mozbrowser')) return; if (!frame.hasAttribute('mozbrowser')) return;
if (webViews.has(frame)) return; if (webViews.has(frame)) return;

View file

@ -1,6 +1,10 @@
// Keep Gaia's keyboard and layouts; replace its retired Gecko input-method API. // Keep Gaia's keyboard and layouts; replace its retired Gecko input-method API.
(() => { (() => {
if (!window.VulpesNativeInput) return; function start() {
// Android emits focus only when enabled. Install the keyboard API before Gaia's
// startup scripts, without waiting for the asynchronous preference handshake.
if ((!window.VulpesNativeInput && window.VulpesPlatform !== 'android') || window.__vulpesInputInstalled) return;
window.__vulpesInputInstalled=true;
const app=location.hostname.split('.')[0], {call}=VulpesCompat; const app=location.hostname.split('.')[0], {call}=VulpesCompat;
if (app==='keyboard') { if (app==='keyboard') {
let viewport, pendingState; let viewport, pendingState;
@ -16,13 +20,14 @@
dispatchEvent(new Event('resize')); dispatchEvent(new Event('resize'));
if(pendingState!==undefined) {setState(pendingState);pendingState=undefined;} if(pendingState!==undefined) {setState(pendingState);pendingState=undefined;}
}); });
addEventListener('DOMContentLoaded', () => { const ready = () => {
ViewManager.prototype.screenInPortraitMode = function() { ViewManager.prototype.screenInPortraitMode = function() {
return viewport ? viewport.width<=viewport.height : return viewport ? viewport.width<=viewport.height :
this.cachedWindowWidth<=this.cachedWindowHeight; this.cachedWindowWidth<=this.cachedWindowHeight;
}; };
parent.postMessage({type:'vulpes-keyboard-ready'},'http://system.localhost:8765'); parent.postMessage({type:'vulpes-keyboard-ready'},'http://system.localhost:8765');
}, {once:true}); };
if(document.readyState==='loading')addEventListener('DOMContentLoaded',ready,{once:true});else ready();
const ime=Object.assign(new EventTarget(),{inputcontext:null,mgmt:{ const ime=Object.assign(new EventTarget(),{inputcontext:null,mgmt:{
supportsSwitching:()=>false, supportsSwitching:()=>false,
hide:()=>call('input.request',{operation:'hide'}), hide:()=>call('input.request',{operation:'hide'}),
@ -93,4 +98,7 @@
for(const type of ['home','lockscreen-appopened','appwillclose']) for(const type of ['home','lockscreen-appopened','appwillclose'])
addEventListener(type,()=>call('input.request',{operation:'hide'}).catch(console.error)); addEventListener(type,()=>call('input.request',{operation:'hide'}).catch(console.error));
} }
}
addEventListener('vulpes-input-configured',start);
start();
})(); })();

View file

@ -0,0 +1,366 @@
// Expose the single native SIM to the original Gaia Settings controllers.
// Other applications retain their existing telephony/message adapters.
(() => {
if (location.hostname !== 'settings.localhost' || !window.VulpesNativeTelephony) return;
const {call} = VulpesCompat;
const read = action => call('platform.mobileSettings', {action});
const unavailable = () => Promise.reject(new DOMException('Not supported by this adapter', 'NotSupportedError'));
const icc = Object.assign(new EventTarget(), {cardState:'unknown', iccInfo:null});
const conn = Object.assign(new EventTarget(), {iccId:null, radioState:'unknown', supportedNetworkTypes:[], voice:{type:null}, data:{type:null}});
const manager = Object.assign(new EventTarget(), {iccIds:[], getIccById:id => id && id === conn.iccId ? icc : null});
let fixedDialing, pinEnabled;
icc.getCardLock = async name => {
await VulpesMobileSettings.ready;
const enabled = name === 'fdn' ? fixedDialing : name === 'pin' ? pinEnabled : undefined;
if (typeof enabled !== 'boolean') return unavailable();
return {enabled};
};
// Fixed-dialling changes, PIN operations and SIM contacts are not ported.
icc.getServiceState = () => Promise.resolve(false);
for (const method of ['unlockCardLock','setCardLock','getCardLockRetryCount','readContacts','updateContact']) icc[method] = unavailable;
// Supplementary services can fail independently of reading the SIM itself.
// Do not pretend a modem query is a successful preference change.
conn.getCallingLineIdRestriction = unavailable;
conn.setCallingLineIdRestriction = unavailable;
conn.getCallWaitingOption = () => read('calls').then(value => {
if (!['enabled','disabled'].includes(value.VoiceCallWaiting)) throw Error('RADIO_NOT_READY');
return value.VoiceCallWaiting === 'enabled';
});
conn.setCallWaitingOption = unavailable;
conn.getVoicePrivacyMode = unavailable; conn.setVoicePrivacyMode = unavailable;
Object.defineProperty(navigator,'mozMobileConnections',{value:[conn]});
Object.defineProperty(navigator,'mozIccManager',{value:manager});
Object.defineProperty(navigator,'mozMobileMessage',{value:{getSmscAddress:() => read('smsc').then(v=>({address:v.address || ''}))}});
async function refresh() {
const state = await read('sim');
const previous = conn.iccId;
conn.radioState = state.radioEnabled ? 'enabled' : 'disabled';
conn.dispatchEvent(new Event('radiostatechange'));
conn.iccId = state.present ? state.id : null;
fixedDialing = state.fixedDialing; pinEnabled = state.pinEnabled;
manager.iccIds = conn.iccId ? [conn.iccId] : [];
icc.cardState = !state.present ? 'absent' : state.pin === 'none' ? 'ready' : state.pin === 'pin' ? 'pinRequired' : state.pin === 'puk' ? 'pukRequired' : 'unknown';
icc.iccInfo = {iccid:conn.iccId, spn:state.operator, msisdn:state.numbers?.[0] || '', iccType:'sim', isDisplaySpnRequired:true};
if (previous !== conn.iccId) manager.dispatchEvent(Object.assign(new Event(conn.iccId ? 'iccdetected' : 'iccundetected'),{iccId:conn.iccId || previous}));
icc.dispatchEvent(new Event('cardstatechange')); icc.dispatchEvent(new Event('iccinfochange'));
}
let messageState, changing = false;
async function messaging(panel) {
const control=panel.querySelector('[name="ril.sms.requestStatusReport.enabled"]');
if (!control || control.dataset.vulpesBound) return;
control.dataset.vulpesBound='true'; control.disabled=true;
control.addEventListener('change', async event => {
event.stopImmediatePropagation();
const desired=control.checked;
if (changing || typeof messageState !== 'boolean') return;
changing=true;control.disabled=true;
try {
const result=await call('platform.mobileSettings',{action:'deliveryReports',enabled:desired});
messageState=result.deliveryReports;
await navigator.mozSettings.createLock().set({'ril.sms.requestStatusReport.enabled':messageState});
} catch(error) {window.VulpesGaiaUI?.notice('SMS: '+error.message);}
finally {control.checked=messageState;changing=false;control.disabled=false;}
},true);
try {
const state=await read('messages');
if (typeof state.deliveryReports !== 'boolean') return;
messageState=state.deliveryReports;
control.checked=messageState;
await navigator.mozSettings.createLock().set({'ril.sms.requestStatusReport.enabled':messageState});
control.disabled=false;control.closest('li').removeAttribute('aria-disabled');
control.dataset.vulpesSupported='true';
} catch(error) {console.warn('Message settings:',error.message);}
}
function carrierPanel() {
let panel, state, pending=false;
const controls = () => [panel.querySelector('#menuItem-enableDataCall gaia-switch'), panel.querySelector('#menuItem-enableDataRoaming gaia-switch')];
async function sync() {
if(typeof state?.enabled==='boolean' && typeof state?.roaming==='boolean')
await navigator.mozSettings.createLock().set({'ril.data.enabled':state.enabled,'ril.data.roaming_enabled':state.roaming});
}
function render() {
const [data,roaming]=controls();
for(const [control,key] of [[data,'enabled'],[roaming,'roaming']]) {
control.disabled=pending || typeof state?.[key]!=='boolean';
control.checked=state?.[key]===true;
control.closest('li').setAttribute('aria-disabled',String(control.disabled));
}
panel.querySelector('.carrier-info').hidden=false;
panel.querySelector('.carrier-advancedSettings').hidden=false;
panel.querySelector('#dataNetwork-desc').textContent=[state?.operator,(state?.technology||'').toUpperCase(),state?.status].filter(Boolean).join(' · ');
}
return {
onInit(element) {
panel=element;
for(const [control,action] of [[controls()[0],'dataEnabled'],[controls()[1],'roaming']]) {
control.addEventListener('change',async event=>{
event.stopImmediatePropagation();if(pending)return;
const enabled=control.checked;pending=true;render();
try {state=await call('platform.mobileSettings',{action,enabled});await sync();}
catch(error) {window.VulpesGaiaUI?.notice(error.message);}
finally {pending=false;render();}
},true);
}
},
async onBeforeShow() {
pending=true;render();
try {state=await read('network');await sync();}
catch(error) {window.VulpesGaiaUI?.notice(error.message);}
finally {pending=false;render();}
}
};
}
const fr = () => document.documentElement.lang.toLowerCase().startsWith('fr');
const text = (f,e) => fr()?f:e;
const navigate = (id,options={}) => require(['modules/settings_service'],service=>service.navigate(id,options));
function explain(panel,key,message) {
let row=panel.querySelector('[data-vulpes-note="'+key+'"]');
if(!row) {row=document.createElement('p');row.className='explanation';row.dataset.vulpesNote=key;(panel.querySelector(':scope > div')||panel).prepend(row);}
row.textContent=message;
}
function failure(error) {
const messages={
APN_IN_USE:text('Désactivez les données mobiles avant de modifier ce profil actif.','Turn off mobile data before editing this active profile.'),
APN_WRITE_FAILED:text('Enregistrement impossible. Le profil précédent a été restauré.','Could not save. The previous profile was restored.'),
APN_ROLLBACK_FAILED:text('Le profil n’a pas pu être restauré. Vérifiez ses paramètres avant de réactiver les données.','The profile could not be restored. Check its settings before re-enabling data.'),
RADIO_READBACK_FAILED:text('Le réseau n’a pas confirmé le réglage. Son état va être relu.','The network did not confirm the setting. Its state will be read again.'),
INVALID_REQUEST:text('Vérifiez les valeurs saisies.','Check the values you entered.')
};
window.VulpesGaiaUI?.notice(messages[error.message]||text('Le service réseau n’a pas pu appliquer ce réglage.','The network service could not apply this setting.'));
}
function callPanel() {
let panel,waiting,caller,state,pending=false;
const modes={default:'CLIR_DEFAULT',enabled:'CLIR_INVOCATION',disabled:'CLIR_SUPPRESSION'};
function render() {
waiting.disabled=pending || !['enabled','disabled'].includes(state?.VoiceCallWaiting);
waiting.checked=state?.VoiceCallWaiting==='enabled';
waiting.closest('li').dataset.state=waiting.disabled?'unknown':waiting.checked?'on':'off';
caller.disabled=pending || !Object.hasOwn(modes,state?.HideCallerId);
caller.value=modes[state?.HideCallerId]||'';
for(const c of [waiting,caller])c.closest('li').setAttribute('aria-disabled',String(c.disabled));
explain(panel,'call-state',pending?text('Interrogation du réseau… Cela peut prendre plusieurs secondes.','Querying the network… This may take several seconds.'):text('Le double appel et la présentation du numéro utilisent les réglages de votre opérateur.','Call waiting and caller ID use your carrier’s settings.'));
}
async function update(params) {
if(pending)return;
pending=true;render();
try {state=await call('platform.mobileSettings',params);}
catch(error) {state=null;failure(error);try {state=await read('calls');}catch(_) {}}
finally {pending=false;render();}
}
return {onInit(element){
panel=element;waiting=panel.querySelector('#menuItem-callWaiting gaia-switch');caller=panel.querySelector('#ril-callerId');caller.removeAttribute('data-setting');
for(const id of ['menuItem-callWaiting','menuItem-callerId','menuItem-callForwarding','menuItem-callFdn','menuItem-callBarring'])panel.querySelector('#'+id).hidden=false;
waiting.addEventListener('change',()=>update({action:'setCallWaiting',enabled:waiting.checked}));
caller.addEventListener('change',()=>update({action:'setCallerId',mode:Object.keys(modes).find(key=>modes[key]===caller.value)}));
for(const [id,target] of [['menuItem-callForwarding','call-forwarding'],['menuItem-callFdn','call-fdnSettings'],['menuItem-callBarring','call-cbSettings']]) {
const a=panel.querySelector('#'+id+' a');a.removeAttribute('data-href');a.href='#'+target;
a.addEventListener('click',event=>{event.preventDefault();event.stopImmediatePropagation();navigate(target);},true);
}
const voicemail=panel.querySelector('.menuItem-voicemail');voicemail.setAttribute('aria-disabled','true');
voicemail.addEventListener('click',event=>{event.preventDefault();event.stopImmediatePropagation();},true);
document.l10n.setAttributes(panel.querySelector('#voiceMail-desc'),'unavailable');
explain(panel,'call-limits',text('Le numéro de messagerie vocale, les numéros autorisés et le blocage des appels ne sont pas encore raccordés.','Voicemail number, fixed dialling and call barring are not connected yet.'));
render();
},onBeforeShow(){void update({action:'calls'});}};
}
function forwardingPanel() {
let panel,state,pending=false;
const rows={
unconditional:['cfu','call-cf-unconditional-settings','.callForwardingUnconditional'],
busy:['cfmb','call-cf-mobile-busy-settings','.callForwardingMobileBusy'],
noReply:['cfnrep','call-cf-no-reply-settings','.callForwardingNoReply'],
notReachable:['cfnrea','call-cf-not-reachable-settings','.callForwardingNotReachable']
};
function render() {
for(const [reason,[id,,selector]] of Object.entries(rows)) {
const known=typeof state?.[reason]==='string';
panel.querySelector(selector).setAttribute('aria-disabled',String(pending||!known));
const node=panel.querySelector('#'+id+'-desc');
document.l10n.setAttributes(node,pending?'callSettingsQuery':!known?'callSettingsQueryError':state[reason]?'callForwardingForwardingVoiceToNumber':'callForwardingNotForwarding',state?.[reason]?{number:state[reason]}:{});
}
}
async function load() {
if(pending)return;
pending=true;render();
try {state=await read('forwarding');}catch(error){state=null;failure(error);}
finally {pending=false;render();}
}
return {onInit(element){
panel=element;
for(const [reason,[,dialog,selector]] of Object.entries(rows))panel.querySelector(selector).addEventListener('click',async event=>{
event.preventDefault();event.stopImmediatePropagation();
if(pending || typeof state?.[reason]!=='string')return;
const service=await new Promise(resolve=>require(['modules/dialog_service'],resolve));
const result=await service.show(dialog,{number:state[reason],enabled:!!state[reason]});
if(result.type!=='submit')return;
const enabled=!!result.value.enabled;
const number=enabled?String(result.value.number||'').replace(/[\s()-]/g,''):'';
pending=true;render();
try {state=await call('platform.mobileSettings',{action:'setForwarding',reason,enabled,number});}
catch(error){failure(error);try{state=await read('forwarding');}catch(_){state=null;}}
finally {pending=false;render();}
});
},onBeforeShow(){void load();}};
}
function operatorPanel() {
let panel,select,pending=false,state;
return {
onInit(element) {
panel=element;select=panel.querySelector('.preferred-network-type select');
// Operator scanning/registration has a different service and remains
// unavailable; it must not be confused with the radio mode selector.
panel.querySelector('.auto-select gaia-switch').disabled=true;
panel.querySelector('.search-btn').disabled=true;
select.addEventListener('change',async()=>{
if(pending || !state)return;
const mode=select.value;pending=true;select.disabled=true;
try {state=await call('platform.mobileSettings',{action:'setRadioMode',mode});}
catch(error){failure(error);}
finally {select.value=state.mode;pending=false;select.disabled=false;}
});
},
async onBeforeShow() {
select.disabled=true;
explain(panel,'operator',text('Le type de réseau est réglable. La recherche et la sélection manuelle d’opérateur ne sont pas encore raccordées.','Network type can be changed. Operator scanning and manual selection are not connected yet.'));
try {
const [radio,network]=await Promise.all([read('radioMode'),read('network')]);state=radio;
select.replaceChildren();
const names={any:text('Automatique','Automatic'),gsm:'2G (GSM)',umts:'3G (UMTS)',lte:'4G (LTE)'};
for(const mode of radio.available)select.add(new Option(names[mode]||mode,mode));
select.value=radio.mode;select.disabled=!radio.available.includes(radio.mode);
panel.querySelector('.auto-select gaia-switch').checked=network.mode==='auto';
} catch(error){failure(error);}
}
};
}
function apnSettingsPanel() {
return {onInit(panel){
for(const anchor of panel.querySelectorAll('[data-apn-type]')) {
const supported=['default','mms'].includes(anchor.dataset.apnType);
if(!supported)anchor.setAttribute('aria-disabled','true');
anchor.addEventListener('click',event=>{
event.preventDefault();event.stopImmediatePropagation();
if(supported)navigate('apn-list',{type:anchor.dataset.apnType});
});
}
panel.querySelector('.reset-apn').disabled=true;
},onBeforeShow(panel){
explain(panel,'apn',text('Modifiez les profils Internet et MMS existants. Les profils IMS restent gérés par le modem. L’ajout, la suppression et la réinitialisation ne sont pas encore disponibles.','Edit existing Internet and MMS profiles. IMS profiles remain managed by the modem. Adding, deleting and resetting profiles are not available yet.'));
}};
}
function apnListPanel() {
let panel,type='default';
return {onInit(element){panel=element;panel.querySelector('.add-apn').disabled=true;panel.querySelector('gaia-header').dataset.href='#apn-settings';},
async onBeforeShow(element,options={}) {
type=options.type==='mms'?'mms':'default';
document.l10n.setAttributes(panel.querySelector('h1'),type==='mms'?'messageSettings-header':'dataSettings-header');
const list=panel.querySelector('.apn-list');list.replaceChildren();
try {
const profiles=(await read('apns')).filter(p=>p.type===type);
const template=await new Promise(resolve=>require(['panels/apn_list/apn_template_factory'],factory=>resolve(factory(type,item=>navigate('apn-editor',{profileId:item.id,type}),()=>{}))));
for(const profile of profiles) {
const li=template({id:profile.id,apn:{apn:profile.fields.apn},active:profile.active});
// This mark reflects an active data context; changing the profile
// does not activate/deactivate a bearer implicitly.
const radio=li.querySelector('gaia-radio');radio.setAttribute('aria-readonly','true');
list.append(li);
}
explain(panel,'list',profiles.length?text('Touchez le nom du profil pour le modifier.','Tap a profile name to edit it.'):text('Aucun profil fourni par le modem.','The modem returned no profile.'));
}catch(error){failure(error);}
}
};
}
function apnEditorPanel() {
let panel,profile,pending=false,dirty=new Set();
const selectors={apn:'.apn',user:'.user',password:'.password',protocol:'.protocol',authtype:'.authtype',mmsc:'.mmsc',mmsproxy:'.mmsproxy'};
const protocols={ip:'IP',ipv6:'IPV6',dual:'IPV4V6'};
const auth={none:'none',pap:'pap',chap:'chap',any:'papOrChap'};
function editable() {
for(const [key,selector] of Object.entries(selectors))panel.querySelector(selector).disabled=pending || !profile || (['mmsc','mmsproxy'].includes(key)&&profile.type!=='mms');
panel.querySelector('.ok').disabled=pending || !profile;
}
function render() {
editable();
for(const [key,selector] of Object.entries(selectors)) {
const input=panel.querySelector(selector);
input.disabled=pending || !profile || (['mmsc','mmsproxy'].includes(key)&&profile.type!=='mms');
const value=profile?.fields[key]||'';
input.value=key==='password'?'':key==='protocol'?protocols[value]||'notDefined':key==='authtype'?auth[value]||'notDefined':value;
if(key==='password')input.placeholder=profile?.hasPassword?text('Inchangé','Unchanged'):'';
}
panel.querySelector('.ok').disabled=pending || !profile;
}
return {onInit(element){
panel=element;
for(const input of panel.querySelectorAll('input,select,button'))input.disabled=true;
for(const [key,selector] of Object.entries(selectors))for(const event of ['input','change'])panel.querySelector(selector).addEventListener(event,()=>dirty.add(key));
panel.querySelector('.ok').addEventListener('click',async()=>{
if(pending||!profile)return;
const fields={};
for(const key of dirty) {
const value=panel.querySelector(selectors[key]).value;
fields[key]=key==='protocol'?Object.keys(protocols).find(k=>protocols[k]===value):key==='authtype'?Object.keys(auth).find(k=>auth[k]===value):value;
if(fields[key]===undefined){failure({message:'INVALID_REQUEST'});return;}
}
if(!dirty.size){navigate('apn-list',{type:profile.type});return;}
pending=true;editable();
try {
profile=await call('platform.mobileSettings',{action:'editApn',id:profile.id,fields});
dirty.clear();navigate('apn-list',{type:profile.type});
}catch(error){failure(error);}
finally {pending=false;editable();}
});
panel.querySelector('gaia-header').addEventListener('action',event=>{event.stopImmediatePropagation();if(!pending)navigate('apn-list',{type:profile?.type||'default'});},true);
},async onBeforeShow(element,options={}) {
profile=null;dirty.clear();render();
explain(panel,'fields',text('Les champs grisés ne sont pas gérés par ce modem. Le champ mot de passe vide conserve le secret existant tant que vous ne le modifiez pas.','Greyed-out fields are not handled by this modem. An untouched password field keeps the existing secret.'));
try {profile=(await read('apns')).find(p=>p.id===options.profileId);if(!profile)throw Error('APN_NOT_FOUND');render();}
catch(error){failure(error);}
},onHide(){panel.querySelector('.password').value='';dirty.clear();}};
}
let clockState, clockUpdating=false;
Object.defineProperty(navigator,'mozTime',{value:{set:async date=>{
try {
await call('platform.deviceSettings',{action:'setTime',milliseconds:+date});
dispatchEvent(new Event('moztimechange'));
} catch(error) {window.VulpesGaiaUI?.notice(error.message);}
}}});
async function clock() {
clockState=await call('platform.deviceSettings',{action:'clock'});
clockUpdating=true;
try {await navigator.mozSettings.createLock().set({
'time.timezone':clockState.timezone, 'time.timezone.user-selected':clockState.timezone,
'time.clock.automatic-update.available':clockState.automaticAvailable,
'time.clock.automatic-update.enabled':clockState.automatic,
'time.timezone.automatic-update.available':false
});} finally {clockUpdating=false;}
return clockState;
}
navigator.mozSettings.addObserver('time.clock.automatic-update.enabled',async event=>{
if(clockUpdating || !clockState || event.settingValue===clockState.automatic)return;
try {
clockState=await call('platform.deviceSettings',{action:'setAutomatic',enabled:event.settingValue});
await call('settings.clockChanged',{});
dispatchEvent(new Event('moztimechange'));
} catch(error) {
window.VulpesGaiaUI?.notice(error.message);
await clock();
}
});
navigator.mozSettings.addObserver('time.timezone',async event=>{
if(clockUpdating || !clockState || event.settingValue===clockState.timezone)return;
try {
clockState=await call('platform.deviceSettings',{action:'setTimezone',timezone:event.settingValue});
await call('settings.clockChanged',{});
dispatchEvent(new Event('moztimechange'));
} catch(error) {
window.VulpesGaiaUI?.notice(error.message);
await navigator.mozSettings.createLock().set({'time.timezone':clockState.timezone});
}
});
clock().catch(error=>console.warn('Clock settings:',error.message));
window.VulpesMobileSettings = {ready:refresh(), refresh, messaging, clock, panels:{call:callPanel,'call-forwarding':forwardingPanel,carrier:carrierPanel,'operator-settings':operatorPanel,'apn-settings':apnSettingsPanel,'apn-list':apnListPanel,'apn-editor':apnEditorPanel}};
VulpesMobileSettings.ready.catch(error=>console.warn('SIM settings:',error.message));
})();

View file

@ -3,6 +3,22 @@
const app = location.hostname.split('.')[0]; const app = location.hostname.split('.')[0];
const fr = () => (document.documentElement.lang || navigator.language).startsWith('fr'); const fr = () => (document.documentElement.lang || navigator.language).startsWith('fr');
const label = (a, b) => fr() ? a : b; const label = (a, b) => fr() ? a : b;
const labels = new Map();
function localize(node, french, english, attribute) {
labels.set(node, {french, english, attribute});
translate();
}
function translate() {
for (const [node, entry] of labels) {
const value = label(entry.french, entry.english);
if (entry.attribute) {
if (node.getAttribute(entry.attribute) !== value) node.setAttribute(entry.attribute, value);
} else if (node.textContent !== value) node.textContent = value;
}
}
addEventListener('localized', translate);
new MutationObserver(translate).observe(document.documentElement, {attributes:true, attributeFilter:['lang']});
function notice(message) { function notice(message) {
document.getElementById('vulpes-gaia-notice')?.remove(); document.getElementById('vulpes-gaia-notice')?.remove();
const link = document.createElement('link'); const link = document.createElement('link');
@ -28,6 +44,14 @@
window.VulpesGaiaUI = {notice}; window.VulpesGaiaUI = {notice};
async function ready() { async function ready() {
if (app === 'system') { if (app === 'system') {
// Android reserves the edge swipe for its own shade. A tap on Gaia's
// status bar provides access without competing with that system gesture.
if (window.VulpesPlatform === 'android') {
document.getElementById('statusbar')?.addEventListener('click', () => {
if (!window.Service?.query('locked')) window.UtilityTray?.show(true);
});
}
// OperatorIcon also owns the date. It must exist even without a modem. // OperatorIcon also owns the date. It must exist even without a modem.
const startDate = async () => { const startDate = async () => {
if (!window.BaseIcon || !window.Service || !window.LazyLoader) return false; if (!window.BaseIcon || !window.Service || !window.LazyLoader) return false;
@ -50,8 +74,8 @@
const li = document.createElement('li'), button = document.createElement('button'), error = document.createElement('p'); const li = document.createElement('li'), button = document.createElement('button'), error = document.createElement('p');
li.id = 'import-sim-option-unavailable';li.setAttribute('role','presentation'); li.id = 'import-sim-option-unavailable';li.setAttribute('role','presentation');
button.className = 'icon icon-sim';button.setAttribute('role','option');button.disabled = true; button.className = 'icon icon-sim';button.setAttribute('role','option');button.disabled = true;
button.textContent = label('Carte SIM', 'SIM card'); localize(button, 'Carte SIM', 'SIM card');
error.className = 'error-message';error.textContent = unavailable(); error.className = 'error-message';localize(error, 'Cette fonction n’est pas encore disponible sur cette plateforme.', 'This feature is not available on this platform yet.');
li.append(button,error);list.prepend(li); li.append(button,error);list.prepend(li);
} }
const item = document.getElementById('import-sd-option'); const item = document.getElementById('import-sd-option');
@ -62,7 +86,7 @@
if(item.classList.contains('disabled')) item.classList.remove('disabled'); if(item.classList.contains('disabled')) item.classList.remove('disabled');
const span = item.querySelector('span'); const span = item.querySelector('span');
if(span && span.dataset.l10nId === 'importMemoryCard') { if(span && span.dataset.l10nId === 'importMemoryCard') {
span.removeAttribute('data-l10n-id');span.textContent = label('Fichier de contacts', 'Contact file'); span.removeAttribute('data-l10n-id');localize(span, 'Fichier de contacts', 'Contact file');
} }
const error = item.querySelector('p'); const error = item.querySelector('p');
if(error && !error.hidden) error.hidden = true; if(error && !error.hidden) error.hidden = true;
@ -79,6 +103,17 @@
}, true); }, true);
} }
if (app === 'sms') {
let mmsAvailable=false;
VulpesCompat.call('platform.capabilities',{}).then(c=>{mmsAvailable=c.mms===true;}).catch(()=>{});
// Keep the composer intact until a real MMS transport is available.
document.addEventListener('click', event => {
if (mmsAvailable || !event.target.closest('#messages-send-button') || window.Compose?.type !== 'mms') return;
event.preventDefault(); event.stopImmediatePropagation();
notice(label('L’envoi de MMS n’est pas encore disponible dans cette version. Votre message et ses pièces jointes sont conservés.',
'MMS sending is not yet available in this version. Your message and attachments have been kept.'));
}, true);
}
if (app === 'sms' && window.VulpesPlatform === 'android') { if (app === 'sms' && window.VulpesPlatform === 'android') {
document.addEventListener('click', event => { document.addEventListener('click', event => {
if (!event.target.closest('#messages-send-button')) return; if (!event.target.closest('#messages-send-button')) return;
@ -103,7 +138,7 @@
if (!header || header.querySelector('[data-vulpes-import]')) return; if (!header || header.querySelector('[data-vulpes-import]')) return;
const action = document.createElement('button'); const action = document.createElement('button');
action.dataset.vulpesImport = ''; action.dataset.icon = 'add'; action.dataset.vulpesImport = ''; action.dataset.icon = 'add';
action.setAttribute('aria-label', label('Importer des fichiers', 'Import files')); localize(action, 'Importer des fichiers', 'Import files', 'aria-label');
action.onclick = () => VulpesCompat.call('platform.pick',{type:mediaType}).catch(error => { action.onclick = () => VulpesCompat.call('platform.pick',{type:mediaType}).catch(error => {
if (error.name !== 'AbortError' && error.message !== 'AbortError') notice(unavailable()); if (error.name !== 'AbortError' && error.message !== 'AbortError') notice(unavailable());
}); });
@ -134,31 +169,79 @@
const unsupported = { const unsupported = {
wifi: !navigator.mozWifiManager, wifi: !navigator.mozWifiManager,
bluetooth: !navigator.mozBluetooth, bluetooth: !navigator.mozBluetooth,
carrier: !navigator.mozMobileConnections, carrier: !window.VulpesMobileSettings,
call: !navigator.mozMobileConnections, call: !window.VulpesMobileSettings,
messaging: !navigator.mozMobileConnections, messaging: !navigator.mozMobileConnections?.length,
'sim-manager': !navigator.mozIccManager, 'sim-manager': !navigator.mozIccManager,
simpin: !navigator.mozIccManager, simpin: true,
hotspot: !navigator.mozWifiManager, 'wifi-manageCertificates': true,
'wifi-selectCertificateFile': true,
hotspot: !window.VulpesWifiSettings,
downloads: capabilities.platform === 'android',
'wifi-manageNetworks': !navigator.mozWifiManager,
'messaging-details': !navigator.mozMobileConnections?.length,
'operator-settings': !window.VulpesMobileSettings,
'carrier-detail': !window.VulpesMobileSettings,
'apn-settings': !window.VulpesMobileSettings,
'apn-list': !window.VulpesMobileSettings,
'apn-editor': !window.VulpesMobileSettings,
'call-forwarding': !window.VulpesMobileSettings,
'call-cbSettings': true,
'call-fdnSettings': true,
'call-fdnList': true,
'developer-service-workers': true,
'developer-hud': true,
'accessibility-screenreader': true,
crashReports: true,
improveBrowserOS: true,
'improveBrowserOS-sendFeedback': true,
'improveBrowserOS-chooseFeedback': true,
'full-developer-mode': true,
findmydevice: true, findmydevice: true,
fxa: true, fxa: true,
fxsync: true, fxsync: true,
addons: true, addons: true,
mediaStorage: !navigator.getDeviceStorages, mediaStorage: !navigator.getDeviceStorages,
applicationStorage: !navigator.getDeviceStorage, applicationStorage: !navigator.getDeviceStorage,
usbStorage: !navigator.getDeviceStorages, usbStorage: true,
}; };
function preservePanel(panel) { function preservePanel(panel) {
const content = panel.querySelector(':scope > div'); const content = panel.querySelector(':scope > div') || panel;
if (!content || content.querySelector('[data-vulpes-unavailable]')) return; if (content.querySelector('[data-vulpes-unavailable]')) return;
const explanation = document.createElement('p'); const explanation = document.createElement('p');
explanation.className = 'explanation'; explanation.dataset.vulpesUnavailable = ''; explanation.className = 'explanation'; explanation.dataset.vulpesUnavailable = '';
explanation.setAttribute('role', 'status'); explanation.textContent = unavailable(); explanation.setAttribute('role', 'status'); localize(explanation, 'Cette fonction n’est pas encore disponible sur cette plateforme.', 'This feature is not available on this platform yet.');
content.prepend(explanation); const header=content===panel ? panel.querySelector(':scope > gaia-header') : null;
if(header)header.after(explanation);else content.prepend(explanation);
const serviceNotes = {
fxa: ['La connexion intégrée au compte Mozilla doit être portée vers l’authentification actuelle. Ouvrir le site du compte ne connecte pas encore Vulpes.', 'Integrated Mozilla account sign-in needs the current authentication protocol. Opening the account website does not sign Vulpes in.'],
fxsync: ['La synchronisation nécessite la connexion au compte Mozilla et un adaptateur pour les données Vulpes. Elle n’est pas encore raccordée.', 'Sync requires Mozilla account sign-in and an adapter for Vulpes data. It is not connected yet.'],
findmydevice: ['La localisation distante nécessite un service Vulpes sécurisé et associé à votre compte. Aucun appareil n’est actuellement suivi.', 'Remote device location requires a secure Vulpes service linked to your account. No device is currently tracked.'],
addons: ['Ce panneau gérait les extensions des applications Firefox OS, pas les extensions du navigateur Firefox. Leur moteur d’exécution reste à porter.', 'This panel managed Firefox OS app add-ons, not Firefox browser extensions. Their runtime still needs to be ported.'],
usbStorage: ['Le transfert de fichiers USB n’est pas encore raccordé. La connexion USB de maintenance reste disponible.', 'USB file transfer is not connected yet. The maintenance USB connection remains available.']
};
if(serviceNotes[panel.id]) localize(explanation,...serviceNotes[panel.id]);
if (panel.id === 'wifi-manageCertificates' || panel.id === 'wifi-selectCertificateFile') localize(explanation, 'Les certificats Wi-Fi et les réseaux WPA-Enterprise ne sont pas encore pris en charge par cet adaptateur.', 'Wi-Fi certificates and WPA-Enterprise networks are not supported by this adapter yet.');
if (panel.id === 'call' && window.VulpesNativeTelephony) {
localize(explanation, 'Les appels fonctionnent. Le renvoi, le double appel et le masquage du numéro ne sont pas encore configurables ici.', 'Calling is available. Call forwarding, call waiting and caller ID cannot be configured here yet.');
for (const item of content.querySelectorAll('li[hidden]')) item.hidden = false;
}
if(panel.id==='improveBrowserOS') {
const list=content.querySelector('ul');
for(const child of [...content.children]) if(child!==list && child!==explanation)child.hidden=true;
const anchor=list.querySelector('a');anchor.href='https://nnsprod.com/git/oversu/VulpesOS/issues';
const title=anchor.querySelector('span');title.removeAttribute('data-l10n-id');
localize(title,'Signaler un problème ou proposer une idée','Report a problem or suggest an idea');
localize(explanation,'Les retours sont recueillis sur le dépôt Vulpes OS. Aucun rapport n’est envoyé automatiquement.','Feedback is collected on the Vulpes OS repository. No report is sent automatically.');
anchor.addEventListener('click',event=>{event.preventDefault();new MozActivity({name:'view',data:{type:'url',url:anchor.href}});});
return;
}
for (const control of content.querySelectorAll('button,input,select,gaia-switch,gaia-checkbox,gaia-radio')) { for (const control of content.querySelectorAll('button,input,select,gaia-switch,gaia-checkbox,gaia-radio')) {
control.disabled = true; control.setAttribute('disabled', ''); control.disabled = true; control.setAttribute('disabled', '');
} }
for (const anchor of content.querySelectorAll('a')) { for (const anchor of content.querySelectorAll('a')) {
const href=anchor.getAttribute('href');
if(href && href!=='#' && href.startsWith('#') && unsupported[href.slice(1)]) continue;
anchor.setAttribute('aria-disabled', 'true'); anchor.setAttribute('aria-disabled', 'true');
anchor.addEventListener('click', event => {event.preventDefault();event.stopImmediatePropagation();}, true); anchor.addEventListener('click', event => {event.preventDefault();event.stopImmediatePropagation();}, true);
} }
@ -168,9 +251,19 @@
const install = setInterval(() => { const install = setInterval(() => {
if (typeof window.require !== 'function' || !require.defined?.('modules/settings_panel') || !require.defined?.('modules/panel_cache')) return; if (typeof window.require !== 'function' || !require.defined?.('modules/settings_panel') || !require.defined?.('modules/panel_cache')) return;
clearInterval(install); clearInterval(install);
require(['modules/panel_cache', 'modules/settings_panel'], (cache, SettingsPanel) => { require(['modules/panel_cache', 'modules/settings_panel', 'modules/dialog_panel'], (cache, SettingsPanel, DialogPanel) => {
const original = cache.get.bind(cache), panels = new Map(); const original = cache.get.bind(cache), panels = new Map();
cache.get = (id, callback) => { cache.get = (id, callback) => {
const dialog = window.VulpesWifiSettings?.dialogs[id];
const native = window.VulpesMobileSettings?.panels[id] || window.VulpesWifiSettings?.panels[id];
if(dialog) {
if(!panels.has(id))panels.set(id,DialogPanel(dialog()));
callback?.(panels.get(id));return;
}
if(native) {
if(!panels.has(id))panels.set(id,SettingsPanel(native()));
callback?.(panels.get(id));return;
}
if (!unsupported[id]) return original(id, callback); if (!unsupported[id]) return original(id, callback);
if (!panels.has(id)) panels.set(id, SettingsPanel({onInit:preservePanel, onBeforeShow:preservePanel})); if (!panels.has(id)) panels.set(id, SettingsPanel({onInit:preservePanel, onBeforeShow:preservePanel}));
callback?.(panels.get(id)); callback?.(panels.get(id));
@ -179,26 +272,70 @@
}, 100); }, 100);
function restore() { function restore() {
batteryText(); batteryText();
if (window.VulpesMobileSettings) {
const panel = document.querySelector('#messaging > div');
if (panel && !panel.querySelector('[data-vulpes-message-limit]')) {
const p = document.createElement('p'); p.className = 'explanation'; p.dataset.vulpesMessageLimit = '';
localize(p, 'Les accusés de réception SMS sont reliés au modem. Les autres options grisées ne sont pas encore prises en charge.', 'SMS delivery reports are connected to the modem. The other greyed-out options are not supported yet.'); panel.prepend(p);
}
if (panel) VulpesMobileSettings.messaging(panel);
for (const control of panel?.querySelectorAll('gaia-switch,select') || []) {
if (control.dataset.vulpesSupported === 'true') continue;
if (!control.hasAttribute('disabled')) control.setAttribute('disabled','');
if (control.disabled !== true) control.disabled = true;
}
}
const clockPanel=document.getElementById('dateTime');
if(clockPanel?.querySelector('.date-picker') && !navigator.mozTime) {
for(const control of clockPanel.querySelectorAll('.date-picker,.time-picker,.timezone-picker select')) if(!control.disabled)control.disabled=true;
if(!clockPanel.querySelector('[data-vulpes-clock-host]')) {
const row=document.createElement('p');row.className='explanation';row.dataset.vulpesClockHost='';
localize(row,'La date et le fuseau horaire sont gérés par le système hôte. Le format de l’heure reste réglable ici.', 'Date and timezone are managed by the host system. The time format can still be changed here.');
clockPanel.querySelector(':scope > div').prepend(row);
}
}
for(const id of ['mediaStorage','applicationStorage']) {
const content=document.querySelector('#'+id+' > div');
if(content && !content.querySelector('[data-vulpes-storage-scope]')) {
const p=document.createElement('p');p.className='explanation';p.dataset.vulpesStorageScope='';
localize(p,'Espace utilisé par Vulpes et espace libre sur le volume hôte. Les données des autres applications du système ne sont pas incluses.', 'Vulpes storage usage and free space on the host volume. Other host applications are not included.');
content.prepend(p);
}
}
const root = document.querySelector('#root'); const root = document.querySelector('#root');
if (root) for (const id of ['wifi','bluetooth','carrier','call','messaging','sim-manager','simpin', if (root) for (const id of ['wifi','bluetooth','carrier','call','messaging','sim-manager','simpin',
'findmydevice','addons','mediaStorage','applicationStorage']) { 'findmydevice','addons','mediaStorage','applicationStorage']) {
const li = root.querySelector('a[href="#' + id + '"]')?.closest('li'); const li = root.querySelector('a[href="#' + id + '"]')?.closest('li');
if (li?.hidden) li.hidden = false; if (li?.hidden) li.hidden = false;
if (li?.hasAttribute('aria-disabled')) li.removeAttribute('aria-disabled');
const anchor = li?.querySelector('a');
if (anchor?.hasAttribute('aria-disabled')) anchor.removeAttribute('aria-disabled');
} }
if (capabilities.platform === 'android' && root && !document.getElementById('vulpes-android-settings')) { if (capabilities.platform === 'android' && root && !document.getElementById('vulpes-android-settings')) {
const ul = document.createElement('ul'), li = document.createElement('li'), a = document.createElement('a'); const ul = document.createElement('ul'), li = document.createElement('li'), a = document.createElement('a');
a.id = 'vulpes-android-settings'; a.className = 'menu-item'; a.href = '#'; a.id = 'vulpes-android-settings'; a.className = 'menu-item'; a.href = '#';
a.textContent = label('Paramètres Android', 'Android settings'); localize(a, 'Paramètres Android', 'Android settings');
a.onclick = event => {event.preventDefault();VulpesCompat.call('platform.settings',{panel:'root'}).catch(() => notice(unavailable()));}; a.onclick = event => {event.preventDefault();VulpesCompat.call('platform.settings',{panel:'root'}).catch(() => notice(unavailable()));};
li.append(a); ul.append(li); root.querySelector(':scope > div')?.append(ul); li.append(a); ul.append(li); root.querySelector(':scope > div')?.append(ul);
} }
if (capabilities.platform === 'android') {
const keyboard=document.querySelector('#keyboard > div');
if(keyboard && !document.getElementById('vulpes-keyboard-mode')) {
const ul=document.createElement('ul'),li=document.createElement('li'),control=document.createElement('gaia-switch'),title=document.createElement('label');
control.id='vulpes-keyboard-mode';
localize(title,'Clavier Gaia (expérimental)','Gaia keyboard (experimental)');
control.append(title);li.append(control);ul.append(li);keyboard.prepend(ul);
VulpesCompat.call('input.config',{}).then(enabled=>{control.checked=enabled;});
control.addEventListener('change',()=>navigator.mozSettings.createLock().set({'keyboard.gaia.enabled':control.checked}).catch(console.error));
}
}
if (capabilities.platform === 'android') { if (capabilities.platform === 'android') {
const homescreens = document.querySelector('#homescreens > div'); const homescreens = document.querySelector('#homescreens > div');
if (homescreens && !document.getElementById('vulpes-default-home')) { if (homescreens && !document.getElementById('vulpes-default-home')) {
const ul = document.createElement('ul'), li = document.createElement('li'), a = document.createElement('a'); const ul = document.createElement('ul'), li = document.createElement('li'), a = document.createElement('a');
a.id = 'vulpes-default-home'; a.className = 'menu-item'; a.href = '#'; a.id = 'vulpes-default-home'; a.className = 'menu-item'; a.href = '#';
a.textContent = label('Accueil Android par défaut', 'Default Android home'); localize(a, 'Accueil Android par défaut', 'Default Android home');
a.onclick = event => {event.preventDefault();VulpesCompat.call('platform.settings',{panel:'home'}).catch(() => notice(unavailable()));}; a.onclick = event => {event.preventDefault();VulpesCompat.call('platform.settings',{panel:'home'}).catch(() => notice(unavailable()));};
li.append(a);ul.append(li);homescreens.append(ul); li.append(a);ul.append(li);homescreens.append(ul);
} }
@ -213,12 +350,12 @@
const legal = document.querySelector('#about-legal > div'); const legal = document.querySelector('#about-legal > div');
if (legal && !document.getElementById('vulpes-credits')) { if (legal && !document.getElementById('vulpes-credits')) {
const p = document.createElement('p'); p.id = 'vulpes-credits'; p.className = 'explanation'; const p = document.createElement('p'); p.id = 'vulpes-credits'; p.className = 'explanation';
p.textContent = label('Vulpes OS repose sur Gaia et Gecko de Mozilla et le travail de leurs contributeurs. Capyloon fait partie des projets qui inspirent cette continuité. Les licences et attributions d’origine sont conservées.', localize(p, 'Vulpes OS repose sur Gaia et Gecko de Mozilla et le travail de leurs contributeurs. Capyloon fait partie des projets qui inspirent cette continuité. Les licences et attributions d’origine sont conservées.',
'Vulpes OS builds on Mozilla Gaia and Gecko and the work of their contributors. Capyloon is among the projects inspiring this continuation. Original licenses and attributions are retained.'); 'Vulpes OS builds on Mozilla Gaia and Gecko and the work of their contributors. Capyloon is among the projects inspiring this continuation. Original licenses and attributions are retained.');
legal.append(p); legal.append(p);
} }
} }
new MutationObserver(restore).observe(document.body, {subtree:true, childList:true, attributes:true, attributeFilter:['hidden']}); new MutationObserver(restore).observe(document.body, {subtree:true, childList:true, attributes:true, attributeFilter:['hidden','aria-disabled','disabled']});
restore(); restore();
} }
if (document.readyState === 'loading') addEventListener('DOMContentLoaded', () => ready().catch(console.error), {once:true}); if (document.readyState === 'loading') addEventListener('DOMContentLoaded', () => ready().catch(console.error), {once:true});

206
host/gaia-select.js Normal file
View file

@ -0,0 +1,206 @@
// Gaia's value-selector markup and stylesheet, without a browser-chrome popup.
(() => {
let active;
function close(commit) {
if (!active) return;
const {select, wrapper, chosen} = active;
active = null;
if (commit && select.isConnected && !select.disabled) {
const before = [...select.options].map(o => o.selected).join(',');
[...select.options].forEach((o, i) => { o.selected = chosen.has(i); });
if (before !== [...select.options].map(o => o.selected).join(',')) {
select.dispatchEvent(new Event('input', {bubbles:true}));
select.dispatchEvent(new Event('change', {bubbles:true}));
}
}
wrapper.remove();
// Several original panels commit modem settings on blur.
select.blur();
}
function open(select) {
if (select.disabled || select.closest('[aria-disabled="true"]')) return;
close(false);
if (!document.querySelector('[data-vulpes-select-style]')) {
const link = document.createElement('link');
link.rel = 'stylesheet'; link.dataset.vulpesSelectStyle = '';
link.href = `${location.protocol}//system.localhost:${location.port}/shared/style/value_selector.css`;
document.head.append(link);
}
const wrapper = document.createElement('div'); wrapper.className = 'value-selector';
wrapper.id = 'vulpes-value-selector-layer';
const form = document.createElement('form');
form.id = 'vulpes-value-selector'; form.role = 'dialog';
form.dataset.type = 'value-selector'; form.setAttribute('aria-modal', 'true');
form.innerHTML = '<section><h1 id="vulpes-select-title"></h1><ol role="listbox"></ol></section><menu class="value-selector-buttons"><button type="button" data-action="cancel"></button><button type="submit" class="affirmative" data-action="confirm">OK</button></menu>';
form.setAttribute('aria-labelledby', 'vulpes-select-title');
const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
form.querySelector('[data-action="cancel"]').textContent = fr ? 'Annuler' : 'Cancel';
form.querySelector('h1').textContent = document.getElementById(select.getAttribute('aria-labelledby'))?.textContent || select.closest('li')?.querySelector('span[data-l10n-id],label,p')?.textContent || (fr ? 'Choisir une option' : 'Choose an option');
const chosen = new Set([...select.options].flatMap((o,i) => o.selected ? [i] : []));
const list = form.querySelector('ol');
if (select.multiple) list.setAttribute('aria-multiselectable', 'true');
[...select.children].forEach(child => {
if (child.tagName === 'OPTGROUP') {
const heading = document.createElement('li'); heading.role = 'subheader';
heading.textContent = child.label; list.append(heading);
}
for (const option of child.tagName === 'OPTGROUP' ? child.children : [child]) {
const i = [...select.options].indexOf(option);
if (i < 0 || option.hidden) continue;
const li = document.createElement('li'); li.role = 'option'; li.tabIndex = 0;
li.dataset.index = i; li.dataset.value = option.value;
li.setAttribute('aria-selected', String(chosen.has(i)));
const disabled = option.disabled || option.parentElement.disabled === true;
li.setAttribute('aria-disabled', String(disabled));
const label = document.createElement('label'), span = document.createElement('span');
label.role = 'presentation'; span.textContent = option.textContent;
label.append(span); li.append(label); list.append(li);
const pick = () => {
if (disabled) return;
if (!select.multiple) chosen.clear();
if (select.multiple && chosen.has(i)) chosen.delete(i); else chosen.add(i);
list.querySelectorAll('[role="option"]').forEach(n => n.setAttribute('aria-selected', String(chosen.has(Number(n.dataset.index)))));
};
li.onclick = pick;
li.onkeydown = event => { if (['Enter',' '].includes(event.key)) {event.preventDefault();pick();} };
}
});
active = {select, form, wrapper, chosen};
select.focus({preventScroll:true});
form.onsubmit = event => {event.preventDefault();close(true);};
form.querySelector('[data-action="cancel"]').onclick = () => close(false);
wrapper.append(form); document.body.append(wrapper);
}
// Gecko's embedded native select may consume the compatibility click.
// Open on a completed tap too, without treating a scroll or a hold as a tap.
let gesture, recentTap;
addEventListener('touchstart', event => {
const select=event.target;
if (select.tagName !== 'SELECT' || select.size > 1 || event.touches.length !== 1) {gesture=null;return;}
const point=event.touches[0];
gesture={select,id:point.identifier,x:point.clientX,y:point.clientY,moved:false,at:performance.now()};
},{capture:true,passive:true});
addEventListener('touchmove', event => {
if (!gesture) return;
const point=[...event.touches].find(p=>p.identifier===gesture.id);
if (!point || event.touches.length!==1 || Math.hypot(point.clientX-gesture.x,point.clientY-gesture.y)>10) gesture.moved=true;
},{capture:true,passive:true});
addEventListener('touchcancel',()=>{gesture=null;},{capture:true,passive:true});
addEventListener('touchend', event => {
const tap=gesture;gesture=null;
if (!tap || tap.moved || performance.now()-tap.at>600 || event.touches.length) return;
const point=[...event.changedTouches].find(p=>p.identifier===tap.id);
if (!point || Math.hypot(point.clientX-tap.x,point.clientY-tap.y)>10) return;
event.preventDefault();event.stopImmediatePropagation();
recentTap={select:tap.select,at:performance.now()};open(tap.select);
},{capture:true,passive:false});
// Cancel the browser-chrome popup. Mouse and keyboard activation use Gaia too.
for (const type of ['mousedown','pointerdown']) addEventListener(type, event => {
if (event.target.tagName === 'SELECT' && event.target.size <= 1) event.preventDefault();
}, true);
addEventListener('click', event => {
if (event.target.tagName !== 'SELECT' || event.target.size > 1) return;
event.preventDefault(); event.stopImmediatePropagation();
if (recentTap?.select===event.target && performance.now()-recentTap.at<700) return;
open(event.target);
}, true);
addEventListener('keydown', event => {
if (active && event.key === 'Escape') {event.preventDefault();close(false);}
else if (event.target.tagName === 'SELECT' && [' ','Enter','ArrowDown'].includes(event.key)) {
event.preventDefault();open(event.target);
}
}, true);
addEventListener('pagehide', () => close(false));
document.addEventListener('visibilitychange', () => {if (document.hidden) close(false);});
addEventListener('panelready', event => {
// A late transition event for the same panel must not dismiss a menu
// opened immediately after navigation. Leaving the panel still cancels it.
if (active && event.detail?.current !== '#' + active.select.closest('section')?.id) close(false);
});
})();
// Date/time pickers still belong to Gaia System. Embedded Gecko does not
// provide the retired mozInputMethod value-selector events for these inputs.
(() => {
const systemOrigin = 'http://system.localhost:8765';
if (location.origin === systemOrigin) {
addEventListener('message', event => {
if (event.data?.type === 'vulpes-keyboard-close' && event.origin === 'http://keyboard.localhost:8765') {
const frame=[...document.querySelectorAll('iframe')].find(f=>f.contentWindow===event.source && f.src.startsWith(event.origin+'/settings.html'));
if(frame) {
const keyboard=Service.query('getApp',event.origin);
const close=()=>frame.isConnected && frame.dispatchEvent(new CustomEvent('mozbrowserclose',{bubbles:true}));
// Closing a still-transitioning background window can send Gaia
// Home over the application which has just replaced it.
if(keyboard?.isActive())keyboard.element.addEventListener('_closed',close,{once:true});
else close();
}
return;
}
const app = window.Service?.query('getActiveApp');
if (!app || event.source !== app.iframe?.contentWindow || event.origin !== app.origin) return;
if (event.data?.type !== 'vulpes-datetime-open' || !['date','time'].includes(event.data.inputType)) return;
const picker = app.valueSelector;
if (!picker) return;
const previous = picker._im;
const reply = value => event.source.postMessage({type:'vulpes-datetime-result', id:event.data.id, value}, event.origin);
picker._im = {
setValue: value => reply(value),
removeFocus() { reply(null); picker._im = previous; }
};
picker.handleEvent({type:'_inputfocus', detail:{inputType:event.data.inputType,
value:String(event.data.value || '').slice(0,32), min:event.data.min, max:event.data.max}});
});
return;
}
if (location.hostname === 'keyboard.localhost' && /settings/.test(location.pathname)) {
window.close = () => parent.postMessage({type:'vulpes-keyboard-close'}, systemOrigin);
}
let pending, sequence=0;
addEventListener('click', event => {
const input=event.target;
if (input.tagName !== 'INPUT' || !['date','time'].includes(input.type) || input.disabled || input.readOnly) return;
event.preventDefault();event.stopImmediatePropagation();
pending={input,id:++sequence};
parent.postMessage({type:'vulpes-datetime-open',id:sequence,inputType:input.type,
value:input.value,min:input.min,max:input.max},systemOrigin);
},true);
addEventListener('message', event => {
if(event.source!==parent || event.origin!==systemOrigin || event.data?.type!=='vulpes-datetime-result' || event.data.id!==pending?.id)return;
const {input}=pending;pending=null;
if(event.data.value!==null && input.isConnected && !input.disabled) {
input.value=event.data.value;
if(input.value) {
input.dispatchEvent(new Event('input',{bubbles:true}));
input.dispatchEvent(new Event('change',{bubbles:true}));
}
}
input.blur();
});
})();
// Modern native select text metrics differ from Gaia's 4rem button box.
// Keep the real select for interaction and accessibility, and paint its label
// in a regular line box so Fira's descenders are not clipped by the control.
(() => {
const labels=new WeakMap();
function refresh() {
for(const select of document.querySelectorAll('.button select,.bb-button select')) {
if(select.multiple || select.size>1)continue;
let label=labels.get(select);
if(!label) {
label=document.createElement('span');label.className='vulpes-select-label';
label.setAttribute('aria-hidden','true');select.after(label);labels.set(select,label);
select.classList.add('vulpes-select-label-owner');
select.addEventListener('change',refresh);
}
const text=select.selectedOptions[0]?.textContent || '';
if(label.textContent!==text)label.textContent=text;
}
}
let queued=false;
new MutationObserver(()=>{if(!queued){queued=true;queueMicrotask(()=>{queued=false;refresh();});}})
.observe(document.documentElement,{childList:true,subtree:true,characterData:true,attributes:true,attributeFilter:['selected','value']});
addEventListener('panelready',refresh);addEventListener('localized',refresh);
refresh();
})();

View file

@ -18,6 +18,21 @@
function unavailable(message) { function unavailable(message) {
window.VulpesGaiaUI?.notice(message); window.VulpesGaiaUI?.notice(message);
} }
if(location.hostname==='system.localhost') {
const colorKeys=['accessibility.colors.enable','accessibility.colors.invert','accessibility.colors.grayscale','accessibility.colors.contrast'];
const values={};
function colors() {
const screen=document.getElementById('screen');if(!screen)return;
const enabled=values[colorKeys[0]]===true;
const contrast=Number(values[colorKeys[3]])||0;
screen.style.filter=enabled ? `invert(${values[colorKeys[1]]?1:0}) grayscale(${values[colorKeys[2]]?1:0}) contrast(${Math.max(0,Math.min(2,1+contrast))})` : '';
}
for(const key of colorKeys) {
navigator.mozSettings.addObserver(key,e=>{values[key]=e.settingValue;colors();});
navigator.mozSettings.createLock().get(key).then(row=>{values[key]=row[key];colors();});
}
addEventListener('DOMContentLoaded',colors,{once:true});
}
addEventListener('DOMContentLoaded', () => { addEventListener('DOMContentLoaded', () => {
if (location.hostname === 'system.localhost' && !window.VulpesPlatform) { if (location.hostname === 'system.localhost' && !window.VulpesPlatform) {
const ids = ['wifi', 'bluetooth', 'data', 'airplane-mode']; const ids = ['wifi', 'bluetooth', 'data', 'airplane-mode'];
@ -72,6 +87,23 @@
); );
} }
}); });
if(['settings.localhost','system.localhost'].includes(location.hostname) && window.VulpesPlatform!=='android') {
const manager=Object.assign(new EventTarget(),{}), downloads=new Map();
manager.getDownloads=async()=>{
const rows=await call('downloads.request',{operation:'list'});
return rows.map(row=>{
let item=downloads.get(row.id);
if(!item) {
item=Object.assign(new EventTarget(),{pause:()=>call('downloads.request',{operation:'pause',id:row.id}),resume:()=>call('downloads.request',{operation:'resume',id:row.id})});
downloads.set(row.id,item);
}
Object.assign(item,row,{vulpesDownload:true,getBlob:()=>call('downloads.request',{operation:'blob',id:row.id}),startTime:new Date(row.startTime)});return item;
});
};
manager.remove=item=>call('downloads.request',{operation:'remove',id:item.id});
manager.clearAllDone=async()=>{for(const item of await manager.getDownloads())if(item.state==='succeeded')await manager.remove(item);};
Object.defineProperty(navigator,'mozDownloadManager',{value:manager});
}
Object.defineProperty(navigator, 'mozAlarms', { Object.defineProperty(navigator, 'mozAlarms', {
value: { value: {
getAll: () => getAll: () =>
@ -222,6 +254,15 @@
} }
window.VulpesCompat.cursor = cursor; window.VulpesCompat.cursor = cursor;
if (location.hostname === 'sms.localhost') { if (location.hostname === 'sms.localhost') {
addEventListener('DOMContentLoaded', () => {
const send = document.getElementById('messages-send-button');
if (!send) return;
send.setAttribute('data-vulpes-keep-input', '');
// Keep the composer focused, including with Android's native keyboard.
// Sending remains a click action, so a cancelled gesture never sends.
for (const type of ['pointerdown', 'mousedown'])
send.addEventListener(type, event => event.preventDefault());
});
Object.defineProperty(navigator, 'mozMobileConnections', { value: Object.freeze([]) }); Object.defineProperty(navigator, 'mozMobileConnections', { value: Object.freeze([]) });
const messages = new EventTarget(); const messages = new EventTarget();
for (const operation of ['getThreads', 'getMessages']) for (const operation of ['getThreads', 'getMessages'])
@ -242,11 +283,10 @@
'getSegmentInfoForText', 'getSegmentInfoForText',
]) ])
messages[operation] = (...args) => request(call('messages.request', { operation, args })); messages[operation] = (...args) => request(call('messages.request', { operation, args }));
const dated = value => value && typeof value==='object' && 'timestamp' in value ? const dated = raw => { const value=window.VulpesSettingsCodec.decode(raw);return value && typeof value==='object' && 'timestamp' in value ?
{...value,timestamp:new Date(value.timestamp)} : value; {...value,timestamp:new Date(value.timestamp)} : value; };
messages.getMessage = id => request(call('messages.request',{operation:'getMessage',args:[id]}).then(dated)); messages.getMessage = id => request(call('messages.request',{operation:'getMessage',args:[id]}).then(dated));
messages.send = (recipients,body,options) => { const submit = (operation,args) => {
const send = recipient => {
let id, settle; let id, settle;
const early = new Map(); const early = new Map();
const terminal = new Promise((resolve,reject)=>{settle=message=>{ const terminal = new Promise((resolve,reject)=>{settle=message=>{
@ -260,7 +300,7 @@
else if(id===undefined && early.size<100) early.set(message.id,message); else if(id===undefined && early.size<100) early.set(message.id,message);
}; };
addEventListener('vulpes-service-event',listener); addEventListener('vulpes-service-event',listener);
const result=call('messages.request',{operation:'send',args:[recipient,body,options]}).then(message=>{ const result=call('messages.request',{operation,args}).then(message=>{
if(message.delivery!=='sending') return dated(message); if(message.delivery!=='sending') return dated(message);
id=message.id; id=message.id;
if(early.has(id)) settle(early.get(id)); if(early.has(id)) settle(early.get(id));
@ -268,9 +308,12 @@
return terminal; return terminal;
}).finally(()=>removeEventListener('vulpes-service-event',listener)); }).finally(()=>removeEventListener('vulpes-service-event',listener));
return request(result); return request(result);
}; };
messages.send = (recipients,body,options) => {
const send=recipient=>submit('send',[recipient,body,options]);
return Array.isArray(recipients) ? recipients.map(send) : send(recipients); return Array.isArray(recipients) ? recipients.map(send) : send(recipients);
}; };
messages.sendMMS = (message,options) => submit('sendMMS',[message,options]);
addEventListener('vulpes-service-event',({detail})=>{ addEventListener('vulpes-service-event',({detail})=>{
const type = {'sms-received':'received','sms-sending':'sending','sms-sent':'sent','sms-failed':'failed'}[detail.type]; const type = {'sms-received':'received','sms-sending':'sending','sms-sent':'sent','sms-failed':'failed'}[detail.type];
if (!type) return; if (!type) return;
@ -281,17 +324,22 @@
Object.defineProperty(navigator, 'mozMobileMessage', { value: messages }); Object.defineProperty(navigator, 'mozMobileMessage', { value: messages });
} }
if ( if (
['gallery', 'music', 'video', 'camera', 'ringtones'].includes(location.hostname.split('.')[0]) ['settings', 'gallery', 'music', 'video', 'camera', 'ringtones'].includes(location.hostname.split('.')[0])
) { ) {
const stores = new Map(); const stores = new Map();
function storage(type) { function storage(type) {
if (!['pictures', 'music', 'videos'].includes(type)) return null; if (!['pictures', 'music', 'videos', ...(location.hostname==='settings.localhost'?['apps','sdcard']:[])].includes(type)) return null;
if (stores.has(type)) return stores.get(type); if (stores.has(type)) return stores.get(type);
const s = new EventTarget(); const s = new EventTarget();
s.storageName = 'sdcard'; s.storageName = 'sdcard';
s.default = true; s.default = true;
s.canBeMounted = false; s.canBeMounted = false;
const run = (operation, params = {}) => call('media.request', { type, operation, ...params }); const run = (operation, params = {}) => {
if(!['apps','sdcard'].includes(type))return call('media.request', { type, operation, ...params });
if(operation==='available')return call('storage.stats',{}).then(()=>'available');
if(['freeSpace','usedSpace'].includes(operation))return call('storage.stats',{}).then(stats=>operation==='freeSpace'?stats.free:stats[type]);
return Promise.reject(new DOMException('Read-only storage statistics','NotSupportedError'));
};
for (const op of ['available', 'freeSpace', 'usedSpace']) s[op] = () => request(run(op)); for (const op of ['available', 'freeSpace', 'usedSpace']) s[op] = () => request(run(op));
s.get = (name) => s.get = (name) =>
request( request(

View file

@ -109,6 +109,14 @@
// Only a custom element's type selector names the scope root. A div // Only a custom element's type selector names the scope root. A div
// scope still contains ordinary div descendants (notably Lockscreen). // scope still contains ordinary div descendants (notably Lockscreen).
if (owner.localName.includes('-')) text = text.replace(tag, ':scope'); if (owner.localName.includes('-')) text = text.replace(tag, ':scope');
if (owner.id) {
const rootId = '#' + CSS.escape(owner.id);
const pattern = new RegExp(rootId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\w-])', 'g');
// Old scoped sheets could style their owner by ID. Modern @scope
// requires :scope for that element (for example the volume HUD).
text = text.replace(/([^{}]+)\{/g, (rule, selector) =>
selector.replace(pattern, ':scope') + '{');
}
text = text.replace(/\.-(?:host|content)(?![\w-])/g, (match) => ':scope' + match); text = text.replace(/\.-(?:host|content)(?![\w-])/g, (match) => ':scope' + match);
style.textContent = '@scope ([data-vulpes-scope="' + id + '"]) {' + text + '}'; style.textContent = '@scope ([data-vulpes-scope="' + id + '"]) {' + text + '}';
style.removeAttribute('scoped'); style.removeAttribute('scoped');

105
host/gaia-theme.css Normal file
View file

@ -0,0 +1,105 @@
/* Keep Gaia geometry, icons and media intact; change the surfaces and ink. */
:root[data-vulpes-theme="dark"] {
color-scheme: dark;
--vulpes-surface: #18232e;
--vulpes-raised: #253442;
--vulpes-text: #f1f5f9;
--vulpes-muted: #b8c8d8;
--vulpes-line: #526272;
--link-color: #79ceff;
--header-background: var(--vulpes-raised);
--header-color: var(--vulpes-text);
--header-title-color: var(--vulpes-text);
--header-button-color: var(--vulpes-text);
--header-icon-color: var(--vulpes-text);
--header-action-button-color: #79ceff;
--header-disabled-button-color: var(--vulpes-muted);
}
:root[data-vulpes-theme="dark"]:is([data-vulpes-app="settings"],
[data-vulpes-app="communications"], [data-vulpes-app="sms"],
[data-vulpes-app="email"], [data-vulpes-app="calendar"],
[data-vulpes-app="ringtones"]) {
--background: var(--vulpes-surface);
--text-color: var(--vulpes-text);
--title-color: var(--vulpes-text);
--label-color: var(--vulpes-text);
background: var(--vulpes-surface) !important;
color: var(--vulpes-text) !important;
}
:root[data-vulpes-theme="dark"] gaia-header {
--header-background: var(--vulpes-raised) !important;
--header-title-color: var(--vulpes-text) !important;
--header-action-button-color: #79ceff !important;
}
#display #vulpes-theme-row {
display: block !important;
min-height: 10rem;
height: auto !important;
padding-bottom: 1rem;
}
#vulpes-theme-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;
}
:root[data-vulpes-theme="dark"]:is([data-vulpes-app="settings"],
[data-vulpes-app="communications"], [data-vulpes-app="sms"],
[data-vulpes-app="email"], [data-vulpes-app="calendar"],
[data-vulpes-app="ringtones"]) :is(body, section, [role="region"],
[data-type="list"], .view, .card, #main, #messages-container, #thread-list,
#contacts-list, #view-contacts-list, #no-contacts, #call-log-container, #messages-compose-form) {
background-color: var(--vulpes-surface) !important;
color: var(--vulpes-text) !important;
}
:root[data-vulpes-theme="dark"] :is(input:not([type="range"]):not([type="checkbox"]):not([type="radio"]), textarea, select, [contenteditable="true"]) {
background-color: var(--vulpes-raised) !important;
color: var(--vulpes-text) !important;
border-color: var(--vulpes-line) !important;
caret-color: #79ceff;
}
:root[data-vulpes-theme="dark"] :is(input, textarea)::placeholder { color: var(--vulpes-muted) !important; }
:root[data-vulpes-theme="dark"][data-vulpes-app="settings"] :is(li, li > a, li > a > span, li > p, li > small, label, .explanation, h1, h2) {
color: var(--vulpes-text) !important;
border-color: var(--vulpes-line) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="settings"] :is(ul, header, .button, button:not(.danger)) {
background-color: var(--vulpes-raised) !important;
color: var(--vulpes-text) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] :is(.contact-item, .contact-item a, .contact-item p, #call-log-container li, #call-log-container a, #call-log-container p, .phone-number, #keypad-phone-number, .keypad-key) {
color: var(--vulpes-text) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] #phone-number-view-container {
background-color: var(--vulpes-raised) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] :is(.keypad-subicon, [role="tab"]) {
color: #79ceff !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] [role="tab"][aria-selected="true"] {
background-color: var(--vulpes-raised) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] #no-contacts :is(p, .info) {
color: var(--vulpes-muted) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="communications"] #no-contacts {
background-blend-mode: soft-light;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="sms"] :is(.threadlist-item, .threadlist-item a, .threadlist-item p, .time, .name, .body-text) {
color: var(--vulpes-text) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="sms"] .bubble {
background-color: var(--vulpes-raised) !important;
color: var(--vulpes-text) !important;
}
:root[data-vulpes-theme="dark"] :is([data-type="confirm"], [data-type="action"]) :is(section, menu, header, p) {
background-color: var(--vulpes-raised) !important;
color: var(--vulpes-text) !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="system"] :is(#utility-tray, #notifications-container) {
background-color: var(--vulpes-surface) !important;
color: var(--vulpes-text) !important;
}

58
host/gaia-theme.js Normal file
View file

@ -0,0 +1,58 @@
// Shared appearance preference; external browser pages are not injected.
(() => {
const key = 'vulpes.theme';
const system = matchMedia('(prefers-color-scheme: dark)');
let mode = 'light', select;
function apply(value) {
mode = ['light', 'dark', 'system'].includes(value) ? value : 'light';
document.documentElement.dataset.vulpesTheme =
mode === 'system' ? (system.matches ? 'dark' : 'light') : mode;
if (select) select.value = mode;
}
system.addEventListener('change', () => apply(mode));
addEventListener('vulpes-service-event', ({detail}) => {
if (detail.type === 'settingchange' && detail.data.key === key) apply(detail.data.value);
});
VulpesCompat.call('appearance.current', {}).then(apply).catch(console.error);
if (location.hostname !== 'settings.localhost') return;
function label() {
const lang = document.documentElement.lang || navigator.language;
const labels = lang.startsWith('fr') ? ['Apparence', 'Clair', 'Sombre', 'Selon le système'] :
lang.startsWith('el') ? ['Εμφάνιση', 'Φωτεινό', 'Σκοτεινό', 'Σύμφωνα με το σύστημα'] :
['Appearance', 'Light', 'Dark', 'Follow system'];
const title = document.getElementById('vulpes-theme-label');
if (title && title.textContent !== labels[0]) title.textContent = labels[0];
if (select) [...select.options].forEach((option, i) => {
if (option.textContent !== labels[i + 1]) option.textContent = labels[i + 1];
});
}
function insert() {
const list = document.querySelector('#display > div > ul');
if (!list || select?.isConnected) return;
const row = document.createElement('li'), title = document.createElement('p');
row.id = 'vulpes-theme-row';
title.id = 'vulpes-theme-label';
const wrapper = document.createElement('span');
wrapper.className = 'button icon icon-dialog';
select = document.createElement('select');
select.id = 'vulpes-theme';
select.setAttribute('aria-labelledby', title.id);
for (const value of ['light', 'dark', 'system']) {
const option = document.createElement('option');
option.value = value; select.append(option);
}
select.value = mode;
select.onchange = async () => {
select.disabled = true;
try { await navigator.mozSettings.createLock().set({[key]:select.value}); }
catch (error) { select.value = mode; console.error(error); }
finally { select.disabled = false; }
};
wrapper.append(select); row.append(title, wrapper); list.append(row); label();
}
addEventListener('localized', label);
new MutationObserver(() => { insert(); label(); }).observe(document.documentElement,
{childList:true, subtree:true, attributes:true, attributeFilter:['lang']});
insert();
})();

View file

@ -6,7 +6,7 @@
enabled:false, connection:{status:'disconnected',network:null}, enabled:false, connection:{status:'disconnected',network:null},
connectionInformation:null, macAddress:'', connectionInformation:null, macAddress:'',
}); });
let networks = []; let networks = [], connectionInfo = {};
function emit(type, values = {}) { function emit(type, values = {}) {
const event = Object.assign(new Event(type), values); const event = Object.assign(new Event(type), values);
manager.dispatchEvent(event); manager['on' + type]?.(event); manager.dispatchEvent(event); manager['on' + type]?.(event);
@ -21,7 +21,7 @@
const status = network ? 'connected' : 'disconnected'; const status = network ? 'connected' : 'disconnected';
const changed = manager.connection.status !== status || manager.connection.network?.ssid !== network?.ssid; const changed = manager.connection.status !== status || manager.connection.network?.ssid !== network?.ssid;
manager.connection = {status, network}; manager.connection = {status, network};
manager.connectionInformation = network ? {relSignalStrength:network.relSignalStrength} : null; manager.connectionInformation = network ? {...connectionInfo,relSignalStrength:network.relSignalStrength} : null;
if (changed) emit('statuschange', manager.connection); if (changed) emit('statuschange', manager.connection);
// Gaia's signal listeners require the associated network. A scan while // Gaia's signal listeners require the associated network. A scan while
// disconnected must not announce connection information for a missing AP. // disconnected must not announce connection information for a missing AP.
@ -36,17 +36,23 @@
async function scanNetworks() { async function scanNetworks() {
const result = await call('platform.wifi',{operation:'scan'}); const result = await call('platform.wifi',{operation:'scan'});
power(result.enabled !== false); power(result.enabled !== false);
connectionInfo=result.connectionInformation || {};
networks = result.networks.map(n => ({ssid:n.ssid, relSignalStrength:n.signal, networks = result.networks.map(n => ({ssid:n.ssid, relSignalStrength:n.signal,
security:n.security === 'open' ? [] : [n.security === 'wpa-psk' ? 'WPA-PSK' : n.security === 'sae' ? 'SAE' : 'WPA-EAP'], security:n.security === 'open' ? [] : [['wpa-psk','sae'].includes(n.security) ? 'WPA-PSK' : 'WPA-EAP'],
known:n.known === true, connected:n.connected, vulpesId:n.id, savedId:n.savedId})); known:n.known === true, connected:n.connected, vulpesId:n.id, savedId:n.savedId}));
update(networks.find(n => n.connected) || null); update(networks.find(n => n.connected) || null);
return networks; return networks;
} }
manager.getNetworks = () => request(scan()); manager.getNetworks = () => request(scan());
manager.getKnownNetworks = () => request(call('platform.wifi',{operation:'known'}).then(r=>r.networks.map(n=>({ssid:n.ssid,security:n.security==='open'?[]:['WPA-PSK'],known:true,savedId:n.id})))); let knownPending;
manager.getKnownNetworks = () => {
if (!knownPending) knownPending = call('platform.wifi',{operation:'known'}).then(r=>r.networks.map(n=>({ssid:n.ssid,security:n.security==='open'?[]:['WPA-PSK'],known:true,savedId:n.id}))).finally(()=>{knownPending=null;});
return request(knownPending);
};
manager.associate = network => request((async () => { manager.associate = network => request((async () => {
if (network.security.length && network.security[0] !== 'WPA-PSK') throw Error('WIFI_UNSUPPORTED_SECURITY'); const security=(network.security || []).filter(Boolean);
await call('platform.wifi',{operation:'connect',id:network.vulpesId,password:network.psk || ''}); if (security.length && security[0] !== 'WPA-PSK') throw Error('WIFI_UNSUPPORTED_SECURITY');
await call('platform.wifi',network.hidden ? {operation:'connectHidden',ssid:network.ssid,security:security.length?'wpa-psk':'open',password:network.psk || ''} : {operation:'connect',id:network.vulpesId,password:network.psk || ''});
await scan(); return network; await scan(); return network;
})()); })());
manager.forget = network => request(call('platform.wifi',{operation:'forget',id:network.savedId}).then(()=>scan())); manager.forget = network => request(call('platform.wifi',{operation:'forget',id:network.savedId}).then(()=>scan()));
@ -54,6 +60,91 @@
manager.wps = unsupported; manager.wps = unsupported;
manager.setPowerSavingMode = unsupported; manager.setPowerSavingMode = unsupported;
Object.defineProperty(navigator,'mozWifiManager',{value:manager}); Object.defineProperty(navigator,'mozWifiManager',{value:manager});
if(location.hostname.startsWith('settings.')) {
const wifi = value => call('platform.wifi',value);
const french = () => (document.documentElement.lang || navigator.language || '').startsWith('fr');
const message = (fr,en) => french() ? fr : en;
const note = panel => {
const p=document.createElement('p');p.className='explanation';p.setAttribute('role','status');
panel.querySelector(':scope > div').prepend(p);return p;
};
function hotspotPanel() {
let panel,toggle,link,status,state,busy=false;
function render() {
toggle.checked=state?.enabled===true;toggle.disabled=busy || !state;
link.setAttribute('aria-disabled',String(busy || !state || state.enabled));
panel.querySelector('#wifi-hotspot-msg').textContent=state?.enabled ? state.ssid : '';
}
async function refresh() {
busy=true;render();
try {state=await wifi({operation:'hotspotStatus'});status.textContent='';
await call('settings.set',{values:{'tethering.wifi.enabled':state.enabled}});
} catch(error) {state=null;status.textContent=message('Impossible de lire le point d’accès Wi-Fi.','Cannot read Wi-Fi hotspot status.');}
finally {busy=false;render();}
}
return {
onInit(element) {
panel=element;toggle=panel.querySelector('#tethering-wifi-enabled');link=panel.querySelector('#hotspot-settings-section a');status=note(panel);
const usb=panel.querySelector('#tethering-usb-enabled');usb.disabled=true;
const explanation=document.createElement('p');explanation.className='explanation';
explanation.textContent=message('Le partage par USB n’est pas encore disponible.','USB tethering is not available yet.');usb.closest('ul').append(explanation);
link.addEventListener('click',event=>{
event.preventDefault();event.stopImmediatePropagation();if(busy || !state || state.enabled)return;
require(['modules/dialog_service'],DialogService=>DialogService.show('hotspot-wifiSettings',{state}).then(refresh));
});
toggle.addEventListener('change',async()=>{
if(busy || !state)return;
if(!state.configured) {toggle.checked=false;status.textContent=message('Configurez d’abord le nom et le mot de passe du point d’accès.','Configure the hotspot name and password first.');return;}
const enabled=toggle.checked;busy=true;render();
status.textContent=message('Modification du partage Wi-Fi…','Changing Wi-Fi sharing…');
try {
if(enabled && (await call('settings.get',{key:'airplaneMode.status'})).value==='enabled')throw Error('AIRPLANE_MODE');
state=await wifi({operation:'hotspotPower',enabled});
await call('settings.set',{values:{'tethering.wifi.enabled':state.enabled}});status.textContent='';
} catch(error) {
try {state=await wifi({operation:'hotspotStatus'});}catch {state=null;}
status.textContent=error.message==='AIRPLANE_MODE' ? message('Désactivez le mode avion avant de partager la connexion.','Turn off airplane mode before sharing the connection.') : message('Le changement a échoué. Vérifiez la connexion réseau.','The change failed. Check the network connection.');
} finally {busy=false;render();}
});
},onBeforeShow:refresh
};
}
function hotspotDialog() {
let panel,ssid,security,password,show,status,busy=false;
function visibility() {
const hidden=security.value==='open';
panel.querySelector('.password').hidden=hidden;panel.querySelector('.password-description').hidden=hidden;
}
return {
onInit(element) {
panel=element;ssid=panel.querySelector('input[type=text]');security=panel.querySelector('select');password=panel.querySelector('input[name=password]');show=panel.querySelector('gaia-checkbox');status=note(panel);
// These credentials belong to NetworkManager, not the global settings database.
for(const control of [ssid,security,password])control.removeAttribute('data-setting');
security.replaceChildren(new Option('WPA2','wpa2-psk'),new Option(message('Ouvert','Open'),'open'));
security.addEventListener('change',visibility);
show.addEventListener('change',()=>{password.type=show.checked?'text':'password';});
},
onBeforeShow(element,options) {
const state=options.state;ssid.value=state.ssid;security.value=state.security;password.value=state.password;
password.type='password';show.checked=false;status.textContent='';visibility();
},
async onSubmit() {
if(busy)throw Error('HOTSPOT_BUSY');
const values={operation:'hotspotSave',ssid:ssid.value,security:security.value,password:password.value};
if(!values.ssid || new TextEncoder().encode(values.ssid).length>32 || (values.security!=='open' && (new TextEncoder().encode(values.password).length<8 || new TextEncoder().encode(values.password).length>63))) {
status.textContent=message('Nom : 1 à 32 octets. Mot de passe WPA2 : 8 à 63 octets.','Name: 1–32 bytes. WPA2 password: 8–63 bytes.');throw Error('HOTSPOT_INVALID');
}
busy=true;const controls=panel.querySelectorAll('input,select,button,gaia-checkbox');controls.forEach(c=>c.disabled=true);
try {await wifi(values);return {};}
catch(error) {status.textContent=message('Impossible d’enregistrer le point d’accès.','Cannot save the hotspot.');throw error;}
finally {busy=false;controls.forEach(c=>c.disabled=false);}
},
onCancel() {if(busy)throw Error('HOTSPOT_BUSY');},
onHide() {password.value='';}
};
}
window.VulpesWifiSettings={panels:{hotspot:hotspotPanel},dialogs:{'hotspot-wifiSettings':hotspotDialog}};
}
addEventListener('DOMContentLoaded', async () => { addEventListener('DOMContentLoaded', async () => {
try { try {
const state = await call('platform.wifi',{operation:'status'}); const state = await call('platform.wifi',{operation:'status'});
@ -67,6 +158,7 @@
try { try {
const result=await call('platform.wifi',{operation:'setEnabled',enabled}); const result=await call('platform.wifi',{operation:'setEnabled',enabled});
power(result.enabled,true); power(result.enabled,true);
if(!result.enabled)await call('settings.set',{values:{'tethering.wifi.enabled':false}});
} catch(error) { } catch(error) {
await call('settings.set',{values:{'wifi.enabled':manager.enabled}}); await call('settings.set',{values:{'wifi.enabled':manager.enabled}});
console.warn('Wi-Fi radio change failed',error); console.warn('Wi-Fi radio change failed',error);

View file

@ -86,6 +86,18 @@
try { return await call('platform.' + operation, params); } try { return await call('platform.' + operation, params); }
catch (error) { failure(error); } catch (error) { failure(error); }
}; };
if (app === 'communications') {
// All Gaia call entry points use the same authorized host operation.
window.VulpesPlatformDial = async number => {
try {
return navigator.mozTelephony ? await navigator.mozTelephony.dial(number) :
await call('platform.dial', {number});
} catch (error) {
failure(error);
throw error;
}
};
}
function button(id, french, english, action) { function button(id, french, english, action) {
const b = text(document.createElement('button'), french, english); const b = text(document.createElement('button'), french, english);
b.id = id; b.id = id;
@ -145,7 +157,10 @@
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
const number = document.getElementById('phone-number-view')?.value; const number = document.getElementById('phone-number-view')?.value;
if (number) run('dial', {number}); if (number) {
if (window.CallHandler) CallHandler.call(number, 0);
else window.VulpesPlatformDial(number).catch(() => {});
}
}, true); }, true);
async function initialize() { async function initialize() {
const caps = await capabilities; const caps = await capabilities;

View file

@ -30,7 +30,15 @@ window.addEventListener('load', async () => {
const pollKeys = async () => { const pollKeys = async () => {
try { try {
const state = await IOUtils.readJSON('/run/tundra-buttons/events.json'); const state = await IOUtils.readJSON('/run/tundra-buttons/events.json');
if (state.session !== session) { session = state.session; sequence = state.sequence; } if (state.session !== session) {
session = state.session;
// Keep events already present when the poller first sees a new
// service session. Setting the sequence to the file's final
// value silently dropped the first physical key presses after a
// Gecko reload.
const first = state.events?.[0]?.id;
sequence = Number.isInteger(first) ? first - 1 : 0;
}
else if (Date.now()/1000-state.recordedAt < 5) { else if (Date.now()/1000-state.recordedAt < 5) {
for (const event of state.events) if (event.id > sequence) { for (const event of state.events) if (event.id > sequence) {
Runtime.broadcast('hardware-key', {type:event.type}, 'system'); Runtime.broadcast('hardware-key', {type:event.type}, 'system');
@ -46,7 +54,7 @@ window.addEventListener('load', async () => {
parent: { esModuleURI: 'resource://vulpes/host/actors/VulpesParent.sys.mjs' }, parent: { esModuleURI: 'resource://vulpes/host/actors/VulpesParent.sys.mjs' },
child: { child: {
esModuleURI: 'resource://vulpes/host/actors/VulpesChild.sys.mjs', esModuleURI: 'resource://vulpes/host/actors/VulpesChild.sys.mjs',
events: { DOMDocElementInserted: { capture: true }, focusin:{capture:true}, pointerdown:{capture:true} }, events: { DOMDocElementInserted: { capture: true }, focusin:{capture:true}, pointerdown:{capture:true}, click:{capture:true} },
}, },
allFrames: true, allFrames: true,
safeForUntrustedWebProcess: true, safeForUntrustedWebProcess: true,
@ -89,6 +97,11 @@ window.addEventListener('load', async () => {
browser.style.width = width + 'px'; browser.style.width = width + 'px';
browser.style.height = height + 'px'; browser.style.height = height + 'px';
homeButton.style.width = landscape ? '44px' : window.innerWidth + 'px'; homeButton.style.width = landscape ? '44px' : window.innerWidth + 'px';
// Flush the embedder's layout before notifying the remote document.
// On Wayland its initial viewport can otherwise remain 0x0 until an
// unrelated chrome layout query, leaving Gaia and video unpainted.
browser.getBoundingClientRect();
browser.frameLoader?.requestUpdatePosition?.();
}; };
window.addEventListener('resize', resize); window.addEventListener('resize', resize);

View file

@ -0,0 +1,101 @@
define(function(require, exports, module) {
'use strict';
// This handles the logic pertaining to the naming of files according
// to the Design rule for Camera File System
// * http://en.wikipedia.org/wiki/Design_rule_for_Camera_File_system
/**
* Dependencies
*/
var asyncStorage = require('asyncStorage');
var debug = require('debug')('dcf');
var format = require('format');
/**
* Locals
*/
var dcfConfigLoaded = false;
var deferredArgs = null;
var defaultSeq = {file: 1, dir: 100};
var dcfConfig = {
key: 'dcf_key',
seq: null,
postFix: 'MZLLA',
prefix: {video: 'VID_', image: 'IMG_'},
ext: {video: window.VulpesVideoExtension || '3gp', image: 'jpg'}
};
exports.init = function() {
debug('initializing');
exports.checkFileCounter(function() {
// We have a previous call to createDCFFilename that is waiting for
// a response, fire it again
if (deferredArgs) {
var args = deferredArgs;
exports.createDCFFilename(args.storage, args.type, args.callback);
deferredArgs = null;
}
debug('initialized');
});
};
exports.checkFileCounter = function(callback) {
asyncStorage.getItem(dcfConfig.key, function(value) {
dcfConfigLoaded = true;
dcfConfig.seq = value ? value : defaultSeq;
if (callback) {
callback();
}
});
};
exports.createDCFFilename = function(storage, type, callback) {
// We havent loaded the current counters from indexedDB yet, defer
// the call
if (!dcfConfigLoaded) {
deferredArgs = {storage: storage, type: type, callback: callback};
return;
}
var dir = 'DCIM/' + dcfConfig.seq.dir + dcfConfig.postFix + '/';
var filename = dcfConfig.prefix[type] +
format.padLeft(dcfConfig.seq.file, 4, '0') + '.' +
dcfConfig.ext[type];
var filepath = dir + filename;
// A file with this name may have been written by the user or
// our indexeddb sequence tracker was cleared, check we wont overwrite
// anything
var req = storage.get(filepath);
// A file existed, we bump the directory then try to generate a
// new filename
req.onsuccess = function() {
dcfConfig.seq.file = 1;
dcfConfig.seq.dir += 1;
asyncStorage.setItem(dcfConfig.key, dcfConfig.seq, function() {
exports.createDCFFilename(storage, type, callback);
});
};
// No file existed, we are good to go
req.onerror = function() {
if (dcfConfig.seq.file < 9999) {
dcfConfig.seq.file += 1;
} else {
dcfConfig.seq.file = 1;
dcfConfig.seq.dir += 1;
}
asyncStorage.setItem(dcfConfig.key, dcfConfig.seq, function() {
callback(filepath, filename, dir);
});
};
};
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,713 @@
'use strict';
/* global
CONFIG_MAX_IMAGE_PIXEL_SIZE,
CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT,
CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH,
cropResizeRotate,
Downsample,
getImageSize,
getVideoRotation,
isPhone,
parseJPEGMetadata,
videostorage
*/
/* exported
metadataParser
*/
//
// This file defines a single metadataParsers object. The two
// properties of this object are metadata parsing functions for image
// files and video files, intended for use with the MediaDB class.
//
// This file depends on JPEGMetadataParser.js and blobview.js
//
var metadataParser = (function() {
// If we generate our own thumbnails, aim for this size.
// Calculate needed size from longer side of the screen.
var THUMBNAIL_WIDTH = computeThumbnailWidth();
var THUMBNAIL_HEIGHT = THUMBNAIL_WIDTH;
function computeThumbnailWidth() {
// Make sure this works regardless of current device orientation
var width = window.innerWidth || screen.width;
var height = window.innerHeight || screen.height;
var portraitWidth = Math.max(1, Math.min(width, height));
var landscapeWidth = Math.max(1, Math.max(width, height));
var thumbnailsPerRowPortrait = isPhone ? 3 : 4;
var thumbnailsPerRowLandscape = isPhone ? 4 : 6;
return Math.round(window.devicePixelRatio *
Math.max(portraitWidth / thumbnailsPerRowPortrait,
landscapeWidth / thumbnailsPerRowLandscape));
}
var thumbnailSize = {
width: THUMBNAIL_WIDTH,
height: THUMBNAIL_HEIGHT
};
// Don't try to decode image files of unknown type if bigger than this
var MAX_UNKNOWN_IMAGE_FILE_SIZE = 0.5 * 1024 * 1024; // half a megabyte
// If a jpeg does not have a suitable EXIF preview and has more than
// this number of pixels, then we create an external preview for it
var MAX_PREVIEWLESS_JPEG_SIZE = 5 * 1024 * 1024; // 5 megapixels
// An <img> element for loading images
var offscreenImage = new Image();
// The screen size. Preview images must be at least this big
// Note that we use screen.width instead of window.innerWidth because
// the window size is different when we are invoked for a pick activity
// (non-fullscreen) and when we are invoked normally (fullscreen).
var sw = screen.width * window.devicePixelRatio;
var sh = screen.height * window.devicePixelRatio;
// Create a thumbnail size canvas, copy the <img> or <video> into it
// cropping the edges as needed to make it fit, and then extract the
// thumbnail image as a blob and pass it to the callback.
// This utility function is used by both the image and video metadata parsers
function createThumbnailFromElement(elt, video, rotation,
mirrored, callback, error) {
try {
// Create a thumbnail image
var canvas = document.createElement('canvas');
canvas.width = THUMBNAIL_WIDTH;
canvas.height = THUMBNAIL_HEIGHT;
var context = canvas.getContext('2d', { willReadFrequently: true });
var eltwidth = elt.width;
var eltheight = elt.height;
var scalex = canvas.width / eltwidth;
var scaley = canvas.height / eltheight;
// Take the larger of the two scales: we crop the image to the thumbnail
var scale = Math.max(scalex, scaley);
// Calculate the region of the image that will be copied to the
// canvas to create the thumbnail
var w = Math.round(THUMBNAIL_WIDTH / scale);
var h = Math.round(THUMBNAIL_HEIGHT / scale);
var x = Math.round((eltwidth - w) / 2);
var y = Math.round((eltheight - h) / 2);
var centerX = Math.floor(THUMBNAIL_WIDTH / 2);
var centerY = Math.floor(THUMBNAIL_HEIGHT / 2);
// If a orientation is specified, rotate/mirroring the canvas context.
if (rotation || mirrored) {
context.save();
// All transformation are applied to the center of the thumbnail.
context.translate(centerX, centerY);
}
if (mirrored) {
context.scale(-1, 1);
}
if (rotation) {
switch (rotation) {
case 90:
context.rotate(Math.PI / 2);
break;
case 180:
context.rotate(Math.PI);
break;
case 270:
context.rotate(-Math.PI / 2);
break;
}
}
if (rotation || mirrored) {
context.translate(-centerX, -centerY);
}
// Draw that region of the image into the canvas, scaling it down
context.drawImage(elt, x, y, w, h,
0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
// Restore the default rotation so the play arrow comes out correctly
if (rotation || mirrored) {
context.restore();
}
// If this is a video, superimpose a translucent play button over
// the captured video frame to distinguish it from a still photo thumbnail
if (video) {
// First draw a transparent gray circle
context.fillStyle = 'rgba(0, 0, 0, .2)';
context.beginPath();
context.arc(THUMBNAIL_WIDTH / 2, THUMBNAIL_HEIGHT / 2,
THUMBNAIL_HEIGHT / 5, 0, 2 * Math.PI, false);
context.fill();
// Now outline the circle in white
context.strokeStyle = 'rgba(255,255,255,.6)';
context.lineWidth = 2;
context.stroke();
// And add a white play arrow.
context.beginPath();
context.fillStyle = 'rgba(255,255,255,.6)';
// The height of an equilateral triangle is sqrt(3)/2 times the side
var side = THUMBNAIL_HEIGHT / 5;
var triangle_height = side * Math.sqrt(3) / 2;
context.moveTo(THUMBNAIL_WIDTH / 2 + triangle_height * 2 / 3,
THUMBNAIL_HEIGHT / 2);
context.lineTo(THUMBNAIL_WIDTH / 2 - triangle_height / 3,
THUMBNAIL_HEIGHT / 2 - side / 2);
context.lineTo(THUMBNAIL_WIDTH / 2 - triangle_height / 3,
THUMBNAIL_HEIGHT / 2 + side / 2);
context.closePath();
context.fill();
}
canvas.toBlob(function(blob) {
context = null;
canvas.width = canvas.height = 0;
canvas = null;
callback(blob);
}, 'image/jpeg');
} catch (ex) {
// An error may be thrown when the drawImage decodes a broken/trancated
// image.
// The elt may be a offscreen image. So, the image metadata is parsed, and
// the image data is loaded but not decoded. The drawImage triggers the
// image decoder to decode the image data. And an error may be thrown.
error('createThumbnailFromElement:' + ex.message);
}
}
var VIDEOFILE = /DCIM\/\d{3}MZLLA\/VID_\d{4}\.jpg/;
function metadataParser(file, metadataCallback, metadataError, bigFile) {
// If the file is a poster image for a video file, then we've want
// video metadata, not image metadata
if (VIDEOFILE.test(file.name)) {
videoMetadataParser(file, metadataCallback, metadataError);
return;
}
// Figure out how big the image is if we can. For JPEG files this
// calls the JPEG parser and returns the EXIF preview if there is one.
getImageSize(file, gotImageSize, gotImageSizeError);
function gotImageSizeError(errmsg) {
// The image is not a JPEG, PNG, GIF or BMP file. We may still be
// able to decode and display it but we don't know the image
// size, so we won't even try if the file is too big.
if (file.size > MAX_UNKNOWN_IMAGE_FILE_SIZE) {
metadataError('Ignoring large file ' + file.name);
return;
}
// If the file is too small to be an image, ignore it
if (file.size < 32) {
metadataError('Ignoring small file ' + file.name);
return;
}
// If the error message is anything other than unknown image type
// it means we've got a corrupt image file, or the image metdata parser
// can't handle the file for some reason. Log a warning but keep going
// in case the image is good and the metadata parser is buggy.
if (errmsg !== 'unknown image type') {
console.warn('getImageSize', errmsg, file.name);
}
// If it is not too big create a preview and thumbnail.
createThumbnailAndPreview(file,
metadataCallback,
metadataError,
false,
bigFile,
{});
}
function gotImageSize(metadata) {
//
// If the image is too big, reject it now so we don't have
// memory trouble later.
//
// CONFIG_MAX_IMAGE_PIXEL_SIZE is maximum image resolution we
// can handle. It's from config.js which is generated at build
// time (see build/application-data.js).
//
// For jpeg images we can downsample while decoding which means that
// we can handle images that are quite a bit larger.
//
// getImageSize() distinguishes pjpeg files from jpeg, because
// progressive jpeg files are quite different than regular jpegs.
// Not only do pjpegs not benefit from using #-moz-samplesize, they
// actually require more memory to decode than other images of the same
// resolution, so we actually need to reduce the image size limit for
// this image type.
//
var imagesizelimit = CONFIG_MAX_IMAGE_PIXEL_SIZE;
if (metadata.type === 'jpeg') {
imagesizelimit *= Downsample.MAX_AREA_REDUCTION;
}
else if (metadata.type === 'pjpeg') {
// As a semi-educated guess, we'll say that we can handle pjpegs
// 2/3rds the size of the largest PNG we can handle.
imagesizelimit *= 2 / 3;
}
//
// Even if we can downsample an image while decoding it, we still
// have to read the entire image file. If the file is particularly
// large we might also have memory problems. (See bug 1008834: a 20mb
// 80mp jpeg file will cause an OOM on Tarako even though we can
// decode it at < 2mp). Rather than adding another build-time config
// variable to specify the maximum file size, however, we'll just
// base the file size limit on CONFIG_MAX_IMAGE_PIXEL_SIZE.
// So if that variable is set to 2M, then we might use up to 12Mb of
// memory. 2 * 2M bytes for the image file and 4 bytes times 2M pixels
// for the decoded image. A 4mb file size limit should accomodate
// most JPEG files up to 12 or 16 megapixels.
//
// Note that this size limit is just a guess. If we continue to see
// OOMs with large files then we should change the number 2 below
// to something smaller.
//
var filesizelimit = 2 * CONFIG_MAX_IMAGE_PIXEL_SIZE;
if (metadata.width * metadata.height > imagesizelimit ||
file.size > filesizelimit) {
metadataError('Ignoring high-resolution image ' + file.name);
return;
}
// If the file included a preview image, see if it is big enough
if (metadata.preview) {
// Create a blob that is just the preview image
var previewblob = file.slice(metadata.preview.start,
metadata.preview.end,
'image/jpeg');
// Check to see if the preview is big enough to use in MediaFrame
parseJPEGMetadata(previewblob, previewsuccess, previewerror);
}
else {
// If there wasn't a preview image, then generate a preview and
// thumbnail from the full size image.
useFullsizeImage();
}
function previewerror(msg) {
// The preview isn't a valid jpeg file, so use the full image to
// create a preview and a thumbnail
console.error(msg);
useFullsizeImage();
}
// If we can't find a valid embedded EXIF preview image then we come here
function useFullsizeImage() {
// If the full size image is a JPEG then we'll just skip the
// preview generation because we can downsample and decode it
// at preview size when needed. So in this case, we just need
// to create a thumbnail for the image. On the other hand, if
// this is not a JPEG image then we have to create both a
// thumbnail and an external preview image. Note that progressive
// jpegs will have a type 'pjpeg' and will be handled below
// like png images.
// For jpeg images with image sizes more than 5MP, we need
// to create preview on sdcard to avoid flash while opening image.
// See bug 1181290.
// This constraint can be removed when gecko supports downscale during
// decode by canvas size for offscreen images. This will
// facilitate removing #moz-sample-size and using ddd.
// See bug 1206206.
if (metadata.type === 'jpeg') {
if (metadata.width * metadata.height <= MAX_PREVIEWLESS_JPEG_SIZE) {
// If the jpeg image isn't too big we don't need a preview
// because we can decode it quickly enough at screen size.
// So in this case we just need to create a thumbnail.
cropResizeRotate(file, null, thumbnailSize, null, metadata,
function gotThumbnail(error, thumbnailBlob) {
if (error) {
var msg = 'Failed to create thumbnail for' +
file.name;
console.error(msg);
metadataError(msg);
return;
}
metadata.preview = null;
metadata.thumbnail = thumbnailBlob;
metadataCallback(metadata);
});
}
else {
// Otherwise, this is a high-resolution jpeg that does not
// have an EXIF preview big enough for the screen. We need
// to create both a thumbnail and a preview image but we can
// do that with efficient downsampling using cropResizeRotate().
createJPEGThumbnailAndPreview(file, metadata,
metadataCallback,
metadataError);
}
}
else {
// In this case we have no preview and the image is not a jpeg
// that can be downsampled with #-moz-samplesize
createThumbnailAndPreview(file,
metadataCallback,
metadataError,
false,
bigFile,
metadata);
}
}
function previewsuccess(previewmetadata) {
// Size of the preview image
var pw = previewmetadata.width;
var ph = previewmetadata.height;
// optional configuration specifying minimum size
var mw = CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH;
var mh = CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT;
var bigenough;
// If config.js specifies a minimum required preview size,
// then this preview is big enough if both dimensions are
// larger than that configured minimum. Otherwise, the preview
// is big enough if at least one dimension is >= the screen
// size in both portait and landscape mode.
if (mw && mh) {
bigenough =
Math.max(pw, ph) >= Math.max(mw, mh) &&
Math.min(pw, ph) >= Math.min(mw, mh);
}
else {
bigenough = (pw >= sw || ph >= sh) && (pw >= sh || ph >= sw);
}
// Does the aspect ratio of the preview match the aspect ratio of
// the full-size image? If not we have to assume it is distorted
// and should not be used.
var aspectRatioMatches =
Math.abs(pw / ph - metadata.width / metadata.height) < 0.01;
// If the preview is good, use it to create a thumbnail.
if (bigenough && aspectRatioMatches) {
metadata.preview.width = pw;
metadata.preview.height = ph;
// This is the most common case. We've got a JPEG image with a
// JPEG preview. All we need is a thumbnail, and we can create that
// most efficiently with cropResizeRotate which will use
// #-moz-samplesize to save memory while decoding the image.
// Note that we apply the EXIF orientation information from
// the full size image to the preview image.
previewmetadata.rotation = metadata.rotation;
previewmetadata.mirrored = metadata.mirrored;
cropResizeRotate(previewblob, null, thumbnailSize, null,
previewmetadata,
function gotThumbnailBlob(error, thumbnailBlob) {
if (error) {
previewerror(error);
return;
}
metadata.thumbnail = thumbnailBlob;
metadataCallback(metadata);
});
} else {
// Preview isn't big enough so get one the hard way
useFullsizeImage();
}
}
}
}
// This function is called for JPEG images that do not have a suitable
// EXIF preview image and that have more than MAX_PREVIEWLESS_JPEG_SIZE
// pixels. It creates and saves a preview image and a thumbnail.
// Unlike createThumbnailAndPreview(), it uses cropResizeRotate() which
// downsamples jpeg images while decoding them to save memory.
function createJPEGThumbnailAndPreview(file, metadata,
metadataCallback, metadataError)
{
// Figure out the preview size.
var scale = Math.max(Math.min(sw / metadata.width,
sh / metadata.height, 1),
Math.min(sh / metadata.width,
sw / metadata.height, 1));
var previewSize = {
width: Math.round(metadata.width * scale),
height: Math.round(metadata.height * scale)
};
// Create a preview image.
// Note that we can't use the full metadata object here
// because we don't want the preview image to be rotated.
// We want the same orientation as the original image
cropResizeRotate(file, null, previewSize, null,
{ width: metadata.width, height: metadata.height },
function gotPreview(error, previewBlob) {
if (error) {
var msg = 'Failed to create preview for' +
file.name;
console.error(msg);
metadataError(msg);
return;
}
// Save this preview to the sdcard
savePreview(file, previewBlob, metadataError, function(filename) {
// Record the preview image in the metadata
metadata.preview = {
filename: filename,
width: previewSize.width,
height: previewSize.height
};
// Now create the thumbnail from this smaller preview image
// In this case we need to pass the size of the preview
// and the rotation of the original image.
cropResizeRotate(previewBlob, null, thumbnailSize, null,
{
width: previewSize.width,
height: previewSize.height,
rotation: metadata.rotation
},
function gotThumbnail(error, thumbnailBlob) {
// If thumbnail creation failed report the error
if (error) {
metadataError(error);
return;
}
// Otherwise, remember thumbnail and we're done
metadata.thumbnail = thumbnailBlob;
metadataCallback(metadata);
});
});
});
}
// Load an image from a file into an <img> tag, and then use that
// to get its dimensions and create a thumbnail. Store these values in
// a metadata object, and pass the object to the callback function.
// If anything goes wrong, pass an error message to the error function.
// If it is a large image, create and save a preview for it as well.
//
// We used to call this function for every image. Now that gecko supports
// the #-moz-samplesize media fragment, however, we handle jpeg images
// with cropResizeRotate() for memory efficiency, and this function is
// used only for non-jpeg files.
function createThumbnailAndPreview(file, callback, error, nopreview,
bigFile, metadata) {
var url = URL.createObjectURL(file);
offscreenImage.src = url;
offscreenImage.onerror = function() {
URL.revokeObjectURL(url);
offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
error('createThumbnailAndPreview: Image failed to load');
};
offscreenImage.onload = function() {
URL.revokeObjectURL(url);
var iw = offscreenImage.width;
var ih = offscreenImage.height;
// Don't overwrite the metadata in the case we read a previewblob.
if (!nopreview) {
metadata.width = iw;
metadata.height = ih;
}
// If this is a big image, then decoding it takes a lot of memory.
// We set this flag to prevent the user from zooming in on other
// images at the same time because that also takes a lot of memory
// and we don't want to crash with an OOM. If we find one big image
// we assume that there may be others, so the flag remains set until
// the current scan is complete.
//
// XXX: When bug 854795 is fixed, we'll be able to create previews
// for large images without using so much memory, and we can remove
// this flag then.
if (iw * ih > 2 * 1024 * 1024 && bigFile) {
bigFile();
}
// If the image was already thumbnail size, it is its own thumbnail
// and it does not need a preview
if (metadata.width <= THUMBNAIL_WIDTH &&
metadata.height <= THUMBNAIL_HEIGHT) {
offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
metadata.thumbnail = file;
callback(metadata);
}
else {
createThumbnailFromElement(
offscreenImage,
false,
metadata.rotation || 0,
metadata.mirrored || false,
gotThumbnail,
error);
}
function gotThumbnail(thumbnail) {
metadata.thumbnail = thumbnail;
// If no preview was requested, or if if the image was less
// than half a megapixel then it does not need a preview
// image, and we can call the callback right away
if (nopreview || metadata.width * metadata.height < 512 * 1024) {
offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
callback(metadata);
}
else {
// Otherwise, this was a big image and we need to create a
// preview for it so we can avoid decoding the full size
// image again when possible
createAndSavePreview();
}
}
function createAndSavePreview() {
// Figure out the preview size.
// Make sure the size is big enough for both landscape and portrait
var scale = Math.max(Math.min(sw / iw, sh / ih, 1),
Math.min(sh / iw, sw / ih, 1));
// preview width and height
var pw = Math.round(iw * scale);
var ph = Math.round(ih * scale);
// Create the preview in a canvas
var canvas = document.createElement('canvas');
canvas.width = pw;
canvas.height = ph;
var context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(offscreenImage, 0, 0, iw, ih, 0, 0, pw, ph);
canvas.toBlob(function(blob) {
offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
canvas.width = canvas.height = 0;
savePreview(file, blob, error, function(filename) {
metadata.preview = {
filename: filename,
width: pw,
height: ph
};
callback(metadata);
});
}, 'image/jpeg');
}
};
}
// Given an image file and a preview blob for it, this function saves
// the preview to a file in .gallery/previews/ and then passes the filename
// to the specified callback. Calls errorCallback if an error occurs.
// This is used by createThumbnailAndPreview() and by
// createJPEGThumbnailAndPreview().
function savePreview(file, previewblob, errorCallback, filenameCallback) {
var storage = navigator.getDeviceStorage('pictures');
var filename;
if (file.name[0] === '/') {
// We expect file.name to be a fully qualified name (perhaps
// something like /sdcard/DCIM/100MZLLA/IMG_0001.jpg).
var slashIndex = file.name.indexOf('/', 1);
if (slashIndex < 0) {
errorCallback('savePreview: Bad filename: \'' + file.name + '\'');
return;
}
filename =
file.name.substring(0, slashIndex) + // storageName (i.e. /sdcard)
'/.gallery/previews' +
file.name.substring(slashIndex); // rest of path (i,e, /DCIM/...)
} else {
// On non-composite storage areas (e.g. desktop), file.name will be
// a relative path.
filename = '.gallery/previews/' + file.name;
}
// Add a '.jpeg' extension to all preview files. This is necessary
// because all previews get saved as jpegs even if the original
// image is not a jpeg. But DeviceStorage uses the filename extension
// to determine the file type.
filename += '.jpeg';
// Delete any existing preview by this name
var delreq = storage.delete(filename);
delreq.onsuccess = delreq.onerror = save;
function save() {
var savereq = storage.addNamed(previewblob, filename);
savereq.onerror = function() {
console.error('Could not save preview image', filename);
};
// Don't actually wait for the save to complete.
filenameCallback(filename);
}
}
function videoMetadataParser(file, metadataCallback, errorCallback) {
var metadata = {};
var extensions = ['webm', '3gp', 'mp4'];
function findVideo() {
var videofilename = file.name.replace(/\.jpg$/, '.' + extensions.shift());
var getreq = videostorage.get(videofilename);
getreq.onerror = function() {
if (extensions.length) findVideo();
else errorCallback('cannot get video file: ' + videofilename);
};
getreq.onsuccess = function() {
metadata.video = videofilename;
if (/\.webm$/.test(videofilename)) {
metadata.rotation = 0;
getVideoThumbnailAndSize();
return;
}
getVideoRotation(getreq.result, function(rotation) {
if (typeof rotation === 'number') {
metadata.rotation = rotation;
getVideoThumbnailAndSize();
} else errorCallback('Video rotation: ' + rotation);
});
};
}
findVideo();
function getVideoThumbnailAndSize() {
var url = URL.createObjectURL(file);
offscreenImage.src = url;
offscreenImage.onerror = function() {
URL.revokeObjectURL(url);
offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
errorCallback('getVideoThumanailAndSize: Image failed to load');
};
offscreenImage.onload = function() {
URL.revokeObjectURL(url);
// We store the unrotated size of the poster image. The video
// itself might be larger or smaller than this, but it will
// have the same orientation and aspect ratio.
metadata.width = offscreenImage.width;
metadata.height = offscreenImage.height;
createThumbnailFromElement(offscreenImage,
true,
metadata.rotation,
false,
function(thumbnail) {
metadata.thumbnail = thumbnail;
offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src');
metadataCallback(metadata);
},
errorCallback);
};
}
}
return metadataParser;
}());

File diff suppressed because it is too large Load diff

View file

@ -38,6 +38,26 @@ var oldValue=this._array[index];this._array[index]=value;this._emitEvent('replac
* *
* @module PageTransitions * @module PageTransitions
*/ */
/**
* PageTransitions provides transition functions used when navigating panels.
*
* @module PageTransitions
*/
/**
* PageTransitions provides transition functions used when navigating panels.
*
* @module PageTransitions
*/
/**
* PageTransitions provides transition functions used when navigating panels.
*
* @module PageTransitions
*/
/**
* PageTransitions provides transition functions used when navigating panels.
*
* @module PageTransitions
*/
define('modules/page_transitions',[],function() { define('modules/page_transitions',[],function() {
'use strict'; 'use strict';
@ -155,7 +175,6 @@ define('modules/page_transitions',[],function() {
} }
}; };
}); });
define('modules/panel',[],function(){'use strict';var _emptyFunc=function panel_emptyFunc(){};var Panel=function ctor_panel(options){var _initialized=false;options=options||{};options.onInit=options.onInit||_emptyFunc;options.onUninit=options.onUninit||_emptyFunc;options.onShow=options.onShow||_emptyFunc;options.onHide=options.onHide||_emptyFunc;options.onBeforeShow=options.onBeforeShow||_emptyFunc;options.onBeforeHide=options.onBeforeHide||_emptyFunc;return{get initialized(){return _initialized;},init:function(panel,initOptions){if(_initialized){return;} define('modules/panel',[],function(){'use strict';var _emptyFunc=function panel_emptyFunc(){};var Panel=function ctor_panel(options){var _initialized=false;options=options||{};options.onInit=options.onInit||_emptyFunc;options.onUninit=options.onUninit||_emptyFunc;options.onShow=options.onShow||_emptyFunc;options.onHide=options.onHide||_emptyFunc;options.onBeforeShow=options.onBeforeShow||_emptyFunc;options.onBeforeHide=options.onBeforeHide||_emptyFunc;return{get initialized(){return _initialized;},init:function(panel,initOptions){if(_initialized){return;}
_initialized=true;return options.onInit(panel,initOptions);},uninit:function(){if(!_initialized){return;} _initialized=true;return options.onInit(panel,initOptions);},uninit:function(){if(!_initialized){return;}
_initialized=false;options.onUninit();},show:function(panel,showOptions){return Promise.resolve(this.init(panel,showOptions)).then(function(){return options.onShow(panel,showOptions);});},hide:function(){return options.onHide();},beforeShow:function(panel,beforeShowOptions){return Promise.resolve(this.init(panel,beforeShowOptions)).then(function(){return options.onBeforeShow(panel,beforeShowOptions);});},beforeHide:function(){return options.onBeforeHide();}};};return Panel;});define('modules/settings_cache',[],function(){'use strict';var _settings=window.navigator.mozSettings;var _settingsCache=null;var _settingsCacheRequestSent=null;var _pendingSettingsCallbacks=[];var _callbacks=[];var _onSettingsChange=function sc_onSettingsChange(event){var key=event.settingName;var value=event.settingValue;if(_settingsCache){_settingsCache[key]=value;} _initialized=false;options.onUninit();},show:function(panel,showOptions){return Promise.resolve(this.init(panel,showOptions)).then(function(){return options.onShow(panel,showOptions);});},hide:function(){return options.onHide();},beforeShow:function(panel,beforeShowOptions){return Promise.resolve(this.init(panel,beforeShowOptions)).then(function(){return options.onBeforeShow(panel,beforeShowOptions);});},beforeHide:function(){return options.onBeforeHide();}};};return Panel;});define('modules/settings_cache',[],function(){'use strict';var _settings=window.navigator.mozSettings;var _settingsCache=null;var _settingsCacheRequestSent=null;var _pendingSettingsCallbacks=[];var _callbacks=[];var _onSettingsChange=function sc_onSettingsChange(event){var key=event.settingName;var value=event.settingValue;if(_settingsCache){_settingsCache[key]=value;}
@ -214,23 +233,403 @@ return this._isOnRealDevice;},getCurrentLayout:function sl_getCurrentLayout(type
if(typeof this.queries[type]!=='undefined'){return this.queries[type].matches;} if(typeof this.queries[type]!=='undefined'){return this.queries[type].matches;}
return false;},watch:function sl_watch(name,media){var mediaString=media||this.queries[name].media;if(!mediaString){return;} return false;},watch:function sl_watch(name,media){var mediaString=media||this.queries[name].media;if(!mediaString){return;}
this.unwatch(name);this.queries[name]=window.matchMedia(mediaString);this.boundHandleChange=this.handleChange.bind(this);this.queries[name].addListener(this.boundHandleChange);},unwatch:function sl_unwatch(name){if(this.queries[name]){this.queries[name].removeListener(this.boundHandleChange);}},handleChange:function sl_handleChange(evt){for(var key in this.queries){if(this.queries[key].media!==evt.media){continue;} this.unwatch(name);this.queries[name]=window.matchMedia(mediaString);this.boundHandleChange=this.handleChange.bind(this);this.queries[name].addListener(this.boundHandleChange);},unwatch:function sl_unwatch(name){if(this.queries[name]){this.queries[name].removeListener(this.boundHandleChange);}},handleChange:function sl_handleChange(evt){for(var key in this.queries){if(this.queries[key].media!==evt.media){continue;}
window.dispatchEvent(new CustomEvent('screenlayoutchange',{detail:{name:key,status:evt.matches}}));}}};ScreenLayout.init();define("shared/screen_layout",(function(global){return function(){var ret,fn;return ret||global.ScreenLayout;};}(this)));define('modules/settings_service',['require','modules/page_transitions','modules/panel_cache','shared/screen_layout','shared/lazy_loader','settings'],function(require){'use strict';var PageTransitions=require('modules/page_transitions');var PanelCache=require('modules/panel_cache');var ScreenLayout=require('shared/screen_layout');var LazyLoader=require('shared/lazy_loader');var Settings=require('settings');var _rootPanelId=null;var _currentNavigation=null;var _navigating=false;var _pendingNavigationRequest=null;var _cachedNavigation=null;var _cachedNavigationOptions={};var _activityHandler=null;var _loadModulesForSubPanelsPromise=null;var _getAppNameToLink=function ss_get_app_name_to_link(panelId){var reAppName=/app:(\w+)/;var name=reAppName.exec(panelId);return name&&name[1];};var _getAppInfo=function ss_get_app_info(appName){var _supportedAppInFrame={keyboard:{},bluetooth:{}};var appInfo=_supportedAppInFrame[appName];if(!appInfo){return false;} window.dispatchEvent(new CustomEvent('screenlayoutchange',{detail:{name:key,status:evt.matches}}));}}};ScreenLayout.init();define("shared/screen_layout",(function(global){return function(){var ret,fn;return ret||global.ScreenLayout;};}(this)));/**
var prefix='app://'+appName+'.gaiamobile.org/';var defaultSrc=prefix+'settings.html';var appMozapp=prefix+'manifest.webapp';var appSrc=appInfo.src?prefix+appInfo.src:defaultSrc;return{src:appSrc,mozapp:appMozapp};};var _isTabletAndLandscape=function ss_is_tablet_and_landscape(){return ScreenLayout.getCurrentLayout('tabletAndLandscaped');};var _retriveParentPanelId=function ss_retriveParentPanelId(panelId){var headerSelector='#'+panelId+' > gaia-header';var header=document.querySelector(headerSelector);return(header&&header.dataset.href||'').replace('#','');};var _shallCloseActivity=function ss_shallCloseActivity(panelId){if(panelId==='close'){return true;} * SettingsService is a singleton that provides the navigation service. It
if(!_currentNavigation){return false;} * gets the corresponding panel module from PanelCache and call to its basic
var parentPanelId=_retriveParentPanelId(_currentNavigation.panelId);return _currentNavigation.panelId===_activityHandler.targetPanelId&&panelId===parentPanelId;};var _transit=function ss_transit(oldPanel,newPanel,callback){var promise=new Promise(function(resolve){var wrappedCallback=function(){if(typeof callback==='function'){callback();} * functions when navigating.
resolve();};if(_isTabletAndLandscape()){PageTransitions.twoColumn(oldPanel,newPanel,wrappedCallback);}else{PageTransitions.oneColumn(oldPanel,newPanel,wrappedCallback);}});return promise;};var _loadPanel=function ss_loadPanel(panelId,callback){var panelElement=document.getElementById(panelId);if(panelElement.dataset.rendered){callback();return;} *
panelElement.dataset.rendered=true;if(panelElement.dataset.requireSubPanels){var selector='section[id^="'+panelElement.id+'-"]';var subPanels=document.querySelectorAll(selector);for(var i=0,il=subPanels.length;i<il;i++){LazyLoader.load([subPanels[i]]);} * @module SettingsService
LazyLoader.load([panelElement],callback);}else{LazyLoader.load([panelElement],callback);}};var _loadModulesForSubPanels=function ss_loadModules(panelId){if(panelId===_rootPanelId){return Promise.resolve();}else{if(!_loadModulesForSubPanelsPromise){_loadModulesForSubPanelsPromise=new Promise(function(resolve){require(['shared/async_storage'],resolve);});} */
return _loadModulesForSubPanelsPromise;}};var _onVisibilityChange=function ss_onVisibilityChange(){_handleVisibilityChange(!document.hidden);};var _handleVisibilityChange=function ss_onVisibilityChange(visible){if(!_currentNavigation){return;} /**
var panel=_currentNavigation.panel;var element=_currentNavigation.panelElement;var options=_currentNavigation.options;if(!panel){return;} * SettingsService is a singleton that provides the navigation service. It
if(visible){panel.beforeShow(element,options);panel.show(element,options);}else{panel.beforeHide();panel.hide();}};var _navigate=function ss_navigate(panelId,options,callback){if(_currentNavigation&&_currentNavigation.panelId===panelId){callback();return;} * gets the corresponding panel module from PanelCache and call to its basic
_loadPanel(panelId,function(){PanelCache.get(panelId,function(panel){var newPanelElement=document.getElementById(panelId);var currentPanelId=_currentNavigation&&_currentNavigation.panelId;var currentPanelElement=_currentNavigation&&_currentNavigation.panelElement;var currentPanel=_currentNavigation&&_currentNavigation.panel;_cachedNavigation=_currentNavigation;_cachedNavigationOptions=options;options=options||{};_loadModulesForSubPanels(panelId).then(function(){if(currentPanel&&currentPanelId!==_rootPanelId){return currentPanel.beforeHide();}}).then(function(){return panel.beforeShow(newPanelElement,options);}).then(function(){return _transit(currentPanelElement,newPanelElement);}).then(function(){if(currentPanel&&currentPanelId!==_rootPanelId){return currentPanel.hide();}}).then(function(){return panel.show(newPanelElement,options);}).then(function(){_currentNavigation={panelId:panelId,panelElement:newPanelElement,panel:panel,options:options};Settings._currentPanel='#'+panelId;callback();});});});};return{reset:function ss_reset(){_rootPanelId=null;_currentNavigation=null;_cachedNavigation=null;_cachedNavigationOptions={};_activityHandler=null;_navigating=false;_pendingNavigationRequest=null;window.removeEventListener('visibilitychange',_onVisibilityChange);},init:function ss_init(options){if(options){_rootPanelId=options.rootPanelId||'root';_activityHandler=options.context&&options.context.activityHandler;} * functions when navigating.
window.addEventListener('visibilitychange',_onVisibilityChange);},navigate:function ss_navigate(panelId,options,callback){if(_activityHandler&&_shallCloseActivity(panelId)){_activityHandler.postResult();return;} *
if(_navigating){_pendingNavigationRequest=arguments;return;} * @module SettingsService
var appName=_getAppNameToLink(panelId);if(appName){var appInfo=_getAppInfo(appName);if(!appInfo){console.error('We only embed trust apps.');return;} */
panelId='frame';options=options||{};options.mozapp=appInfo.mozapp;options.src=appInfo.src;} /**
_navigating=true;_navigate(panelId,options,(function(){_navigating=false;if(_pendingNavigationRequest){var args=_pendingNavigationRequest;_pendingNavigationRequest=null;this.navigate.apply(this,args);} * SettingsService is a singleton that provides the navigation service. It
if(callback){callback();}}).bind(this));},back:function ss_back(){if(_activityHandler){_activityHandler.postResult();}else if(_cachedNavigation){this.navigate(_cachedNavigation.panelId,_cachedNavigationOptions);_cachedNavigation=null;_cachedNavigationOptions={};}}};});require(['config/require'],function(){'use strict';define('boot',['require','shared/settings_listener','modules/mvvm/observable','modules/mvvm/observable_array','modules/base/event_emitter','modules/settings_service','shared/screen_layout','settings'],function(require){require('shared/settings_listener');require('modules/mvvm/observable');require('modules/mvvm/observable_array');require('modules/base/event_emitter');var SettingsService=require('modules/settings_service');var ScreenLayout=require('shared/screen_layout');var Settings=require('settings');function isInitialPanel(panel){if(Settings.isTabletAndLandscape()){return panel===Settings.initialPanelForTablet;}else{return panel===('#'+window.LaunchContext.initialPanelId);}} * gets the corresponding panel module from PanelCache and call to its basic
* functions when navigating.
*
* @module SettingsService
*/
/**
* SettingsService is a singleton that provides the navigation service. It
* gets the corresponding panel module from PanelCache and call to its basic
* functions when navigating.
*
* @module SettingsService
*/
define('modules/settings_service',["require", "modules/page_transitions", "modules/panel_cache", "shared/screen_layout", "shared/lazy_loader", "settings"],function(require) {
'use strict';
var PageTransitions = require('modules/page_transitions');
var PanelCache = require('modules/panel_cache');
var ScreenLayout = require('shared/screen_layout');
var LazyLoader = require('shared/lazy_loader');
var Settings = require('settings');
var _rootPanelId = null;
/**
* _currentNavigation caches information of the current panel including id,
* element, module, and options.
*/
var _currentNavigation = null;
var _navigating = false;
var _pendingNavigationRequest = null;
var _cachedNavigation = null;
var _cachedNavigationOptions = {};
var _activityHandler = null;
var _loadModulesForSubPanelsPromise = null;
var _getAppNameToLink = function ss_get_app_name_to_link(panelId) {
var reAppName = /app:(\w+)/;
var name = reAppName.exec(panelId);
return name && name[1];
};
var _getAppInfo = function ss_get_app_info(appName) {
// We can customize the path for specific apps
var _supportedAppInFrame = {
keyboard: {},
bluetooth: {}
};
var appInfo = _supportedAppInFrame[appName];
if (!appInfo) {
return false;
}
var prefix = 'app://' + appName + '.gaiamobile.org/';
var defaultSrc = prefix + 'settings.html';
var appMozapp = prefix + 'manifest.webapp';
var appSrc = appInfo.src ? prefix + appInfo.src : defaultSrc;
return {
src: appSrc,
mozapp: appMozapp
};
};
var _isTabletAndLandscape = function ss_is_tablet_and_landscape() {
return ScreenLayout.getCurrentLayout('tabletAndLandscaped');
};
var _retriveParentPanelId = function ss_retriveParentPanelId(panelId) {
var headerSelector = '#' + panelId + ' > gaia-header';
var header = document.querySelector(headerSelector);
return (header && header.dataset.href || '').replace('#', '');
};
var _shallCloseActivity = function ss_shallCloseActivity(panelId) {
// If we're handling an activity and the 'back' button is hit, close the
// activity if the panel id to be navigated equals the parent panel id.
// This is for the root panel
if (panelId === 'close') {
return true;
}
if (!_currentNavigation) {
return false;
}
// Get the parent panel id of the current panel.
var parentPanelId = _retriveParentPanelId(_currentNavigation.panelId);
// Close the activity if the current panel is the original target panel,
// and the new panel is the parent panel of the current panel.
return _currentNavigation.panelId === _activityHandler.targetPanelId &&
panelId === parentPanelId;
};
var _transit = function ss_transit(oldPanel, newPanel, callback) {
var promise = new Promise(function(resolve) {
var wrappedCallback = function() {
if (typeof callback === 'function') {
callback();
}
resolve();
};
if (_isTabletAndLandscape()) {
PageTransitions.twoColumn(oldPanel, newPanel, wrappedCallback);
} else {
PageTransitions.oneColumn(oldPanel, newPanel, wrappedCallback);
}
});
return promise;
};
var _loadPanel = function ss_loadPanel(panelId, callback) {
var panelElement = document.getElementById(panelId);
if (panelElement.dataset.rendered) { // already initialized
callback();
return;
}
panelElement.dataset.rendered = true;
// XXX remove SubPanel loader once sub panel are modulized
if (panelElement.dataset.requireSubPanels) {
// load the panel and its sub-panels (dependencies)
// (load the main panel last because it contains the scripts)
var selector = 'section[id^="' + panelElement.id + '-"]';
var subPanels = document.querySelectorAll(selector);
for (var i = 0, il = subPanels.length; i < il; i++) {
LazyLoader.load([subPanels[i]]);
}
LazyLoader.load([panelElement], callback);
} else {
LazyLoader.load([panelElement], callback);
}
};
var _loadModulesForSubPanels = function ss_loadModules(panelId) {
if (panelId === _rootPanelId) {
return Promise.resolve();
} else {
if (!_loadModulesForSubPanelsPromise) {
_loadModulesForSubPanelsPromise = new Promise(function(resolve) {
require(['shared/async_storage'], resolve);
});
}
return _loadModulesForSubPanelsPromise;
}
};
var _onVisibilityChange = function ss_onVisibilityChange() {
_handleVisibilityChange(!document.hidden);
};
/**
* When the app becomes invisible, we should call to beforeHide and hide
* functions of the current panel. When the app becomes visible, we should
* call to beforeShow and show functions of the current panel with the
* cached options.
*/
var _handleVisibilityChange = function ss_onVisibilityChange(visible) {
if (!_currentNavigation) {
return;
}
var panel = _currentNavigation.panel;
var element = _currentNavigation.panelElement;
var options = _currentNavigation.options;
if (!panel) {
return;
}
if (visible) {
panel.beforeShow(element, options);
panel.show(element, options);
} else {
panel.beforeHide();
panel.hide();
}
};
var _navigate = function ss_navigate(panelId, options, callback) {
// Early return if the panel to be navigated is the same as the
// current one.
if (_currentNavigation && _currentNavigation.panelId === panelId) {
callback();
return;
}
_loadPanel(panelId, function() {
PanelCache.get(panelId, function(panel) {
var newPanelElement = document.getElementById(panelId);
var currentPanelId =
_currentNavigation && _currentNavigation.panelId;
var currentPanelElement =
_currentNavigation && _currentNavigation.panelElement;
var currentPanel = _currentNavigation && _currentNavigation.panel;
// Keep these to make sure we can use when going back
_cachedNavigation = _currentNavigation;
_cachedNavigationOptions = options;
// Prepare options and calls to the panel object's before
// show function.
options = options || {};
// 0. start the chain
_loadModulesForSubPanels(panelId)
// 1. beforeHide previous panel
.then(function() {
// We don't deactivate the root panel.
if (currentPanel && currentPanelId !== _rootPanelId) {
return currentPanel.beforeHide();
}
})
// 2. beforeShow next panel
.then(function() {
return panel.beforeShow(newPanelElement, options);
})
// 3. do the transition
.then(function() {
return _transit(currentPanelElement, newPanelElement);
})
// 4. hide previous panel
.then(function() {
// We don't deactivate the root panel.
if (currentPanel && currentPanelId !== _rootPanelId) {
return currentPanel.hide();
}
})
// 5. show next panel
.then(function() {
return panel.show(newPanelElement, options);
})
// 6. keep information
.then(function() {
// Update the current navigation object
_currentNavigation = {
panelId: panelId,
panelElement: newPanelElement,
panel: panel,
options: options
};
// XXX we need to remove this line in the future
// to make sure we won't manipulate Settings
// directly
Settings._currentPanel = '#' + panelId;
callback();
}).catch(function(error) {
console.error('Settings panel ' + panelId + ':', error);
newPanelElement.classList.remove('current');
if (currentPanelElement) currentPanelElement.classList.add('current');
// Release the navigation lock even when a hardware query fails.
callback(error);
if (window.VulpesGaiaUI) {
window.VulpesGaiaUI.notice(document.documentElement.lang.indexOf('fr') === 0 ?
'Ce panneau n’a pas pu être chargé. Vous pouvez revenir aux autres paramètres.' :
'This panel could not be loaded. You can return to the other settings.');
}
});
});
});
};
return {
reset: function ss_reset() {
_rootPanelId = null;
_currentNavigation = null;
_cachedNavigation = null;
_cachedNavigationOptions = {};
_activityHandler = null;
_navigating = false;
_pendingNavigationRequest = null;
window.removeEventListener('visibilitychange', _onVisibilityChange);
},
/**
* Init SettingsService.
*
* @alias module:SettingsService#init
* @param {Object} options
* @param {String} options.rootPanelId
* Panel with the specified id is assumed to be be kept on
* on the screen always. We don't call to its hide and
* beforeHide functions.
* @param {Object} options.context
* The launch context specifying the default panel and the
* activity handler if the app is invoked by web
* activities.
* @param {String} options.context.initialPanelId
* @param {ActivityHandler} options.context.activityHandler
*/
init: function ss_init(options) {
if (options) {
_rootPanelId = options.rootPanelId || 'root';
_activityHandler = options.context && options.context.activityHandler;
}
window.addEventListener('visibilitychange', _onVisibilityChange);
},
/**
* Navigate to a panel with options. The navigation transition is
* determined based on the current screen size and orientation.
*
* @alias module:SettingsService#navigate
* @param {String} panelId
* @param {Object} options
* @param {Function} callback
*/
navigate: function ss_navigate(panelId, options, callback) {
// Check if the app is invoked by web activity and shall post result.
if (_activityHandler && _shallCloseActivity(panelId)) {
_activityHandler.postResult();
return;
}
// Cache the navigation request if it is navigating.
if (_navigating) {
_pendingNavigationRequest = arguments;
return;
}
// If we find out the link includes information about app's name,
// it means that we are going to embed the app into our app.
//
// In this way, we have to navigate to `frame` panel and embed it.
var appName = _getAppNameToLink(panelId);
if (appName) {
var appInfo = _getAppInfo(appName);
if (!appInfo) {
console.error('We only embed trust apps.');
return;
}
panelId = 'frame';
options = options || {};
options.mozapp = appInfo.mozapp;
options.src = appInfo.src;
}
_navigating = true;
_navigate(panelId, options, (function() {
_navigating = false;
// Navigate to the pending navigation if any.
if (_pendingNavigationRequest) {
var args = _pendingNavigationRequest;
_pendingNavigationRequest = null;
this.navigate.apply(this, args);
}
if (callback) {
callback();
}
}).bind(this));
},
/**
* Go back to previous panel
*
* @alias module:SettingsService#back
*/
back: function ss_back() {
if (_activityHandler) {
_activityHandler.postResult();
} else if (_cachedNavigation) {
this.navigate(_cachedNavigation.panelId, _cachedNavigationOptions);
_cachedNavigation = null;
_cachedNavigationOptions = {};
}
}
};
});
require(['config/require'],function(){'use strict';define('boot',['require','shared/settings_listener','modules/mvvm/observable','modules/mvvm/observable_array','modules/base/event_emitter','modules/settings_service','shared/screen_layout','settings'],function(require){require('shared/settings_listener');require('modules/mvvm/observable');require('modules/mvvm/observable_array');require('modules/base/event_emitter');var SettingsService=require('modules/settings_service');var ScreenLayout=require('shared/screen_layout');var Settings=require('settings');function isInitialPanel(panel){if(Settings.isTabletAndLandscape()){return panel===Settings.initialPanelForTablet;}else{return panel===('#'+window.LaunchContext.initialPanelId);}}
window.addEventListener('panelready',function onPanelReady(e){if(!isInitialPanel(e.detail.current)){return;} window.addEventListener('panelready',function onPanelReady(e){if(!isInitialPanel(e.detail.current)){return;}
var initialPanelHandler=window.LaunchContext.initialPanelHandler;if(initialPanelHandler){initialPanelHandler.release();var pendingTargetPanel=initialPanelHandler.pendingTargetPanel;switch(pendingTargetPanel){case'call':var mozMobileConnections=navigator.mozMobileConnections;if(mozMobileConnections&&mozMobileConnections.length>1){pendingTargetPanel='call-iccs';} var initialPanelHandler=window.LaunchContext.initialPanelHandler;if(initialPanelHandler){initialPanelHandler.release();var pendingTargetPanel=initialPanelHandler.pendingTargetPanel;switch(pendingTargetPanel){case'call':var mozMobileConnections=navigator.mozMobileConnections;if(mozMobileConnections&&mozMobileConnections.length>1){pendingTargetPanel='call-iccs';}
SettingsService.navigate(pendingTargetPanel);break;default:if(pendingTargetPanel){SettingsService.navigate(pendingTargetPanel);} SettingsService.navigate(pendingTargetPanel);break;default:if(pendingTargetPanel){SettingsService.navigate(pendingTargetPanel);}

View file

@ -0,0 +1,419 @@
/**
* DateTime is a singleton that caches date, time, timezone values for
* Date & Time panel fast access
*
* @module DateTime
*/
/**
* DateTime is a singleton that caches date, time, timezone values for
* Date & Time panel fast access
*
* @module DateTime
*/
/**
* DateTime is a singleton that caches date, time, timezone values for
* Date & Time panel fast access
*
* @module DateTime
*/
/**
* DateTime is a singleton that caches date, time, timezone values for
* Date & Time panel fast access
*
* @module DateTime
*/
/**
* DateTime is a singleton that caches date, time, timezone values for
* Date & Time panel fast access
*
* @module DateTime
*/
/**
* DateTime is a singleton that caches date, time, timezone values for
* Date & Time panel fast access
*
* @module DateTime
*/
/**
* DateTime is a singleton that caches date, time, timezone values for
* Date & Time panel fast access
*
* @module DateTime
*/
/**
* DateTime is a singleton that caches date, time, timezone values for
* Date & Time panel fast access
*
* @module DateTime
*/
define('modules/date_time',["require", "modules/settings_cache", "modules/base/module", "modules/mvvm/observable"],function(require) {
'use strict';
var SettingsCache = require('modules/settings_cache');
var Module = require('modules/base/module');
var Observable = require('modules/mvvm/observable');
var settings = window.navigator.mozSettings;
// keys
var _kClockAutoEnabled = 'time.clock.automatic-update.enabled';
var _kTimezoneAutoEnabled = 'time.timezone.automatic-update.enabled';
var _kClockAutoAvailable = 'time.clock.automatic-update.available';
var _kTimezoneAutoAvailable = 'time.timezone.automatic-update.available';
var _kTimezone = 'time.timezone';
var _kUserSelected = 'time.timezone.user-selected';
var _kLocaleTime = 'locale.hour12';
// handler
var _updateDateTimeout = null;
var _updateTimeTimeout = null;
var DateTime = Module.create(function DateTime() {
this.super(Observable).call(this);
this._init();
}).extend(Observable);
// public observers
/**
* Auto setting Date & Time.
*
* @access public
* @memberOf DateTime
* @type {Boolean}
*/
Observable.defineObservableProperty(DateTime.prototype, 'clockAutoEnabled', {
readonly: true,
value: true
});
/**
* SIM provide Auto clock function.
*
* @access public
* @memberOf DateTime
* @type {Boolean}
*/
Observable.defineObservableProperty(DateTime.prototype,
'clockAutoAvailable', {
readonly: true,
value: true
});
/**
* SIM provide Auto timezone function.
*
* @access public
* @memberOf DateTime
* @type {Boolean}
*/
Observable.defineObservableProperty(DateTime.prototype,
'timezoneAutoAvailable', {
readonly: true,
value: true
});
/**
* Current Timezone.
*
* @access public
* @memberOf DateTime
* @type {String}
*/
Observable.defineObservableProperty(DateTime.prototype, 'timezone', {
readonly: true,
value: ''
});
/**
* User Selected Timezone.
*
* @access public
* @memberOf DateTime
* @type {String}
*/
Observable.defineObservableProperty(DateTime.prototype,
'userSelectedTimezone', {
readonly: true,
value: ''
});
/**
* Current Date string.
*
* @access public
* @memberOf DateTime
* @type {String}
*/
Observable.defineObservableProperty(DateTime.prototype, 'date', {
readonly: true,
value: ''
});
/**
* Localized current time string.
*
* @access public
* @memberOf DateTime
* @type {String}
*/
Observable.defineObservableProperty(DateTime.prototype, 'time', {
readonly: true,
value: ''
});
/**
* Current AM/PM or 24 state.
*
* @access public
* @memberOf DateTime
* @type {Boolean}
*/
Observable.defineObservableProperty(DateTime.prototype, 'currentHour12', {
readonly: true,
value: true
});
/**
* Init DateTime module.
*
* @access private
* @memberOf DateTime
*/
DateTime.prototype._init = function() {
this._mozTime = window.navigator.mozTime;
this._boundSetTimeAutoEnabled = function(event) {
this._clockAutoEnabled = event.settingValue;
// also set timezoneAutoEnabled
this._setTimezoneAutoEnabled(event.settingValue);
}.bind(this);
this._boundSetClockAutoAvailable = function(event) {
this._clockAutoAvailable = event.settingValue;
}.bind(this);
this._boundSetTimezoneAutoAvailable = function(event) {
this._timezoneAutoAvailable = event.settingValue;
}.bind(this);
this._boundSetTimezone = function(event) {
this._timezone = event.settingValue;
}.bind(this);
this._boundUserSelectedTimezone = function(event) {
this._userSelectedTimezone = event.settingValue;
}.bind(this);
this._boundCurrentHour12 = function(event) {
this._currentHour12 = event.settingValue;
this._autoUpdateDateTime();
}.bind(this);
this._getDefaults();
this._attachListeners();
};
DateTime.prototype._attachListeners = function() {
window.navigator.mozSettings.addObserver(_kClockAutoEnabled,
this._boundSetTimeAutoEnabled);
window.navigator.mozSettings.addObserver(_kClockAutoAvailable,
this._boundSetClockAutoAvailable);
window.navigator.mozSettings.addObserver(_kTimezoneAutoAvailable,
this._boundSetTimezoneAutoAvailable);
window.navigator.mozSettings.addObserver(_kTimezone,
this._boundSetTimezone);
window.navigator.mozSettings.addObserver(_kUserSelected,
this._boundUserSelectedTimezone);
window.navigator.mozSettings.addObserver(_kLocaleTime,
this._boundCurrentHour12);
// Listen to 'DOMRetranslated' to get the latest l10n resource.
document.addEventListener('DOMRetranslated', this);
// Listen to 'moztimechange' to update clock
window.addEventListener('moztimechange', this);
};
DateTime.prototype.handleEvent = function(evt) {
switch (evt.type) {
case 'moztimechange':
this._autoUpdateDateTime();
break;
case 'DOMRetranslated':
var d = new Date();
this._date = this._formatDate(d);
this._time = this._formatTime(d);
break;
}
};
/**
* fill in default values to public observers
*
* @access private
* @memberOf DateTime
*/
DateTime.prototype._getDefaults = function() {
SettingsCache.getSettings(function(results) {
this._clockAutoEnabled = results[_kClockAutoEnabled];
this._clockAutoAvailable = results[_kClockAutoAvailable];
this._timezoneAutoAvailable = results[_kTimezoneAutoAvailable];
this._timezone = results[_kTimezone];
this._currentHour12 = results[_kLocaleTime];
// render date/time after get proper format
this._autoUpdateDateTime();
}.bind(this));
};
/**
* Auto update date and time
*
* @access private
* @memberOf DateTime
*/
DateTime.prototype._autoUpdateDateTime = function() {
window.clearTimeout(_updateDateTimeout);
window.clearTimeout(_updateTimeTimeout);
this._autoUpdateDate();
this._autoUpdateTime();
};
/**
* Update Date periodically.
*
* DONT call this function directly,
* call _autoUpdateDateTime instead.
*
* @access private
* @memberOf DateTime
*/
DateTime.prototype._autoUpdateDate = function() {
var d = new Date();
this._date = this._formatDate(d);
var remainMillisecond = (24 - d.getHours()) * 3600 * 1000 -
d.getMinutes() * 60 * 1000 - d.getMilliseconds();
_updateDateTimeout = window.setTimeout(
function updateDateTimeout() {
this._autoUpdateDate();
}.bind(this), remainMillisecond);
};
/**
* Update Time periodically.
*
* DONT call this function directly,
* call _autoUpdateDateTime instead.
*
* @access private
* @memberOf DateTime
*/
DateTime.prototype._autoUpdateTime = function() {
var d = new Date();
this._time = this._formatTime(d);
var remainMillisecond = (60 - d.getSeconds()) * 1000;
_updateTimeTimeout = window.setTimeout(
function updateTimeTimeout() {
this._autoUpdateTime();
}.bind(this), remainMillisecond);
};
/**
* Return preffered date format.
*
* @access private
* @memberOf DateTime
* @param {Date} input as Date
* @returns {String}
*/
DateTime.prototype._formatDate = function(d) {
return d.toLocaleString(navigator.languages, {
year: 'numeric',
month: 'long',
day: '2-digit',
});
};
/**
* Return preffered time format.
*
* @access private
* @memberOf DateTime
* @param {Date} input as Date
* @returns {String}
*/
DateTime.prototype._formatTime = function(d) {
return d.toLocaleString(navigator.languages, {
hour12: navigator.mozHour12,
hour: 'numeric',
minute: 'numeric',
});
};
/**
* Change system time
*
* @access public
* @memberOf DateTime
*/
DateTime.prototype.setTime = function(type, value) {
var newDate = new Date();
var tokens;
switch (type) {
case 'date':
tokens = value.split('-');
newDate.setFullYear(Number(tokens[0]), Number(tokens[1]) - 1, Number(tokens[2]));
break;
case 'time':
tokens = value.split(':');
newDate.setHours(parseInt(tokens[0]));
newDate.setMinutes(parseInt(tokens[1]));
break;
}
if (!this._mozTime) return;
return this._mozTime.set(newDate);
};
/**
* Change TimezoneAutoEnabled settings.
*
* @access private
* @memberOf DateTime
*/
DateTime.prototype._setTimezoneAutoEnabled = function(enabled) {
var cset = {};
cset[_kTimezoneAutoEnabled] = enabled;
settings.createLock().set(cset);
},
/**
* Change UserSelectedTimezone settings.
*
* @access public
* @memberOf DateTime
*/
DateTime.prototype.setUserSelectedTimezone = function(selected) {
var cset = {};
cset[_kTimezone] = selected;
settings.createLock().set(cset);
};
/**
* Change hour12 settings.
* this valie is used to determine 12 or 24 time format.
*
* @access public
* @memberOf DateTime
*/
DateTime.prototype.setCurrentHour12 = function(selected) {
var cset = {};
cset[_kLocaleTime] = selected;
settings.createLock().set(cset);
};
DateTime.prototype.getHour12ForCurrentLocale = function() {
var format = new Intl.DateTimeFormat(navigator.languages, {
hour: 'numeric'
});
return format.resolvedOptions().hour12;
};
return DateTime();
});

View file

@ -0,0 +1,483 @@
define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
'use strict';
var PanelCache = require('modules/panel_cache');
var LazyLoader = require('shared/lazy_loader');
var DialogManager = function() {
this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
};
DialogManager.prototype = {
/**
* load panel based on passed in panelId
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_loadPanel: function dm__loadPanel(panelId) {
var promise = new Promise(function(resolve, reject) {
var panelElement = document.getElementById(panelId);
if (panelElement.dataset.rendered) { // already initialized
resolve();
return;
}
panelElement.dataset.rendered = true;
// XXX remove SubPanel loader once sub panel are modulized
if (panelElement.dataset.requireSubPanels) {
// load the panel and its sub-panels (dependencies)
// (load the main panel last because it contains the scripts)
var selector = 'section[id^="' + panelElement.id + '-"]';
var subPanels = document.querySelectorAll(selector);
for (var i = 0, il = subPanels.length; i < il; i++) {
LazyLoader.load([subPanels[i]]);
}
LazyLoader.load([panelElement], resolve);
} else {
LazyLoader.load([panelElement], resolve);
}
});
return promise;
},
/**
* promised version of PanelCache.get()
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_getPanel: function dm__getPanel(panelId) {
var promise = new Promise(function(resolve) {
PanelCache.get(panelId, function(panel) {
resolve(panel);
});
});
return promise;
},
/**
* this is used to control visibility of overlayDOM
*
* @memberOf DialogManager
* @access private
* @param {Boolean} show
*/
_showOverlay: function dm__showOverlay(show) {
this._overlayDOM.hidden = !show;
},
/**
* It is used to control the timing of transitions so that we can make sure
* whether animation is done or not.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_transit: function dm__transit(method, dialog, options) {
var promise = new Promise(function(resolve) {
var panel = dialog.panel;
var completed = false;
var fallback;
function finish(event) {
if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
if (completed) return;
completed = true;
clearTimeout(fallback);
panel.removeEventListener('transitionend', finish);
panel.removeEventListener('transitioncancel', finish);
if (method === 'close') panel.hidden = true;
resolve();
}
if (method === 'open') panel.hidden = false;
setTimeout(function() {
panel.addEventListener('transitionend', finish);
panel.addEventListener('transitioncancel', finish);
panel.classList.toggle('current', method === 'open');
var style = window.getComputedStyle(panel);
function milliseconds(value) {
return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
}
var durations = style.transitionDuration.split(',').map(milliseconds);
var delays = style.transitionDelay.split(',').map(milliseconds);
var duration = Math.max.apply(Math, durations.map(function(value, index) {
return value + delays[index % delays.length];
}));
// Reduced motion, display changes or interrupted transitions may
// omit the visibility event. Always release the dialog queue.
fallback = setTimeout(finish, Math.max(0, duration) + 100);
}, 150);
});
return promise;
},
/**
* Do necessary works to open panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_open: function dm__open(dialog, options) {
var self = this;
var foundPanel;
return Promise.resolve()
.then(function() {
// 1: load panel
return self._loadPanel(dialog.panel.id);
})
.then(function() {
// 2: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 3: call beforeShow
foundPanel = panel;
return foundPanel.beforeShow(dialog.panel, options);
})
.then(function() {
// 4. UI stuffs + transition
dialog.init();
dialog.initUI();
dialog.bindEvents();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(true);
}
return self._transit('open', dialog, options);
})
.then(function() {
// 5. show that panel as a dialog
return foundPanel.show(dialog.panel, options);
});
},
/**
* Do necessary works to close panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_close: function dm__close(dialog, options) {
var self = this;
var foundPanel;
var cachedResult;
return Promise.resolve()
.then(function() {
// 1: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 2: Let's validate to see whether we can close this dialog or not.
foundPanel = panel;
var promise;
// custom dialog - onSubmit
if (foundPanel.onSubmit && options._type === 'submit') {
promise = foundPanel.onSubmit();
// custom dialog - onCancel
} else if (foundPanel.onCancel && options._type === 'cancel') {
promise = foundPanel.onCancel();
// if no onSubmit & onCancel, pass directly
} else {
promise = Promise.resolve();
}
return promise;
})
.then(function(result) {
cachedResult = result;
// 3: call beforeHide
return foundPanel.beforeHide();
})
.then(function() {
// 4. transition
return self._transit('close', dialog, options);
})
.then(function() {
// 5. call hide
return foundPanel.hide();
})
.then(function() {
// 6. Get result and cleanup dialog
var result;
// for prompt dialog, we have to get its own result from input text.
if (dialog.DIALOG_CLASS === 'prompt-dialog') {
result = dialog.getResult();
} else if (cachedResult) {
result = cachedResult;
}
dialog.cleanup();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(false);
}
return result;
});
},
/**
* It is a bridge to call open or close function.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_navigate: function dm__navigate(method, dialog, options) {
method = (method === 'open') ? '_open' : '_close';
return this[method](dialog, options);
},
/**
* DialogService would use this exposed API to open dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
open: function dm_open(dialog, options) {
return this._navigate('open', dialog, options);
},
/**
* DialogService would use this exposed API to close dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
close: function dm_close(dialog, type, options) {
options._type = type;
return this._navigate('close', dialog, options);
}
};
var dialogManager = new DialogManager();
return dialogManager;
});
define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
'use strict';
var Settings = require('settings');
var Defer = require('modules/defer');
var DialogManager = require('modules/dialog_manager');
var PanelDialog = require('modules/dialog/panel_dialog');
var AlertDialog = require('modules/dialog/alert_dialog');
var ConfirmDialog = require('modules/dialog/confirm_dialog');
var PromptDialog = require('modules/dialog/prompt_dialog');
var DialogService = function() {
this._navigating = false;
this._pendingRequests = [];
this._settingsAlertDialogId = 'settings-alert-dialog';
this._settingsBaseDialogId = 'settings-base-dialog';
this._settingsConfirmDialogId = 'settings-confirm-dialog';
this._settingsPromptDialogId = 'settings-prompt-dialog';
};
DialogService.prototype = {
/**
* Alert dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
alert: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsAlertDialogId, {
type: 'alert',
message: message,
title: options.title,
submitButton: options.submitButton
});
},
/**
* Confirm dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
confirm: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsConfirmDialogId, {
type: 'confirm',
message: message,
title: options.title,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Prompt dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
prompt: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsPromptDialogId, {
type: 'prompt',
message: message,
title: options.title,
defaultValue: options.defaultValue,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Panel dialog. If you are going to show any panel as a dialog,
* you have to use this method to show them.
*
* @memberOf DialogService
* @access public
* @param {String} panelId
* @param {Object} userOptions
* @return {Promise}
*/
show: function dm_show(panelId, userOptions, _pendingDefer) {
var self = this;
var defer;
var dialog;
var dialogDOM = document.getElementById(panelId);
var currentPanel = Settings.currentPanel;
var options = userOptions || {};
if (_pendingDefer) {
defer = _pendingDefer;
} else {
defer = Defer();
}
if (this._navigating) {
this._pendingRequests.push({
defer: defer,
panelId: panelId,
userOptions: userOptions
});
} else {
if ('#' + panelId === currentPanel) {
defer.reject('You are showing the same panel #' + panelId);
} else {
var opening;
var closing = false;
function close(type) {
if (closing) return;
closing = true;
// A tap during the opening animation must not run hide/show
// concurrently, or a queued dialog can inherit stale form values.
Promise.resolve(opening).then(function() {
return DialogManager.close(dialog, type, options);
}).then(function(result) {
self._navigating = false;
defer.resolve({type: type, value: result});
self._execPendingRequest();
}, function(error) {
// Panel validation rejects to keep the form open. Allow a
// correction or cancellation without rejecting the show promise.
closing = false;
dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
detail: error
}));
});
}
options.onWrapSubmit = function() { close('submit'); };
options.onWrapCancel = function() { close('cancel'); };
switch (options.type) {
case 'alert':
dialog = AlertDialog(dialogDOM, options);
break;
case 'confirm':
dialog = ConfirmDialog(dialogDOM, options);
break;
case 'prompt':
dialog = PromptDialog(dialogDOM, options);
break;
default:
dialog = PanelDialog(dialogDOM, options);
break;
}
this._navigating = true;
opening = DialogManager.open(dialog, options);
opening.catch(function(error) {
// A missing host API must not lock every later Settings dialog.
dialogDOM.classList.remove('current');
dialogDOM.hidden = true;
var overlay = document.querySelector('.settings-dialog-overlay');
if (overlay) overlay.hidden = true;
self._navigating = false;
defer.reject(error);
self._execPendingRequest();
});
}
}
return defer.promise;
},
/**
* This method can help us pop up any pending request and would try to
* show it after previous request was done.
*
* @memberOf DialogService
* @access private
*/
_execPendingRequest: function() {
var request = this._pendingRequests.pop();
if (request) {
this.show(request.panelId, request.userOptions, request.defer);
}
}
};
var dialogService = new DialogService();
return dialogService;
});

View file

@ -1,4 +1,52 @@
/**
* Update setting value based on slider position.
* Base on sound panel's implementation.
*
* @module SliderHandler
*/
/**
* Update setting value based on slider position.
* Base on sound panel's implementation.
*
* @module SliderHandler
*/
/**
* Update setting value based on slider position.
* Base on sound panel's implementation.
*
* @module SliderHandler
*/
/**
* Update setting value based on slider position.
* Base on sound panel's implementation.
*
* @module SliderHandler
*/
/**
* Update setting value based on slider position.
* Base on sound panel's implementation.
*
* @module SliderHandler
*/
/**
* Update setting value based on slider position.
* Base on sound panel's implementation.
*
* @module SliderHandler
*/
/**
* Update setting value based on slider position.
* Base on sound panel's implementation.
*
* @module SliderHandler
*/
/**
* Update setting value based on slider position.
* Base on sound panel's implementation.
*
* @module SliderHandler
*/
/** /**
* Update setting value based on slider position. * Update setting value based on slider position.
* Base on sound panel's implementation. * Base on sound panel's implementation.
@ -128,6 +176,5 @@ define('panels/display/slider_handler',["require"],function(require) {
return new SliderHandler(); return new SliderHandler();
}; };
}); });
define('panels/display/display',['require','shared/settings_listener','panels/display/slider_handler'],function(require){'use strict';var SettingsListener=require('shared/settings_listener');var SliderHandler=require('panels/display/slider_handler');const AUTO_BRIGHTNESS_SETTING='screen.automatic-brightness';const SCREEN_BRIGHTNESS='screen.brightness';var Display=function(){this.elements=null;};Display.prototype={init:function d_init(elements,data){this.elements=elements;this.initBrightnessItems(data);SliderHandler().init(elements.brightnessManualInput,SCREEN_BRIGHTNESS);},initBrightnessItems:function d_init_brightness_items(data){var brightnessAuto=this.elements.brightnessAuto;var brightnessAutoCheckbox=this.elements.brightnessAutoCheckbox;var brightnessManual=this.elements.brightnessManual;var brightnessManualInput=this.elements.brightnessManualInput;if(data.ambientLight){brightnessAuto.hidden=false;SettingsListener.observe(AUTO_BRIGHTNESS_SETTING,false,function(value){brightnessAutoCheckbox.checked=value;brightnessManual.hidden=value;}.bind(this));}else{brightnessAuto.hidden=true;brightnessManual.hidden=false;var cset={};cset[AUTO_BRIGHTNESS_SETTING]=false;SettingsListener.getSettingsLock().set(cset);} define('panels/display/display',['require','shared/settings_listener','panels/display/slider_handler'],function(require){'use strict';var SettingsListener=require('shared/settings_listener');var SliderHandler=require('panels/display/slider_handler');const AUTO_BRIGHTNESS_SETTING='screen.automatic-brightness';const SCREEN_BRIGHTNESS='screen.brightness';var Display=function(){this.elements=null;};Display.prototype={init:function d_init(elements,data){this.elements=elements;this.initBrightnessItems(data);SliderHandler().init(elements.brightnessManualInput,SCREEN_BRIGHTNESS);},initBrightnessItems:function d_init_brightness_items(data){var brightnessAuto=this.elements.brightnessAuto;var brightnessAutoCheckbox=this.elements.brightnessAutoCheckbox;var brightnessManual=this.elements.brightnessManual;var brightnessManualInput=this.elements.brightnessManualInput;if(data.ambientLight){brightnessAuto.hidden=false;SettingsListener.observe(AUTO_BRIGHTNESS_SETTING,false,function(value){brightnessAutoCheckbox.checked=value;brightnessManual.hidden=value;}.bind(this));}else{brightnessAuto.hidden=true;brightnessManual.hidden=false;var cset={};cset[AUTO_BRIGHTNESS_SETTING]=false;SettingsListener.getSettingsLock().set(cset);}
SettingsListener.observe(SCREEN_BRIGHTNESS,0.5,function(value){brightnessManualInput.value=value;}.bind(this));}};return function ctor_display(){return new Display();};});define('panels/display/panel',['require','modules/settings_panel','panels/display/display','shared/lazy_loader'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var DisplayModule=require('panels/display/display');var LazyLoader=require('shared/lazy_loader');var displayElements={};return function ctor_display_panel(){var display=DisplayModule();return SettingsPanel({onInit:function dp_onInit(panel){displayElements={brightnessManual:panel.querySelector('.brightness-manual'),brightnessManualInput:panel.querySelector('.brightness-manual input'),brightnessAuto:panel.querySelector('.brightness-auto'),brightnessAutoCheckbox:panel.querySelector('.brightness-auto gaia-checkbox')};LazyLoader.getJSON('/resources/device-features.json').then(function(data){display.init(displayElements,data);});}});};}); SettingsListener.observe(SCREEN_BRIGHTNESS,0.5,function(value){brightnessManualInput.value=value;}.bind(this));}};return function ctor_display(){return new Display();};});define('panels/display/panel',['require','modules/settings_panel','panels/display/display','shared/lazy_loader'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var DisplayModule=require('panels/display/display');var LazyLoader=require('shared/lazy_loader');var displayElements={};return function ctor_display_panel(){var display=DisplayModule();return SettingsPanel({onInit:function dp_onInit(panel){displayElements={brightnessManual:panel.querySelector('.brightness-manual'),brightnessManualInput:panel.querySelector('.brightness-manual input'),brightnessAuto:panel.querySelector('.brightness-auto'),brightnessAutoCheckbox:panel.querySelector('.brightness-auto gaia-checkbox')};LazyLoader.getJSON('/resources/device-features.json').then(function(data){display.init(displayElements,data);});}});};});

View file

@ -0,0 +1,492 @@
define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
'use strict';
var PanelCache = require('modules/panel_cache');
var LazyLoader = require('shared/lazy_loader');
var DialogManager = function() {
this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
};
DialogManager.prototype = {
/**
* load panel based on passed in panelId
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_loadPanel: function dm__loadPanel(panelId) {
var promise = new Promise(function(resolve, reject) {
var panelElement = document.getElementById(panelId);
if (panelElement.dataset.rendered) { // already initialized
resolve();
return;
}
panelElement.dataset.rendered = true;
// XXX remove SubPanel loader once sub panel are modulized
if (panelElement.dataset.requireSubPanels) {
// load the panel and its sub-panels (dependencies)
// (load the main panel last because it contains the scripts)
var selector = 'section[id^="' + panelElement.id + '-"]';
var subPanels = document.querySelectorAll(selector);
for (var i = 0, il = subPanels.length; i < il; i++) {
LazyLoader.load([subPanels[i]]);
}
LazyLoader.load([panelElement], resolve);
} else {
LazyLoader.load([panelElement], resolve);
}
});
return promise;
},
/**
* promised version of PanelCache.get()
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_getPanel: function dm__getPanel(panelId) {
var promise = new Promise(function(resolve) {
PanelCache.get(panelId, function(panel) {
resolve(panel);
});
});
return promise;
},
/**
* this is used to control visibility of overlayDOM
*
* @memberOf DialogManager
* @access private
* @param {Boolean} show
*/
_showOverlay: function dm__showOverlay(show) {
this._overlayDOM.hidden = !show;
},
/**
* It is used to control the timing of transitions so that we can make sure
* whether animation is done or not.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_transit: function dm__transit(method, dialog, options) {
var promise = new Promise(function(resolve) {
var panel = dialog.panel;
var completed = false;
var fallback;
function finish(event) {
if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
if (completed) return;
completed = true;
clearTimeout(fallback);
panel.removeEventListener('transitionend', finish);
panel.removeEventListener('transitioncancel', finish);
if (method === 'close') panel.hidden = true;
resolve();
}
if (method === 'open') panel.hidden = false;
setTimeout(function() {
panel.addEventListener('transitionend', finish);
panel.addEventListener('transitioncancel', finish);
panel.classList.toggle('current', method === 'open');
var style = window.getComputedStyle(panel);
function milliseconds(value) {
return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
}
var durations = style.transitionDuration.split(',').map(milliseconds);
var delays = style.transitionDelay.split(',').map(milliseconds);
var duration = Math.max.apply(Math, durations.map(function(value, index) {
return value + delays[index % delays.length];
}));
// Reduced motion, display changes or interrupted transitions may
// omit the visibility event. Always release the dialog queue.
fallback = setTimeout(finish, Math.max(0, duration) + 100);
}, 150);
});
return promise;
},
/**
* Do necessary works to open panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_open: function dm__open(dialog, options) {
var self = this;
var foundPanel;
return Promise.resolve()
.then(function() {
// 1: load panel
return self._loadPanel(dialog.panel.id);
})
.then(function() {
// 2: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 3: call beforeShow
foundPanel = panel;
return foundPanel.beforeShow(dialog.panel, options);
})
.then(function() {
// 4. UI stuffs + transition
dialog.init();
dialog.initUI();
dialog.bindEvents();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(true);
}
return self._transit('open', dialog, options);
})
.then(function() {
// 5. show that panel as a dialog
return foundPanel.show(dialog.panel, options);
});
},
/**
* Do necessary works to close panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_close: function dm__close(dialog, options) {
var self = this;
var foundPanel;
var cachedResult;
return Promise.resolve()
.then(function() {
// 1: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 2: Let's validate to see whether we can close this dialog or not.
foundPanel = panel;
var promise;
// custom dialog - onSubmit
if (foundPanel.onSubmit && options._type === 'submit') {
promise = foundPanel.onSubmit();
// custom dialog - onCancel
} else if (foundPanel.onCancel && options._type === 'cancel') {
promise = foundPanel.onCancel();
// if no onSubmit & onCancel, pass directly
} else {
promise = Promise.resolve();
}
return promise;
})
.then(function(result) {
cachedResult = result;
// 3: call beforeHide
return foundPanel.beforeHide();
})
.then(function() {
// 4. transition
return self._transit('close', dialog, options);
})
.then(function() {
// 5. call hide
return foundPanel.hide();
})
.then(function() {
// 6. Get result and cleanup dialog
var result;
// for prompt dialog, we have to get its own result from input text.
if (dialog.DIALOG_CLASS === 'prompt-dialog') {
result = dialog.getResult();
} else if (cachedResult) {
result = cachedResult;
}
dialog.cleanup();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(false);
}
return result;
});
},
/**
* It is a bridge to call open or close function.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_navigate: function dm__navigate(method, dialog, options) {
method = (method === 'open') ? '_open' : '_close';
return this[method](dialog, options);
},
/**
* DialogService would use this exposed API to open dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
open: function dm_open(dialog, options) {
return this._navigate('open', dialog, options);
},
/**
* DialogService would use this exposed API to close dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
close: function dm_close(dialog, type, options) {
options._type = type;
return this._navigate('close', dialog, options);
}
};
var dialogManager = new DialogManager();
return dialogManager;
});
define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
'use strict';
var Settings = require('settings');
var Defer = require('modules/defer');
var DialogManager = require('modules/dialog_manager');
var PanelDialog = require('modules/dialog/panel_dialog');
var AlertDialog = require('modules/dialog/alert_dialog');
var ConfirmDialog = require('modules/dialog/confirm_dialog');
var PromptDialog = require('modules/dialog/prompt_dialog');
var DialogService = function() {
this._navigating = false;
this._pendingRequests = [];
this._settingsAlertDialogId = 'settings-alert-dialog';
this._settingsBaseDialogId = 'settings-base-dialog';
this._settingsConfirmDialogId = 'settings-confirm-dialog';
this._settingsPromptDialogId = 'settings-prompt-dialog';
};
DialogService.prototype = {
/**
* Alert dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
alert: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsAlertDialogId, {
type: 'alert',
message: message,
title: options.title,
submitButton: options.submitButton
});
},
/**
* Confirm dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
confirm: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsConfirmDialogId, {
type: 'confirm',
message: message,
title: options.title,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Prompt dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
prompt: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsPromptDialogId, {
type: 'prompt',
message: message,
title: options.title,
defaultValue: options.defaultValue,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Panel dialog. If you are going to show any panel as a dialog,
* you have to use this method to show them.
*
* @memberOf DialogService
* @access public
* @param {String} panelId
* @param {Object} userOptions
* @return {Promise}
*/
show: function dm_show(panelId, userOptions, _pendingDefer) {
var self = this;
var defer;
var dialog;
var dialogDOM = document.getElementById(panelId);
var currentPanel = Settings.currentPanel;
var options = userOptions || {};
if (_pendingDefer) {
defer = _pendingDefer;
} else {
defer = Defer();
}
if (this._navigating) {
this._pendingRequests.push({
defer: defer,
panelId: panelId,
userOptions: userOptions
});
} else {
if ('#' + panelId === currentPanel) {
defer.reject('You are showing the same panel #' + panelId);
} else {
var opening;
var closing = false;
function close(type) {
if (closing) return;
closing = true;
// A tap during the opening animation must not run hide/show
// concurrently, or a queued dialog can inherit stale form values.
Promise.resolve(opening).then(function() {
return DialogManager.close(dialog, type, options);
}).then(function(result) {
self._navigating = false;
defer.resolve({type: type, value: result});
self._execPendingRequest();
}, function(error) {
// Panel validation rejects to keep the form open. Allow a
// correction or cancellation without rejecting the show promise.
closing = false;
dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
detail: error
}));
});
}
options.onWrapSubmit = function() { close('submit'); };
options.onWrapCancel = function() { close('cancel'); };
switch (options.type) {
case 'alert':
dialog = AlertDialog(dialogDOM, options);
break;
case 'confirm':
dialog = ConfirmDialog(dialogDOM, options);
break;
case 'prompt':
dialog = PromptDialog(dialogDOM, options);
break;
default:
dialog = PanelDialog(dialogDOM, options);
break;
}
this._navigating = true;
opening = DialogManager.open(dialog, options);
opening.catch(function(error) {
// A missing host API must not lock every later Settings dialog.
dialogDOM.classList.remove('current');
dialogDOM.hidden = true;
var overlay = document.querySelector('.settings-dialog-overlay');
if (overlay) overlay.hidden = true;
self._navigating = false;
defer.reject(error);
self._execPendingRequest();
});
}
}
return defer.promise;
},
/**
* This method can help us pop up any pending request and would try to
* show it after previous request was done.
*
* @memberOf DialogService
* @access private
*/
_execPendingRequest: function() {
var request = this._pendingRequests.pop();
if (request) {
this.show(request.panelId, request.userOptions, request.defer);
}
}
};
var dialogService = new DialogService();
return dialogService;
});
define('panels/feedback_send/feedback_send',['require','modules/dialog_service','modules/settings_service','modules/settings_cache','shared/async_storage'],function(require){'use strict';var DialogService=require('modules/dialog_service');var SettingsService=require('modules/settings_service');var SettingsCache=require('modules/settings_cache');require('shared/async_storage');var SendFeedback=function(){};SendFeedback.prototype={_SettingsCache:SettingsCache,_SettingsService:SettingsService,init:function(elements){this.elements=elements;this.options={};this._sendData={product:'Firefox OS',platform:'Firefox OS'};this._showEmail=false;},_isHappy:function(){var happy=this.options&&this.options.feel;return happy==='feedback-happy';},updateTitle:function(){var id='feedback_whyfeel_'+(this._isHappy()?'happy':'sad');this.elements.title.setAttribute('data-l10n-id',id);},getPreviousInputs:function(){window.asyncStorage.getItem('feedback',function(value){this._inputData=value||{};}.bind(this));},keepAllInputs:function(){window.asyncStorage.setItem('feedback',this._inputData);},get _inputData(){return{description:this.elements.description.value,email:this.elements.emailInput.value,emailEnable:this._showEmail};},set _inputData(data){this.elements.description.value=data.description||'';this.elements.emailInput.value=data.email||'';this._showEmail=!data.emailEnable;this.enableEmail();},send:function(){this.elements.sendBtn.disabled=true;if(!navigator.onLine){this._messageHandler('connect-error');return;}
var emailBar=this.elements.emailColumn;var emailInput=this.elements.emailInput;var contextInput=this.elements.description;if(contextInput.textLength===0){this._messageHandler('empty-comment');return;}else{this._sendData.description=contextInput.value;}
if(!emailBar.hidden){this._sendData.email=emailInput.value;}else{delete this._sendData.email;}
if(!emailBar.hidden&&(!emailInput.value.length||!emailInput.validity.valid)){this._messageHandler('wrong-email');return;}
var currentSetting=this._SettingsCache.cache;var feedbackUrl=currentSetting['feedback.url'];this._sendData.version=currentSetting['deviceinfo.os'];this._sendData.device=currentSetting['deviceinfo.hardware'];this._sendData.locale=currentSetting['language.current'];this._sendData.happy=this._isHappy();this._xhr=new XMLHttpRequest({mozSystem:true});this._xhr.open('POST',feedbackUrl,true);this._xhr.setRequestHeader('Content-type','application/json');this._xhr.timeout=5000;this._xhr.onload=this._responseHandler.bind(this);this._xhr.ontimeout=function(){this._messageHandler('timeout');}.bind(this);this._xhr.send(JSON.stringify(this._sendData));},enableEmail:function(){var original=this._showEmail;this._showEmail=!original;this.elements.emailEnable.checked=!original;this.elements.emailColumn.hidden=original;},back:function(){this.keepAllInputs();this._SettingsService.navigate('improveBrowserOS-chooseFeedback');},_responseHandler:function(){switch(this._xhr.status){case 201:this._messageHandler('success');break;case 429:this._messageHandler('success');break;case 404:this._messageHandler('server-off');break;case 400:this._messageHandler('unknown-error');break;default:this._messageHandler('unknown-error');break;}},_messageHandler:function(type){if(type==='success'){this._openDoneDialog();}else{this.keepAllInputs();this._openAlertDialog(type);}
this.elements.sendBtn.disabled=false;},_openAlertDialog:function(type){DialogService.alert({id:'feedback-errormessage-'+type},{submitButton:{id:'ok',style:'full'}});},_openDoneDialog:function(){DialogService.alert({id:'feedback-complete-msg'},{title:'sendFeedbackTitle',submitButton:{id:'done',style:'full'}}).then(function(){this._SettingsService.navigate('improveBrowserOS');}.bind(this));}};return function ctor_send_feedback(){return new SendFeedback();};});define('panels/feedback_send/panel',['require','modules/settings_panel','panels/feedback_send/feedback_send'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var SendFeedback=require('panels/feedback_send/feedback_send');return function ctor_sendFeedbackPanel(){var elements={};var sendFeedback=SendFeedback();var eventMapping=[{elementName:'sendBtn',eventType:'click',methodName:'send'},{elementName:'emailEnable',eventType:'change',methodName:'enableEmail'},{elementName:'header',eventType:'action',methodName:'back'}];function bindEvents(elements){eventMapping.forEach(function(map){map.method=sendFeedback[map.methodName].bind(sendFeedback);elements[map.elementName].addEventListener(map.eventType,map.method);});}
function unbindEvents(elements){eventMapping.forEach(function(map){if(!map.method){return;}
elements[map.elementName].removeEventListener(map.eventType,map.method);});}
return SettingsPanel({onInit:function(panel){elements={title:panel.querySelector('#feedback-title'),description:panel.querySelector('#feedback-description'),emailInput:panel.querySelector('#feedback-email'),emailColumn:panel.querySelector('#feedback-emailbar'),emailEnable:panel.querySelector('#email-enable'),sendBtn:panel.querySelector('#feedback-send-btn'),header:panel.querySelector('#feedback-header')};sendFeedback.init(elements);},onBeforeShow:function(panel,options){bindEvents(elements);sendFeedback.options=options;sendFeedback.updateTitle();sendFeedback.getPreviousInputs();},onBeforeHide:function(){unbindEvents(elements);if(document.hidden){sendFeedback.keepAllInputs();}}});};});

View file

@ -0,0 +1,508 @@
var Normalizer={initAsciiNormalizer:function normalizer_init(){var equivalentChars={'a':'áăǎâäȧạȁàảȃāąåḁⱥãǽǣæ','A':'ÁĂǍÂÄȦẠȀÀẢȂĀĄÅḀȺÃǼǢÆ','b':'ḃḅɓḇƀƃ','B':'ḂḄƁḆɃƂ','c':'ćčçĉċƈȼ','C':'ĆČÇĈĊƇȻ','d':'ďḑḓḋḍɗḏđƌð','D':'ĎḐḒḊḌƊḎĐƋ','e':'éĕěȩêḙëėẹȅèẻȇēę','E':'ÉĔĚȨÊḘËĖẸȄÈẺȆĒĘ','f':'ḟƒ','F':'ḞƑ','g':'ǵğǧģĝġɠḡǥ','G':'ǴĞǦĢĜĠƓḠǤ','h':'ḫȟḩĥⱨḧḣḥħ','H':'ḪȞḨĤⱧḦḢḤĦ','i':'íĭǐîïịȉìỉȋīįɨĩḭı','I':'ÍĬǏÎÏỊȈÌỈȊĪĮƗĨḬ','j':'ĵɉ','J':'ĴɈ','k':'ḱǩķⱪꝃḳƙḵꝁ','K':'ḰǨĶⱩꝂḲƘḴꝀ','l':'ĺƚľļḽḷⱡꝉḻŀɫł','L':'ĹȽĽĻḼḶⱠꝈḺĿⱢŁ','m':'ḿṁṃɱ','M':'ḾṀṂⱮ','n':'ńňņṋṅṇǹɲṉƞñ','N':'ŃŇŅṊṄṆǸƝṈȠÑ','o':'óŏǒôöȯọőȍòỏơȏꝋꝍōǫøõœ','O':'ÓŎǑÔÖȮỌŐȌÒỎƠȎꝊꝌŌǪØÕŒ','p':'ṕṗꝓƥᵽꝑ','P':'ṔṖꝒƤⱣꝐ','q':'ꝗ','Q':'Ꝗ','r':'ŕřŗṙṛȑȓṟɍɽ','R':'ŔŘŖṘṚȐȒṞɌⱤ','s':'śšşŝșṡṣß','S':'ŚŠŞŜȘṠṢ','t':'ťţṱțⱦṫṭƭṯʈŧ','T':'ŤŢṰȚȾṪṬƬṮƮŦ','u':'úŭǔûṷüṳụűȕùủưȗūųůũṵ','U':'ÚŬǓÛṶÜṲỤŰȔÙỦƯȖŪŲŮŨṴ','v':'ṿʋṽ','V':'ṾƲṼ','w':'ẃŵẅẇẉẁⱳ','W':'ẂŴẄẆẈẀⱲ','x':'ẍẋ','X':'ẌẊ','y':'ýŷÿẏỵỳƴỷỿȳɏỹ','Y':'ÝŶŸẎỴỲƳỶỾȲɎỸ','z':'źžẑⱬżẓȥẕƶ','Z':'ŹŽẐⱫŻẒȤẔƵ'};this._toAsciiForm={};for(var letter in equivalentChars){var accentedForms=equivalentChars[letter];for(var i=accentedForms.length-1;i>=0;i--){this._toAsciiForm[accentedForms[i]]=letter;}}},toAscii:function normalizer_toAscii(str){if(!str||typeof str!='string'){return'';}
if(!this._toAsciiForm){Normalizer.initAsciiNormalizer();}
var result='';for(var i=0,len=str.length;i<len;i++){result+=this._toAsciiForm[str.charAt(i)]||str.charAt(i);}
return result;},escapeHTML:function normalizer_escapeHTML(str,escapeQuotes){if(Array.isArray(str)){return Normalizer.escapeHTML(str.join(' '),escapeQuotes);}
if(!str||typeof str!='string'){return'';}
var escaped=str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');if(escapeQuotes){return escaped.replace(/"/g,'&quot;').replace(/'/g,'&#x27;');}
return escaped;},unescapeHTML:function normalizer_unescapeHTML(text){return text.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'"').replace(/&#x27;/g,'\'');},escapeRegExp:function normalizer_escapeRegExp(str){return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,'\\$&');}};define("shared/text_normalizer",(function(global){return function(){var ret,fn;return ret||global.Normalizer;};}(this)));var FxAccountsIACHelper=function FxAccountsIACHelper(){var DEFAULT_CONNECTION_STRING='fxa-mgmt';var default_rules={'manifestURLs':['app://system.gaiamobile.org/manifest.webapp','app://smart-system.gaiamobile.org/manifest.webapp']};var CONNECTION_STRING=DEFAULT_CONNECTION_STRING;var rules=default_rules;var port;var callbacks={};var eventListeners={};var init=function init(options){if(!options){return;}
if(options.keyword&&typeof(options.keyword)=='string'&&options.keyword.length>0){CONNECTION_STRING=options.keyword;}
if(options.rules&&typeof(options.rules)=='object'){rules=options.rules;}};var addEventListener=function addEventListener(eventName,listener){if(!eventListeners[eventName]){eventListeners[eventName]=[];}
eventListeners[eventName].push(listener);};var removeEventListener=function removeEventListener(eventName,listener){var listeners=eventListeners[eventName];if(!listeners){return;}
var index=listeners.indexOf(listener);if(index===-1){return;}
listeners.splice(index,1);};var reset=function reset(){CONNECTION_STRING=DEFAULT_CONNECTION_STRING;rules=default_rules;eventListeners={};callbacks={};port=null;};var onMessage=function onMessage(evt){if(evt&&evt.data){var message=evt.data;if(message.eventName){var listeners=eventListeners[message.eventName];if(!listeners){return;}
for(var listener in listeners){if(listeners[listener]&&typeof listeners[listener]==='function'){listeners[listener]();}}
return;}
var cbs,cb;if(message.methodName){cbs=callbacks[message.methodName];if(!cbs||!cbs.length){console.warn('No callbacks for method '+message.methodName);return;}
while(cbs.length){cb=cbs.shift();if(!message.error){cb.successCb(message.data);}else{cb.errorCb(message.error);}}}}else{console.error('Unknown');}};var getSelf=function getSelf(cb,error){var request=navigator.mozApps.getSelf();request.onsuccess=function onSuccess(evt){cb(evt.target.result);};request.onerror=function onError(){if(error&&typeof(error)==='function'){error();}};};var requestQueue=[];var isConnecting=false;var connect=function connect(cb){if(isConnecting){return requestQueue.push(cb);}
isConnecting=true;_connect(function onConnect(err){var next;isConnecting=false;if(typeof cb==='function'){cb(err);}
while(!!(next=requestQueue.shift())){next&&next(err);}});};var _connect=function _connect(callback){getSelf(function onApp(app){if(!app){return;}
app.connect(CONNECTION_STRING,rules).then(function(ports){if(!ports||ports.length!==1){return;}
port=ports[0];callback&&callback();});});};var sendMessage=function sendMessage(message,successCb,errorCb){if(port){_sendMessage(message,successCb,errorCb);return;}
connect(function(){_sendMessage(message,successCb,errorCb);});};var _sendMessage=function _sendMessage(message,successCb,errorCb){var name=message.name;if(!name){return;}
if(!callbacks[name]){callbacks[name]=[];}
callbacks[name].push({successCb:successCb,errorCb:errorCb});port.onmessage=onMessage;port.postMessage(message);};var getAccount=function getAccount(successCb,errorCb){sendMessage({'name':'getAccount'},successCb,errorCb);};var openFlow=function openFlow(successCb,errorCb){sendMessage({'name':'openFlow'},successCb,errorCb);};var logout=function logout(successCb,errorCb){sendMessage({'name':'logout'},successCb,errorCb);};var refreshAuthentication=function refreshAuthentication(email,successCb,errorCb){sendMessage({'name':'refreshAuthentication','email':email},successCb,errorCb);};var resendVerificationEmail=function resendVerificationEmail(email,successCb,errorCb){sendMessage({'name':'resendVerificationEmail','email':email},successCb,errorCb);};connect();return{'addEventListener':addEventListener,'getAccount':getAccount,'init':init,'logout':logout,'openFlow':openFlow,'refreshAuthentication':refreshAuthentication,'removeEventListener':removeEventListener,'resendVerificationEmail':resendVerificationEmail,'reset':reset};}();define("shared/fxa_iac_client",(function(global){return function(){var ret,fn;return ret||global.FxAccountsIACHelper;};}(this)));define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
'use strict';
var PanelCache = require('modules/panel_cache');
var LazyLoader = require('shared/lazy_loader');
var DialogManager = function() {
this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
};
DialogManager.prototype = {
/**
* load panel based on passed in panelId
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_loadPanel: function dm__loadPanel(panelId) {
var promise = new Promise(function(resolve, reject) {
var panelElement = document.getElementById(panelId);
if (panelElement.dataset.rendered) { // already initialized
resolve();
return;
}
panelElement.dataset.rendered = true;
// XXX remove SubPanel loader once sub panel are modulized
if (panelElement.dataset.requireSubPanels) {
// load the panel and its sub-panels (dependencies)
// (load the main panel last because it contains the scripts)
var selector = 'section[id^="' + panelElement.id + '-"]';
var subPanels = document.querySelectorAll(selector);
for (var i = 0, il = subPanels.length; i < il; i++) {
LazyLoader.load([subPanels[i]]);
}
LazyLoader.load([panelElement], resolve);
} else {
LazyLoader.load([panelElement], resolve);
}
});
return promise;
},
/**
* promised version of PanelCache.get()
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_getPanel: function dm__getPanel(panelId) {
var promise = new Promise(function(resolve) {
PanelCache.get(panelId, function(panel) {
resolve(panel);
});
});
return promise;
},
/**
* this is used to control visibility of overlayDOM
*
* @memberOf DialogManager
* @access private
* @param {Boolean} show
*/
_showOverlay: function dm__showOverlay(show) {
this._overlayDOM.hidden = !show;
},
/**
* It is used to control the timing of transitions so that we can make sure
* whether animation is done or not.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_transit: function dm__transit(method, dialog, options) {
var promise = new Promise(function(resolve) {
var panel = dialog.panel;
var completed = false;
var fallback;
function finish(event) {
if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
if (completed) return;
completed = true;
clearTimeout(fallback);
panel.removeEventListener('transitionend', finish);
panel.removeEventListener('transitioncancel', finish);
if (method === 'close') panel.hidden = true;
resolve();
}
if (method === 'open') panel.hidden = false;
setTimeout(function() {
panel.addEventListener('transitionend', finish);
panel.addEventListener('transitioncancel', finish);
panel.classList.toggle('current', method === 'open');
var style = window.getComputedStyle(panel);
function milliseconds(value) {
return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
}
var durations = style.transitionDuration.split(',').map(milliseconds);
var delays = style.transitionDelay.split(',').map(milliseconds);
var duration = Math.max.apply(Math, durations.map(function(value, index) {
return value + delays[index % delays.length];
}));
// Reduced motion, display changes or interrupted transitions may
// omit the visibility event. Always release the dialog queue.
fallback = setTimeout(finish, Math.max(0, duration) + 100);
}, 150);
});
return promise;
},
/**
* Do necessary works to open panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_open: function dm__open(dialog, options) {
var self = this;
var foundPanel;
return Promise.resolve()
.then(function() {
// 1: load panel
return self._loadPanel(dialog.panel.id);
})
.then(function() {
// 2: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 3: call beforeShow
foundPanel = panel;
return foundPanel.beforeShow(dialog.panel, options);
})
.then(function() {
// 4. UI stuffs + transition
dialog.init();
dialog.initUI();
dialog.bindEvents();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(true);
}
return self._transit('open', dialog, options);
})
.then(function() {
// 5. show that panel as a dialog
return foundPanel.show(dialog.panel, options);
});
},
/**
* Do necessary works to close panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_close: function dm__close(dialog, options) {
var self = this;
var foundPanel;
var cachedResult;
return Promise.resolve()
.then(function() {
// 1: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 2: Let's validate to see whether we can close this dialog or not.
foundPanel = panel;
var promise;
// custom dialog - onSubmit
if (foundPanel.onSubmit && options._type === 'submit') {
promise = foundPanel.onSubmit();
// custom dialog - onCancel
} else if (foundPanel.onCancel && options._type === 'cancel') {
promise = foundPanel.onCancel();
// if no onSubmit & onCancel, pass directly
} else {
promise = Promise.resolve();
}
return promise;
})
.then(function(result) {
cachedResult = result;
// 3: call beforeHide
return foundPanel.beforeHide();
})
.then(function() {
// 4. transition
return self._transit('close', dialog, options);
})
.then(function() {
// 5. call hide
return foundPanel.hide();
})
.then(function() {
// 6. Get result and cleanup dialog
var result;
// for prompt dialog, we have to get its own result from input text.
if (dialog.DIALOG_CLASS === 'prompt-dialog') {
result = dialog.getResult();
} else if (cachedResult) {
result = cachedResult;
}
dialog.cleanup();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(false);
}
return result;
});
},
/**
* It is a bridge to call open or close function.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_navigate: function dm__navigate(method, dialog, options) {
method = (method === 'open') ? '_open' : '_close';
return this[method](dialog, options);
},
/**
* DialogService would use this exposed API to open dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
open: function dm_open(dialog, options) {
return this._navigate('open', dialog, options);
},
/**
* DialogService would use this exposed API to close dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
close: function dm_close(dialog, type, options) {
options._type = type;
return this._navigate('close', dialog, options);
}
};
var dialogManager = new DialogManager();
return dialogManager;
});
define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
'use strict';
var Settings = require('settings');
var Defer = require('modules/defer');
var DialogManager = require('modules/dialog_manager');
var PanelDialog = require('modules/dialog/panel_dialog');
var AlertDialog = require('modules/dialog/alert_dialog');
var ConfirmDialog = require('modules/dialog/confirm_dialog');
var PromptDialog = require('modules/dialog/prompt_dialog');
var DialogService = function() {
this._navigating = false;
this._pendingRequests = [];
this._settingsAlertDialogId = 'settings-alert-dialog';
this._settingsBaseDialogId = 'settings-base-dialog';
this._settingsConfirmDialogId = 'settings-confirm-dialog';
this._settingsPromptDialogId = 'settings-prompt-dialog';
};
DialogService.prototype = {
/**
* Alert dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
alert: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsAlertDialogId, {
type: 'alert',
message: message,
title: options.title,
submitButton: options.submitButton
});
},
/**
* Confirm dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
confirm: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsConfirmDialogId, {
type: 'confirm',
message: message,
title: options.title,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Prompt dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
prompt: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsPromptDialogId, {
type: 'prompt',
message: message,
title: options.title,
defaultValue: options.defaultValue,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Panel dialog. If you are going to show any panel as a dialog,
* you have to use this method to show them.
*
* @memberOf DialogService
* @access public
* @param {String} panelId
* @param {Object} userOptions
* @return {Promise}
*/
show: function dm_show(panelId, userOptions, _pendingDefer) {
var self = this;
var defer;
var dialog;
var dialogDOM = document.getElementById(panelId);
var currentPanel = Settings.currentPanel;
var options = userOptions || {};
if (_pendingDefer) {
defer = _pendingDefer;
} else {
defer = Defer();
}
if (this._navigating) {
this._pendingRequests.push({
defer: defer,
panelId: panelId,
userOptions: userOptions
});
} else {
if ('#' + panelId === currentPanel) {
defer.reject('You are showing the same panel #' + panelId);
} else {
var opening;
var closing = false;
function close(type) {
if (closing) return;
closing = true;
// A tap during the opening animation must not run hide/show
// concurrently, or a queued dialog can inherit stale form values.
Promise.resolve(opening).then(function() {
return DialogManager.close(dialog, type, options);
}).then(function(result) {
self._navigating = false;
defer.resolve({type: type, value: result});
self._execPendingRequest();
}, function(error) {
// Panel validation rejects to keep the form open. Allow a
// correction or cancellation without rejecting the show promise.
closing = false;
dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
detail: error
}));
});
}
options.onWrapSubmit = function() { close('submit'); };
options.onWrapCancel = function() { close('cancel'); };
switch (options.type) {
case 'alert':
dialog = AlertDialog(dialogDOM, options);
break;
case 'confirm':
dialog = ConfirmDialog(dialogDOM, options);
break;
case 'prompt':
dialog = PromptDialog(dialogDOM, options);
break;
default:
dialog = PanelDialog(dialogDOM, options);
break;
}
this._navigating = true;
opening = DialogManager.open(dialog, options);
opening.catch(function(error) {
// A missing host API must not lock every later Settings dialog.
dialogDOM.classList.remove('current');
dialogDOM.hidden = true;
var overlay = document.querySelector('.settings-dialog-overlay');
if (overlay) overlay.hidden = true;
self._navigating = false;
defer.reject(error);
self._execPendingRequest();
});
}
}
return defer.promise;
},
/**
* This method can help us pop up any pending request and would try to
* show it after previous request was done.
*
* @memberOf DialogService
* @access private
*/
_execPendingRequest: function() {
var request = this._pendingRequests.pop();
if (request) {
this.show(request.panelId, request.userOptions, request.defer);
}
}
};
var dialogService = new DialogService();
return dialogService;
});
define('panels/firefox_accounts/firefox_accounts',['require','shared/text_normalizer','shared/fxa_iac_client','modules/dialog_service'],function(require){var normalizer=require('shared/text_normalizer');var fxaHelper=require('shared/fxa_iac_client');var dialogService=require('modules/dialog_service');var FirefoxAccounts=function(){this._boundRefreshStatus=this._boundRefreshStatus||this.refreshStatus.bind(this);};FirefoxAccounts.prototype={onInit:function fxa_init(elements){this._elements=elements;},onBeforeShow:function fxa_beforeShow(){fxaHelper.addEventListener('onlogin',this._boundRefreshStatus);fxaHelper.addEventListener('onverified',this._boundRefreshStatus);fxaHelper.addEventListener('onlogout',this._boundRefreshStatus);this._boundRefreshStatus();},onBeforeHide:function fxa_beforeHide(){fxaHelper.removeEventListener('onlogin',this._boundRefreshStatus);fxaHelper.removeEventListener('onverified',this._boundRefreshStatus);fxaHelper.removeEventListener('onlogout',this._boundRefreshStatus);},refreshStatus:function fxa_refreshStatus(){fxaHelper.getAccount(this._onFxAccountStateChange.bind(this),this._onFxAccountError.bind(this));},_onFxAccountStateChange:function fxa_onFxAccountStateChange(e){var email=e?normalizer.escapeHTML(e.email):'';if(!e){this._hideLoggedInPanel();this._hideUnverifiedPanel();this._showLoggedOutPanel();}else if(e.verified){this._hideLoggedOutPanel();this._hideUnverifiedPanel();this._showLoggedInPanel(email);}else{this._hideLoggedOutPanel();this._hideLoggedInPanel();this._showUnverifiedPanel(email);}},_onFxAccountError:function fxa_onFxAccountError(err){console.error('FxaPanel: Error getting Firefox Account: '+err.error);},_hideLoggedOutPanel:function fxa_hideLoggedOutPanel(){this._elements.loginBtn.onclick=null;this._elements.loggedOutPanel.hidden=true;},_showLoggedOutPanel:function fxa_showLoggedOutPanel(){this._elements.loginBtn.onclick=this._onLoginClick.bind(this);this._elements.loggedOutPanel.hidden=false;},_hideLoggedInPanel:function fxa_hideLoggedInPanel(){this._elements.loggedInPanel.hidden=true;this._elements.loggedInEmail.textContent='';this._elements.logoutBtn.onclick=null;},_showLoggedInPanel:function fxa_showLoggedInPanel(email){document.l10n.setAttributes(this._elements.loggedInEmail,'fxa-logged-in-text',{email:email});this._elements.loggedInPanel.hidden=false;this._elements.logoutBtn.onclick=this._onLogoutClick.bind(this);},_hideUnverifiedPanel:function fxa_hideUnverifiedPanel(){this._elements.unverifiedPanel.hidden=true;this._elements.unverifiedEmail.textContent='';this._elements.cancelBtn.onclick=null;if(this._elements.resendLink){this._elements.resendLink.onclick=null;}},_showUnverifiedPanel:function fxa_showUnverifiedPanel(email){this._elements.unverifiedPanel.hidden=false;this._elements.cancelBtn.onclick=this._onLogoutClick.bind(this);document.l10n.setAttributes(this._elements.unverifiedEmail,'fxa-verification-email-sent-msg',{email:email});this._elements.resendLink.onclick=this._onResendClick.bind(this);},_onLogoutClick:function fxa_onLogoutClick(e){e.stopPropagation();e.preventDefault();fxaHelper.logout(this._onFxAccountStateChange.bind(this),this._onFxAccountError.bind(this));},_onResendClick:function fxa__onResendClick(e){e.stopPropagation();e.preventDefault();if(e.target.classList.contains('disabled')){return;}
fxaHelper.getAccount((accts)=>{var email=accts&&accts.email;if(!email){return this._onFxAccountStateChange(accts);}
fxaHelper.resendVerificationEmail(email,this._onResend.bind(this,email),this._onFxAccountError.bind(this));},this._onFxAccountError.bind(this));},_onResend:function fxa__onResend(email){dialogService.alert({id:'fxa-resend-alert',args:{email:email}});this._elements.resendLink.classList.add('disabled');setTimeout(()=>{this._elements.resendLink.classList.remove('disabled');},60000);},_onLoginClick:function fxa_onLoginClick(e){e.stopPropagation();e.preventDefault();fxaHelper.openFlow(this._onFxAccountStateChange.bind(this),this._onFxAccountError.bind(this));}};return function ctor_firefox_accounts(){return new FirefoxAccounts();};});define('panels/firefox_accounts/panel',['require','modules/settings_panel','panels/firefox_accounts/firefox_accounts'],function(require){var SettingsPanel=require('modules/settings_panel');var FirefoxAccounts=require('panels/firefox_accounts/firefox_accounts');return function ctor_firefox_account_panel(){var elements;var firefoxAccounts=FirefoxAccounts();return SettingsPanel({onInit:function(panel){elements={fxaContainer:document.getElementById('fxa'),loggedOutPanel:document.getElementById('fxa-logged-out'),loggedInPanel:document.getElementById('fxa-logged-in'),unverifiedPanel:document.getElementById('fxa-unverified'),resendEmail:document.getElementById('fxa-resend-email'),cancelBtn:document.getElementById('fxa-cancel-confirmation'),loginBtn:document.getElementById('fxa-login'),logoutBtn:document.getElementById('fxa-logout'),loggedInEmail:document.getElementById('fxa-logged-in-text'),unverifiedEmail:document.getElementById('fxa-unverified-text'),resendLink:document.getElementById('fxa-resend')};firefoxAccounts.onInit(elements);},onBeforeShow:function(){firefoxAccounts.onBeforeShow();},onBeforeHide:function(){firefoxAccounts.onBeforeHide();}});};});

View file

@ -0,0 +1,505 @@
define('modules/sync_manager_bridge',['require'],function(require){'use strict';var SyncManagerBridge={_port:null,_requests:new Map(),set onsyncchange(callback){this._onsyncchange=callback;},connect:function(){if(this._port){return Promise.resolve(this._port);}
return new Promise((resolve,reject)=>{navigator.mozApps.getSelf().onsuccess=event=>{var app=event.target.result;app.connect('gaia-sync-management').then(ports=>{if(!ports||!ports.length){return reject();}
this._port=ports[0];this._port.onmessage=this.onmessage.bind(this);resolve(this._port);}).catch(reject);};});},iacRequest:function(request){return new Promise(resolve=>{this.connect().then(port=>{if(request.id){this._requests.set(request.id,resolve);}
port.postMessage(request);});});},onmessage:function(event){var message=event.data;if(!message){return;}
if(message.id&&this._requests.has(message.id)){this._requests.get(message.id)(message);this._requests.delete(message.id);return;}
if(message.name!=='onsyncchange'){return;}
if(this._onsyncchange){this._onsyncchange(message);}},getInfo:function(){return this.iacRequest({name:'getInfo',id:Date.now()});},};['enable','disable','sync'].forEach(requestName=>{SyncManagerBridge[requestName]=()=>{return SyncManagerBridge.iacRequest({name:requestName});};});return SyncManagerBridge;});define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
'use strict';
var PanelCache = require('modules/panel_cache');
var LazyLoader = require('shared/lazy_loader');
var DialogManager = function() {
this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
};
DialogManager.prototype = {
/**
* load panel based on passed in panelId
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_loadPanel: function dm__loadPanel(panelId) {
var promise = new Promise(function(resolve, reject) {
var panelElement = document.getElementById(panelId);
if (panelElement.dataset.rendered) { // already initialized
resolve();
return;
}
panelElement.dataset.rendered = true;
// XXX remove SubPanel loader once sub panel are modulized
if (panelElement.dataset.requireSubPanels) {
// load the panel and its sub-panels (dependencies)
// (load the main panel last because it contains the scripts)
var selector = 'section[id^="' + panelElement.id + '-"]';
var subPanels = document.querySelectorAll(selector);
for (var i = 0, il = subPanels.length; i < il; i++) {
LazyLoader.load([subPanels[i]]);
}
LazyLoader.load([panelElement], resolve);
} else {
LazyLoader.load([panelElement], resolve);
}
});
return promise;
},
/**
* promised version of PanelCache.get()
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_getPanel: function dm__getPanel(panelId) {
var promise = new Promise(function(resolve) {
PanelCache.get(panelId, function(panel) {
resolve(panel);
});
});
return promise;
},
/**
* this is used to control visibility of overlayDOM
*
* @memberOf DialogManager
* @access private
* @param {Boolean} show
*/
_showOverlay: function dm__showOverlay(show) {
this._overlayDOM.hidden = !show;
},
/**
* It is used to control the timing of transitions so that we can make sure
* whether animation is done or not.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_transit: function dm__transit(method, dialog, options) {
var promise = new Promise(function(resolve) {
var panel = dialog.panel;
var completed = false;
var fallback;
function finish(event) {
if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
if (completed) return;
completed = true;
clearTimeout(fallback);
panel.removeEventListener('transitionend', finish);
panel.removeEventListener('transitioncancel', finish);
if (method === 'close') panel.hidden = true;
resolve();
}
if (method === 'open') panel.hidden = false;
setTimeout(function() {
panel.addEventListener('transitionend', finish);
panel.addEventListener('transitioncancel', finish);
panel.classList.toggle('current', method === 'open');
var style = window.getComputedStyle(panel);
function milliseconds(value) {
return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
}
var durations = style.transitionDuration.split(',').map(milliseconds);
var delays = style.transitionDelay.split(',').map(milliseconds);
var duration = Math.max.apply(Math, durations.map(function(value, index) {
return value + delays[index % delays.length];
}));
// Reduced motion, display changes or interrupted transitions may
// omit the visibility event. Always release the dialog queue.
fallback = setTimeout(finish, Math.max(0, duration) + 100);
}, 150);
});
return promise;
},
/**
* Do necessary works to open panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_open: function dm__open(dialog, options) {
var self = this;
var foundPanel;
return Promise.resolve()
.then(function() {
// 1: load panel
return self._loadPanel(dialog.panel.id);
})
.then(function() {
// 2: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 3: call beforeShow
foundPanel = panel;
return foundPanel.beforeShow(dialog.panel, options);
})
.then(function() {
// 4. UI stuffs + transition
dialog.init();
dialog.initUI();
dialog.bindEvents();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(true);
}
return self._transit('open', dialog, options);
})
.then(function() {
// 5. show that panel as a dialog
return foundPanel.show(dialog.panel, options);
});
},
/**
* Do necessary works to close panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_close: function dm__close(dialog, options) {
var self = this;
var foundPanel;
var cachedResult;
return Promise.resolve()
.then(function() {
// 1: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 2: Let's validate to see whether we can close this dialog or not.
foundPanel = panel;
var promise;
// custom dialog - onSubmit
if (foundPanel.onSubmit && options._type === 'submit') {
promise = foundPanel.onSubmit();
// custom dialog - onCancel
} else if (foundPanel.onCancel && options._type === 'cancel') {
promise = foundPanel.onCancel();
// if no onSubmit & onCancel, pass directly
} else {
promise = Promise.resolve();
}
return promise;
})
.then(function(result) {
cachedResult = result;
// 3: call beforeHide
return foundPanel.beforeHide();
})
.then(function() {
// 4. transition
return self._transit('close', dialog, options);
})
.then(function() {
// 5. call hide
return foundPanel.hide();
})
.then(function() {
// 6. Get result and cleanup dialog
var result;
// for prompt dialog, we have to get its own result from input text.
if (dialog.DIALOG_CLASS === 'prompt-dialog') {
result = dialog.getResult();
} else if (cachedResult) {
result = cachedResult;
}
dialog.cleanup();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(false);
}
return result;
});
},
/**
* It is a bridge to call open or close function.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_navigate: function dm__navigate(method, dialog, options) {
method = (method === 'open') ? '_open' : '_close';
return this[method](dialog, options);
},
/**
* DialogService would use this exposed API to open dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
open: function dm_open(dialog, options) {
return this._navigate('open', dialog, options);
},
/**
* DialogService would use this exposed API to close dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
close: function dm_close(dialog, type, options) {
options._type = type;
return this._navigate('close', dialog, options);
}
};
var dialogManager = new DialogManager();
return dialogManager;
});
define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
'use strict';
var Settings = require('settings');
var Defer = require('modules/defer');
var DialogManager = require('modules/dialog_manager');
var PanelDialog = require('modules/dialog/panel_dialog');
var AlertDialog = require('modules/dialog/alert_dialog');
var ConfirmDialog = require('modules/dialog/confirm_dialog');
var PromptDialog = require('modules/dialog/prompt_dialog');
var DialogService = function() {
this._navigating = false;
this._pendingRequests = [];
this._settingsAlertDialogId = 'settings-alert-dialog';
this._settingsBaseDialogId = 'settings-base-dialog';
this._settingsConfirmDialogId = 'settings-confirm-dialog';
this._settingsPromptDialogId = 'settings-prompt-dialog';
};
DialogService.prototype = {
/**
* Alert dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
alert: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsAlertDialogId, {
type: 'alert',
message: message,
title: options.title,
submitButton: options.submitButton
});
},
/**
* Confirm dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
confirm: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsConfirmDialogId, {
type: 'confirm',
message: message,
title: options.title,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Prompt dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
prompt: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsPromptDialogId, {
type: 'prompt',
message: message,
title: options.title,
defaultValue: options.defaultValue,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Panel dialog. If you are going to show any panel as a dialog,
* you have to use this method to show them.
*
* @memberOf DialogService
* @access public
* @param {String} panelId
* @param {Object} userOptions
* @return {Promise}
*/
show: function dm_show(panelId, userOptions, _pendingDefer) {
var self = this;
var defer;
var dialog;
var dialogDOM = document.getElementById(panelId);
var currentPanel = Settings.currentPanel;
var options = userOptions || {};
if (_pendingDefer) {
defer = _pendingDefer;
} else {
defer = Defer();
}
if (this._navigating) {
this._pendingRequests.push({
defer: defer,
panelId: panelId,
userOptions: userOptions
});
} else {
if ('#' + panelId === currentPanel) {
defer.reject('You are showing the same panel #' + panelId);
} else {
var opening;
var closing = false;
function close(type) {
if (closing) return;
closing = true;
// A tap during the opening animation must not run hide/show
// concurrently, or a queued dialog can inherit stale form values.
Promise.resolve(opening).then(function() {
return DialogManager.close(dialog, type, options);
}).then(function(result) {
self._navigating = false;
defer.resolve({type: type, value: result});
self._execPendingRequest();
}, function(error) {
// Panel validation rejects to keep the form open. Allow a
// correction or cancellation without rejecting the show promise.
closing = false;
dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
detail: error
}));
});
}
options.onWrapSubmit = function() { close('submit'); };
options.onWrapCancel = function() { close('cancel'); };
switch (options.type) {
case 'alert':
dialog = AlertDialog(dialogDOM, options);
break;
case 'confirm':
dialog = ConfirmDialog(dialogDOM, options);
break;
case 'prompt':
dialog = PromptDialog(dialogDOM, options);
break;
default:
dialog = PanelDialog(dialogDOM, options);
break;
}
this._navigating = true;
opening = DialogManager.open(dialog, options);
opening.catch(function(error) {
// A missing host API must not lock every later Settings dialog.
dialogDOM.classList.remove('current');
dialogDOM.hidden = true;
var overlay = document.querySelector('.settings-dialog-overlay');
if (overlay) overlay.hidden = true;
self._navigating = false;
defer.reject(error);
self._execPendingRequest();
});
}
}
return defer.promise;
},
/**
* This method can help us pop up any pending request and would try to
* show it after previous request was done.
*
* @memberOf DialogService
* @access private
*/
_execPendingRequest: function() {
var request = this._pendingRequests.pop();
if (request) {
this.show(request.panelId, request.userOptions, request.defer);
}
}
};
var dialogService = new DialogService();
return dialogService;
});
define('panels/firefox_sync/firefox_sync',['require','shared/settings_listener','modules/sync_manager_bridge','modules/dialog_service'],function(require){'use strict';var SettingsListener=require('shared/settings_listener');var SyncManagerBridge=require('modules/sync_manager_bridge');var DialogService=require('modules/dialog_service');const LOGGED_OUT_SCREEN='loggedout';const LOGGED_IN_SCREEN='loggedin';const ELEMENTS=[{screen:LOGGED_OUT_SCREEN,selector:'.fxsync-login',event:'click',listener:'enable'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-logout',event:'click',listener:'disable'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-user'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-sync-now',event:'click',listener:'sync'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-tos',event:'click',listener:'openTos'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-privacy',event:'click',listener:'openPrivacy'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-collections-bookmarks'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-collections-history'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-collections-passwords'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-last-sync'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-unverified'},{screen:LOGGED_IN_SCREEN,selector:'.fxsync-empty-account'}];function getElementName(str){str=str.toLowerCase().replace('.fxsync-','');return str.replace(/-([a-z])/g,g=>{return g[1].toUpperCase();});}
function FirefoxSyncPanel(panel,screens){this.panel=panel;this.screens=screens;this.elements={};this.currentScreen=null;this.collections=new Map();['sync.collections.bookmarks.enabled','sync.collections.history.enabled','sync.collections.passwords.enabled'].forEach(setting=>{SettingsListener.observe(setting,true,enabled=>{enabled?this.collections.set(setting,enabled):this.collections.delete(setting);this.maybeEnableSyncNow();});});SyncManagerBridge.onsyncchange=this.onsyncchange.bind(this);}
FirefoxSyncPanel.prototype={refresh(){SyncManagerBridge.getInfo().then(this.onsyncchange.bind(this));},clean(){window.clearTimeout(this.timeout);this.timeout=undefined;window.removeEventListener('offline',this.onlineListener);window.removeEventListener('online',this.onlineListener);},onsyncchange(message){if(!message||!message.state){throw new Error('Missing sync state');}
switch(message.state){case'disabled':this.showScreen(LOGGED_OUT_SCREEN);this.hideEnabling();this.clean();break;case'enabling':this.showScreen(LOGGED_OUT_SCREEN);this.showEnabling();break;case'enabled':this.hideEnabling();this.showScreen(LOGGED_IN_SCREEN);this.showSyncNow();this.showUser(message.user);this.showLastSync(message.lastSync);if(message.error){LazyLoader.load('shared/js/sync/errors.js',()=>{if(message.error==ERROR_INVALID_SYNC_ACCOUNT){this.showEmptyAccount(message.user);}});}else{this.hideEmptyAccount();}
break;case'syncing':this.showScreen(LOGGED_IN_SCREEN);this.showUser(message.user);this.showSyncing();break;case'errored':LazyLoader.load('shared/js/sync/errors.js',()=>{if(message.error==ERROR_UNVERIFIED_ACCOUNT){this.showScreen(LOGGED_IN_SCREEN);this.showUnverified(message.user);return;}
if(message.error==ERROR_INVALID_SYNC_ACCOUNT){this.showScreen(LOGGED_IN_SCREEN);this.showEmptyAccount(message.user);return;}
const IGNORED_ERRORS=[ERROR_DIALOG_CLOSED_BY_USER,ERROR_SYNC_APP_KILLED];if(IGNORED_ERRORS.indexOf(message.error)>-1){return;}
var errorMsg=ERROR_UNKNOWN;var title;const KNOWN_ERRORS=[ERROR_OFFLINE,ERROR_NO_KEY_FETCH_TOKEN];if(KNOWN_ERRORS.indexOf(message.error)>-1){title=message.error;errorMsg=message.error+'-explanation';}
DialogService.alert(errorMsg,{title:title}).then(()=>{this.showScreen(LOGGED_OUT_SCREEN);this.clean();});});break;}},addListener(element,event,listener){if(!element||!event||!listener){return;}
element.addEventListener(event,this[listener]);},removeListener(element,event,listener){if(!element||!event||!listener){return;}
element.removeEventListener(event,this[listener]);},loadElements(screen){ELEMENTS.forEach(element=>{var name=getElementName(element.selector);if(element.screen==screen){this.elements[name]=this.panel.querySelector(element.selector);this.addListener(this.elements[name],element.event,element.listener);return;}
this.removeListener(this.elements[name],element.event,element.listener);this.elements[name]=null;});},showScreen(screen){if(this.currentScreen==screen){return;}
this.currentScreen=screen;this.screens.loggedIn.hidden=(screen!=LOGGED_IN_SCREEN);this.screens.loggedOut.hidden=(screen!=LOGGED_OUT_SCREEN);this.loadElements(screen);},showEnabling(){this.elements.login.disabled=true;this.elements.login.dataset.l10nId='fxsync-signing';},hideEnabling(){if(!this.elements.login){return;}
this.elements.login.disabled=false;this.elements.login.dataset.l10nId='fxsync-get-started';},showSyncing(){this.elements.syncNow.dataset.l10nId='fxsync-syncing';this.disableSyncNowAndCollections(true);},maybeEnableSyncNow(){if(!this.elements.syncNow){return;}
this.elements.syncNow.disabled=(this.collections.size<=0)||!navigator.onLine;},showSyncNow(){this.elements.syncNow.dataset.l10nId='fxsync-sync-now';this.disableSyncNowAndCollections(false);this.elements.unverified.hidden=true;this.maybeEnableSyncNow();this.onlineListener=this.maybeEnableSyncNow.bind(this);window.addEventListener('offline',this.onlineListener);window.addEventListener('online',this.onlineListener);},showUser(email){this.elements.user.textContent=email;},showUnverified(email){this.showUser(email);this.showLastSync();this.disableSyncNowAndCollections(true);this.elements.unverified.hidden=false;},showEmptyAccount(email){this.showUser(email);this.showLastSync();this.disableSyncNowAndCollections(false);this.elements.emptyAccount.hidden=false;},hideEmptyAccount(){this.elements.emptyAccount.hidden=true;},showLastSync(time){if(!time){this.elements.lastSync.classList.add('hidden');return;}
var formatter=mozIntl._gaia.RelativeDate(navigator.languages,{style:'short'});formatter.format(time).then(relDate=>{var selector='span[data-l10n-id=fxsync-last-synced]';var lastSync=this.elements.lastSync.querySelector(selector);document.l10n.setAttributes(lastSync,'fxsync-last-synced',{when:relDate});this.elements.lastSync.classList.remove('hidden');});this.timeout=window.setTimeout(()=>{this.showLastSync(time);},60*1000);},disableSyncNowAndCollections(disabled){['syncNow','collectionsBookmarks','collectionsHistory','collectionsPasswords'].forEach(name=>{this.elements[name].disabled=disabled;});},enable(){SyncManagerBridge.enable();},disable(){SyncManagerBridge.disable();},sync(){SyncManagerBridge.sync();},openTos(){window.open('https://accounts.firefox.com/legal/terms');},openPrivacy(){window.open('https://accounts.firefox.com/legal/privacy');}};return(panel,screens)=>{return new FirefoxSyncPanel(panel,screens);};});define('panels/firefox_sync/panel',['require','modules/settings_panel','modules/settings_utils','panels/firefox_sync/firefox_sync'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var SettingsUtils=require('modules/settings_utils');var FirefoxSyncPanel=require('panels/firefox_sync/firefox_sync');return()=>{return SettingsPanel({onInit:function fxsync_onInit(panel){var screens={loggedIn:panel.querySelector('.fxsync-logged-in'),loggedOut:panel.querySelector('.fxsync-logged-out')};this.syncPanel=FirefoxSyncPanel(panel,screens);var header=panel.querySelector('gaia-header');SettingsUtils.runHeaderFontFit(header);},onShow:function fxsync_onShow(){this.syncPanel.refresh();},onHide:function fxsync_onHide(){this.syncPanel.clean();}});};});

View file

@ -0,0 +1,171 @@
/**
* Handle support panel functionality with SIM and without SIM
*/
/**
* Handle support panel functionality with SIM and without SIM
*/
/**
* Handle support panel functionality with SIM and without SIM
*/
/**
* Handle support panel functionality with SIM and without SIM
*/
/**
* Handle support panel functionality with SIM and without SIM
*/
/**
* Handle support panel functionality with SIM and without SIM
*/
define('panels/help/support',["require", "modules/settings_cache", "shared/lazy_loader"],function(require) {
'use strict';
var SettingsCache = require('modules/settings_cache');
var LazyLoader = require('shared/lazy_loader');
var Support = function() {};
Support.prototype = {
/**
* initialization
*/
init: function support_init(elements) {
this._elements = elements;
/**
* We'll stash the support info in here when reading from the
* Settings.
* If no values are found in Settings for support info We'll
* refer to the JSON File Data.
*/
this._callSupportInfo = null;
var url = 'https://vulpes-os.org/';
this._elements.userGuide.addEventListener('click', () => {
this._elements.userGuide.blur();
var activity = new window.MozActivity({
name: 'view',
data: { type: 'url', url: url }
});
// For workaround jshint.
activity.onsuccess = function() {};
});
// parse support information from data
this._getSupportInfo(this._displaySupportInfo.bind(this));
},
/**
* Clean states for test
*/
uninit: function() {
this._elements = null;
this._supportInfo = null;
this._callSupportInfo = null;
},
/**
* get support data from cache or from resource file
*/
_getSupportInfo: function support_getSupportInfo(callback) {
if (this._supportInfo) {
callback(this._supportInfo);
return;
}
LazyLoader.getJSON('/resources/support.json')
.then(function loadSupportInfo(data) {
this._supportInfo = data;
callback(this._supportInfo);
}.bind(this)).catch(function(error) {
console.warn('Support information:', error);
callback({onlinesupport: {title: 'Vulpes OS', href: 'https://vulpes-os.org/'}, callsupport: []});
});
},
/**
* create support url items
*/
_createLinkNode: function support_createLinkNode(data, type) {
var link = document.createElement('a');
link.setAttribute('href', data.href);
link.classList.add('link-text');
if (type == 'tel') {
link.textContent = data.title + ' (' + data.href + ')';
} else {
link.textContent = data.title;
}
return link;
},
/**
* Indicate to our panel that there is support info present.
*/
_enableSupportInfo: function support_enableSupportInfo() {
this._elements.help.dataset.hasSupportInfo = true;
},
/**
* Local helper function to set the online support information
* once it's retrieved or we've determined that we'll use
* what's in the build JSON file.
*/
_setOnlineSupportInfo:
function support_setOnlineSupportInfo(onlineSupportInfo) {
this._enableSupportInfo();
var nodes = this._createLinkNode(onlineSupportInfo);
this._elements.supportText.appendChild(nodes);
},
/**
* Local helper function to set the information once we've
* retrieved it.
*/
_setCallSupportInfo:
function support_setCallSupportInfo(supportInfo) {
this._enableSupportInfo();
for (var id in supportInfo) {
this._elements.supportNumber
.appendChild(this._createLinkNode(supportInfo[id], 'tel'));
}
},
_renderSupportInfo: function support_renderSupportInfo(result) {
var onlineSupportInfo = null;
var onlineSupportTitle = result['support.onlinesupport.title'];
if (typeof onlineSupportTitle === 'string' && onlineSupportTitle !== '') {
onlineSupportInfo = { title: onlineSupportTitle };
onlineSupportInfo.href = result['support.onlinesupport.href'];
this._setOnlineSupportInfo(onlineSupportInfo);
} else if (this._supportInfo) {
this._setOnlineSupportInfo(this._supportInfo.onlinesupport);
}
// Check to see if we have a title for the first support number.
var callSupport1Title = result['support.callsupport1.title'];
// If we have a title we'll go ahead and load the href for it too.
if (typeof callSupport1Title === 'string' && callSupport1Title !== '') {
this._callSupportInfo = [
{
'title': callSupport1Title,
'href': result['support.callsupport1.href']
}
];
// Now check to see if we have a title for the second
// support number. If we do, we'll load it's href as well.
var callSupport2Title = result['support.callsupport2.title'];
if (typeof callSupport2Title === 'string' && callSupport2Title !== '') {
this._callSupportInfo.push({
'title': callSupport2Title,
'href': result['support.callsupport2.href']
});
// Finally set the support info retreived from Settings.
this._setCallSupportInfo(this._callSupportInfo);
}
} else if (this._supportInfo) {
// No customized values, use what's in the JSON file.
this._setCallSupportInfo(this._supportInfo.callsupport);
}
},
/**
* display Support information
*/
_displaySupportInfo: function support_displaySupportInfo(supportInfo) {
SettingsCache.getSettings(this._renderSupportInfo.bind(this));
}
};
return function ctor_support() {
return new Support();
};
});
define('panels/help/panel',['require','modules/settings_panel','panels/help/support'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var Support=require('panels/help/support');return function ctor_support_panel(){var help=Support();return SettingsPanel({onInit:function(panel){var elements={userGuide:panel.querySelector('#user-guide'),help:panel,supportText:panel.querySelector('#help-online-support-text'),supportNumber:panel.querySelector('#help-call-support-numbers')};help.init(elements);}});};});

View file

@ -0,0 +1,490 @@
define('panels/keyboard_add_layouts/nested_template_factory',['require'],function(require){'use strict';return function ctor_nestedTemplate(parentTemplate,childTemplate){var listViews=[];var template=parentTemplate.bind({listViews:listViews,childTemplate:childTemplate});Object.defineProperty(template,'listViews',{configurable:false,get:function(){return listViews;}});return template;};});define('panels/keyboard_add_layouts/keyboard_template',['require','modules/mvvm/list_view'],function(require){'use strict';var ListView=require('modules/mvvm/list_view');return function kal_keyboardTemplate(keyboard,recycled,helper){var layoutTemplate=this.childTemplate;var listViews=this.listViews;var container,header,h2,ul,listView;if(recycled){container=recycled;h2=container.querySelector('h2');ul=container.querySelector('ul');}else{container=document.createElement('div');header=document.createElement('header');h2=document.createElement('h2');ul=document.createElement('ul');header.appendChild(h2);container.appendChild(header);container.appendChild(ul);}
listView=listViews.some(function eachListView(list){if(list.element===ul){list.set(keyboard.layouts);list.enabled=true;return true;}});if(!listView){listView=ListView(ul,keyboard.layouts,layoutTemplate);listView.enabled=true;listViews.push(listView);}
helper.observeAndCall(keyboard,{name:function refreshName(){h2.textContent=keyboard.name;}});return container;};});define('panels/keyboard_add_layouts/layout_template',['require'],function(require){'use strict';return function kal_layoutTemplate(layout,recycled,helper){var container=null;var nameBdi,checkbox;if(recycled){container=recycled;checkbox=container.querySelector('gaia-checkbox');nameBdi=container.querySelector('bdi');}else{container=document.createElement('li');checkbox=document.createElement('gaia-checkbox');nameBdi=document.createElement('bdi');checkbox.appendChild(document.createElement('label'));checkbox.lastChild.appendChild(nameBdi);container.appendChild(checkbox);}
if(layout.nameL10nId){nameBdi.dataset.l10nId=layout.nameL10nId;}
checkbox.addEventListener('change',function(e){layout.enabled=e.target.checked;});helper.observeAndCall(layout,{name:function refreshName(){nameBdi.textContent=layout.name;},enabled:function(){checkbox.checked=layout.enabled;}});return container;};});define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
'use strict';
var PanelCache = require('modules/panel_cache');
var LazyLoader = require('shared/lazy_loader');
var DialogManager = function() {
this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
};
DialogManager.prototype = {
/**
* load panel based on passed in panelId
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_loadPanel: function dm__loadPanel(panelId) {
var promise = new Promise(function(resolve, reject) {
var panelElement = document.getElementById(panelId);
if (panelElement.dataset.rendered) { // already initialized
resolve();
return;
}
panelElement.dataset.rendered = true;
// XXX remove SubPanel loader once sub panel are modulized
if (panelElement.dataset.requireSubPanels) {
// load the panel and its sub-panels (dependencies)
// (load the main panel last because it contains the scripts)
var selector = 'section[id^="' + panelElement.id + '-"]';
var subPanels = document.querySelectorAll(selector);
for (var i = 0, il = subPanels.length; i < il; i++) {
LazyLoader.load([subPanels[i]]);
}
LazyLoader.load([panelElement], resolve);
} else {
LazyLoader.load([panelElement], resolve);
}
});
return promise;
},
/**
* promised version of PanelCache.get()
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_getPanel: function dm__getPanel(panelId) {
var promise = new Promise(function(resolve) {
PanelCache.get(panelId, function(panel) {
resolve(panel);
});
});
return promise;
},
/**
* this is used to control visibility of overlayDOM
*
* @memberOf DialogManager
* @access private
* @param {Boolean} show
*/
_showOverlay: function dm__showOverlay(show) {
this._overlayDOM.hidden = !show;
},
/**
* It is used to control the timing of transitions so that we can make sure
* whether animation is done or not.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_transit: function dm__transit(method, dialog, options) {
var promise = new Promise(function(resolve) {
var panel = dialog.panel;
var completed = false;
var fallback;
function finish(event) {
if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
if (completed) return;
completed = true;
clearTimeout(fallback);
panel.removeEventListener('transitionend', finish);
panel.removeEventListener('transitioncancel', finish);
if (method === 'close') panel.hidden = true;
resolve();
}
if (method === 'open') panel.hidden = false;
setTimeout(function() {
panel.addEventListener('transitionend', finish);
panel.addEventListener('transitioncancel', finish);
panel.classList.toggle('current', method === 'open');
var style = window.getComputedStyle(panel);
function milliseconds(value) {
return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
}
var durations = style.transitionDuration.split(',').map(milliseconds);
var delays = style.transitionDelay.split(',').map(milliseconds);
var duration = Math.max.apply(Math, durations.map(function(value, index) {
return value + delays[index % delays.length];
}));
// Reduced motion, display changes or interrupted transitions may
// omit the visibility event. Always release the dialog queue.
fallback = setTimeout(finish, Math.max(0, duration) + 100);
}, 150);
});
return promise;
},
/**
* Do necessary works to open panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_open: function dm__open(dialog, options) {
var self = this;
var foundPanel;
return Promise.resolve()
.then(function() {
// 1: load panel
return self._loadPanel(dialog.panel.id);
})
.then(function() {
// 2: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 3: call beforeShow
foundPanel = panel;
return foundPanel.beforeShow(dialog.panel, options);
})
.then(function() {
// 4. UI stuffs + transition
dialog.init();
dialog.initUI();
dialog.bindEvents();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(true);
}
return self._transit('open', dialog, options);
})
.then(function() {
// 5. show that panel as a dialog
return foundPanel.show(dialog.panel, options);
});
},
/**
* Do necessary works to close panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_close: function dm__close(dialog, options) {
var self = this;
var foundPanel;
var cachedResult;
return Promise.resolve()
.then(function() {
// 1: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 2: Let's validate to see whether we can close this dialog or not.
foundPanel = panel;
var promise;
// custom dialog - onSubmit
if (foundPanel.onSubmit && options._type === 'submit') {
promise = foundPanel.onSubmit();
// custom dialog - onCancel
} else if (foundPanel.onCancel && options._type === 'cancel') {
promise = foundPanel.onCancel();
// if no onSubmit & onCancel, pass directly
} else {
promise = Promise.resolve();
}
return promise;
})
.then(function(result) {
cachedResult = result;
// 3: call beforeHide
return foundPanel.beforeHide();
})
.then(function() {
// 4. transition
return self._transit('close', dialog, options);
})
.then(function() {
// 5. call hide
return foundPanel.hide();
})
.then(function() {
// 6. Get result and cleanup dialog
var result;
// for prompt dialog, we have to get its own result from input text.
if (dialog.DIALOG_CLASS === 'prompt-dialog') {
result = dialog.getResult();
} else if (cachedResult) {
result = cachedResult;
}
dialog.cleanup();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(false);
}
return result;
});
},
/**
* It is a bridge to call open or close function.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_navigate: function dm__navigate(method, dialog, options) {
method = (method === 'open') ? '_open' : '_close';
return this[method](dialog, options);
},
/**
* DialogService would use this exposed API to open dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
open: function dm_open(dialog, options) {
return this._navigate('open', dialog, options);
},
/**
* DialogService would use this exposed API to close dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
close: function dm_close(dialog, type, options) {
options._type = type;
return this._navigate('close', dialog, options);
}
};
var dialogManager = new DialogManager();
return dialogManager;
});
define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
'use strict';
var Settings = require('settings');
var Defer = require('modules/defer');
var DialogManager = require('modules/dialog_manager');
var PanelDialog = require('modules/dialog/panel_dialog');
var AlertDialog = require('modules/dialog/alert_dialog');
var ConfirmDialog = require('modules/dialog/confirm_dialog');
var PromptDialog = require('modules/dialog/prompt_dialog');
var DialogService = function() {
this._navigating = false;
this._pendingRequests = [];
this._settingsAlertDialogId = 'settings-alert-dialog';
this._settingsBaseDialogId = 'settings-base-dialog';
this._settingsConfirmDialogId = 'settings-confirm-dialog';
this._settingsPromptDialogId = 'settings-prompt-dialog';
};
DialogService.prototype = {
/**
* Alert dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
alert: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsAlertDialogId, {
type: 'alert',
message: message,
title: options.title,
submitButton: options.submitButton
});
},
/**
* Confirm dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
confirm: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsConfirmDialogId, {
type: 'confirm',
message: message,
title: options.title,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Prompt dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
prompt: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsPromptDialogId, {
type: 'prompt',
message: message,
title: options.title,
defaultValue: options.defaultValue,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Panel dialog. If you are going to show any panel as a dialog,
* you have to use this method to show them.
*
* @memberOf DialogService
* @access public
* @param {String} panelId
* @param {Object} userOptions
* @return {Promise}
*/
show: function dm_show(panelId, userOptions, _pendingDefer) {
var self = this;
var defer;
var dialog;
var dialogDOM = document.getElementById(panelId);
var currentPanel = Settings.currentPanel;
var options = userOptions || {};
if (_pendingDefer) {
defer = _pendingDefer;
} else {
defer = Defer();
}
if (this._navigating) {
this._pendingRequests.push({
defer: defer,
panelId: panelId,
userOptions: userOptions
});
} else {
if ('#' + panelId === currentPanel) {
defer.reject('You are showing the same panel #' + panelId);
} else {
var opening;
var closing = false;
function close(type) {
if (closing) return;
closing = true;
// A tap during the opening animation must not run hide/show
// concurrently, or a queued dialog can inherit stale form values.
Promise.resolve(opening).then(function() {
return DialogManager.close(dialog, type, options);
}).then(function(result) {
self._navigating = false;
defer.resolve({type: type, value: result});
self._execPendingRequest();
}, function(error) {
// Panel validation rejects to keep the form open. Allow a
// correction or cancellation without rejecting the show promise.
closing = false;
dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
detail: error
}));
});
}
options.onWrapSubmit = function() { close('submit'); };
options.onWrapCancel = function() { close('cancel'); };
switch (options.type) {
case 'alert':
dialog = AlertDialog(dialogDOM, options);
break;
case 'confirm':
dialog = ConfirmDialog(dialogDOM, options);
break;
case 'prompt':
dialog = PromptDialog(dialogDOM, options);
break;
default:
dialog = PanelDialog(dialogDOM, options);
break;
}
this._navigating = true;
opening = DialogManager.open(dialog, options);
opening.catch(function(error) {
// A missing host API must not lock every later Settings dialog.
dialogDOM.classList.remove('current');
dialogDOM.hidden = true;
var overlay = document.querySelector('.settings-dialog-overlay');
if (overlay) overlay.hidden = true;
self._navigating = false;
defer.reject(error);
self._execPendingRequest();
});
}
}
return defer.promise;
},
/**
* This method can help us pop up any pending request and would try to
* show it after previous request was done.
*
* @memberOf DialogService
* @access private
*/
_execPendingRequest: function() {
var request = this._pendingRequests.pop();
if (request) {
this.show(request.panelId, request.userOptions, request.defer);
}
}
};
var dialogService = new DialogService();
return dialogService;
});
define('panels/keyboard_add_layouts/core',['require','modules/mvvm/list_view'],function(require){'use strict';var ListView=require('modules/mvvm/list_view');function KeyboardAddLayoutsCore(context,template){this._enabled=false;this._listView=null;this._keyboardContext=context;this._keyboardTemplate=template;}
KeyboardAddLayoutsCore.prototype={get enabled(){return this._enabled;},set enabled(value){this._enabled=value;if(this._listView){this._listView.enabled=this._enabled;}
this._keyboardTemplate.listViews.forEach(function(listView){listView.enabled=this._enabled;}.bind(this));},_initInstalledLayoutListView:function kal_initListView(listViewRoot,keyboards,keyboardTemplate){this._listView=ListView(listViewRoot,keyboards,keyboardTemplate);},_showEnabledDefaultDialog:function kal_showDialog(layout,missingType){require(['modules/dialog_service'],function(DialogService){document.l10n.formatValue('keyboardType-'+missingType).then(type=>{DialogService.alert({id:'defaultKeyboardEnabled',args:{layoutName:layout.inputManifest.name,appName:layout.manifest.name}},{title:{id:'mustHaveOneKeyboard',args:{type:type}}});});});},init:function kal_onInit(elements){var that=this;this._keyboardContext.init(function(){that._keyboardContext.keyboards(function(keyboards){that._initInstalledLayoutListView(elements.listViewRoot,keyboards,that._keyboardTemplate);that.enabled=true;});});this._keyboardContext.defaultKeyboardEnabled(this._showEnabledDefaultDialog);}};return function ctor_kalCore(context,template){return new KeyboardAddLayoutsCore(context,template);};});define('panels/keyboard_add_layouts/panel',['require','modules/settings_panel','modules/keyboard_context','shared/keyboard_helper','panels/keyboard_add_layouts/nested_template_factory','panels/keyboard_add_layouts/keyboard_template','panels/keyboard_add_layouts/layout_template','panels/keyboard_add_layouts/core'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var KeyboardContext=require('modules/keyboard_context');var KeyboardHelper=require('shared/keyboard_helper');var NestedTemplateFactory=require('panels/keyboard_add_layouts/nested_template_factory');var keyboardTemplate=require('panels/keyboard_add_layouts/keyboard_template');var layoutTemplate=require('panels/keyboard_add_layouts/layout_template');var Core=require('panels/keyboard_add_layouts/core');return function ctor_addLayoutsPanel(){var nestedTemplate=NestedTemplateFactory(keyboardTemplate,layoutTemplate);var core=Core(KeyboardContext,nestedTemplate);return SettingsPanel({onInit:function kalp_onInit(rootElement){core.init({listViewRoot:rootElement.querySelector('.keyboardAppContainer')});},onBeforeShow:function kalp_onBeforeShow(){core.enabled=true;},onBeforeHide:function kalp_onBeforeHide(){KeyboardHelper.saveToSettings();},onHide:function kalp_onHide(){core.enabled=false;}});};});

View file

@ -0,0 +1,486 @@
define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
'use strict';
var PanelCache = require('modules/panel_cache');
var LazyLoader = require('shared/lazy_loader');
var DialogManager = function() {
this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
};
DialogManager.prototype = {
/**
* load panel based on passed in panelId
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_loadPanel: function dm__loadPanel(panelId) {
var promise = new Promise(function(resolve, reject) {
var panelElement = document.getElementById(panelId);
if (panelElement.dataset.rendered) { // already initialized
resolve();
return;
}
panelElement.dataset.rendered = true;
// XXX remove SubPanel loader once sub panel are modulized
if (panelElement.dataset.requireSubPanels) {
// load the panel and its sub-panels (dependencies)
// (load the main panel last because it contains the scripts)
var selector = 'section[id^="' + panelElement.id + '-"]';
var subPanels = document.querySelectorAll(selector);
for (var i = 0, il = subPanels.length; i < il; i++) {
LazyLoader.load([subPanels[i]]);
}
LazyLoader.load([panelElement], resolve);
} else {
LazyLoader.load([panelElement], resolve);
}
});
return promise;
},
/**
* promised version of PanelCache.get()
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_getPanel: function dm__getPanel(panelId) {
var promise = new Promise(function(resolve) {
PanelCache.get(panelId, function(panel) {
resolve(panel);
});
});
return promise;
},
/**
* this is used to control visibility of overlayDOM
*
* @memberOf DialogManager
* @access private
* @param {Boolean} show
*/
_showOverlay: function dm__showOverlay(show) {
this._overlayDOM.hidden = !show;
},
/**
* It is used to control the timing of transitions so that we can make sure
* whether animation is done or not.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_transit: function dm__transit(method, dialog, options) {
var promise = new Promise(function(resolve) {
var panel = dialog.panel;
var completed = false;
var fallback;
function finish(event) {
if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
if (completed) return;
completed = true;
clearTimeout(fallback);
panel.removeEventListener('transitionend', finish);
panel.removeEventListener('transitioncancel', finish);
if (method === 'close') panel.hidden = true;
resolve();
}
if (method === 'open') panel.hidden = false;
setTimeout(function() {
panel.addEventListener('transitionend', finish);
panel.addEventListener('transitioncancel', finish);
panel.classList.toggle('current', method === 'open');
var style = window.getComputedStyle(panel);
function milliseconds(value) {
return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
}
var durations = style.transitionDuration.split(',').map(milliseconds);
var delays = style.transitionDelay.split(',').map(milliseconds);
var duration = Math.max.apply(Math, durations.map(function(value, index) {
return value + delays[index % delays.length];
}));
// Reduced motion, display changes or interrupted transitions may
// omit the visibility event. Always release the dialog queue.
fallback = setTimeout(finish, Math.max(0, duration) + 100);
}, 150);
});
return promise;
},
/**
* Do necessary works to open panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_open: function dm__open(dialog, options) {
var self = this;
var foundPanel;
return Promise.resolve()
.then(function() {
// 1: load panel
return self._loadPanel(dialog.panel.id);
})
.then(function() {
// 2: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 3: call beforeShow
foundPanel = panel;
return foundPanel.beforeShow(dialog.panel, options);
})
.then(function() {
// 4. UI stuffs + transition
dialog.init();
dialog.initUI();
dialog.bindEvents();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(true);
}
return self._transit('open', dialog, options);
})
.then(function() {
// 5. show that panel as a dialog
return foundPanel.show(dialog.panel, options);
});
},
/**
* Do necessary works to close panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_close: function dm__close(dialog, options) {
var self = this;
var foundPanel;
var cachedResult;
return Promise.resolve()
.then(function() {
// 1: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 2: Let's validate to see whether we can close this dialog or not.
foundPanel = panel;
var promise;
// custom dialog - onSubmit
if (foundPanel.onSubmit && options._type === 'submit') {
promise = foundPanel.onSubmit();
// custom dialog - onCancel
} else if (foundPanel.onCancel && options._type === 'cancel') {
promise = foundPanel.onCancel();
// if no onSubmit & onCancel, pass directly
} else {
promise = Promise.resolve();
}
return promise;
})
.then(function(result) {
cachedResult = result;
// 3: call beforeHide
return foundPanel.beforeHide();
})
.then(function() {
// 4. transition
return self._transit('close', dialog, options);
})
.then(function() {
// 5. call hide
return foundPanel.hide();
})
.then(function() {
// 6. Get result and cleanup dialog
var result;
// for prompt dialog, we have to get its own result from input text.
if (dialog.DIALOG_CLASS === 'prompt-dialog') {
result = dialog.getResult();
} else if (cachedResult) {
result = cachedResult;
}
dialog.cleanup();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(false);
}
return result;
});
},
/**
* It is a bridge to call open or close function.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_navigate: function dm__navigate(method, dialog, options) {
method = (method === 'open') ? '_open' : '_close';
return this[method](dialog, options);
},
/**
* DialogService would use this exposed API to open dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
open: function dm_open(dialog, options) {
return this._navigate('open', dialog, options);
},
/**
* DialogService would use this exposed API to close dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
close: function dm_close(dialog, type, options) {
options._type = type;
return this._navigate('close', dialog, options);
}
};
var dialogManager = new DialogManager();
return dialogManager;
});
define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
'use strict';
var Settings = require('settings');
var Defer = require('modules/defer');
var DialogManager = require('modules/dialog_manager');
var PanelDialog = require('modules/dialog/panel_dialog');
var AlertDialog = require('modules/dialog/alert_dialog');
var ConfirmDialog = require('modules/dialog/confirm_dialog');
var PromptDialog = require('modules/dialog/prompt_dialog');
var DialogService = function() {
this._navigating = false;
this._pendingRequests = [];
this._settingsAlertDialogId = 'settings-alert-dialog';
this._settingsBaseDialogId = 'settings-base-dialog';
this._settingsConfirmDialogId = 'settings-confirm-dialog';
this._settingsPromptDialogId = 'settings-prompt-dialog';
};
DialogService.prototype = {
/**
* Alert dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
alert: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsAlertDialogId, {
type: 'alert',
message: message,
title: options.title,
submitButton: options.submitButton
});
},
/**
* Confirm dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
confirm: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsConfirmDialogId, {
type: 'confirm',
message: message,
title: options.title,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Prompt dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
prompt: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsPromptDialogId, {
type: 'prompt',
message: message,
title: options.title,
defaultValue: options.defaultValue,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Panel dialog. If you are going to show any panel as a dialog,
* you have to use this method to show them.
*
* @memberOf DialogService
* @access public
* @param {String} panelId
* @param {Object} userOptions
* @return {Promise}
*/
show: function dm_show(panelId, userOptions, _pendingDefer) {
var self = this;
var defer;
var dialog;
var dialogDOM = document.getElementById(panelId);
var currentPanel = Settings.currentPanel;
var options = userOptions || {};
if (_pendingDefer) {
defer = _pendingDefer;
} else {
defer = Defer();
}
if (this._navigating) {
this._pendingRequests.push({
defer: defer,
panelId: panelId,
userOptions: userOptions
});
} else {
if ('#' + panelId === currentPanel) {
defer.reject('You are showing the same panel #' + panelId);
} else {
var opening;
var closing = false;
function close(type) {
if (closing) return;
closing = true;
// A tap during the opening animation must not run hide/show
// concurrently, or a queued dialog can inherit stale form values.
Promise.resolve(opening).then(function() {
return DialogManager.close(dialog, type, options);
}).then(function(result) {
self._navigating = false;
defer.resolve({type: type, value: result});
self._execPendingRequest();
}, function(error) {
// Panel validation rejects to keep the form open. Allow a
// correction or cancellation without rejecting the show promise.
closing = false;
dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
detail: error
}));
});
}
options.onWrapSubmit = function() { close('submit'); };
options.onWrapCancel = function() { close('cancel'); };
switch (options.type) {
case 'alert':
dialog = AlertDialog(dialogDOM, options);
break;
case 'confirm':
dialog = ConfirmDialog(dialogDOM, options);
break;
case 'prompt':
dialog = PromptDialog(dialogDOM, options);
break;
default:
dialog = PanelDialog(dialogDOM, options);
break;
}
this._navigating = true;
opening = DialogManager.open(dialog, options);
opening.catch(function(error) {
// A missing host API must not lock every later Settings dialog.
dialogDOM.classList.remove('current');
dialogDOM.hidden = true;
var overlay = document.querySelector('.settings-dialog-overlay');
if (overlay) overlay.hidden = true;
self._navigating = false;
defer.reject(error);
self._execPendingRequest();
});
}
}
return defer.promise;
},
/**
* This method can help us pop up any pending request and would try to
* show it after previous request was done.
*
* @memberOf DialogService
* @access private
*/
_execPendingRequest: function() {
var request = this._pendingRequests.pop();
if (request) {
this.show(request.panelId, request.userOptions, request.defer);
}
}
};
var dialogService = new DialogService();
return dialogService;
});
define('panels/screen_lock/screen_lock',['require','modules/dialog_service','shared/settings_listener'],function(require){'use strict';var DialogService=require('modules/dialog_service');var SettingsListener=require('shared/settings_listener');var ScreenLock=function ctor_screenlock(){return{_settings:{passcodeEnabled:false,lockscreenEnabled:false},onInit:function sl_onInit(elements){this._elements=elements;this._elements.passcodeEnable.addEventListener('click',this);this._elements.lockscreenEnable.addEventListener('click',this);this._elements.passcodeEditButton.addEventListener('click',this);this._fetchSettings();},_fetchSettings:function sl_fetchSettings(){SettingsListener.observe('lockscreen.enabled',false,function(enabled){this._toggleLockscreen(enabled);}.bind(this));SettingsListener.observe('lockscreen.passcode-lock.enabled',false,function(enabled){this._togglePasscode(enabled);}.bind(this));},_togglePasscode:function sl_togglePasscode(enabled){this._settings.passcodeEnabled=enabled;this._elements.panel.dataset.passcodeEnabled=enabled;this._elements.passcodeEnable.checked=enabled;},_toggleLockscreen:function sl_toggleLockscreen(enabled){this._settings.lockscreenEnabled=enabled;this._elements.panel.dataset.lockscreenEnabled=enabled;this._elements.lockscreenEnable.checked=enabled;},_showDialog:function sl_showDialog(mode){DialogService.show('screenLock-passcode',{mode:mode});},handleEvent:function sl_handleEvent(evt){switch(evt.target){case this._elements.passcodeEnable:evt.preventDefault();if(this._settings.passcodeEnabled){this._showDialog('confirm');}else{this._showDialog('create');}
break;case this._elements.lockscreenEnable:if(this._settings.lockscreenEnabled===true&&this._settings.passcodeEnabled===true){evt.preventDefault();this._showDialog('confirmLock');}
break;case this._elements.passcodeEditButton:this._showDialog('edit');break;}}};};return ScreenLock;});define('panels/screen_lock/panel',['require','modules/settings_panel','panels/screen_lock/screen_lock'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var ScreenLock=require('panels/screen_lock/screen_lock');return function ctor_screenlock(){var screenLock=ScreenLock();return SettingsPanel({onInit:function(panel){var elements={panel:panel,lockscreenEnable:panel.querySelector('.lockscreen-enable'),passcodeEnable:panel.querySelector('.passcode-enable'),passcodeEditButton:panel.querySelector('.passcode-edit')};screenLock.onInit(elements);}});};});

View file

@ -0,0 +1,17 @@
define('panels/simcard_manager/sim_ui_model',['require'],function(require){'use strict';var SimUIModel=function(cardIndex){this._cardIndex=cardIndex;this._enabled=false;this._absent=false;this._locked=false;this._defaultName={id:'simWithIndex',args:{index:this._cardIndex+1}};this._name=this._defaultName;this._number='';this._operator=null;};SimUIModel.prototype={getInfo:function(){var keys=['enabled','absent','locked','name','number','operator'];var info={};keys.forEach(function(key){info[key]=this['_'+key];}.bind(this));return info;},setState:function(key,options){switch(key){case'nosim':this._enabled=false;this._absent=true;this._locked=false;this._number='';this._operator={id:'noSimCard'};this._name=this._defaultName;break;case'locked':this._enabled=false;this._absent=false;this._locked=true;this._number='';this._operator={id:'sim-pin-locked'};this._name=this._defaultName;break;case'blocked':this._enabled=true;this._absent=true;this._locked=false;this._number='';this._operator=null;this._name={id:'noSimCard'};break;case'normal':this._enabled=true;this._absent=false;this._locked=false;this._number=options.number;if(options.operator){this._operator={text:options.operator};}else{this._operator={id:'no-operator'};}
this._name=this._defaultName;break;}}};return function ctor_simUIModel(cardIndex){return new SimUIModel(cardIndex);};});define('panels/simcard_manager/simcard_manager',['require','shared/sanitizer','shared/sim_settings_helper','shared/airplane_mode_helper','shared/mobile_operator','panels/simcard_manager/sim_ui_model'],function(require){'use strict';var l10n=document.l10n;var Sanitizer=require('shared/sanitizer');var SimSettingsHelper=require('shared/sim_settings_helper');var AirplaneModeHelper=require('shared/airplane_mode_helper');var MobileOperator=require('shared/mobile_operator');var SimUIModel=require('panels/simcard_manager/sim_ui_model');var SimCardManager=function(elements){this._elements=elements;this._simcards=[];this._isAirplaneMode=false;};SimCardManager.prototype={init:function scm_init(){this._elements.outgoingCallSelect.addEventListener('change',this);this._elements.outgoingMessagesSelect.addEventListener('change',this);this._addOutgoingDataSelectEvent();this._addVoiceChangeEventOnConns();this._addCardStateChangeEventOnIccs();this._addLocalizedChangeEventOnIccs();SimSettingsHelper.observe('outgoingData',this._outgoingDataChangeEvent.bind(this));this._addAirplaneModeChangeEvent();this._isAirplaneMode=AirplaneModeHelper.getStatus()==='enabled'?true:false;this._initSimCardsInfo();this._initSimCardManagerUI();},simItemView:function({simIndex}){return Sanitizer.escapeHTML`<li class="sim-card sim-card-${simIndex}">
<div class="sim-card-icon"></div>
<div class="information-container">
<p class="sim-card-name"></p>
<p class="sim-card-operator"></p>
<bdi class="sim-card-number"></bdi>
</div>
</li>`;},_initSimCardsInfo:function scm__initSimCardsInfo(){var conns=window.navigator.mozMobileConnections;for(var cardIndex=0;cardIndex<conns.length;cardIndex++){var conn=conns[cardIndex];var iccId=conn.iccId;var simcard=SimUIModel(cardIndex);this._simcards.push(simcard);this._updateCardState(cardIndex,iccId);}},handleEvent:function scm_handlEvent(evt){var cardIndex=evt.target.value;if(cardIndex===SimSettingsHelper.EMPTY_OPTION_VALUE){return;}
switch(evt.target){case this._elements.outgoingCallSelect:SimSettingsHelper.setServiceOnCard('outgoingCall',cardIndex);break;case this._elements.outgoingMessagesSelect:SimSettingsHelper.setServiceOnCard('outgoingMessages',cardIndex);break;}},_outgoingDataChangeEvent:function scm__outgoingDataChangeEvent(cardIndex){this._elements.outgoingDataSelect.value=cardIndex;},_addOutgoingDataSelectEvent:function scm__addOutgoingDataSelectEvent(){var prevCardIndex;var newCardIndex;this._elements.outgoingDataSelect.addEventListener('focus',function(){prevCardIndex=this.selectedIndex;newCardIndex=this.selectedIndex;});this._elements.outgoingDataSelect.addEventListener('blur',function(){newCardIndex=this.selectedIndex;if(prevCardIndex!==newCardIndex){l10n.formatValue('change-outgoing-data-confirm').then(value=>{var wantToChange=window.confirm(value);if(wantToChange){SimSettingsHelper.setServiceOnCard('outgoingData',newCardIndex);}else{this.selectedIndex=prevCardIndex;}});}});},_getSimCardsCount:function scm__getSimCardsCount(){return this._simcards.length;},_getSimCardInfo:function scm__getSimCardInfo(cardIndex){return this._simcards[cardIndex].getInfo();},_getSimCard:function scm__getSimCard(cardIndex){return this._simcards[cardIndex];},_updateSimCardsUI:function scm__updateSimCardsUI(){this._simcards.forEach(function(simcard,cardIndex){this._updateSimCardUI(cardIndex);}.bind(this));},_updateSimCardUI:function scm__updateSimCardUI(cardIndex){var simcardInfo=this._getSimCardInfo(cardIndex);var selectors=['name','number','operator'];var cardSelector='.sim-card-'+cardIndex;var cardDom=this._elements.simCardContainer.querySelector(cardSelector);cardDom.classList.toggle('absent',simcardInfo.absent);cardDom.classList.toggle('locked',simcardInfo.locked);cardDom.classList.toggle('enabled',simcardInfo.enabled);cardDom.classList.toggle('with-number',!!simcardInfo.number);selectors.forEach(function(selector){var targetSelector=cardSelector+' .sim-card-'+selector;var element=this._elements.simCardContainer.querySelector(targetSelector);if(selector==='number'){element.textContent=simcardInfo[selector];}else{var l10nObj=simcardInfo[selector];if(l10nObj.id){l10n.setAttributes(element,l10nObj.id,l10nObj.args);}else{element.removeAttribute('data-l10n-id');element.textContent=l10nObj.text;}}}.bind(this));},_initSimCardManagerUI:function scm__initSimCardManagerUI(){this._initSimCardsUI();this._updateSelectOptionsUI();this._updateSimCardsUI();this._updateSimSettingsUI();},_initSimCardsUI:function scm__initSimCardsUI(){var simItemHTMLs=[];this._simcards.forEach(function(simcard,index){simItemHTMLs.push(this.simItemView({simIndex:index.toString()}));}.bind(this));this._elements.simCardContainer.innerHTML=simItemHTMLs.join('');},_updateSimSettingsUI:function scm__updateSimSettingsUI(){var firstCardInfo=this._simcards[0].getInfo();var secondCardInfo=(this._simcards[1]?this._simcards[1].getInfo():{absent:true});var hidden=firstCardInfo.absent&&secondCardInfo.absent||this._isAirplaneMode;this._elements.simSettingsHeader.hidden=hidden;this._elements.simSettingsList.hidden=hidden;},_updateSelectOptionsUI:function scm__updateSelectOptionsUI(){var firstCardInfo=this._simcards[0].getInfo();var secondCardInfo=(this._simcards[1]?this._simcards[1].getInfo():{absent:true});if(!firstCardInfo.absent&&!secondCardInfo.absent){SimSettingsHelper.getCardIndexFrom('outgoingCall',function(cardIndex){this._updateSelectOptionUI('outgoingCall',cardIndex,this._elements.outgoingCallSelect);}.bind(this));SimSettingsHelper.getCardIndexFrom('outgoingMessages',function(cardIndex){this._updateSelectOptionUI('outgoingMessages',cardIndex,this._elements.outgoingMessagesSelect);}.bind(this));SimSettingsHelper.getCardIndexFrom('outgoingData',function(cardIndex){this._updateSelectOptionUI('outgoingData',cardIndex,this._elements.outgoingDataSelect);}.bind(this));}else{var selectedCardIndex;if(firstCardInfo.absent&&secondCardInfo.absent){selectedCardIndex=0;}else{selectedCardIndex=firstCardInfo.absent?1:0;}
this._elements.outgoingCallSelect.disabled=true;this._elements.outgoingMessagesSelect.disabled=true;this._elements.outgoingDataSelect.disabled=true;this._updateSelectOptionUI('outgoingCall',selectedCardIndex,this._elements.outgoingCallSelect);this._updateSelectOptionUI('outgoingMessages',selectedCardIndex,this._elements.outgoingMessagesSelect);this._updateSelectOptionUI('outgoingData',selectedCardIndex,this._elements.outgoingDataSelect);}},_updateSelectOptionUI:function scm__updateSelectOptionUI(storageKey,selectedCardIndex,selectDOM){while(selectDOM.firstChild){selectDOM.removeChild(selectDOM.firstChild);}
this._simcards.forEach(function(simcard,index){var simcardInfo=simcard.getInfo();var option=document.createElement('option');option.value=index;if(simcardInfo.absent){option.value=SimSettingsHelper.EMPTY_OPTION_VALUE;option.text=SimSettingsHelper.EMPTY_OPTION_TEXT;}else{if(simcardInfo.name.id){l10n.setAttributes(option,simcardInfo.name.id,simcardInfo.name.args);}else{option.text=simcardInfo.name.text;}}
if(index==selectedCardIndex){option.selected=true;}
selectDOM.add(option);});if(storageKey==='outgoingCall'||storageKey==='outgoingMessages'){var option=document.createElement('option');option.value=SimSettingsHelper.ALWAYS_ASK_OPTION_VALUE;option.setAttribute('data-l10n-id','sim-manager-always-ask');if(SimSettingsHelper.ALWAYS_ASK_OPTION_VALUE===selectedCardIndex){option.selected=true;}
selectDOM.add(option);}},_isSimCardLocked:function scm__isSimCardLocked(cardState){var lockedState=['pinRequired','pukRequired','networkLocked','serviceProviderLocked','corporateLocked','network1Locked','network2Locked','hrpdNetworkLocked','ruimCorporateLocked','ruimServiceProviderLocked'];return lockedState.indexOf(cardState)!==-1;},_isSimCardBlocked:function scm__isSimCardBlocked(cardState){var uselessState=['permanentBlocked'];return uselessState.indexOf(cardState)!==-1;},_addVoiceChangeEventOnConns:function scm__addVoiceChangeEventOnConns(){var conns=window.navigator.mozMobileConnections;for(var i=0;i<conns.length;i++){var iccId=conns[i].iccId;conns[i].addEventListener('voicechange',this._updateCardStateWithUI.bind(this,i,iccId));}},_addCardStateChangeEventOnIccs:function scm__addCardStateChangeEventOnIccs(){var conns=window.navigator.mozMobileConnections;var iccManager=window.navigator.mozIccManager;for(var i=0;i<conns.length;i++){var iccId=conns[i].iccId;var icc=iccManager.getIccById(iccId);if(icc){this._addChangeEventOnIccByIccId(iccId);}}},_addLocalizedChangeEventOnIccs:function scm__addLocalizedChangeEventOnIccs(){var conns=window.navigator.mozMobileConnections;document.addEventListener('DOMRetranslated',function(){for(var i=0;i<conns.length;i++){var iccId=conns[i].iccId;this._updateCardStateWithUI(i,iccId);}}.bind(this));},_addChangeEventOnIccByIccId:function scm__addChangeEventOnIccByIccId(iccId){var self=this;var icc=window.navigator.mozIccManager.getIccById(iccId);if(icc){icc.addEventListener('cardstatechange',function(){var cardIndex=self._getCardIndexByIccId(iccId);self._updateCardStateWithUI(cardIndex,iccId);if(self._isSimCardBlocked(icc.cardState)){self._updateSelectOptionsUI();}});}},_addAirplaneModeChangeEvent:function scm__addAirplaneModeChangeEvent(){var self=this;AirplaneModeHelper.addEventListener('statechange',function(state){if(state==='enabled'||state==='disabled'){var enabled=(state==='enabled')?true:false;self._isAirplaneMode=enabled;self._updateCardsState();self._updateSimCardsUI();self._updateSimSettingsUI();}});},_updateCardsState:function scm__updateCardsState(){var conns=window.navigator.mozMobileConnections;for(var cardIndex=0;cardIndex<conns.length;cardIndex++){var iccId=conns[cardIndex].iccId;this._updateCardState(cardIndex,iccId);}},_updateCardState:function scm__updateCardState(cardIndex,iccId){var iccManager=window.navigator.mozIccManager;var conn=window.navigator.mozMobileConnections[cardIndex];var simcard=this._simcards[cardIndex];if(!iccId||this._isAirplaneMode){simcard.setState('nosim');}else{var icc=iccManager.getIccById(iccId);var iccInfo=icc.iccInfo;var cardState=icc.cardState;var operatorInfo=MobileOperator.userFacingInfo(conn);if(this._isSimCardLocked(cardState)){simcard.setState('locked');}else if(this._isSimCardBlocked(cardState)){simcard.setState('blocked');}else{simcard.setState('normal',{number:iccInfo.msisdn||iccInfo.mdn||'',operator:operatorInfo.operator});}}},_updateCardStateWithUI:function scm__updateCardStateWithUI(cardIndex,iccId){this._updateCardState(cardIndex,iccId);this._updateSimCardUI(cardIndex);this._updateSimSettingsUI();},_getCardIndexByIccId:function scm__getCardIndexByIccId(iccId){var conns=window.navigator.mozMobileConnections;var cardIndex;for(var i=0;i<conns.length;i++){if(conns[i].iccId==iccId){cardIndex=i;}}
return cardIndex;}};return SimCardManager;});define('panels/simcard_manager/panel',['require','modules/settings_panel','panels/simcard_manager/simcard_manager'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var SimCardManager=require('panels/simcard_manager/simcard_manager');return function ctor_sim_manager_panel(){return SettingsPanel({onInit:function(panel){var simcardManager=new SimCardManager({simCardContainer:panel.querySelector('.sim-card-container'),simSettingsHeader:panel.querySelector('.sim-manager-settings-header'),simSettingsList:panel.querySelector('.sim-manager-select-list'),outgoingCallSelect:panel.querySelector('.sim-manager-outgoing-call-select'),outgoingMessagesSelect:panel.querySelector('.sim-manager-outgoing-messages-select'),outgoingDataSelect:panel.querySelector('.sim-manager-outgoing-data-select'),});simcardManager.init();}});};});

View file

@ -0,0 +1,502 @@
define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
'use strict';
var PanelCache = require('modules/panel_cache');
var LazyLoader = require('shared/lazy_loader');
var DialogManager = function() {
this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
};
DialogManager.prototype = {
/**
* load panel based on passed in panelId
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_loadPanel: function dm__loadPanel(panelId) {
var promise = new Promise(function(resolve, reject) {
var panelElement = document.getElementById(panelId);
if (panelElement.dataset.rendered) { // already initialized
resolve();
return;
}
panelElement.dataset.rendered = true;
// XXX remove SubPanel loader once sub panel are modulized
if (panelElement.dataset.requireSubPanels) {
// load the panel and its sub-panels (dependencies)
// (load the main panel last because it contains the scripts)
var selector = 'section[id^="' + panelElement.id + '-"]';
var subPanels = document.querySelectorAll(selector);
for (var i = 0, il = subPanels.length; i < il; i++) {
LazyLoader.load([subPanels[i]]);
}
LazyLoader.load([panelElement], resolve);
} else {
LazyLoader.load([panelElement], resolve);
}
});
return promise;
},
/**
* promised version of PanelCache.get()
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_getPanel: function dm__getPanel(panelId) {
var promise = new Promise(function(resolve) {
PanelCache.get(panelId, function(panel) {
resolve(panel);
});
});
return promise;
},
/**
* this is used to control visibility of overlayDOM
*
* @memberOf DialogManager
* @access private
* @param {Boolean} show
*/
_showOverlay: function dm__showOverlay(show) {
this._overlayDOM.hidden = !show;
},
/**
* It is used to control the timing of transitions so that we can make sure
* whether animation is done or not.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_transit: function dm__transit(method, dialog, options) {
var promise = new Promise(function(resolve) {
var panel = dialog.panel;
var completed = false;
var fallback;
function finish(event) {
if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
if (completed) return;
completed = true;
clearTimeout(fallback);
panel.removeEventListener('transitionend', finish);
panel.removeEventListener('transitioncancel', finish);
if (method === 'close') panel.hidden = true;
resolve();
}
if (method === 'open') panel.hidden = false;
setTimeout(function() {
panel.addEventListener('transitionend', finish);
panel.addEventListener('transitioncancel', finish);
panel.classList.toggle('current', method === 'open');
var style = window.getComputedStyle(panel);
function milliseconds(value) {
return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
}
var durations = style.transitionDuration.split(',').map(milliseconds);
var delays = style.transitionDelay.split(',').map(milliseconds);
var duration = Math.max.apply(Math, durations.map(function(value, index) {
return value + delays[index % delays.length];
}));
// Reduced motion, display changes or interrupted transitions may
// omit the visibility event. Always release the dialog queue.
fallback = setTimeout(finish, Math.max(0, duration) + 100);
}, 150);
});
return promise;
},
/**
* Do necessary works to open panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_open: function dm__open(dialog, options) {
var self = this;
var foundPanel;
return Promise.resolve()
.then(function() {
// 1: load panel
return self._loadPanel(dialog.panel.id);
})
.then(function() {
// 2: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 3: call beforeShow
foundPanel = panel;
return foundPanel.beforeShow(dialog.panel, options);
})
.then(function() {
// 4. UI stuffs + transition
dialog.init();
dialog.initUI();
dialog.bindEvents();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(true);
}
return self._transit('open', dialog, options);
})
.then(function() {
// 5. show that panel as a dialog
return foundPanel.show(dialog.panel, options);
});
},
/**
* Do necessary works to close panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_close: function dm__close(dialog, options) {
var self = this;
var foundPanel;
var cachedResult;
return Promise.resolve()
.then(function() {
// 1: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 2: Let's validate to see whether we can close this dialog or not.
foundPanel = panel;
var promise;
// custom dialog - onSubmit
if (foundPanel.onSubmit && options._type === 'submit') {
promise = foundPanel.onSubmit();
// custom dialog - onCancel
} else if (foundPanel.onCancel && options._type === 'cancel') {
promise = foundPanel.onCancel();
// if no onSubmit & onCancel, pass directly
} else {
promise = Promise.resolve();
}
return promise;
})
.then(function(result) {
cachedResult = result;
// 3: call beforeHide
return foundPanel.beforeHide();
})
.then(function() {
// 4. transition
return self._transit('close', dialog, options);
})
.then(function() {
// 5. call hide
return foundPanel.hide();
})
.then(function() {
// 6. Get result and cleanup dialog
var result;
// for prompt dialog, we have to get its own result from input text.
if (dialog.DIALOG_CLASS === 'prompt-dialog') {
result = dialog.getResult();
} else if (cachedResult) {
result = cachedResult;
}
dialog.cleanup();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(false);
}
return result;
});
},
/**
* It is a bridge to call open or close function.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_navigate: function dm__navigate(method, dialog, options) {
method = (method === 'open') ? '_open' : '_close';
return this[method](dialog, options);
},
/**
* DialogService would use this exposed API to open dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
open: function dm_open(dialog, options) {
return this._navigate('open', dialog, options);
},
/**
* DialogService would use this exposed API to close dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
close: function dm_close(dialog, type, options) {
options._type = type;
return this._navigate('close', dialog, options);
}
};
var dialogManager = new DialogManager();
return dialogManager;
});
define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
'use strict';
var Settings = require('settings');
var Defer = require('modules/defer');
var DialogManager = require('modules/dialog_manager');
var PanelDialog = require('modules/dialog/panel_dialog');
var AlertDialog = require('modules/dialog/alert_dialog');
var ConfirmDialog = require('modules/dialog/confirm_dialog');
var PromptDialog = require('modules/dialog/prompt_dialog');
var DialogService = function() {
this._navigating = false;
this._pendingRequests = [];
this._settingsAlertDialogId = 'settings-alert-dialog';
this._settingsBaseDialogId = 'settings-base-dialog';
this._settingsConfirmDialogId = 'settings-confirm-dialog';
this._settingsPromptDialogId = 'settings-prompt-dialog';
};
DialogService.prototype = {
/**
* Alert dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
alert: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsAlertDialogId, {
type: 'alert',
message: message,
title: options.title,
submitButton: options.submitButton
});
},
/**
* Confirm dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
confirm: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsConfirmDialogId, {
type: 'confirm',
message: message,
title: options.title,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Prompt dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
prompt: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsPromptDialogId, {
type: 'prompt',
message: message,
title: options.title,
defaultValue: options.defaultValue,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Panel dialog. If you are going to show any panel as a dialog,
* you have to use this method to show them.
*
* @memberOf DialogService
* @access public
* @param {String} panelId
* @param {Object} userOptions
* @return {Promise}
*/
show: function dm_show(panelId, userOptions, _pendingDefer) {
var self = this;
var defer;
var dialog;
var dialogDOM = document.getElementById(panelId);
var currentPanel = Settings.currentPanel;
var options = userOptions || {};
if (_pendingDefer) {
defer = _pendingDefer;
} else {
defer = Defer();
}
if (this._navigating) {
this._pendingRequests.push({
defer: defer,
panelId: panelId,
userOptions: userOptions
});
} else {
if ('#' + panelId === currentPanel) {
defer.reject('You are showing the same panel #' + panelId);
} else {
var opening;
var closing = false;
function close(type) {
if (closing) return;
closing = true;
// A tap during the opening animation must not run hide/show
// concurrently, or a queued dialog can inherit stale form values.
Promise.resolve(opening).then(function() {
return DialogManager.close(dialog, type, options);
}).then(function(result) {
self._navigating = false;
defer.resolve({type: type, value: result});
self._execPendingRequest();
}, function(error) {
// Panel validation rejects to keep the form open. Allow a
// correction or cancellation without rejecting the show promise.
closing = false;
dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
detail: error
}));
});
}
options.onWrapSubmit = function() { close('submit'); };
options.onWrapCancel = function() { close('cancel'); };
switch (options.type) {
case 'alert':
dialog = AlertDialog(dialogDOM, options);
break;
case 'confirm':
dialog = ConfirmDialog(dialogDOM, options);
break;
case 'prompt':
dialog = PromptDialog(dialogDOM, options);
break;
default:
dialog = PanelDialog(dialogDOM, options);
break;
}
this._navigating = true;
opening = DialogManager.open(dialog, options);
opening.catch(function(error) {
// A missing host API must not lock every later Settings dialog.
dialogDOM.classList.remove('current');
dialogDOM.hidden = true;
var overlay = document.querySelector('.settings-dialog-overlay');
if (overlay) overlay.hidden = true;
self._navigating = false;
defer.reject(error);
self._execPendingRequest();
});
}
}
return defer.promise;
},
/**
* This method can help us pop up any pending request and would try to
* show it after previous request was done.
*
* @memberOf DialogService
* @access private
*/
_execPendingRequest: function() {
var request = this._pendingRequests.pop();
if (request) {
this.show(request.panelId, request.userOptions, request.defer);
}
}
};
var dialogService = new DialogService();
return dialogService;
});
define('panels/simpin/simpin',['require','shared/airplane_mode_helper','shared/simslot_manager','modules/dialog_service','shared/sanitizer','shared/toaster','modules/sim_security'],function(require){'use strict';var AirplaneModeHelper=require('shared/airplane_mode_helper');var SIMSlotManager=require('shared/simslot_manager');var DialogService=require('modules/dialog_service');var Sanitizer=require('shared/sanitizer');var Toaster=require('shared/toaster');var SimSecurity=require('modules/sim_security');var SimPin=function(elements){this._elements=elements;};SimPin.prototype={init:function simpin_init(){AirplaneModeHelper.ready(()=>{this.conns=window.navigator.mozMobileConnections;this.iccManager=window.navigator.mozIccManager;this.isAirplaneMode=(AirplaneModeHelper.getStatus()==='enabled');this._elements.simPinContainer.addEventListener('click',this);this._elements.simPinContainer.addEventListener('change',this.simPinSwitched.bind(this));this.addIccDetectedEvent();this.addIccUndetectedEvent();this.addAirplaneModeChangeEvent();this.initSimPinBack();this.initSimPinsUI();this.updateSimPinsUI();this.addChangeEventOnIccs();});},simPinView:function({simIndex,simPinArgs}){return Sanitizer.escapeHTML`
<li class="simpin-enabled simpin-enabled-${simIndex}
simpin-${simIndex}">
<gaia-switch data-ignore data-sim-index="${simIndex}">
<label data-l10n-id="simPinWithIndex"
data-l10n-args="${simPinArgs}"></label>
</gaia-switch>
</li>
<li class="simpin-change simpin-change-${simIndex} simpin-${simIndex}"
hidden>
<a class="menu-item" href="#" data-sim-index="${simIndex}"
data-type="changeSimPin">
<span data-l10n-id="changeSimPin"></span>
</a>
</li>`;},initSimPinBack:function simpin_initSimPinBack(){this._elements.simPinHeader.dataset.href=SIMSlotManager.isMultiSIM()?'#sim-manager':'#root';},initSimPinsUI:function simpin_initSimPinsUI(){var simPinHTMLs=[];[].forEach.call(this.conns,(conn,index)=>{var simPinIndex=index+1;if(!SIMSlotManager.isMultiSIM()){simPinIndex='';}
simPinHTMLs.push(this.simPinView({'simIndex':index.toString(),'simPinArgs':JSON.stringify({'index':simPinIndex})}));});this._elements.simPinContainer.innerHTML=simPinHTMLs.join('');},updateSimPinUI:function simpin_updateSimPinUI(cardIndex){var iccId=this.conns[cardIndex].iccId;var icc=this.iccManager.getIccById(iccId);var changeSimPinItem=this._elements.simPinContainer.querySelector('.simpin-change-'+cardIndex);var simPinCheckbox=this._elements.simPinContainer.querySelector('.simpin-enabled-'+cardIndex+' gaia-switch');var isSimAvailable=icc&&icc.cardState&&icc.cardState!=='unknown';if(!isSimAvailable||this.isAirplaneMode){simPinCheckbox.disabled=true;changeSimPinItem.hidden=true;return Promise.resolve();}
return SimSecurity.getCardLock(cardIndex,'pin').then((result)=>{var enabled=result.enabled;simPinCheckbox.disabled=false;simPinCheckbox.checked=enabled;changeSimPinItem.hidden=!enabled;},()=>{console.log('onerror');console.log('cardIndex',cardIndex);});},updateSimPinsUI:function simpin_updateSimPinsUI(){[].forEach.call(this.conns,(simcard,cardIndex)=>{this.updateSimPinUI(cardIndex);});},handleEvent:function simpin_handleEvent(evt){var target=evt.target;var cardIndex=target.dataset&&target.dataset.simIndex;var type=target.dataset&&target.dataset.type;cardIndex=parseInt(cardIndex,10);switch(type){case'changeSimPin':this.changeSimPin(cardIndex);break;}},simPinSwitched:function simpin_simPinSwitched(evt){var target=evt.target;var cardIndex=target.dataset&&target.dataset.simIndex;cardIndex=parseInt(cardIndex,10);this.checkSimPin(target,cardIndex);},checkSimPin:function simpin_checkSimPin(checkbox,cardIndex){var enabled=checkbox.checked;var iccId=this.conns[cardIndex].iccId;var icc=this.iccManager.getIccById(iccId);switch(icc.cardState){case'pukRequired':return DialogService.show('simpin-dialog',{method:'unlock_puk',cardIndex:cardIndex}).then((result)=>{var type=result.type;if(type==='submit'){checkbox.checked=true;this.updateSimPinUI(cardIndex);}else{checkbox.checked=!enabled;this.updateSimPinUI(cardIndex);}});default:var action=enabled?'enable_lock':'disable_lock';return DialogService.show('simpin-dialog',{method:action,cardIndex:cardIndex}).then((result)=>{var type=result.type;if(type==='submit'){this.updateSimPinUI(cardIndex);}else{checkbox.checked=!enabled;this.updateSimPinUI(cardIndex);}});}},changeSimPin:function(cardIndex){return DialogService.show('simpin-dialog',{method:'change_pin',cardIndex:cardIndex}).then(function(result){var type=result.type;if(type==='submit'){var toast;if(SIMSlotManager.isMultiSIM()){toast={messageL10nId:'simPinChangedSuccessfullyWithIndex',messageL10nArgs:{'index':+(cardIndex)+1},latency:3000,useTransition:true};}else{toast={messageL10nId:'simPinChangedSuccessfully',latency:3000,useTransition:true};}
Toaster.showToast(toast);}});},addIccDetectedEvent:function simpin_addIccDetectedEvent(){this.iccManager.addEventListener('iccdetected',(evt)=>{var iccId=evt.iccId;var icc=this.iccManager.getIccById(iccId);if(icc){var cardIndex=this.getCardIndexByIccId(iccId);this.updateSimPinUI(cardIndex);this.addChangeEventOnIccByIccId(iccId);}});},addIccUndetectedEvent:function simpin_addIccDetectedEvent(){this.iccManager.addEventListener('iccundetected',(evt)=>{this.updateSimPinsUI();});},addAirplaneModeChangeEvent:function simpin_addAirplaneModeChangeEvent(){AirplaneModeHelper.addEventListener('statechange',(status)=>{this.isAirplaneMode=(status==='enabled');this.updateSimPinsUI();});},addChangeEventOnIccs:function simpin_addChangeEventOnIccs(){for(var i=0;i<this.conns.length;i++){var iccId=this.conns[i].iccId;var icc=this.iccManager.getIccById(iccId);if(icc){this.addChangeEventOnIccByIccId(iccId);}}},addChangeEventOnIccByIccId:function simpin_addChangeEventOnIccByIccId(iccId){var icc=this.iccManager.getIccById(iccId);if(icc){icc.addEventListener('cardstatechange',()=>{var cardIndex=this.getCardIndexByIccId(iccId);this.updateSimPinUI(cardIndex);});}},getCardIndexByIccId:function simpin_getCardIndexByIccId(iccId){var cardIndex;for(var i=0;i<this.conns.length;i++){if(this.conns[i].iccId==iccId){cardIndex=i;}}
return cardIndex;}};return function ctor_simpin(elements){return new SimPin(elements);};});define('panels/simpin/panel',['require','modules/settings_panel','panels/simpin/simpin'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var SimPin=require('panels/simpin/simpin');return function ctor_simpin_panel(){return SettingsPanel({onInit:function(panel){var elements={};elements.simPinContainer=panel.querySelector('.simpin-container');elements.simPinHeader=panel.querySelector('.simpin-header');var simpin=SimPin(elements);simpin.init();}});};});

View file

@ -0,0 +1,487 @@
define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
'use strict';
var PanelCache = require('modules/panel_cache');
var LazyLoader = require('shared/lazy_loader');
var DialogManager = function() {
this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
};
DialogManager.prototype = {
/**
* load panel based on passed in panelId
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_loadPanel: function dm__loadPanel(panelId) {
var promise = new Promise(function(resolve, reject) {
var panelElement = document.getElementById(panelId);
if (panelElement.dataset.rendered) { // already initialized
resolve();
return;
}
panelElement.dataset.rendered = true;
// XXX remove SubPanel loader once sub panel are modulized
if (panelElement.dataset.requireSubPanels) {
// load the panel and its sub-panels (dependencies)
// (load the main panel last because it contains the scripts)
var selector = 'section[id^="' + panelElement.id + '-"]';
var subPanels = document.querySelectorAll(selector);
for (var i = 0, il = subPanels.length; i < il; i++) {
LazyLoader.load([subPanels[i]]);
}
LazyLoader.load([panelElement], resolve);
} else {
LazyLoader.load([panelElement], resolve);
}
});
return promise;
},
/**
* promised version of PanelCache.get()
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_getPanel: function dm__getPanel(panelId) {
var promise = new Promise(function(resolve) {
PanelCache.get(panelId, function(panel) {
resolve(panel);
});
});
return promise;
},
/**
* this is used to control visibility of overlayDOM
*
* @memberOf DialogManager
* @access private
* @param {Boolean} show
*/
_showOverlay: function dm__showOverlay(show) {
this._overlayDOM.hidden = !show;
},
/**
* It is used to control the timing of transitions so that we can make sure
* whether animation is done or not.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_transit: function dm__transit(method, dialog, options) {
var promise = new Promise(function(resolve) {
var panel = dialog.panel;
var completed = false;
var fallback;
function finish(event) {
if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
if (completed) return;
completed = true;
clearTimeout(fallback);
panel.removeEventListener('transitionend', finish);
panel.removeEventListener('transitioncancel', finish);
if (method === 'close') panel.hidden = true;
resolve();
}
if (method === 'open') panel.hidden = false;
setTimeout(function() {
panel.addEventListener('transitionend', finish);
panel.addEventListener('transitioncancel', finish);
panel.classList.toggle('current', method === 'open');
var style = window.getComputedStyle(panel);
function milliseconds(value) {
return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
}
var durations = style.transitionDuration.split(',').map(milliseconds);
var delays = style.transitionDelay.split(',').map(milliseconds);
var duration = Math.max.apply(Math, durations.map(function(value, index) {
return value + delays[index % delays.length];
}));
// Reduced motion, display changes or interrupted transitions may
// omit the visibility event. Always release the dialog queue.
fallback = setTimeout(finish, Math.max(0, duration) + 100);
}, 150);
});
return promise;
},
/**
* Do necessary works to open panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_open: function dm__open(dialog, options) {
var self = this;
var foundPanel;
return Promise.resolve()
.then(function() {
// 1: load panel
return self._loadPanel(dialog.panel.id);
})
.then(function() {
// 2: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 3: call beforeShow
foundPanel = panel;
return foundPanel.beforeShow(dialog.panel, options);
})
.then(function() {
// 4. UI stuffs + transition
dialog.init();
dialog.initUI();
dialog.bindEvents();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(true);
}
return self._transit('open', dialog, options);
})
.then(function() {
// 5. show that panel as a dialog
return foundPanel.show(dialog.panel, options);
});
},
/**
* Do necessary works to close panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_close: function dm__close(dialog, options) {
var self = this;
var foundPanel;
var cachedResult;
return Promise.resolve()
.then(function() {
// 1: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 2: Let's validate to see whether we can close this dialog or not.
foundPanel = panel;
var promise;
// custom dialog - onSubmit
if (foundPanel.onSubmit && options._type === 'submit') {
promise = foundPanel.onSubmit();
// custom dialog - onCancel
} else if (foundPanel.onCancel && options._type === 'cancel') {
promise = foundPanel.onCancel();
// if no onSubmit & onCancel, pass directly
} else {
promise = Promise.resolve();
}
return promise;
})
.then(function(result) {
cachedResult = result;
// 3: call beforeHide
return foundPanel.beforeHide();
})
.then(function() {
// 4. transition
return self._transit('close', dialog, options);
})
.then(function() {
// 5. call hide
return foundPanel.hide();
})
.then(function() {
// 6. Get result and cleanup dialog
var result;
// for prompt dialog, we have to get its own result from input text.
if (dialog.DIALOG_CLASS === 'prompt-dialog') {
result = dialog.getResult();
} else if (cachedResult) {
result = cachedResult;
}
dialog.cleanup();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(false);
}
return result;
});
},
/**
* It is a bridge to call open or close function.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_navigate: function dm__navigate(method, dialog, options) {
method = (method === 'open') ? '_open' : '_close';
return this[method](dialog, options);
},
/**
* DialogService would use this exposed API to open dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
open: function dm_open(dialog, options) {
return this._navigate('open', dialog, options);
},
/**
* DialogService would use this exposed API to close dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
close: function dm_close(dialog, type, options) {
options._type = type;
return this._navigate('close', dialog, options);
}
};
var dialogManager = new DialogManager();
return dialogManager;
});
define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
'use strict';
var Settings = require('settings');
var Defer = require('modules/defer');
var DialogManager = require('modules/dialog_manager');
var PanelDialog = require('modules/dialog/panel_dialog');
var AlertDialog = require('modules/dialog/alert_dialog');
var ConfirmDialog = require('modules/dialog/confirm_dialog');
var PromptDialog = require('modules/dialog/prompt_dialog');
var DialogService = function() {
this._navigating = false;
this._pendingRequests = [];
this._settingsAlertDialogId = 'settings-alert-dialog';
this._settingsBaseDialogId = 'settings-base-dialog';
this._settingsConfirmDialogId = 'settings-confirm-dialog';
this._settingsPromptDialogId = 'settings-prompt-dialog';
};
DialogService.prototype = {
/**
* Alert dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
alert: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsAlertDialogId, {
type: 'alert',
message: message,
title: options.title,
submitButton: options.submitButton
});
},
/**
* Confirm dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
confirm: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsConfirmDialogId, {
type: 'confirm',
message: message,
title: options.title,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Prompt dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
prompt: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsPromptDialogId, {
type: 'prompt',
message: message,
title: options.title,
defaultValue: options.defaultValue,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Panel dialog. If you are going to show any panel as a dialog,
* you have to use this method to show them.
*
* @memberOf DialogService
* @access public
* @param {String} panelId
* @param {Object} userOptions
* @return {Promise}
*/
show: function dm_show(panelId, userOptions, _pendingDefer) {
var self = this;
var defer;
var dialog;
var dialogDOM = document.getElementById(panelId);
var currentPanel = Settings.currentPanel;
var options = userOptions || {};
if (_pendingDefer) {
defer = _pendingDefer;
} else {
defer = Defer();
}
if (this._navigating) {
this._pendingRequests.push({
defer: defer,
panelId: panelId,
userOptions: userOptions
});
} else {
if ('#' + panelId === currentPanel) {
defer.reject('You are showing the same panel #' + panelId);
} else {
var opening;
var closing = false;
function close(type) {
if (closing) return;
closing = true;
// A tap during the opening animation must not run hide/show
// concurrently, or a queued dialog can inherit stale form values.
Promise.resolve(opening).then(function() {
return DialogManager.close(dialog, type, options);
}).then(function(result) {
self._navigating = false;
defer.resolve({type: type, value: result});
self._execPendingRequest();
}, function(error) {
// Panel validation rejects to keep the form open. Allow a
// correction or cancellation without rejecting the show promise.
closing = false;
dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
detail: error
}));
});
}
options.onWrapSubmit = function() { close('submit'); };
options.onWrapCancel = function() { close('cancel'); };
switch (options.type) {
case 'alert':
dialog = AlertDialog(dialogDOM, options);
break;
case 'confirm':
dialog = ConfirmDialog(dialogDOM, options);
break;
case 'prompt':
dialog = PromptDialog(dialogDOM, options);
break;
default:
dialog = PanelDialog(dialogDOM, options);
break;
}
this._navigating = true;
opening = DialogManager.open(dialog, options);
opening.catch(function(error) {
// A missing host API must not lock every later Settings dialog.
dialogDOM.classList.remove('current');
dialogDOM.hidden = true;
var overlay = document.querySelector('.settings-dialog-overlay');
if (overlay) overlay.hidden = true;
self._navigating = false;
defer.reject(error);
self._execPendingRequest();
});
}
}
return defer.promise;
},
/**
* This method can help us pop up any pending request and would try to
* show it after previous request was done.
*
* @memberOf DialogService
* @access private
*/
_execPendingRequest: function() {
var request = this._pendingRequests.pop();
if (request) {
this.show(request.panelId, request.userOptions, request.defer);
}
}
};
var dialogService = new DialogService();
return dialogService;
});
define('panels/usb_storage/usb_transfer',['require','modules/settings_cache','shared/settings_listener','modules/dialog_service','shared/async_storage','modules/media_storage'],function(require){'use strict';var SettingsCache=require('modules/settings_cache');var SettingsListener=require('shared/settings_listener');var DialogService=require('modules/dialog_service');var AsyncStorage=require('shared/async_storage');var MediaStorage=require('modules/media_storage');var _debug=false;var debug=function(){};if(_debug){debug=function ut_debug(msg){console.log('--> [UsbTransfer]: '+msg);};}
var UsbTransfer=function(){this._keyUmsEnabled='ums.enabled';this._keyUmsMode='ums.mode';this._keyTransferProtocol='usb.transfer';this._elements=null;this._usbHotProtocolSwitch=false;this._partialUmsSupport=!navigator.getDeviceStorages('sdcard').every(storage=>storage.canBeShared);this.PROTOCOL_UMS='0';this.PROTOCOL_MTP='1';this.MODE_UMS=1;this.MODE_MTP=3;};UsbTransfer.prototype={init:function ut_init(elements,option){this._elements=elements;if(option&&option.usbHotProtocolSwitch!==undefined){this._usbHotProtocolSwitch=option.usbHotProtocolSwitch;}
SettingsListener.observe(this._keyTransferProtocol,this.PROTOCOL_UMS,this._configProtocol.bind(this));SettingsListener.observe(this._keyUmsEnabled,false,this._umsEnabledHandler.bind(this));this._elements.usbEnabledCheckBox.addEventListener('change',this._umsCheckboxChange.bind(this));MediaStorage.observe('volumeState',this._updateUmsState.bind(this));},_umsCheckboxChange:function storage_umsCheckboxChange(evt){var checkbox=evt.target;var cset={};var warningKey='ums-turn-on-warning';debug('state change '+checkbox.checked);if(checkbox.checked){AsyncStorage.getItem(warningKey,(showed)=>{if(!showed){debug('show turn-on warning');this._umsTurnOnWarning(checkbox,warningKey);}else{debug('turn-on warning is showed');SettingsCache.getSettings(this._openIncompatibleSettingsDialogIfNeeded.bind(this));}});}else{cset[this._keyUmsEnabled]=false;navigator.mozSettings.createLock().set(cset);}},_umsTurnOnWarning:function storage_umsTurnOnWarning(checkbox,warningKey){DialogService.confirm('ums-confirm',{title:'ums-warning-title',submitButton:'ok',cancelButton:'cancel'}).then((result)=>{var type=result.type;if(type==='submit'){debug('turn on success');AsyncStorage.setItem(warningKey,true);SettingsCache.getSettings(this._openIncompatibleSettingsDialogIfNeeded.bind(this));}else{debug('turn on fail');var cset={};cset[this._keyUmsEnabled]=false;navigator.mozSettings.createLock().set(cset);checkbox.checked=false;}});},_openIncompatibleSettingsDialogIfNeeded:function storage_openIncompatibleSettingsDialogIfNeeded(settings){var cset={};var umsSettingKey=this._keyUmsEnabled;var oldSetting='tethering.usb.enabled';var usbTetheringSetting=settings[oldSetting];if(!usbTetheringSetting){cset[umsSettingKey]=true;navigator.mozSettings.createLock().set(cset);}else{DialogService.confirm('is-warning-storage-tethering-message',{title:'is-warning-storage-header',submitButton:{id:'enable',style:'recommend'},cancelButton:{id:'cancel'}}).then((result)=>{var enabled=result.type==='submit';var lock=navigator.mozSettings.createLock();var cset={};cset[umsSettingKey]=enabled;cset[oldSetting]=!enabled;lock.set(cset);});}},_umsEnabledHandler:function ut_umsEnabledHandler(enabled){debug('ums.enabled: '+enabled);this._elements.usbEnabledCheckBox.checked=enabled;this._updateUmsState();},_configProtocol:function ut_configProtocol(protocol){SettingsCache.getSettings((results)=>{var enabled=results[this._keyUmsEnabled];var mode=results[this._keyUmsMode];if(enabled){this._changeMode(mode,protocol);}});},_changeMode:function ut_changeMode(mode,protocol){if(mode!==this.MODE_MTP&&protocol===this.PROTOCOL_MTP){this._setMode(this.MODE_MTP);}else if(mode===this.MODE_MTP&&protocol===this.PROTOCOL_UMS){if(this._partialUmsSupport){debug('show partial warning');DialogService.show('ums-partial-warning').then((result)=>{if(result.type==='cancel'){var param={};param[this._keyTransferProtocol]=this.PROTOCOL_MTP;SettingsListener.getSettingsLock().set(param);}else{this._setMode(this.MODE_UMS);}});}else{this._setMode(this.MODE_UMS);}}else{console.log('Error: should not be executed');}},_updateUmsState:function ut_updateUmsState(){var key;debug('enabled:'+this._elements.usbEnabledCheckBox.checked+'/'+'volumeState:'+MediaStorage.volumeState);if(this._elements.usbEnabledCheckBox.checked){key='enabled';if(MediaStorage.volumeState==='shared'){this._disableProtocolSelections(true);}else{this._disableProtocolSelections(false);}}else if(MediaStorage.volumeState==='shared'){key='umsUnplugToDisable';this._disableProtocolSelections(true);}else{key='disabled';this._disableProtocolSelections(false);}
this._elements.usbEnabledInfoBlock.setAttribute('data-l10n-id',key);},_disableProtocolSelections:function ut_disableProtocolSelections(disabled){var i;if(disabled){if(!this._usbHotProtocolSwitch){for(i=0;i<this._elements.protocols.length;i++){this._elements.protocols[i].setAttribute('disabled',true);}}}else{for(i=0;i<this._elements.protocols.length;i++){this._elements.protocols[i].removeAttribute('disabled');}}},_setMode:function ut_setMode(mode){var param={};param[this._keyUmsMode]=mode;SettingsListener.getSettingsLock().set(param);}};return function ctor_usb_transfer(){return new UsbTransfer();};});define('panels/usb_storage/panel',['require','modules/settings_panel','panels/usb_storage/usb_transfer','shared/lazy_loader'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var UsbTransferModule=require('panels/usb_storage/usb_transfer');var LazyLoader=require('shared/lazy_loader');var elements={};return function ctor_usb_storage_panel(){var usbTransfer=UsbTransferModule();return SettingsPanel({onInit:function us_onInit(panel){elements={usbEnabledCheckBox:panel.querySelector('.ums-switch'),usbEnabledInfoBlock:panel.querySelector('.ums-switch-desc'),protocols:panel.querySelectorAll('gaia-radio')};LazyLoader.getJSON('/resources/device-features.json').then(({usbHotProtocolSwitch})=>usbTransfer.init(elements,{usbHotProtocolSwitch:usbHotProtocolSwitch}));}});};});

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,485 @@
define('modules/dialog_manager',["require", "modules/panel_cache", "shared/lazy_loader"],function(require) {
'use strict';
var PanelCache = require('modules/panel_cache');
var LazyLoader = require('shared/lazy_loader');
var DialogManager = function() {
this.OVERLAY_SELECTOR = '.settings-dialog-overlay';
this._overlayDOM = document.querySelector(this.OVERLAY_SELECTOR);
};
DialogManager.prototype = {
/**
* load panel based on passed in panelId
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_loadPanel: function dm__loadPanel(panelId) {
var promise = new Promise(function(resolve, reject) {
var panelElement = document.getElementById(panelId);
if (panelElement.dataset.rendered) { // already initialized
resolve();
return;
}
panelElement.dataset.rendered = true;
// XXX remove SubPanel loader once sub panel are modulized
if (panelElement.dataset.requireSubPanels) {
// load the panel and its sub-panels (dependencies)
// (load the main panel last because it contains the scripts)
var selector = 'section[id^="' + panelElement.id + '-"]';
var subPanels = document.querySelectorAll(selector);
for (var i = 0, il = subPanels.length; i < il; i++) {
LazyLoader.load([subPanels[i]]);
}
LazyLoader.load([panelElement], resolve);
} else {
LazyLoader.load([panelElement], resolve);
}
});
return promise;
},
/**
* promised version of PanelCache.get()
*
* @memberOf DialogManager
* @access private
* @param {String} panelId
* @return {Promise}
*/
_getPanel: function dm__getPanel(panelId) {
var promise = new Promise(function(resolve) {
PanelCache.get(panelId, function(panel) {
resolve(panel);
});
});
return promise;
},
/**
* this is used to control visibility of overlayDOM
*
* @memberOf DialogManager
* @access private
* @param {Boolean} show
*/
_showOverlay: function dm__showOverlay(show) {
this._overlayDOM.hidden = !show;
},
/**
* It is used to control the timing of transitions so that we can make sure
* whether animation is done or not.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_transit: function dm__transit(method, dialog, options) {
var promise = new Promise(function(resolve) {
var panel = dialog.panel;
var completed = false;
var fallback;
function finish(event) {
if (event && (event.target !== panel || event.propertyName !== 'visibility')) return;
if (completed) return;
completed = true;
clearTimeout(fallback);
panel.removeEventListener('transitionend', finish);
panel.removeEventListener('transitioncancel', finish);
if (method === 'close') panel.hidden = true;
resolve();
}
if (method === 'open') panel.hidden = false;
setTimeout(function() {
panel.addEventListener('transitionend', finish);
panel.addEventListener('transitioncancel', finish);
panel.classList.toggle('current', method === 'open');
var style = window.getComputedStyle(panel);
function milliseconds(value) {
return parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0;
}
var durations = style.transitionDuration.split(',').map(milliseconds);
var delays = style.transitionDelay.split(',').map(milliseconds);
var duration = Math.max.apply(Math, durations.map(function(value, index) {
return value + delays[index % delays.length];
}));
// Reduced motion, display changes or interrupted transitions may
// omit the visibility event. Always release the dialog queue.
fallback = setTimeout(finish, Math.max(0, duration) + 100);
}, 150);
});
return promise;
},
/**
* Do necessary works to open panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_open: function dm__open(dialog, options) {
var self = this;
var foundPanel;
return Promise.resolve()
.then(function() {
// 1: load panel
return self._loadPanel(dialog.panel.id);
})
.then(function() {
// 2: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 3: call beforeShow
foundPanel = panel;
return foundPanel.beforeShow(dialog.panel, options);
})
.then(function() {
// 4. UI stuffs + transition
dialog.init();
dialog.initUI();
dialog.bindEvents();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(true);
}
return self._transit('open', dialog, options);
})
.then(function() {
// 5. show that panel as a dialog
return foundPanel.show(dialog.panel, options);
});
},
/**
* Do necessary works to close panel like loading panel, doing transition
* and call related functions.
*
* @memberOf DialogManager
* @access private
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_close: function dm__close(dialog, options) {
var self = this;
var foundPanel;
var cachedResult;
return Promise.resolve()
.then(function() {
// 1: Get that panel
return self._getPanel(dialog.panel.id);
})
.then(function(panel) {
// 2: Let's validate to see whether we can close this dialog or not.
foundPanel = panel;
var promise;
// custom dialog - onSubmit
if (foundPanel.onSubmit && options._type === 'submit') {
promise = foundPanel.onSubmit();
// custom dialog - onCancel
} else if (foundPanel.onCancel && options._type === 'cancel') {
promise = foundPanel.onCancel();
// if no onSubmit & onCancel, pass directly
} else {
promise = Promise.resolve();
}
return promise;
})
.then(function(result) {
cachedResult = result;
// 3: call beforeHide
return foundPanel.beforeHide();
})
.then(function() {
// 4. transition
return self._transit('close', dialog, options);
})
.then(function() {
// 5. call hide
return foundPanel.hide();
})
.then(function() {
// 6. Get result and cleanup dialog
var result;
// for prompt dialog, we have to get its own result from input text.
if (dialog.DIALOG_CLASS === 'prompt-dialog') {
result = dialog.getResult();
} else if (cachedResult) {
result = cachedResult;
}
dialog.cleanup();
if (dialog.TRANSITION_CLASS === 'zoom-in-80') {
self._showOverlay(false);
}
return result;
});
},
/**
* It is a bridge to call open or close function.
*
* @memberOf DialogManager
* @access private
* @param {String} method
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
_navigate: function dm__navigate(method, dialog, options) {
method = (method === 'open') ? '_open' : '_close';
return this[method](dialog, options);
},
/**
* DialogService would use this exposed API to open dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
open: function dm_open(dialog, options) {
return this._navigate('open', dialog, options);
},
/**
* DialogService would use this exposed API to close dialog.
*
* @memberOf DialogManager
* @access public
* @param {BaseDialog} dialog
* @param {Object} options
* @return {Promise}
*/
close: function dm_close(dialog, type, options) {
options._type = type;
return this._navigate('close', dialog, options);
}
};
var dialogManager = new DialogManager();
return dialogManager;
});
define('modules/dialog/base_dialog',['require'],function(require){'use strict';var BaseDialog=function(panelDOM,options){this.panel=panelDOM;this._options=options||{};};BaseDialog.prototype.DIALOG_CLASS='dialog';BaseDialog.prototype.TRANSITION_CLASS='fade';BaseDialog.prototype.SUBMIT_BUTTON_SELECTOR='[type="submit"]';BaseDialog.prototype.CANCEL_BUTTON_SELECTOR='[type="reset"]';BaseDialog.prototype.MESSAGE_SELECTOR='.settings-dialog-message';BaseDialog.prototype.TITLE_SELECTOR='.settings-dialog-title';BaseDialog.prototype.init=function bd_init(){this.TRANSITION_CLASS=this._options.transition||this.TRANSITION_CLASS;this.panel.classList.add(this.DIALOG_CLASS);this.panel.classList.add(this.TRANSITION_CLASS);};BaseDialog.prototype.initUI=function bd_initUI(){var message=this._options.message;var title=this._options.title;var submitButton=this._options.submitButton;var cancelButton=this._options.cancelButton;this._updateMessage(message);this._updateTitle(title);this._updateSubmitButton(submitButton);this._updateCancelButton(cancelButton);};BaseDialog.prototype.bindEvents=function bd_bindEvent(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};BaseDialog.prototype._updateMessage=function bd__updateMessage(message){var messageDOM=this.panel.querySelector(this.MESSAGE_SELECTOR);if(messageDOM&&message){message=this._getWrapL10nObject(message);document.l10n.setAttributes(messageDOM,message.id,message.args);}};BaseDialog.prototype._updateTitle=function bd__updateTitle(title){var titleDOM=this.panel.querySelector(this.TITLE_SELECTOR);if(titleDOM&&title){title=this._getWrapL10nObject(title);document.l10n.setAttributes(titleDOM,title.id,title.args);}};BaseDialog.prototype._updateSubmitButton=function bd__update(options){var buttonDOM=this.getSubmitButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'recommend';}};BaseDialog.prototype._updateCancelButton=function bd__updateText(options){var buttonDOM=this.getCancelButton();if(buttonDOM&&options){options=this._getWrapL10nObject(options);document.l10n.setAttributes(buttonDOM,options.id,options.args);buttonDOM.className=options.style||'';}};BaseDialog.prototype._getWrapL10nObject=function bd__getWrapL10nObject(input){if(typeof input==='string'){return{id:input,args:null};}else if(typeof input==='object'){if(typeof input.id==='undefined'){throw new Error('You forgot to put l10nId - '+
JSON.stringify(input));}else{return{id:input.id,args:input.args||null,style:input.style};}}else{throw new Error('You are using the wrong L10nObject, '+'please check its format again');}};BaseDialog.prototype.getDOM=function bd_getDOM(){return this.panel;};BaseDialog.prototype.getSubmitButton=function bd_getSubmitButton(){return this.panel.querySelector(this.SUBMIT_BUTTON_SELECTOR);};BaseDialog.prototype.getCancelButton=function bd_getCancelButton(){return this.panel.querySelector(this.CANCEL_BUTTON_SELECTOR);};BaseDialog.prototype.cleanup=function bd_cleanup(){if(this.DIALOG_CLASS!=='panel-dialog'){this._updateTitle('settings-'+this.DIALOG_CLASS+'-header');this._updateSubmitButton('ok');this._updateCancelButton('cancel');}
this.panel.classList.remove(this.DIALOG_CLASS);this.panel.classList.remove(this.TRANSITION_CLASS);};return BaseDialog;});define('modules/dialog/panel_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PanelDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PanelDialog.prototype=Object.create(BaseDialog.prototype);PanelDialog.prototype.constructor=PanelDialog;PanelDialog.prototype.DIALOG_CLASS='panel-dialog';PanelDialog.prototype.TRANSITION_CLASS='fade';return function ctor_PanelDialog(panelDOM,options){return new PanelDialog(panelDOM,options);};});define('modules/dialog/alert_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var AlertDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};AlertDialog.prototype=Object.create(BaseDialog.prototype);AlertDialog.prototype.constructor=AlertDialog;AlertDialog.prototype.DIALOG_CLASS='alert-dialog';AlertDialog.prototype.TRANSITION_CLASS='fade';AlertDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};};return function ctor_alertDialog(panelDOM,options){return new AlertDialog(panelDOM,options);};});define('modules/dialog/confirm_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var ConfirmDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};ConfirmDialog.prototype=Object.create(BaseDialog.prototype);ConfirmDialog.prototype.constructor=ConfirmDialog;ConfirmDialog.prototype.DIALOG_CLASS='confirm-dialog';ConfirmDialog.prototype.TRANSITION_CLASS='fade';ConfirmDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};return function ctor_confirmDialog(panelDOM,options){return new ConfirmDialog(panelDOM,options);};});define('modules/dialog/prompt_dialog',['require','modules/dialog/base_dialog'],function(require){'use strict';var BaseDialog=require('modules/dialog/base_dialog');var PromptDialog=function(panelDOM,options){BaseDialog.call(this,panelDOM,options);};PromptDialog.prototype=Object.create(BaseDialog.prototype);PromptDialog.prototype.constructor=PromptDialog;PromptDialog.prototype.DIALOG_CLASS='prompt-dialog';PromptDialog.prototype.TRANSITION_CLASS='fade';PromptDialog.prototype.INPUT_SELECTOR='.settings-dialog-input';PromptDialog.prototype.bindEvents=function(){var self=this;this.getSubmitButton().onclick=function(){self._options.onWrapSubmit();};this.getCancelButton().onclick=function(){self._options.onWrapCancel();};};PromptDialog.prototype.initUI=function(){BaseDialog.prototype.initUI.call(this);this.getInput().value=this._options.defaultValue||'';};PromptDialog.prototype.getInput=function(){return this.panel.querySelector(this.INPUT_SELECTOR);};PromptDialog.prototype.getResult=function(){return this.getInput().value;};return function ctor_promptDialog(panelDOM,options){return new PromptDialog(panelDOM,options);};});define('modules/dialog_service',["require", "settings", "modules/defer", "modules/dialog_manager", "modules/dialog/panel_dialog", "modules/dialog/alert_dialog", "modules/dialog/confirm_dialog", "modules/dialog/prompt_dialog"],function(require) {
'use strict';
var Settings = require('settings');
var Defer = require('modules/defer');
var DialogManager = require('modules/dialog_manager');
var PanelDialog = require('modules/dialog/panel_dialog');
var AlertDialog = require('modules/dialog/alert_dialog');
var ConfirmDialog = require('modules/dialog/confirm_dialog');
var PromptDialog = require('modules/dialog/prompt_dialog');
var DialogService = function() {
this._navigating = false;
this._pendingRequests = [];
this._settingsAlertDialogId = 'settings-alert-dialog';
this._settingsBaseDialogId = 'settings-base-dialog';
this._settingsConfirmDialogId = 'settings-confirm-dialog';
this._settingsPromptDialogId = 'settings-prompt-dialog';
};
DialogService.prototype = {
/**
* Alert dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
alert: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsAlertDialogId, {
type: 'alert',
message: message,
title: options.title,
submitButton: options.submitButton
});
},
/**
* Confirm dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
confirm: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsConfirmDialogId, {
type: 'confirm',
message: message,
title: options.title,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Prompt dialog with more controls.
*
* @memberOf DialogService
* @access public
* @param {String} message
* @param {Object} userOptions
* @return {Promise}
*/
prompt: function(message, userOptions) {
var options = userOptions || {};
return this.show(this._settingsPromptDialogId, {
type: 'prompt',
message: message,
title: options.title,
defaultValue: options.defaultValue,
submitButton: options.submitButton,
cancelButton: options.cancelButton
});
},
/**
* Panel dialog. If you are going to show any panel as a dialog,
* you have to use this method to show them.
*
* @memberOf DialogService
* @access public
* @param {String} panelId
* @param {Object} userOptions
* @return {Promise}
*/
show: function dm_show(panelId, userOptions, _pendingDefer) {
var self = this;
var defer;
var dialog;
var dialogDOM = document.getElementById(panelId);
var currentPanel = Settings.currentPanel;
var options = userOptions || {};
if (_pendingDefer) {
defer = _pendingDefer;
} else {
defer = Defer();
}
if (this._navigating) {
this._pendingRequests.push({
defer: defer,
panelId: panelId,
userOptions: userOptions
});
} else {
if ('#' + panelId === currentPanel) {
defer.reject('You are showing the same panel #' + panelId);
} else {
var opening;
var closing = false;
function close(type) {
if (closing) return;
closing = true;
// A tap during the opening animation must not run hide/show
// concurrently, or a queued dialog can inherit stale form values.
Promise.resolve(opening).then(function() {
return DialogManager.close(dialog, type, options);
}).then(function(result) {
self._navigating = false;
defer.resolve({type: type, value: result});
self._execPendingRequest();
}, function(error) {
// Panel validation rejects to keep the form open. Allow a
// correction or cancellation without rejecting the show promise.
closing = false;
dialogDOM.dispatchEvent(new CustomEvent('dialogvalidationfailed', {
detail: error
}));
});
}
options.onWrapSubmit = function() { close('submit'); };
options.onWrapCancel = function() { close('cancel'); };
switch (options.type) {
case 'alert':
dialog = AlertDialog(dialogDOM, options);
break;
case 'confirm':
dialog = ConfirmDialog(dialogDOM, options);
break;
case 'prompt':
dialog = PromptDialog(dialogDOM, options);
break;
default:
dialog = PanelDialog(dialogDOM, options);
break;
}
this._navigating = true;
opening = DialogManager.open(dialog, options);
opening.catch(function(error) {
// A missing host API must not lock every later Settings dialog.
dialogDOM.classList.remove('current');
dialogDOM.hidden = true;
var overlay = document.querySelector('.settings-dialog-overlay');
if (overlay) overlay.hidden = true;
self._navigating = false;
defer.reject(error);
self._execPendingRequest();
});
}
}
return defer.promise;
},
/**
* This method can help us pop up any pending request and would try to
* show it after previous request was done.
*
* @memberOf DialogService
* @access private
*/
_execPendingRequest: function() {
var request = this._pendingRequests.pop();
if (request) {
this.show(request.panelId, request.userOptions, request.defer);
}
}
};
var dialogService = new DialogService();
return dialogService;
});
define('panels/wifi_select_certificate_file/panel',['require','modules/settings_panel','modules/settings_service','modules/dialog_service','shared/device_storage/enumerate_all','shared/wifi_helper'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var SettingsService=require('modules/settings_service');var DialogService=require('modules/dialog_service');var EnumerateAll=require('shared/device_storage/enumerate_all');var WifiHelper=require('shared/wifi_helper');var wifiManager=WifiHelper.getWifiManager();return function ctor_selectCertificateWifi(){var elements={};return SettingsPanel({onInit:function(panel){elements={};elements.panel=panel;elements.certificateFilesList=panel.querySelector('.wifi-certificate-files-List');},onBeforeShow:function(panel){this._cleanup();this._createScanList(elements.certificateFilesList);},_cleanup:function(){while(elements.certificateFilesList.hasChildNodes()){elements.certificateFilesList.removeChild(elements.certificateFilesList.lastChild);}},_createScanList:function(list){var storages=navigator.getDeviceStorages('sdcard');var cursor=EnumerateAll(storages,'');cursor.onsuccess=function(){var file=cursor.result;if(file){var extension=this._parseExtension(file.name);if(this._isCertificateFile(extension)){var li=this._createLinkAnchor(file);list.appendChild(li);}
cursor.continue();}}.bind(this);cursor.onerror=function(){console.warn('failed to get file:'+cursor.error.name);};},_setCertificateItemsEnabled:function(enabled){var items=elements.certificateFilesList.querySelectorAll('li');var update=enabled?function(item){item.classList.remove('disabled');}:function(item){item.classList.add('disabled');};for(var i=0;i<items.length;i++){update(items[i]);}},_createLinkAnchor:function(file){var anchor=document.createElement('a');var certificateName=this._parseFilename(file.name);anchor.textContent=certificateName;var li=document.createElement('li');li.appendChild(anchor);anchor.onclick=()=>{DialogService.show('wifi-enterCertificateNickname',{certificateName:certificateName}).then((result)=>{var type=result.type;var value=result.value;if(type==='submit'){var certRequest=wifiManager.importCert(file,'',value.nickname);this._setCertificateItemsEnabled(false);certRequest.onsuccess=()=>{SettingsService.navigate('wifi-manageCertificates');};certRequest.onerror=()=>{DialogService.alert('certificate-import-failed-description',{title:'certificate-import-failed'}).then(()=>{this._setCertificateItemsEnabled(true);});};}});};return li;},_parseFilename:function(path){return path.slice(path.lastIndexOf('/')+1,path.lastIndexOf('.'));},_parseExtension:function(filename){var array=filename.split('.');return array.length>1?array.pop():'';},_isCertificateFile:function(extension){var cerExtension=['cer','crt','pem','der'];return cerExtension.indexOf(extension)>-1;},});};});

View file

@ -0,0 +1,50 @@
define('panels/wifi_status/panel',["require", "modules/dialog_panel", "shared/wifi_helper", "modules/wifi_context"],function(require) {
'use strict';
var DialogPanel = require('modules/dialog_panel');
var WifiHelper = require('shared/wifi_helper');
var WifiContext = require('modules/wifi_context');
var wifiManager = WifiHelper.getWifiManager();
return function ctor_statusWifi() {
var elements = {};
return DialogPanel({
onInit: function(panel) {
elements = {};
elements.ip = panel.querySelector('[data-ip]');
elements.speed = panel.querySelector('[data-speed]');
elements.ssid = panel.querySelector('[data-ssid]');
elements.signal = panel.querySelector('[data-signal]');
elements.security = panel.querySelector('[data-security]');
},
onBeforeShow: function(panel, options) {
this._updateNetworkInfo();
elements.ssid.textContent = options.network.ssid;
elements.signal.setAttribute('data-l10n-id',
'signalLevel' + options.sl);
if (options.security) {
elements.security.removeAttribute('data-l10n-id');
elements.security.textContent = options.security;
} else {
elements.security.setAttribute('data-l10n-id', 'securityNone');
}
WifiContext.addEventListener('wifiConnectionInfoUpdate',
this._updateNetworkInfo);
},
onBeforeHide: function() {
WifiContext.removeEventListener('wifiConnectionInfoUpdate',
this._updateNetworkInfo);
},
_updateNetworkInfo: function() {
var info = wifiManager.connectionInformation || {};
elements.ip.textContent = info.ipAddress || '';
document.l10n.setAttributes(elements.speed,
Number.isFinite(info.linkSpeed) ? 'linkSpeedMbs' : 'unavailable',
Number.isFinite(info.linkSpeed) ? { linkSpeed: info.linkSpeed } : {});
}
});
};
});

View file

@ -0,0 +1 @@
{"onlinesupport":{"title":"Vulpes OS","href":"https://vulpes-os.org/"},"callsupport":[]}

View file

@ -0,0 +1,693 @@
'use strict';
/* exported DownloadHelper */
/* global DownloadFormatter */
/* global DownloadStore */
/* global DownloadUI */
/* global LazyLoader */
/* global MimeMapper */
/* global MozActivity */
/* global SettingsListener */
/*
* DownloadHelper.js: Perform some utility functions with DOMDownload
* objects.
*
* - You have to include in you HTML:
*
* <!-- <script src="shared/js/mime_mapper.js"></script> -->
* <!-- <script src="shared/js/download/download_store.js"></script> -->
* <script defer src="shared/js/lazy_loader.js"></script>
*
* - How to use this component.
*
* For launching a download
*
* var req = DownloadHelper.open(download);
*
* req.onsuccess = function req_onsuccess() {
* alert('The download was opened so we can remove the notification');
* }
*
* req.onerror = function req_onerror() {
* alert('Something wrong!: ' + req.error.message);
* }
*
*/
var DownloadHelper = (function() {
// Exception code constants
var CODE = {
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
DEVICE_STORAGE: 'DEVICE_STORAGE',
MIME_TYPE_NOT_SUPPORTED: 'MIME_TYPE_NOT_SUPPORTED',
INVALID_STATE: 'INVALID_STATE',
NO_SDCARD: 'NO_SDCARD',
NO_PROVIDER: 'NO_PROVIDER',
UNMOUNTED_SDCARD: 'UNMOUNTED_SDCARD'
};
/*
* Request auxiliary object to support asynchronous calls
*/
var Request = function() {
this.done = function(result) {
if (typeof this.onsuccess === 'function') {
this.result = result;
window.setTimeout(function() {
this.onsuccess({
target: this
});
}.bind(this), 0);
}
};
this.failed = function(error) {
if (typeof this.onerror === 'function') {
this.error = error;
window.setTimeout(function() {
this.onerror({
target: this
});
}.bind(this), 0);
}
};
};
// Storage name by default
var STORAGE_NAME_BY_DEFAULT = 'sdcard';
// Storage name settings key
var STORAGE_NAME_KEY = 'device.storage.writable.name';
// Current storage name
var storageName = STORAGE_NAME_BY_DEFAULT;
LazyLoader.load('shared/js/settings_listener.js', function settingsLoaded() {
SettingsListener.observe(STORAGE_NAME_KEY, STORAGE_NAME_BY_DEFAULT,
function setStorageName(evt) {
var settingValue = evt.settingValue;
if (settingValue) {
storageName = settingValue;
}
}
);
});
/*
* Error auxiliary method
*/
function sendError(req, message, code) {
req.failed({
message: message,
code: code
});
console.error(message);
}
/*
* Returns a blob from the sdcard
*
* @param{Object} The download object
*
*/
function getBlob(download) {
var req = new Request();
if (download.vulpesDownload) {
download.getBlob().then(function(blob) { req.done(blob); }, function(error) { req.failed(error); });
return req;
}
var storage = getVolume(download.storageName);
var storeAvailableReq = storage.available();
storeAvailableReq.onsuccess = function available_onsuccess(e) {
var path = download.storagePath;
switch (storeAvailableReq.result) {
case 'unavailable':
sendError(req, ' Could not open the file: ' + path + ' from ' +
download.storageName, CODE.NO_SDCARD);
break;
case 'shared':
sendError(req, ' Could not open the file: ' + path + ' from ' +
download.storageName, CODE.UNMOUNTED_SDCARD);
break;
default:
try {
var storeGetReq = storage.get(path);
storeGetReq.onsuccess = function store_onsuccess() {
req.done(storeGetReq.result);
};
storeGetReq.onerror = function store_onerror() {
sendError(req, storeGetReq.error.name +
' Could not open the file: ' + path + ' from ' +
download.storageName, CODE.FILE_NOT_FOUND);
};
} catch (ex) {
sendError(req, 'Error getting the file ' + path + ' from ' +
download.storageName, CODE.DEVICE_STORAGE);
}
}
};
storeAvailableReq.onerror = function available_onerror() {
sendError(req, 'Error getting storage state ', CODE.DEVICE_STORAGE);
};
return req;
}
/*
* Base action that implements the commom logic for actions based on the
* MozActivity API
*
* @param{Object} Configuration parameters
*/
var Action = function Action(params) {
this.req = params.request;
this.name = params.actionType.activityName;
};
Action.prototype = {
/*
* It performs the action based on MozActivity
*/
run: function a_run() {
var activity = new MozActivity({
name: this.name,
data: this.data
});
activity.onsuccess = this._onsuccess.bind(this);
activity.onerror = this._onerror.bind(this);
},
/*
* This method implements the generic onsuccess callback
*/
_onsuccess: function a_onsuccess() {
this.req.done();
},
/*
* This method implements the generic onerror callback
*/
_onerror: function a_onerror(evt) {
this.req.failed({
message: evt.target.error.name
});
}
};
/*
* This action opens downloads extending the Action Object
*
* @param{Object} Configuration parameters
*/
var OpenAction = function OpenAction(params) {
Action.call(this, params);
this.data = {
url: params.download.path,
filename: DownloadFormatter.getFileName(params.download),
type: params.type,
blob: params.blob
};
};
OpenAction.prototype = {
__proto__: Action.prototype
};
/*
* This action shares downloads extending the Action Object
*
* @param{Object} Configuration parameters
*/
var ShareAction = function ShareAction(params) {
Action.call(this, params);
this.data = {
// 'share' activities do not work with specific mime types
type: params.type.split('/')[0] + '/*',
blobs: [params.blob],
filenames: [DownloadFormatter.getFileName(params.download)]
};
};
ShareAction.prototype = {
__proto__: Action.prototype,
/*
* It overrides the generic onerror callback for another more suitable
*/
_onerror: function sa_onerror(evt) {
if (evt.target.error.name !== 'NO_PROVIDER') {
return;
}
sendError(this.req, 'No provider to share file', CODE.NO_PROVIDER);
}
};
/*
* This action gets the info for a download
*
* @param{Object} Configuration parameters
*/
var InfoAction = function InfoAction(params) {
Action.call(this, params);
this.data = {
name: DownloadFormatter.getFileName(params.download),
type: params.type,
blob: params.blob,
size: params.download.totalBytes,
path: params.download.path
};
};
InfoAction.prototype = {
__proto__: Action.prototype,
/*
* It overrides the generic run method
*/
run: function ia_run() {
this.req.done(this.data);
}
};
// This is a factory that deals with different <Action> objects
var ActionsFactory = {
TYPE: {
OPEN: {
activityName: 'open',
actionClass: OpenAction
},
SHARE: {
activityName: 'share',
actionClass: ShareAction
},
INFO: {
actionClass: InfoAction
},
WALLPAPER: {
activityName: 'setwallpaper',
actionClass: ShareAction
},
RINGTONE: {
activityName: 'setringtone',
actionClass: ShareAction
}
},
create: function af_create(params) {
return new params.actionType.actionClass(params);
}
};
/*
* Returns the mime type
*
* @param{Object} It represents a DOMDownload object
*
* @returns(String) Mime type
*/
function getType(download) {
var fileName = DownloadFormatter.getFileName(download);
var type = MimeMapper.guessTypeFromFileProperties(fileName,
download.contentType);
return type;
}
/*
* This method allows third-parties to open or share downloads
*
* @param{Object} Action types: <ActionsFactory.TYPE.OPEN> or
* <ActionsFactory.TYPE.SHARE>
*
* @param{Object} It represents a DOMDownload object
*/
function runAction(actionType, download) {
var req = new Request();
window.setTimeout(function launching() {
var state = download.state;
if (state === 'succeeded' || state === 'finalized') {
LazyLoader.load(['shared/js/mime_mapper.js',
'shared/js/download/download_formatter.js'],
function loaded() {
var type = getType(download);
//
// The 'open' action will always launch an activity using the original
// content type to allow for third party applications to handle
// arbitrary types of content.
//
// The 'share' action on the other hand only works with known mime
// types at this time.
//
if (type.length === 0) {
type = download.contentType;
if (actionType !== ActionsFactory.TYPE.OPEN &&
actionType !== ActionsFactory.TYPE.INFO) {
sendError(req, 'Mime type not supported: ' + type,
CODE.MIME_TYPE_NOT_SUPPORTED);
return;
}
}
var blobReq = getBlob(download);
blobReq.onsuccess = function() {
ActionsFactory.create({
actionType: actionType,
download: download,
type: type,
blob: blobReq.result,
request: req
}).run();
};
blobReq.onerror = function() {
// Problem getting the blob from the sdcard
req.failed(blobReq.error);
};
});
return;
}
sendError(req, 'Becareful, the download is not finished!',
CODE.INVALID_STATE);
}, 0);
return req;
}
/*
* This method allows clients to remove a download, from the
* list and the phone.
*
* @param{Object} It represents a DOMDownload object
*/
function remove(download) {
var req = new Request();
if (download.vulpesDownload) {
navigator.mozDownloadManager.remove(download).then(function() { req.done(download); }, function(error) { req.failed(error); });
return req;
}
var incompleteDownload = download.state !== 'succeeded';
// If is not done, use download manager to remove it,
// otherwise, deal with the datastore.
setTimeout(function() {
if (incompleteDownload) {
if (!navigator.mozDownloadManager) {
sendError(req, 'DownloadManager not present', CODE.INVALID_STATE);
} else {
// First we pause the download so that everyone knows it's being
// stopped. The Downloads API itself won't stop the download first,
// it will simply kill it.
// XXXAus: Remove when we fix bug #1090551
download.pause().then(
function() {
navigator.mozDownloadManager.remove(download).then(
function success() {
req.done(download);
},
function error() {
sendError(req,
'DownloadManager doesnt know about this download',
CODE.INVALID_STATE);
}
);
},
function() {
sendError(req,
'Failed to pause download before removal',
CODE.INVALID_STATE);
}
);
}
} else {
req.done(download);
}
}, 0);
return incompleteDownload ? req : doRemoveFromPhone(req, download);
}
/*
* Performs the proper delete of the physical file, also
* from the datastore if the download has finished.
*
* @param{Object} This is the Request object
* @param{Object} It represents a DOMDownload object
*/
function doRemoveFromPhone(deleteRequest, download) {
var req = new Request();
deleteRequest.onsuccess = function() {
var storage = getVolume(download.storageName);
var storeAvailableReq = storage.available();
storeAvailableReq.onsuccess = function available_onsuccess(e) {
var path = download.path;
switch (storeAvailableReq.result) {
case 'unavailable':
sendError(req, ' Could not delete the file: ' + path + ' from ' +
storageName, CODE.NO_SDCARD);
break;
case 'shared':
sendError(req, ' Could not delete the file: ' + path + ' from ' +
storageName, CODE.UNMOUNTED_SDCARD);
break;
default:
var storeDeleteReq = storage.delete(download.storagePath);
storeDeleteReq.onsuccess = function store_onsuccess() {
// Remove from the datastore if status is 'succeeded'
// if we find any problem with the datastore, don't send
// an error, since the physical remove already happened
if (download.state === 'succeeded') {
LazyLoader.load(['/shared/js/download/download_store.js'],
function() {
DownloadStore.remove(download);
}
);
}
req.done(storeDeleteReq.result);
};
storeDeleteReq.onerror = function store_onerror() {
sendError(req, storeDeleteReq.error.name +
' Could not remove the file: ' + download.path + ' from ' +
storageName, CODE.FILE_NOT_FOUND);
};
}
};
storeAvailableReq.onerror = function available_onerror() {
sendError(req, 'Error getting storage state ', CODE.DEVICE_STORAGE);
};
};
deleteRequest.onerror = function(error) {
sendError(req, error.message, error.code);
};
return req;
}
function handlerError(error, download, cb) {
LazyLoader.load('shared/js/download/download_ui.js', (function loaded() {
var req;
var show = DownloadUI.show;
// Canceled activites are normal and shouldn't be interpreted as errors.
// Unfortunately, this isn't reported in a standard way by our
// applications (or third party apps for that matter). This is why we
// have this lazy filter here that may need to be updated in the future
// but hopefully will just get removed.
if (error.message &&
(error.message.endsWith('canceled') ||
error.message.endsWith('cancelled'))) {
// Since this isn't actually an error, we invoke the callback with null.
cb && cb(null);
// and return early.
return;
}
switch (error.code) {
case CODE.NO_SDCARD:
case CODE.UNMOUNTED_SDCARD:
case CODE.FILE_NOT_FOUND:
case CODE.NO_PROVIDER:
req = show(DownloadUI.TYPE[error.code], download, true);
req.onconfirm = cb;
break;
case CODE.MIME_TYPE_NOT_SUPPORTED:
req = show(DownloadUI.TYPE.UNSUPPORTED_FILE_TYPE, download, true);
req.onconfirm = function tuftOnConfirm() {
showRemoveFileUI(download, cb);
};
break;
default:
req = show(DownloadUI.TYPE.FILE_OPEN_ERROR, download, true);
req.onconfirm = function tfoeOnConfirm() {
showRemoveFileUI(download, cb);
};
break;
}
// We have to remove the notification if the user cancels
req.oncancel = cb;
}));
}
function showRemoveFileUI(download, cb) {
var req = DownloadUI.show(DownloadUI.TYPE.DELETE, download, true);
req.oncancel = cb;
req.onconfirm = function doRemove() {
if (typeof cb === 'function') {
cb(download);
} else {
remove(download);
}
};
}
function getFreeSpace(cb) {
var storage = navigator.getDeviceStorage(storageName);
if (!storage) {
console.error('Cannot get free space size in sdcard');
cb(null);
return;
}
var req = storage.freeSpace();
req.onsuccess = function(e) {
cb(e.target.result);
};
req.onerror = function() {
cb(null);
};
}
/**
* Gets the storage for the download based on the volumen
* it was saved (storageName)
*/
function getVolume(volumeName) {
// Per API design, all media type return the same volumes.
// So we use 'sdcard' here for no reason.
// https://bugzilla.mozilla.org/show_bug.cgi?id=856782#c10
var volumes = navigator.getDeviceStorages('sdcard');
if (!volumeName || volumeName === '') {
return volumes[0];
}
for (var i = 0; i < volumes.length; ++i) {
if (volumes[i].storageName === volumeName) {
return volumes[i];
}
}
return volumes[0];
}
return {
/*
* This method allows clients to open a downlaod
*
* @param{Object} It represents a DOMDownload object
*/
open: function(download) {
return runAction(ActionsFactory.TYPE.OPEN, download);
},
/*
* This method allows clients to share a downlaoded file
*
* @param{Object} It represents a DOMDownload object
*/
share: function(download) {
return runAction(ActionsFactory.TYPE.SHARE, download);
},
/*
* This method allows clients to set as wallaper a downlaoded file
*
* @param{Object} It represents a DOMDownload object
*/
wallpaper: function(download) {
return runAction(ActionsFactory.TYPE.WALLPAPER, download);
},
/*
* This method allows clients to set as ringtone a downlaoded file
*
* @param{Object} It represents a DOMDownload object
*/
ringtone: function(download) {
return runAction(ActionsFactory.TYPE.RINGTONE, download);
},
/*
* This method returns information about a download
*
* @param{Object} It represents a DOMDownload object
*/
info: function(download) {
return runAction(ActionsFactory.TYPE.INFO, download);
},
/*
* Given a download, remove it from the DownloadManager
* list, and the file system.
*
* @param{Object} It represents a DOMDownload object
*/
remove: remove,
/*
* Returns exception code constants
*/
get CODE() {
return CODE;
},
/*
* This method handles different errors when users attemp to open files
*
* @param{Object} Error object
*
* @param{Object} It represents a DOMDownload object
*
* @param{Function} This function is performed when the flow is finished
*/
handlerError: handlerError,
/*
* Returns the free memory size in bytes
*
* @param{Function} This function is performed when the free memory size has
* been calculated
*/
getFreeSpace: getFreeSpace
};
}());

File diff suppressed because it is too large Load diff

15
package-lock.json generated
View file

@ -6,6 +6,7 @@
"": { "": {
"name": "vulpes-maintenance", "name": "vulpes-maintenance",
"devDependencies": { "devDependencies": {
"esprima": "4.0.1",
"prettier": "3.6.2", "prettier": "3.6.2",
"prettier-plugin-java": "2.7.7" "prettier-plugin-java": "2.7.7"
}, },
@ -85,6 +86,20 @@
"chevrotain": "^11.0.0" "chevrotain": "^11.0.0"
} }
}, },
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/java-parser": { "node_modules/java-parser": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/java-parser/-/java-parser-3.0.1.tgz", "resolved": "https://registry.npmjs.org/java-parser/-/java-parser-3.0.1.tgz",

View file

@ -9,6 +9,7 @@
"format:check": "prettier --check 'host/**/*.{js,mjs,css}' 'services/*.mjs' 'adapters/*.mjs' 'tests/*.mjs' 'android/{extension,ui,tests}/**/*.{js,mjs}' 'android/app/src/main/java/**/*.java'" "format:check": "prettier --check 'host/**/*.{js,mjs,css}' 'services/*.mjs' 'adapters/*.mjs' 'tests/*.mjs' 'android/{extension,ui,tests}/**/*.{js,mjs}' 'android/app/src/main/java/**/*.java'"
}, },
"devDependencies": { "devDependencies": {
"esprima": "4.0.1",
"prettier": "3.6.2", "prettier": "3.6.2",
"prettier-plugin-java": "2.7.7" "prettier-plugin-java": "2.7.7"
} }

View file

@ -1,15 +1,15 @@
{ {
"gaia": "2.7.0", "gaia": "2.7.0",
"channel": "preview", "channel": "preview",
"revision": 17, "revision": 20,
"androidVersionCode": 17, "androidVersionCode": 20,
"geckoview": "156.0.20260921121718", "geckoview": "156.0.20260921121718",
"engineAlignment": { "engineAlignment": {
"family": "156", "family": "156",
"patchParity": false, "patchParity": false,
"reason": "Firefox desktop 156.0.1; Mozilla's GeckoView release repository currently offers 156.0 only. Keep the desktop patch and report actual engine versions." "reason": "Firefox desktop 156.0.1; Mozilla's GeckoView release repository currently offers 156.0 only. Keep the desktop patch and report actual engine versions."
}, },
"version": "2.7-preview.17", "version": "2.7-preview.20",
"adapters": { "adapters": {
"desktop": "0.1.0-dev", "desktop": "0.1.0-dev",
"android": "0.1.0-dev" "android": "0.1.0-dev"

View file

@ -1,5 +1,7 @@
export function isGaiaActivity(source) { export function isGaiaActivity(source) {
return source?.name === 'pick' || return source?.name === 'pick' ||
(source?.name === 'moz_configure_window' && source.data?.target === 'device') ||
(source?.name === 'configure' && source.data?.target === 'ringtone') ||
(source?.name === 'dial' && source.data?.type === 'webtelephony/number') || (source?.name === 'dial' && source.data?.type === 'webtelephony/number') ||
(source?.name === 'new' && source.data?.type === 'websms/sms') || (source?.name === 'new' && source.data?.type === 'websms/sms') ||
(['new','update','open'].includes(source?.name) && source.data?.type === 'webcontacts/contact'); (['new','update','open'].includes(source?.name) && source.data?.type === 'webcontacts/contact');
@ -14,19 +16,24 @@ function entryPointFor(app, path) {
// Route Gaia's built-in pickers; only the selected provider can finish a request. // Route Gaia's built-in pickers; only the selected provider can finish a request.
export class Activities { export class Activities {
constructor({identity, apps, launch, notify, uuid}) { constructor({identity, apps, launch, notify, uuid, choose}) {
Object.assign(this, {identity, apps, launch, notify, uuid}); Object.assign(this, {identity, apps, launch, notify, uuid, choose});
this.pending = new Map(); this.pending = new Map();
this.listeners = new Map(); this.listeners = new Map();
this.choices = new Map();
} }
route(source, caller) { route(source, caller) {
const types = [].concat(source?.data?.type || []), name=source?.name; const types = [].concat(source?.data?.type || []), name=source?.name;
let id; let id;
if (name === 'pick') { if (name === 'pick') {
if (types.some(t=>['ringtone','alerttone'].includes(t))) id='ringtones'; if (types.some(t=>['ringtone','alerttone'].includes(t))) id='ringtones';
else if (types.some(t=>['webcontacts/contact','webcontacts/tel','webcontacts/email','webcontacts/select'].includes(t))) id='communications'; else if (types.includes('wallpaper')) id='wallpaper';
else if (types.some(t=>typeof t==='string' && t.startsWith('image/'))) id=caller?.id==='communications'?'gallery':'wallpaper'; else if (types.some(t=>['webcontacts/contact','webcontacts/tel','webcontacts/email','webcontacts/select','text/vcard','text/x-vcard','text/directory'].includes(t))) id='communications';
} else if (name === 'dial' && types.includes('webtelephony/number')) id='communications'; else if (types.some(t=>typeof t==='string' && t.startsWith('image/'))) id=!caller || caller.id==='settings'?'wallpaper':'gallery';
else if (types.some(t=>typeof t==='string' && t.startsWith('audio/'))) id='music';
else if (types.some(t=>typeof t==='string' && t.startsWith('video/'))) id='video';
} else if (name === 'configure' && source.data?.target === 'ringtone') id='ringtones';
else if (name === 'dial' && types.includes('webtelephony/number')) id='communications';
else if (name === 'new' && types.includes('websms/sms')) id='sms'; else if (name === 'new' && types.includes('websms/sms')) id='sms';
else if (['new','update','open'].includes(name) && types.includes('webcontacts/contact')) id='communications'; else if (['new','update','open'].includes(name) && types.includes('webcontacts/contact')) id='communications';
const app = this.apps().find(a=>a.id===id); const app = this.apps().find(a=>a.id===id);
@ -48,9 +55,54 @@ export class Activities {
provider(source) { return this.route(source).app; } provider(source) { return this.route(source).app; }
start(caller, source) { start(caller, source) {
const app = this.identity(caller); const app = this.identity(caller);
if(source?.name==='moz_configure_window' && source.data?.target==='device') {
if(app?.id!=='keyboard')throw Error('PERMISSION_DENIED');
const settings=this.apps().find(a=>a.id==='settings');
if(!settings)throw Error('ACTIVITY_NOT_SUPPORTED');
this.launch(settings,settings.manifest.launch_path || '/index.html',{});
return Promise.resolve(null);
}
if (!app || !['settings','system','homescreen','verticalhome','ringtones','sms','communications','email','calendar'].includes(app.id)) if (!app || !['settings','system','homescreen','verticalhome','ringtones','sms','communications','email','calendar'].includes(app.id))
throw Error('PERMISSION_DENIED'); throw Error('PERMISSION_DENIED');
const {app:provider,definition,url,entryPoint}=this.route(source,app); const types = [].concat(source?.data?.type || []);
if (source?.name==='pick' && types.length>1 && this.choose) {
const routes=[];
for (const type of types) {
try {
const route=this.route({...source,data:{...source.data,type}},app);
if (!routes.some(r=>r.app.id===route.app.id)) routes.push(route);
} catch (error) { if (error.message!=='ACTIVITY_NOT_SUPPORTED') throw error; }
}
if (routes.length>1) {
if (this.choices.size) throw Error('ACTIVITY_BUSY');
const id=this.uuid();
return new Promise((resolve,reject)=>{
this.choices.set(id,{caller,app,source,routes,resolve,reject});
this.choose({id,name:source.name,activityType:types.join(','),
choices:routes.map(r=>{
const icons=r.app.manifest.icons || {};
const icon=icons[128] || icons[60] || Object.values(icons)[0];
return {manifest:r.app.manifestURL,icon:icon?new URL(icon,r.app.origin).href:undefined};
})});
});
}
if (routes.length===1) return this.open(caller,app,source,routes[0]);
}
return this.open(caller,app,source,this.route(source,app));
}
select(peer,{id,value}) {
if (this.identity(peer)?.id!=='system') throw Error('PERMISSION_DENIED');
const row=this.choices.get(id);
if (!row) throw Error('ACTIVITY_CANCELLED');
if (!/^(?:-1|\d+)$/.test(String(value))) throw Error('INVALID_ACTIVITY_CHOICE');
const index=Number(value);
if (index!==-1 && !row.routes[index]) throw Error('INVALID_ACTIVITY_CHOICE');
this.choices.delete(id);
if (index===-1) {row.reject(Error('ACTIVITY_CANCELLED'));return;}
try {this.open(row.caller,row.app,row.source,row.routes[index]).then(row.resolve,row.reject);}
catch(error) {row.reject(error);}
}
open(caller,app,source,{app:provider,definition,url,entryPoint}) {
if ([...this.pending.values()].some(p=>p.provider.id===provider.id)) throw Error('ACTIVITY_BUSY'); if ([...this.pending.values()].some(p=>p.provider.id===provider.id)) throw Error('ACTIVITY_BUSY');
const id = this.uuid(); const id = this.uuid();
return new Promise((resolve,reject) => { return new Promise((resolve,reject) => {
@ -102,8 +154,13 @@ export class Activities {
if (this.identity(peer)?.id !== 'system') throw Error('PERMISSION_DENIED'); if (this.identity(peer)?.id !== 'system') throw Error('PERMISSION_DENIED');
for (const row of this.pending.values()) row.reject(Error('ACTIVITY_CANCELLED')); for (const row of this.pending.values()) row.reject(Error('ACTIVITY_CANCELLED'));
this.pending.clear(); this.pending.clear();
for (const row of this.choices.values()) row.reject(Error('ACTIVITY_CANCELLED'));
this.choices.clear();
} }
cleanup(peer) { cleanup(peer) {
for (const [id,row] of this.choices) if (row.caller===peer) {
this.choices.delete(id);row.reject(Error('ACTIVITY_CANCELLED'));
}
for (const [app, listener] of this.listeners) if (listener === peer) this.listeners.delete(app); for (const [app, listener] of this.listeners) if (listener === peer) this.listeners.delete(app);
for (const [id,row] of this.pending) { for (const [id,row] of this.pending) {
if (row.caller === peer || row.delivered === peer) { if (row.caller === peer || row.delivered === peer) {

68
services/input-editor.mjs Normal file
View file

@ -0,0 +1,68 @@
// Shared editing operations for the desktop actor and Android content bridge.
export class InputEditor {
constructor(win) { this.contentWindow=win; this.document=win.document; this.editing=false; }
focus(element) { this.inputElement=element; }
state() {
const e=this.inputElement;
const text=e.isContentEditable ? e.textContent : e.value;
const selection=this.contentWindow.getSelection();
let start=e.selectionStart ?? text.length, end=e.selectionEnd ?? start;
if(e.isContentEditable && selection.rangeCount && e.contains(selection.anchorNode)) {
const range=this.document.createRange();range.selectNodeContents(e);
range.setEnd(selection.anchorNode,selection.anchorOffset);start=range.toString().length;
range.setEnd(selection.focusNode,selection.focusOffset);end=range.toString().length;
[start,end]=[Math.min(start,end),Math.max(start,end)];
}
return {type:e.isContentEditable?'contenteditable':e.localName,inputType:e.type || (e.classList.contains('recipient')?'tel':'text'),
inputMode:e.inputMode || (e.classList.contains('recipient')?'tel':''),
text:e.type==='password'?'':text,selectionStart:start,selectionEnd:end};
}
edit(p) {
const e=this.inputElement,win=this.contentWindow;
if(!e?.isConnected || this.document.hidden) throw Error('INPUT_CONTEXT_EXPIRED');
this.editing=true;
try {e.focus();} finally {this.editing=false;}
// A touch can focus a new contenteditable while Gecko retains the previous
// field's selection. execCommand follows that selection, not activeElement.
if(e.isContentEditable) {
const selection=win.getSelection();
if(!e.contains(selection.anchorNode) || !e.contains(selection.focusNode)) {
const range=this.document.createRange();
range.selectNodeContents(e);range.collapse(false);
selection.removeAllRanges();selection.addRange(range);
}
}
if(p.operation==='key') {
let key=typeof p.key==='object'?p.key.key:p.key;
if(key===8 || key==='Backspace') this.document.execCommand('delete');
else if(key===13 || key==='Enter') {
const options={key:'Enter',code:'Enter',keyCode:13,charCode:13,which:13,bubbles:true,cancelable:true};
const accepted=e.dispatchEvent(new win.KeyboardEvent('keydown',options)) && e.dispatchEvent(new win.KeyboardEvent('keypress',options));
if(accepted && (e.isContentEditable || e.localName==='textarea')) this.document.execCommand('insertLineBreak');
e.dispatchEvent(new win.KeyboardEvent('keyup',options));
} else {
const text=typeof key==='string'?key:String.fromCodePoint(p.charCode || key);
if(text.length>16) throw Error('INVALID_VALUE');
this.document.execCommand('insertText',false,text);
}
} else if(['selection','replace'].includes(p.operation)) {
const state=this.state();
const start=p.operation==='selection'?p.start:state.selectionStart+(p.offset||0);
const length=p.length||0;
if(!Number.isInteger(start)||!Number.isInteger(length)||start<0||length<0||start+length>(e.value ?? e.textContent).length) throw Error('INVALID_VALUE');
if(e.isContentEditable) {
const walker=this.document.createTreeWalker(e,win.NodeFilter.SHOW_TEXT);
const points=[];let offset=0,node;
while((node=walker.nextNode())) {points.push({node,start:offset,end:offset+node.length});offset+=node.length;}
const point=index=>{const part=points.find(p=>index<=p.end);return part?[part.node,index-part.start]:[e,0];};
const range=this.document.createRange();range.setStart(...point(start));range.setEnd(...point(start+length));
win.getSelection().removeAllRanges();win.getSelection().addRange(range);
} else e.setSelectionRange(start,start+length);
if(p.operation==='replace') {
if(typeof p.text!=='string'||p.text.length>4096) throw Error('INVALID_VALUE');
this.document.execCommand('insertText',false,p.text);
}
} else throw Error('METHOD_NOT_SUPPORTED');
return this.state();
}
}

36
services/input.mjs Normal file
View file

@ -0,0 +1,36 @@
// Only the bundled keyboard can edit the field reported by a content actor.
export class Input {
constructor(runtime) { this.runtime=runtime; this.focus=null; this.sequence=0; }
changed(actor, state) {
const app=this.runtime.identity(actor);
if (!app || app.id==='keyboard') return;
if (!state) { if(this.focus?.actor===actor) this.hide(); return; }
this.focus={actor,state:{...state,id:++this.sequence}};
this.runtime.broadcast('input-focus',this.focus.state,'system');
this.runtime.broadcast('input-focus',this.focus.state,'keyboard');
}
hide() {
this.focus=null;
this.runtime.broadcast('input-focus',null,'system');
this.runtime.broadcast('input-focus',null,'keyboard');
}
cleanup(actor) { if(this.focus?.actor===actor) this.hide(); }
async request(actor, p) {
const app=this.runtime.identity(actor);
if (!app || !['keyboard','system'].includes(app.id)) throw Error('PERMISSION_DENIED');
if (p.operation==='hide') { this.hide();return null; }
if(app.id!=='keyboard') throw Error('PERMISSION_DENIED');
if(p.operation==='state') return this.focus?.state || null;
if(p.operation==='height') {
if(!Number.isFinite(p.height) || p.height<0 || p.height>600) throw Error('INVALID_VALUE');
this.runtime.broadcast('input-height',{height:p.height},'system');return null;
}
if(!this.focus || p.id!==this.focus.state.id) throw Error('INPUT_CONTEXT_EXPIRED');
const context=this.focus;
const result=await context.actor.sendQuery('Vulpes:Edit',p);
// An application can close or focus another field while the edit is pending.
if(this.focus!==context) throw Error('INPUT_CONTEXT_EXPIRED');
Object.assign(context.state,result);
return context.state;
}
}

29
services/mms.mjs Normal file
View file

@ -0,0 +1,29 @@
// SPDX-License-Identifier: MPL-2.0
// JSON transport for Gaia attachments; blobs are encoded at the app boundary.
export const MMS_MAX_BYTES = 600 * 1024;
export function mmsPayload(message) {
if (!message || !Array.isArray(message.receivers) || !message.receivers.length || message.receivers.length>10 ||
message.receivers.some(n=>typeof n!=='string' || !/^\+?[0-9]{3,20}$/.test(n))) throw Error('INVALID_NUMBER');
const parts=[];
if(message.smil) parts.push({id:'smil.xml',type:'application/smil',data:utf8Base64(message.smil)});
for(const [i,a] of (message.attachments || []).entries()) {
const content=a.content;
if(!content?.__vulpesBlobV1 || typeof content.base64!=='string' || !/^[A-Za-z0-9+/]*={0,2}$/.test(content.base64)) throw Error('INVALID_MMS');
parts.push({id:a.location || a.id?.replace(/[<>]/g,'') || 'attachment-'+i,type:content.type,data:content.base64});
}
if(!parts.length || parts.length>20 || parts.reduce((n,p)=>n+Math.floor(p.data.length*3/4),0)>MMS_MAX_BYTES) throw Error('MMS_TOO_LARGE');
return {recipients:message.receivers,subject:message.subject || '',parts};
}
function utf8Base64(text) {const bytes=new TextEncoder().encode(text);let s='';for(const byte of bytes)s+=String.fromCharCode(byte);return btoa(s);}
export function receivedMms(row) {
let smil='',body='';const attachments=[];
for(const part of row.parts || []) {
if(['application/smil','text/plain'].includes(part.type)) {
const text=new TextDecoder().decode(Uint8Array.from(atob(part.data),c=>c.charCodeAt(0)));
if(part.type==='application/smil') {smil=text;continue;}
body+=text;
}
attachments.push({id:part.id,location:part.id,content:{__vulpesBlobV1:true,type:part.type,base64:part.data}});
}
return {...row,body,smil,attachments,timestamp:row.timestamp || Date.now()};
}

View file

@ -13,6 +13,9 @@ export function authorizePlatform(app, operation, params = {}) {
settings: ['settings', 'system', 'costcontrol'].includes(app.id), settings: ['settings', 'system', 'costcontrol'].includes(app.id),
snapshot: ['settings', 'system', 'costcontrol'].includes(app.id), snapshot: ['settings', 'system', 'costcontrol'].includes(app.id),
wifi: ['settings', 'system'].includes(app.id), wifi: ['settings', 'system'].includes(app.id),
mobileSettings: app.id === 'settings',
deviceSettings: app.id === 'settings',
bluetooth: ['settings', 'system', 'bluetooth'].includes(app.id),
notification: !!permissions['desktop-notification'], notification: !!permissions['desktop-notification'],
capture: app.id === 'camera' && writeMedia('pictures'), capture: app.id === 'camera' && writeMedia('pictures'),
cameraPreview: app.id === 'camera' && writeMedia('pictures'), cameraPreview: app.id === 'camera' && writeMedia('pictures'),
@ -30,6 +33,11 @@ export function authorizePlatform(app, operation, params = {}) {
}; };
if (!Object.hasOwn(allowed, operation)) throw Error('METHOD_NOT_SUPPORTED'); if (!Object.hasOwn(allowed, operation)) throw Error('METHOD_NOT_SUPPORTED');
if (!allowed[operation]) throw Error('PERMISSION_DENIED'); if (!allowed[operation]) throw Error('PERMISSION_DENIED');
if (operation === 'wifi' && ['hotspotStatus','hotspotSave','hotspotPower'].includes(params.operation) && app.id !== 'settings') throw Error('PERMISSION_DENIED');
if (operation === 'mobileSettings' && !['sim','smsc','calls','messages','deliveryReports','network','dataEnabled','roaming','radioMode','setRadioMode','apns','editApn','setCallWaiting','setCallerId','forwarding','setForwarding'].includes(params.action)) throw Error('INVALID_VALUE');
if (operation === 'mobileSettings' && ['deliveryReports','dataEnabled','roaming','setCallWaiting','setForwarding'].includes(params.action) && typeof params.enabled !== 'boolean') throw Error('INVALID_VALUE');
if (operation === 'mobileSettings' && JSON.stringify(params).length > 6144) throw Error('INVALID_VALUE');
if (operation === 'deviceSettings' && (!['clock','setTime','setTimezone','setAutomatic'].includes(params.action) || JSON.stringify(params).length > 1024)) throw Error('INVALID_VALUE');
if (operation === 'cameraPreview' && if (operation === 'cameraPreview' &&
(!['start', 'frame', 'stop', 'focus', 'zoom', 'picture'].includes(params.action) || (!['start', 'frame', 'stop', 'focus', 'zoom', 'picture'].includes(params.action) ||
(params.action === 'start' && !['back', 'front'].includes(params.camera)))) (params.action === 'start' && !['back', 'front'].includes(params.camera))))
@ -47,7 +55,7 @@ export function authorizePlatform(app, operation, params = {}) {
throw Error('INVALID_VALUE'); throw Error('INVALID_VALUE');
} }
if (operation === 'ackCallHistory' && (!Array.isArray(params.ids) || params.ids.length > 500 || params.ids.some(id => typeof id !== 'string' || !/^\d{1,20}$/.test(id)))) throw Error('INVALID_VALUE'); if (operation === 'ackCallHistory' && (!Array.isArray(params.ids) || params.ids.length > 500 || params.ids.some(id => typeof id !== 'string' || !/^\d{1,20}$/.test(id)))) throw Error('INVALID_VALUE');
if (operation === 'wifi' && JSON.stringify(params).length > 4096) if (['wifi', 'bluetooth'].includes(operation) && JSON.stringify(params).length > 4096)
throw Error('QuotaExceededError'); throw Error('QuotaExceededError');
if (operation === 'notification' && JSON.stringify(params).length > 65536) if (operation === 'notification' && JSON.stringify(params).length > 65536)
throw Error('QuotaExceededError'); throw Error('QuotaExceededError');

View file

@ -1,6 +1,21 @@
// Test-only actor, registered by tools/inspect-host.py through Marionette. // Test-only actor, registered by tools/inspect-host.py through Marionette.
export class VulpesProbeChild extends JSWindowActorChild { export class VulpesProbeChild extends JSWindowActorChild {
async receiveMessage(message) { async receiveMessage(message) {
if (message.name === 'Swipe') {
const {x,y,toX,toY}=message.data, win=this.contentWindow;
if (![x,y,toX,toY].every(Number.isFinite) ||
[x,toX].some(v=>v<0 || v>=win.innerWidth) ||
[y,toY].some(v=>v<0 || v>=win.innerHeight)) throw Error('INVALID_TEST_POINT');
const {setTimeout}=ChromeUtils.importESModule('resource://gre/modules/Timer.sys.mjs');
for (let step=0;step<=12;step++) {
const points=[{identifier:0,offsetX:x+(toX-x)*step/12,
offsetY:y+(toY-y)*step/12,radiiX:1,radiiY:1}];
win.synthesizeTouchEvent(step===0?'touchstart':'touchmove',points,0,{isAsyncEnabled:false});
await new Promise(resolve=>setTimeout(resolve,20));
}
win.synthesizeTouchEvent('touchend',[{identifier:0,offsetX:toX,offsetY:toY,radiiX:1,radiiY:1}],0,{isAsyncEnabled:false});
return true;
}
if (message.name === 'MouseClick') { if (message.name === 'MouseClick') {
const {x,y}=message.data, win=this.contentWindow; const {x,y}=message.data, win=this.contentWindow;
if (!Number.isFinite(x) || !Number.isFinite(y) || x<0 || y<0 || if (!Number.isFinite(x) || !Number.isFinite(y) || x<0 || y<0 ||

View file

@ -1,5 +1,5 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import {Activities} from '../services/activities.mjs'; import {Activities, isGaiaActivity} from '../services/activities.mjs';
const apps=[{id:'settings',origin:'http://settings.localhost'},{id:'wallpaper',origin:'http://wallpaper.localhost',manifest:{activities:{pick:{href:'/pick.html'}}}},{id:'ringtones',origin:'http://ringtones.localhost',manifest:{activities:{pick:{href:'/pick.html'}}}}]; const apps=[{id:'settings',origin:'http://settings.localhost'},{id:'wallpaper',origin:'http://wallpaper.localhost',manifest:{activities:{pick:{href:'/pick.html'}}}},{id:'ringtones',origin:'http://ringtones.localhost',manifest:{activities:{pick:{href:'/pick.html'}}}}];
const caller={app:apps[0],manager:{documentURI:{spec:'http://settings.localhost/#display'}}}; const caller={app:apps[0],manager:{documentURI:{spec:'http://settings.localhost/#display'}}};
const picker={app:apps[1]}, stranger={app:apps[2]}; const picker={app:apps[1]}, stranger={app:apps[2]};
@ -81,3 +81,41 @@ const photoRequest=events.at(-1)[2].id;
assert.throws(()=>nested.finish(normalGallery,{id:photoRequest,result:{}}),/PERMISSION_DENIED/); assert.throws(()=>nested.finish(normalGallery,{id:photoRequest,result:{}}),/PERMISSION_DENIED/);
nested.finish(galleryPicker,{id:photoRequest,result:{blob:new Blob(['image'])}}); nested.finish(galleryPicker,{id:photoRequest,result:{blob:new Blob(['image'])}});
assert.equal(await (await photo).blob.text(),'image'); assert.equal(await (await photo).blob.text(),'image');
const music={id:'music',origin:'http://music.localhost',manifestURL:'http://music.localhost/manifest.webapp',manifest:{activities:{pick:{href:'/pick.html',filters:{type:['audio/*']}}}}};
const video={id:'video',origin:'http://video.localhost',manifestURL:'http://video.localhost/manifest.webapp',manifest:{activities:{pick:{href:'/index.html#pick',filters:{type:['video/*']}}}}};
apps.push(music,video);
for(const app of apps) app.manifestURL ||= app.origin+'/manifest.webapp';
gallery.manifest.activities.pick.filters.type.push('image/*');
phone.manifest.activities.pick.filters.type.value.push('text/vcard');
let menu;
const chooser=new Activities({identity:p=>p.app,apps:()=>apps,launch:(...a)=>launches.push(a),notify:(...a)=>events.push(a),uuid:()=>String(++serial),choose:data=>{menu=data;}});
const attachment={name:'pick',data:{type:['image/*','audio/*','video/*','text/vcard']}};
const attach=chooser.start(smsPeer,attachment);
assert.deepEqual(menu.choices.map(c=>c.manifest),[gallery,music,video,phone].map(a=>a.manifestURL));
assert.throws(()=>chooser.select(smsPeer,{id:menu.id,value:0}),/PERMISSION_DENIED/);
assert.throws(()=>chooser.select({app:{id:'system'}},{id:menu.id,value:99}),/INVALID_ACTIVITY_CHOICE/);
chooser.select({app:{id:'system'}},{id:menu.id,value:'0'});
const peer={app:gallery,manager:{documentURI:{spec:launches.at(-1)[1]}}};
chooser.subscribe(peer);
chooser.finish(peer,{id:events.at(-1)[2].id,result:{blob:new Blob(['picked']),name:'photo.jpg'}});
assert.equal((await attach).name,'photo.jpg');
const cancelChoice=chooser.start(smsPeer,attachment);
chooser.select({app:{id:'system'}},{id:menu.id,value:-1});await assert.rejects(cancelChoice,/ACTIVITY_CANCELLED/);
const closed=chooser.start(smsPeer,attachment);chooser.cleanup(smsPeer);await assert.rejects(closed,/ACTIVITY_CANCELLED/);
console.log('SMS attachment chooser: four original providers, ownership, cancellation and result: OK');
const tones={id:'ringtones',origin:'http://ringtones.localhost',manifest:{activities:{configure:{href:'/manage.html',filters:{target:{required:true,pattern:'ringtone'}},disposition:'inline'}}}};
apps[2].manifest.activities.configure=tones.manifest.activities.configure;
assert.equal(isGaiaActivity({name:'configure',data:{target:'ringtone'}}),true);
assert.equal(broker.route({name:'configure',data:{target:'ringtone'}}).url.pathname,'/manage.html');
assert.throws(()=>broker.route({name:'configure',data:{target:'other'}}),/ACTIVITY_NOT_SUPPORTED/);
apps[0].manifest={launch_path:'/index.html'};
const keyboardPeer={app:{id:'keyboard'}};
const configure={name:'moz_configure_window',data:{target:'device'}};
assert.equal(isGaiaActivity(configure),true);
await broker.start(keyboardPeer,configure);
assert.equal(launches.at(-1)[0].id,'settings');
assert.equal(broker.pending.size,0);
assert.throws(()=>broker.start({app:{id:'untrusted'}},configure),/PERMISSION_DENIED/);
console.log('Keyboard returns to Settings without a pending activity: OK');

View file

@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const source=readFileSync(new URL('../android/extension/content.js',import.meta.url),'utf8');
async function setup(enabled=true) {
const sent=[],events=new Map();let receive;
const page={navigator:{},dispatchEvent(){}};
const port={postMessage:m=>sent.push(m),onMessage:{addListener:fn=>receive=fn},onDisconnect:{addListener(){}}};
class Editor {
constructor(){this.editing=false;}
focus(e){this.inputElement=e;}
state(){return {inputMode:this.inputElement.getAttribute('inputmode'),text:'',inputType:'text'};}
edit(){return this.state();}
}
const context={InputEditor:Editor,browser:{runtime:{connect:()=>port}},console,
location:{port:'18765',hostname:'sms.localhost'},document:{hidden:false},
window:{wrappedJSObject:page,Promise,Error,Event,CustomEvent:class extends Event{constructor(name,p){super(name);this.detail=p.detail;}}},
cloneInto:x=>x,exportFunction:(fn,obj,p)=>{obj[p.defineAs]=fn;},
addEventListener:(name,fn)=>events.set(name,fn),setTimeout,clearTimeout};
vm.runInNewContext(source,context);
receive({id:sent[0].id,reply:{result:enabled}});
await new Promise(resolve=>setImmediate(resolve));
return {sent,events,page,receive,context};
}
function field(mode='numeric') {
const attrs=new Map([['inputmode',mode]]);
return {localName:'input',type:'text',classList:{contains:()=>false},
getAttribute:key=>attrs.get(key)??null,setAttribute:(k,v)=>attrs.set(k,v),removeAttribute:k=>attrs.delete(k)};
}
test('Android bridge keeps the intended keypad mode after editing, while suppressing the Android IME',async()=>{
const s=await setup(),e=field();
s.events.get('focusin')({target:e});
assert.equal(e.getAttribute('inputmode'),'none');
assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric');
s.receive({edit:{id:3,params:{operation:'key',key:1}}});
assert.equal(s.sent.at(-1).editReply,3);
assert.equal(s.sent.at(-1).result.inputMode,'numeric');
s.receive({event:{type:'input-mode',data:{enabled:false}}});
assert.equal(e.getAttribute('inputmode'),'numeric');
assert.equal(s.page.VulpesNativeInput,false);
});
test('Android keyboard remains the default and hidden Gaia restores the editor mode',async()=>{
const s=await setup(false),e=field();
s.events.get('focusin')({target:e});
assert.equal(e.getAttribute('inputmode'),'numeric');
assert.equal(s.sent.length,1);
s.receive({event:{type:'input-mode',data:{enabled:true}}});
s.events.get('focusin')({target:e});
s.context.document.hidden=true;s.events.get('visibilitychange')();
assert.equal(e.getAttribute('inputmode'),'numeric');
assert.equal(s.sent.at(-1).inputFocus,null);
});

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