A first Pithos app for phones. It runs the browser app in a WebView and answers the page's own files from the APK under the gateway's origin, so the page stays the same-origin client it was designed as and the files are still encrypted and decrypted in the page. What the phone adds: "Save to Pithos" in other apps' share menus (the files wait in a bar until a folder is open), downloads written to the Downloads folder, the system file picker for uploads, and outside links opened in the phone's browser. The browser app learns the two hooks (window.PithosAndroid) and says plainly when a browser is too old to sign in, instead of a bare "NotSupportedError".
432 lines
19 KiB
JavaScript
432 lines
19 KiB
JavaScript
// Pithos in the browser: Drives on Silent Mode, opened with a key file.
|
||
// Everything is decrypted in this page; see lib/hosted.js for the protocol.
|
||
|
||
import * as Hosted from './lib/hosted.js';
|
||
|
||
const $ = (s) => document.querySelector(s);
|
||
const h = (tag, attrs = {}, ...kids) => {
|
||
const el = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(attrs)) {
|
||
if (v == null || v === false) continue;
|
||
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
||
else if (k === 'class') el.className = v;
|
||
else el.setAttribute(k, v === true ? '' : v);
|
||
}
|
||
for (const c of kids.flat(Infinity)) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c));
|
||
return el;
|
||
};
|
||
const put = (el, ...kids) => { el.replaceChildren(); for (const c of kids.flat(Infinity)) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c)); };
|
||
|
||
function toast(msg, err) {
|
||
const t = h('div', { class: `toast${err ? ' err' : ''}` }, msg);
|
||
$('#toasts').append(t);
|
||
setTimeout(() => t.remove(), err ? 7000 : 3500);
|
||
return t;
|
||
}
|
||
const fail = (e) => toast(e?.message || String(e), true);
|
||
|
||
function fmtBytes(n) {
|
||
if (!Number.isFinite(n)) return '';
|
||
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
let i = 0;
|
||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||
return `${n < 10 && i ? n.toFixed(1) : Math.round(n)} ${u[i]}`;
|
||
}
|
||
const fmtDate = (s) => { const d = new Date(s); return Number.isNaN(+d) ? '' : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); };
|
||
const baseName = (p) => p.replace(/\/$/, '').split('/').pop();
|
||
const KIND = (k) => {
|
||
const e = k.split('.').pop().toLowerCase();
|
||
if (/^(png|jpe?g|gif|webp|svg|avif)$/.test(e)) return 'image';
|
||
if (/^(mp3|ogg|oga|wav|flac|m4a|opus)$/.test(e)) return 'audio';
|
||
if (/^(mp4|webm|mov)$/.test(e)) return 'video';
|
||
if (/^(txt|md|json|csv|log|xml|yml|yaml|ini|conf|js|css|html?)$/.test(e)) return 'text';
|
||
return 'file';
|
||
};
|
||
const ICON = { folder: '📁', image: '🖼️', audio: '🎵', video: '🎬', text: '📄', file: '📦', drive: '💽' };
|
||
|
||
// ---- session -----------------------------------------------------------------------------
|
||
|
||
const S = { key: null, token: null, drives: null, user: null, fixedBucket: null, label: '' };
|
||
|
||
async function signInWith(text) {
|
||
const key = await Hosted.readKeyFile(text);
|
||
if (key.kind === 'owner') {
|
||
const s = await Hosted.signIn(key.identitySeed);
|
||
if (s.state !== 'active') throw new Error(s.state === 'empty' ? 'Nothing is on Silent Mode for this account yet. Move your drives there from Pithos first.' : 'This account is being moved; try again in a few minutes.');
|
||
const s3 = await Hosted.ownerS3(s.token);
|
||
const token = s.token;
|
||
let rulesCache = null;
|
||
S.token = token;
|
||
S.user = s3.user;
|
||
S.label = `Signed in · ${s.id.slice(0, 8)}…`;
|
||
try { const me = await Hosted.api('GET', '/hosted/v1/me', { token }); if (me.gate?.name) S.label = `Signed in as ${me.gate.name}`; } catch {}
|
||
S.drives = new Hosted.Drives({
|
||
accessKeyId: s3.accessKeyId, secretKey: s3.secretKey, master: key.encryptionKey,
|
||
rules: async () => {
|
||
if (!rulesCache || Date.now() - rulesCache.at > 60_000) rulesCache = { at: Date.now(), list: await Hosted.api('GET', '/hosted/v1/rules', { token }).catch(() => []) };
|
||
return Array.isArray(rulesCache.list) ? rulesCache.list : rulesCache.list?.rules || [];
|
||
},
|
||
});
|
||
} else {
|
||
if (key.endpoint && new URL(key.endpoint).host !== location.host && !/silentmode\.st$/.test(new URL(key.endpoint).hostname)) {
|
||
throw new Error(`This key is for ${new URL(key.endpoint).host}, not Drives on Silent Mode. Use it with Pithos or another S3 app.`);
|
||
}
|
||
S.drives = new Hosted.Drives({ accessKeyId: key.accessKeyId, secretKey: key.secretKey });
|
||
S.fixedBucket = key.bucket || null;
|
||
S.label = `S3 key ${key.accessKeyId.slice(0, 6)}…`;
|
||
}
|
||
S.key = key;
|
||
}
|
||
|
||
function signedIn() {
|
||
$('#signin').hidden = true;
|
||
$('#app').hidden = false;
|
||
$('#who').textContent = S.label;
|
||
$('#who').hidden = false;
|
||
$('#signout').hidden = false;
|
||
if (!location.hash || location.hash === '#/') location.hash = S.fixedBucket ? `#/d/${encodeURIComponent(S.fixedBucket)}/` : '#/';
|
||
route();
|
||
}
|
||
|
||
async function signOut() {
|
||
if (S.token) await Hosted.signOut(S.token);
|
||
location.hash = '';
|
||
location.reload();
|
||
}
|
||
|
||
// ---- sign-in screen --------------------------------------------------------------------------
|
||
|
||
async function showSignin() {
|
||
$('#app').hidden = true;
|
||
$('#signin').hidden = false;
|
||
const saved = await Hosted.remembered();
|
||
$('#unlock').hidden = !saved;
|
||
$('#keyform').hidden = !!saved;
|
||
if (saved) $('#unlock-pass').focus();
|
||
}
|
||
|
||
function signinError(e) {
|
||
const el = $('#signin-error');
|
||
el.textContent = e?.message || String(e);
|
||
el.hidden = false;
|
||
}
|
||
|
||
async function trySignIn(text, rememberPass) {
|
||
$('#signin-error').hidden = true;
|
||
const btns = [$('#go'), ...document.querySelectorAll('#unlock-form button')];
|
||
btns.forEach((b) => { b.disabled = true; });
|
||
try {
|
||
await signInWith(text);
|
||
if (rememberPass) { await Hosted.remember(text, rememberPass); }
|
||
signedIn();
|
||
} catch (e) { signinError(e); } finally { btns.forEach((b) => { b.disabled = false; }); }
|
||
}
|
||
|
||
let pickedText = null;
|
||
async function pickFile(file) {
|
||
if (!file) return;
|
||
if (file.size > 64 * 1024) return signinError(new Error('That file is too big to be a key file.'));
|
||
pickedText = await file.text();
|
||
$('#drop strong').textContent = `Key: ${file.name}`;
|
||
}
|
||
|
||
function wireSignin() {
|
||
const drop = $('#drop');
|
||
drop.addEventListener('click', () => $('#keyfile').click());
|
||
drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('#keyfile').click(); } });
|
||
$('#keyfile').addEventListener('change', () => pickFile($('#keyfile').files[0]));
|
||
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); });
|
||
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
|
||
drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('over'); pickFile(e.dataTransfer.files[0]); });
|
||
$('#remember').addEventListener('change', () => { $('#pass-row').hidden = !$('#remember').checked; });
|
||
$('#go').addEventListener('click', () => {
|
||
const text = pickedText || $('#keytext').value.trim();
|
||
if (!text) return signinError(new Error('Choose your key file first, or paste it.'));
|
||
let pass = null;
|
||
if ($('#remember').checked) {
|
||
pass = $('#pass').value;
|
||
if (pass.length < 10) return signinError(new Error('Use a passphrase of at least 10 characters to remember the key.'));
|
||
}
|
||
trySignIn(text, pass);
|
||
});
|
||
$('#unlock-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
const text = await Hosted.recall($('#unlock-pass').value);
|
||
if (!text) return showSignin();
|
||
await trySignIn(text, null);
|
||
} catch (err) { signinError(err); }
|
||
});
|
||
$('#forget').addEventListener('click', async () => {
|
||
await Hosted.forget();
|
||
$('#unlock').hidden = true;
|
||
$('#keyform').hidden = false;
|
||
});
|
||
$('#signout').addEventListener('click', signOut);
|
||
}
|
||
|
||
// ---- routes: #/ (drives) · #/d/<drive>/<folder>/ -----------------------------------------------
|
||
|
||
function current() {
|
||
const m = /^#\/d\/([^/]+)\/(.*)$/.exec(location.hash);
|
||
if (!m) return { bucket: null, prefix: '' };
|
||
const prefix = m[2].split('/').map((s) => { try { return decodeURIComponent(s); } catch { return s; } }).join('/');
|
||
return { bucket: decodeURIComponent(m[1]), prefix };
|
||
}
|
||
const linkTo = (bucket, prefix = '') => `#/d/${encodeURIComponent(bucket)}/${prefix.split('/').map(encodeURIComponent).join('/')}`;
|
||
|
||
let routeSeq = 0;
|
||
async function route() {
|
||
if (!S.drives) return;
|
||
const seq = ++routeSeq;
|
||
const { bucket, prefix } = current();
|
||
paintShared();
|
||
if (!bucket) return showDrives(seq);
|
||
return showFolder(seq, bucket, prefix);
|
||
}
|
||
|
||
function crumbs(bucket, prefix) {
|
||
const parts = [];
|
||
if (!S.fixedBucket) parts.push(h('a', { href: '#/' }, 'Drives'));
|
||
if (bucket) {
|
||
if (parts.length) parts.push(h('span', { class: 'sep' }, '›'));
|
||
parts.push(h('a', { href: linkTo(bucket) }, `${ICON.drive} ${bucket}`));
|
||
let acc = '';
|
||
for (const seg of prefix.split('/').filter(Boolean)) {
|
||
acc += seg + '/';
|
||
parts.push(h('span', { class: 'sep' }, '›'), h('a', { href: linkTo(bucket, acc) }, seg));
|
||
}
|
||
}
|
||
put($('#crumbs'), parts);
|
||
}
|
||
|
||
async function showDrives(seq) {
|
||
crumbs(null, '');
|
||
$('#toolbar').hidden = true;
|
||
put($('#list'), h('p', { class: 'empty' }, 'Loading…'));
|
||
try {
|
||
const list = await S.drives.listBuckets();
|
||
if (seq !== routeSeq) return;
|
||
if (!list.length) return put($('#list'), h('p', { class: 'empty' }, 'No drives yet. Create one in Pithos.'));
|
||
put($('#list'), h('table', { class: 'files' },
|
||
h('thead', {}, h('tr', {}, h('th', {}, 'Drive'), h('th', { class: 'hide-sm' }, 'Created'))),
|
||
h('tbody', {}, list.map((b) => h('tr', { class: 'clickable', onclick: () => { location.hash = linkTo(b.name); } },
|
||
h('td', {}, h('span', { class: 'name' }, h('span', { class: 'ico' }, ICON.drive), h('span', { class: 'label' }, b.name))),
|
||
h('td', { class: 'num hide-sm' }, fmtDate(b.created)))))));
|
||
$('#count').textContent = '';
|
||
} catch (e) {
|
||
if (seq === routeSeq) put($('#list'), h('p', { class: 'empty error' }, e.message));
|
||
}
|
||
}
|
||
|
||
async function showFolder(seq, bucket, prefix) {
|
||
crumbs(bucket, prefix);
|
||
$('#toolbar').hidden = false;
|
||
put($('#list'), h('p', { class: 'empty' }, 'Loading…'));
|
||
let l;
|
||
try {
|
||
l = await S.drives.list(bucket, prefix);
|
||
} catch (e) {
|
||
// a folder key may only list its own folder: go there
|
||
const only = /may only list (.+)$/.exec(e.message || '');
|
||
if (only && !prefix && seq === routeSeq) { location.hash = linkTo(bucket, only[1]); return; }
|
||
if (seq === routeSeq) put($('#list'), h('p', { class: 'empty error' }, e.message));
|
||
return;
|
||
}
|
||
if (seq !== routeSeq) return;
|
||
const rows = [];
|
||
for (const f of l.folders) {
|
||
rows.push(h('tr', { class: 'clickable', onclick: () => { location.hash = linkTo(bucket, f.path); } },
|
||
h('td', {}, h('span', { class: 'name' }, h('span', { class: 'ico' }, ICON.folder), h('span', { class: 'label' }, baseName(f.path)), f.locked && h('span', { class: 'lock' }, 'encrypted'))),
|
||
h('td', { class: 'num hide-sm' }, ''), h('td', { class: 'num hide-sm' }, ''),
|
||
h('td', { class: 'actions', onclick: (e) => e.stopPropagation() },
|
||
!S.fixedBucket || S.key.kind === 'owner' ? h('button', { class: 'btn small danger', onclick: () => deleteFolder(bucket, f.path) }, 'Delete') : null)));
|
||
}
|
||
for (const o of l.files) {
|
||
const kind = KIND(o.key);
|
||
const open = o.locked ? null : () => view(bucket, o);
|
||
rows.push(h('tr', { class: open ? 'clickable' : '', onclick: open },
|
||
h('td', {}, h('span', { class: 'name' }, h('span', { class: 'ico' }, ICON[kind]), h('span', { class: 'label', title: o.key }, baseName(o.key)), o.locked && h('span', { class: 'lock' }, 'encrypted'))),
|
||
h('td', { class: 'num hide-sm' }, fmtBytes(o.size)),
|
||
h('td', { class: 'num hide-sm' }, fmtDate(o.modified)),
|
||
h('td', { class: 'actions', onclick: (e) => e.stopPropagation() },
|
||
!o.locked && h('button', { class: 'btn small', onclick: () => download(bucket, o.key) }, 'Download'), ' ',
|
||
!o.locked && h('button', { class: 'btn small', onclick: () => rename(bucket, o.key) }, 'Rename'), ' ',
|
||
h('button', { class: 'btn small danger', onclick: () => remove(bucket, o.key) }, 'Delete'))));
|
||
}
|
||
$('#count').textContent = `${l.folders.length} folders · ${l.files.length} files`;
|
||
if (!rows.length) return put($('#list'), h('p', { class: 'empty' }, 'This folder is empty. Drop files here or press Upload.'));
|
||
put($('#list'), h('table', { class: 'files' },
|
||
h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'hide-sm' }, 'Size'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))),
|
||
h('tbody', {}, rows)));
|
||
}
|
||
|
||
// ---- actions ------------------------------------------------------------------------------------------
|
||
|
||
async function uploadFiles(files) {
|
||
const { bucket, prefix } = current();
|
||
if (!bucket || !files.length) return;
|
||
for (const file of files) {
|
||
const bar = h('progress', { max: file.size || 1, value: 0 });
|
||
const t = h('div', { class: 'toast' }, `Uploading ${file.name}`, bar);
|
||
$('#toasts').append(t);
|
||
try {
|
||
await S.drives.write(bucket, prefix + file.name, file, (n) => { bar.value = n; });
|
||
t.replaceChildren(`Uploaded ${file.name}`);
|
||
} catch (e) { t.classList.add('err'); t.replaceChildren(`${file.name}: ${e.message}`); }
|
||
setTimeout(() => t.remove(), 4000);
|
||
}
|
||
route();
|
||
}
|
||
|
||
async function newFolder() {
|
||
const { bucket, prefix } = current();
|
||
const name = (prompt('Folder name') || '').trim();
|
||
if (!name) return;
|
||
if (name.includes('/')) return fail(new Error('A folder name cannot contain /'));
|
||
try { await S.drives.mkdir(bucket, `${prefix}${name}/`); route(); } catch (e) { fail(e); }
|
||
}
|
||
|
||
async function saveBlob(blob, name) {
|
||
if (ANDROID) return saveToPhone(blob, name);
|
||
const url = URL.createObjectURL(blob);
|
||
const a = h('a', { href: url, download: name });
|
||
document.body.append(a);
|
||
a.click();
|
||
a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||
}
|
||
|
||
async function download(bucket, key) {
|
||
const t = toast(`Preparing ${baseName(key)}…`);
|
||
try { await saveBlob(await S.drives.blob(bucket, key), baseName(key)); t.remove(); } catch (e) { t.remove(); fail(e); }
|
||
}
|
||
|
||
async function rename(bucket, key) {
|
||
const name = (prompt('New name', baseName(key)) || '').trim();
|
||
if (!name || name === baseName(key)) return;
|
||
if (name.includes('/')) return fail(new Error('A name cannot contain /'));
|
||
const t = toast(`Renaming ${baseName(key)}…`);
|
||
try { await S.drives.rename(bucket, key, key.slice(0, key.length - baseName(key).length) + name); t.remove(); route(); } catch (e) { t.remove(); fail(e); }
|
||
}
|
||
|
||
async function remove(bucket, key) {
|
||
if (!confirm(`Delete ${baseName(key)}? This cannot be undone.`)) return;
|
||
try { await S.drives.remove(bucket, key); toast('Deleted'); route(); } catch (e) { fail(e); }
|
||
}
|
||
|
||
async function deleteFolder(bucket, prefix) {
|
||
if (!confirm(`Delete the folder ${baseName(prefix)} and everything in it? This cannot be undone.`)) return;
|
||
const t = toast(`Deleting ${baseName(prefix)}…`);
|
||
try { await S.drives.removeFolder(bucket, prefix); t.remove(); toast('Folder deleted'); route(); } catch (e) { t.remove(); fail(e); }
|
||
}
|
||
|
||
// ---- viewer ---------------------------------------------------------------------------------------------
|
||
|
||
let viewerUrl = null;
|
||
async function view(bucket, o) {
|
||
const kind = KIND(o.key);
|
||
if (kind === 'file') return download(bucket, o.key);
|
||
const body = $('#viewer-body');
|
||
$('#viewer-name').textContent = baseName(o.key);
|
||
put(body, h('p', { class: 'muted' }, 'Opening…'));
|
||
$('#viewer').hidden = false;
|
||
$('#viewer-dl').onclick = () => download(bucket, o.key);
|
||
try {
|
||
let src;
|
||
if (!o.encrypted && (kind === 'audio' || kind === 'video')) src = await S.drives.presigned(bucket, o.storageKey);
|
||
else {
|
||
if (kind !== 'text' && o.size > 300 * 1024 * 1024) throw new Error('This file is too large to open in the browser; download it instead.');
|
||
const blob = await S.drives.blob(bucket, o.key);
|
||
if (kind === 'text') {
|
||
if (blob.size > 2 * 1024 * 1024) throw new Error('This text file is too large to show; download it instead.');
|
||
return put(body, h('pre', {}, await blob.text()));
|
||
}
|
||
viewerUrl = URL.createObjectURL(blob);
|
||
src = viewerUrl;
|
||
}
|
||
if (kind === 'image') put(body, h('img', { src, alt: baseName(o.key) }));
|
||
else if (kind === 'audio') put(body, h('audio', { src, controls: true, autoplay: true }));
|
||
else put(body, h('video', { src, controls: true, autoplay: true }));
|
||
} catch (e) { put(body, h('p', { class: 'error' }, e.message)); }
|
||
}
|
||
function closeViewer() {
|
||
$('#viewer').hidden = true;
|
||
put($('#viewer-body'));
|
||
if (viewerUrl) { URL.revokeObjectURL(viewerUrl); viewerUrl = null; }
|
||
}
|
||
|
||
// ---- Pithos for Android -------------------------------------------------------------------------------
|
||
// The Android app runs this page in a WebView and adds window.PithosAndroid:
|
||
// downloads are written to the phone's Downloads folder, and files shared
|
||
// to Pithos from another app wait here until a folder is open to take them.
|
||
// Bytes cross as base64 in 1 MB pieces (the bridge only passes strings).
|
||
|
||
const ANDROID = window.PithosAndroid || null;
|
||
const PIECE = 1 << 20;
|
||
|
||
const blobToB64 = (blob) => new Promise((resolve, reject) => {
|
||
const r = new FileReader();
|
||
r.onload = () => resolve(String(r.result).slice(String(r.result).indexOf(',') + 1));
|
||
r.onerror = () => reject(r.error);
|
||
r.readAsDataURL(blob);
|
||
});
|
||
|
||
async function saveToPhone(blob, name) {
|
||
const id = ANDROID.saveBegin(name, blob.type || '');
|
||
if (!id) throw new Error('Could not save to Downloads.');
|
||
try {
|
||
for (let off = 0; off < blob.size; off += PIECE) {
|
||
if (!ANDROID.saveChunk(id, await blobToB64(blob.slice(off, off + PIECE)))) throw new Error('Could not write to Downloads.');
|
||
}
|
||
if (!ANDROID.saveEnd(id)) throw new Error('Could not finish saving to Downloads.');
|
||
} catch (e) { ANDROID.saveAbort(id); throw e; }
|
||
}
|
||
|
||
function readShared() {
|
||
const files = [];
|
||
for (let i = 0; i < ANDROID.sharedCount(); i++) {
|
||
let info = {};
|
||
try { info = JSON.parse(ANDROID.sharedInfo(i)); } catch {}
|
||
const parts = [];
|
||
for (let b64; (b64 = ANDROID.sharedRead(i, PIECE));) parts.push(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)));
|
||
files.push(new File(parts, info.name || `shared-${i + 1}`, { type: info.type || '' }));
|
||
}
|
||
ANDROID.sharedDone();
|
||
return files;
|
||
}
|
||
|
||
// A bar above the list while shared files wait: "Upload here" once a folder is open.
|
||
function paintShared() {
|
||
$('#shared-bar')?.remove();
|
||
if (!ANDROID || !S.drives) return;
|
||
const n = ANDROID.sharedCount();
|
||
if (!n) return;
|
||
const { bucket } = current();
|
||
const what = n === 1 ? '1 file' : `${n} files`;
|
||
const bar = h('div', { class: 'shared-bar', id: 'shared-bar' },
|
||
h('span', {}, bucket ? `${what} shared to Pithos.` : `${what} shared to Pithos. Open the folder to put them in.`),
|
||
bucket && h('button', { class: 'btn small primary', onclick: () => { bar.remove(); uploadFiles(readShared()); } }, 'Upload here'),
|
||
h('button', { class: 'btn small', onclick: () => { ANDROID.sharedDone(); bar.remove(); } }, 'Discard'));
|
||
$('#list').before(bar);
|
||
}
|
||
|
||
// ---- boot ------------------------------------------------------------------------------------------------
|
||
|
||
function wireApp() {
|
||
$('#upload').addEventListener('click', () => $('#files').click());
|
||
$('#files').addEventListener('change', () => { uploadFiles([...$('#files').files]); $('#files').value = ''; });
|
||
$('#newfolder').addEventListener('click', newFolder);
|
||
const list = $('#list');
|
||
list.addEventListener('dragover', (e) => { if (current().bucket) { e.preventDefault(); list.classList.add('over'); } });
|
||
list.addEventListener('dragleave', () => list.classList.remove('over'));
|
||
list.addEventListener('drop', (e) => { e.preventDefault(); list.classList.remove('over'); uploadFiles([...e.dataTransfer.files]); });
|
||
$('#viewer-close').addEventListener('click', closeViewer);
|
||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('#viewer').hidden) closeViewer(); });
|
||
window.addEventListener('hashchange', route);
|
||
window.addEventListener('pithos-shared', paintShared);
|
||
}
|
||
|
||
wireSignin();
|
||
wireApp();
|
||
showSignin().catch(fail);
|