444 lines
16 KiB
JavaScript
444 lines
16 KiB
JavaScript
// Lichttisch – Nextcloud-Fotobetrachter
|
||||
|
|
// Hauptprozess: spricht per WebDAV/OCS mit der Nextcloud und reicht Bilder
|
|||
|
|
// über das eigene Protokoll nc:// an die Oberfläche durch. Nichts wird auf
|
|||
|
|
// die Festplatte geschrieben außer der Anmeldung (falls gewünscht).
|
|||
|
|
|
|||
|
|
const { app, BrowserWindow, ipcMain, protocol, shell, safeStorage, Menu } = require('electron');
|
|||
|
|
const path = require('path');
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const { XMLParser } = require('fast-xml-parser');
|
|||
|
|
|
|||
|
|
const UA = 'Lichttisch (Linux Fotobetrachter)';
|
|||
|
|
const PAGE_SIZE = 200;
|
|||
|
|
|
|||
|
|
protocol.registerSchemesAsPrivileged([
|
|||
|
|
{ scheme: 'nc', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } },
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
let win = null;
|
|||
|
|
let conn = null; // { server, loginName, userId, displayName, auth }
|
|||
|
|
let loginFlowAbort = null;
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------- Konfiguration
|
|||
|
|
|
|||
|
|
const configFile = () => path.join(app.getPath('userData'), 'config.json');
|
|||
|
|
|
|||
|
|
function loadConfig() {
|
|||
|
|
try { return JSON.parse(fs.readFileSync(configFile(), 'utf8')); } catch { return {}; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveConfig(cfg) {
|
|||
|
|
fs.mkdirSync(path.dirname(configFile()), { recursive: true });
|
|||
|
|
fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function encryptSecret(s) {
|
|||
|
|
if (safeStorage.isEncryptionAvailable()) {
|
|||
|
|
return { enc: true, v: safeStorage.encryptString(s).toString('base64') };
|
|||
|
|
}
|
|||
|
|
return { enc: false, v: Buffer.from(s, 'utf8').toString('base64') };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function decryptSecret(o) {
|
|||
|
|
if (!o) return null;
|
|||
|
|
try {
|
|||
|
|
const buf = Buffer.from(o.v, 'base64');
|
|||
|
|
return o.enc ? safeStorage.decryptString(buf) : buf.toString('utf8');
|
|||
|
|
} catch { return null; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------- Hilfsfunktionen
|
|||
|
|
|
|||
|
|
function normalizeServer(s) {
|
|||
|
|
s = String(s || '').trim();
|
|||
|
|
if (!s) throw new Error('Bitte die Adresse deiner Nextcloud eingeben.');
|
|||
|
|
if (!/^https?:\/\//i.test(s)) s = 'https://' + s;
|
|||
|
|
s = s.replace(/\/(index\.php|remote\.php|apps|login)(\/.*)?$/i, '');
|
|||
|
|
return s.replace(/\/+$/, '');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const origin = (server) => new URL(server).origin;
|
|||
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|||
|
|
|
|||
|
|
function encodePath(p) {
|
|||
|
|
return p.split('/').map((seg) => encodeURIComponent(seg)).join('/');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function xmlEscape(s) {
|
|||
|
|
return String(s).replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function ncFetch(url, opts = {}) {
|
|||
|
|
if (!conn) throw new Error('Nicht angemeldet.');
|
|||
|
|
const headers = { Authorization: conn.auth, 'User-Agent': UA, 'OCS-APIRequest': 'true', ...(opts.headers || {}) };
|
|||
|
|
let res;
|
|||
|
|
try {
|
|||
|
|
res = await fetch(url, { ...opts, headers });
|
|||
|
|
} catch (e) {
|
|||
|
|
throw new Error('Server nicht erreichbar: ' + (e.cause?.code || e.message));
|
|||
|
|
}
|
|||
|
|
if (res.status === 401) throw new Error('Die Anmeldung wurde abgelehnt. Bitte neu anmelden.');
|
|||
|
|
return res;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------- WebDAV-Antworten lesen
|
|||
|
|
|
|||
|
|
const parser = new XMLParser({
|
|||
|
|
removeNSPrefix: true,
|
|||
|
|
ignoreAttributes: true,
|
|||
|
|
parseTagValue: false,
|
|||
|
|
isArray: (name) => name === 'response' || name === 'propstat',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function parseMultistatus(xml) {
|
|||
|
|
const doc = parser.parse(xml);
|
|||
|
|
const responses = doc?.multistatus?.response || [];
|
|||
|
|
return responses.map((r) => {
|
|||
|
|
const props = {};
|
|||
|
|
for (const ps of r.propstat || []) {
|
|||
|
|
if (/\s200\s/.test(String(ps.status) + ' ')) Object.assign(props, ps.prop || {});
|
|||
|
|
}
|
|||
|
|
return { href: String(r.href), props };
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function kindOf(mime, isDir) {
|
|||
|
|
if (isDir) return 'dir';
|
|||
|
|
if (mime.startsWith('image/')) return 'image';
|
|||
|
|
if (mime.startsWith('video/')) return 'video';
|
|||
|
|
return 'other';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toItem({ href, props }) {
|
|||
|
|
const isDir = typeof props.resourcetype === 'object' && props.resourcetype !== null && 'collection' in props.resourcetype;
|
|||
|
|
const mime = String(props.getcontenttype || '');
|
|||
|
|
const decoded = decodeURIComponent(href);
|
|||
|
|
const name = String(props.displayname || '') || decoded.replace(/\/+$/, '').split('/').pop();
|
|||
|
|
const mtime = props.getlastmodified ? Date.parse(props.getlastmodified) : 0;
|
|||
|
|
const takenRaw = Number(props['metadata-photos-original_date_time'] || 0);
|
|||
|
|
return {
|
|||
|
|
href,
|
|||
|
|
name,
|
|||
|
|
isDir,
|
|||
|
|
mime,
|
|||
|
|
kind: kindOf(mime, isDir),
|
|||
|
|
size: Number(props.getcontentlength || 0),
|
|||
|
|
mtime,
|
|||
|
|
taken: takenRaw > 0 ? takenRaw * 1000 : 0,
|
|||
|
|
fileId: props.fileid ? String(props.fileid) : null,
|
|||
|
|
hasPreview: String(props['has-preview']) === 'true',
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const FILE_PROPS = `
|
|||
|
|
<d:getlastmodified/><d:getcontenttype/><d:resourcetype/><d:getcontentlength/><d:displayname/>
|
|||
|
|
<oc:fileid/><nc:has-preview/><nc:metadata-photos-original_date_time/>`;
|
|||
|
|
|
|||
|
|
const NS = 'xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"';
|
|||
|
|
|
|||
|
|
async function propfind(url, propsXml, depth = '1') {
|
|||
|
|
const body = `<?xml version="1.0" encoding="UTF-8"?><d:propfind ${NS}><d:prop>${propsXml}</d:prop></d:propfind>`;
|
|||
|
|
const res = await ncFetch(url, { method: 'PROPFIND', headers: { Depth: depth, 'Content-Type': 'text/xml; charset=utf-8' }, body });
|
|||
|
|
if (res.status === 404) { const e = new Error('Nicht gefunden.'); e.code = 404; throw e; }
|
|||
|
|
if (res.status !== 207) throw new Error(`Unerwartete Antwort vom Server (${res.status}).`);
|
|||
|
|
return parseMultistatus(await res.text());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------- Anmeldung
|
|||
|
|
|
|||
|
|
async function finishLogin(server, loginName, password, remember) {
|
|||
|
|
server = normalizeServer(server);
|
|||
|
|
const auth = 'Basic ' + Buffer.from(`${loginName}:${password}`, 'utf8').toString('base64');
|
|||
|
|
let res;
|
|||
|
|
try {
|
|||
|
|
res = await fetch(server + '/ocs/v2.php/cloud/user?format=json', {
|
|||
|
|
headers: { Authorization: auth, 'OCS-APIRequest': 'true', Accept: 'application/json', 'User-Agent': UA },
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
throw new Error(`Keine Verbindung zu ${server}. Stimmt die Adresse? (${e.cause?.code || e.message})`);
|
|||
|
|
}
|
|||
|
|
if (res.status === 401) throw new Error('Benutzername oder Passwort stimmt nicht. Bei aktivierter Zwei-Faktor-Anmeldung brauchst du ein App-Passwort.');
|
|||
|
|
if (!res.ok) throw new Error(`Der Server antwortet mit Status ${res.status}. Ist das wirklich eine Nextcloud-Adresse?`);
|
|||
|
|
let data;
|
|||
|
|
try { data = (await res.json()).ocs.data; } catch { throw new Error('Unter dieser Adresse läuft offenbar keine Nextcloud.'); }
|
|||
|
|
|
|||
|
|
conn = {
|
|||
|
|
server,
|
|||
|
|
loginName,
|
|||
|
|
userId: data.id,
|
|||
|
|
displayName: data['display-name'] || data.displayname || data.id,
|
|||
|
|
auth,
|
|||
|
|
};
|
|||
|
|
thumbCache.clear();
|
|||
|
|
|
|||
|
|
const cfg = loadConfig();
|
|||
|
|
cfg.server = server;
|
|||
|
|
cfg.loginName = loginName;
|
|||
|
|
if (remember) cfg.secret = encryptSecret(password);
|
|||
|
|
else delete cfg.secret;
|
|||
|
|
saveConfig(cfg);
|
|||
|
|
|
|||
|
|
return publicConn();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function publicConn() {
|
|||
|
|
return conn && { server: conn.server, userId: conn.userId, displayName: conn.displayName };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loginFlow(serverInput, remember) {
|
|||
|
|
const server = normalizeServer(serverInput);
|
|||
|
|
let res;
|
|||
|
|
try {
|
|||
|
|
res = await fetch(server + '/index.php/login/v2', { method: 'POST', headers: { 'User-Agent': UA } });
|
|||
|
|
} catch (e) {
|
|||
|
|
throw new Error(`Keine Verbindung zu ${server}. Stimmt die Adresse? (${e.cause?.code || e.message})`);
|
|||
|
|
}
|
|||
|
|
if (!res.ok) throw new Error(`Die Browser-Anmeldung wird von diesem Server nicht angeboten (Status ${res.status}).`);
|
|||
|
|
const flow = await res.json();
|
|||
|
|
await shell.openExternal(flow.login);
|
|||
|
|
|
|||
|
|
const ctrl = { aborted: false };
|
|||
|
|
loginFlowAbort = ctrl;
|
|||
|
|
const deadline = Date.now() + 20 * 60 * 1000;
|
|||
|
|
while (Date.now() < deadline) {
|
|||
|
|
await sleep(2000);
|
|||
|
|
if (ctrl.aborted) throw new Error('Anmeldung abgebrochen.');
|
|||
|
|
let poll;
|
|||
|
|
try {
|
|||
|
|
poll = await fetch(flow.poll.endpoint, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': UA },
|
|||
|
|
body: 'token=' + encodeURIComponent(flow.poll.token),
|
|||
|
|
});
|
|||
|
|
} catch { continue; }
|
|||
|
|
if (poll.status === 200) {
|
|||
|
|
const d = await poll.json();
|
|||
|
|
loginFlowAbort = null;
|
|||
|
|
return finishLogin(d.server || server, d.loginName, d.appPassword, remember);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
throw new Error('Die Anmeldung im Browser hat zu lange gedauert. Bitte noch einmal versuchen.');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------- Datenquellen
|
|||
|
|
|
|||
|
|
async function listFolder(relPath) {
|
|||
|
|
const base = `${conn.server}/remote.php/dav/files/${encodeURIComponent(conn.userId)}`;
|
|||
|
|
const rel = ('/' + (relPath || '').replace(/^\/+|\/+$/g, '')).replace(/\/$/, '') + '/';
|
|||
|
|
const entries = await propfind(base + encodePath(rel), FILE_PROPS, '1');
|
|||
|
|
const marker = `/remote.php/dav/files/${conn.userId}`;
|
|||
|
|
const items = entries.slice(1).map(toItem).map((it) => {
|
|||
|
|
if (it.isDir) {
|
|||
|
|
const dec = decodeURIComponent(it.href);
|
|||
|
|
const i = dec.indexOf(marker);
|
|||
|
|
it.path = i >= 0 ? dec.slice(i + marker.length).replace(/\/+$/, '') : rel + it.name;
|
|||
|
|
}
|
|||
|
|
return it;
|
|||
|
|
});
|
|||
|
|
return items;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function searchMedia(offset) {
|
|||
|
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|||
|
|
<d:searchrequest ${NS} xmlns:ns="https://github.com/icewind1991/SearchDAV/ns">
|
|||
|
|
<d:basicsearch>
|
|||
|
|
<d:select><d:prop>${FILE_PROPS}</d:prop></d:select>
|
|||
|
|
<d:from><d:scope><d:href>/files/${xmlEscape(conn.userId)}</d:href><d:depth>infinity</d:depth></d:scope></d:from>
|
|||
|
|
<d:where>
|
|||
|
|
<d:or>
|
|||
|
|
<d:like><d:prop><d:getcontenttype/></d:prop><d:literal>image/%</d:literal></d:like>
|
|||
|
|
<d:like><d:prop><d:getcontenttype/></d:prop><d:literal>video/%</d:literal></d:like>
|
|||
|
|
</d:or>
|
|||
|
|
</d:where>
|
|||
|
|
<d:orderby><d:order><d:prop><d:getlastmodified/></d:prop><d:descending/></d:order></d:orderby>
|
|||
|
|
<d:limit><d:nresults>${PAGE_SIZE}</d:nresults><ns:firstresult>${Number(offset) || 0}</ns:firstresult></d:limit>
|
|||
|
|
</d:basicsearch>
|
|||
|
|
</d:searchrequest>`;
|
|||
|
|
const res = await ncFetch(`${conn.server}/remote.php/dav/`, {
|
|||
|
|
method: 'SEARCH',
|
|||
|
|
headers: { 'Content-Type': 'text/xml; charset=utf-8' },
|
|||
|
|
body,
|
|||
|
|
});
|
|||
|
|
if (res.status !== 207) throw new Error(`Die Suche nach Fotos ist fehlgeschlagen (Status ${res.status}).`);
|
|||
|
|
const items = parseMultistatus(await res.text()).map(toItem).filter((i) => !i.isDir);
|
|||
|
|
return { items, done: items.length < PAGE_SIZE };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ALBUM_PROPS = `<nc:last-photo/><nc:nbItems/><nc:location/><nc:dateRange/><d:displayname/><d:resourcetype/>`;
|
|||
|
|
|
|||
|
|
async function listAlbums() {
|
|||
|
|
const u = encodeURIComponent(conn.userId);
|
|||
|
|
const out = [];
|
|||
|
|
let available = false;
|
|||
|
|
for (const [kind, shared] of [['albums', false], ['sharedalbums', true]]) {
|
|||
|
|
try {
|
|||
|
|
const entries = await propfind(`${conn.server}/remote.php/dav/photos/${u}/${kind}/`, ALBUM_PROPS, '1');
|
|||
|
|
available = true;
|
|||
|
|
for (const e of entries.slice(1)) {
|
|||
|
|
const name = decodeURIComponent(e.href).replace(/\/+$/, '').split('/').pop();
|
|||
|
|
let range = null;
|
|||
|
|
try { range = e.props.dateRange ? JSON.parse(e.props.dateRange) : null; } catch {}
|
|||
|
|
out.push({
|
|||
|
|
href: e.href,
|
|||
|
|
name: shared ? name.replace(/\s*\([^)]*\)$/, '') : name,
|
|||
|
|
shared,
|
|||
|
|
count: Number(e.props.nbItems || 0),
|
|||
|
|
cover: e.props['last-photo'] && e.props['last-photo'] !== '-1' ? String(e.props['last-photo']) : null,
|
|||
|
|
location: e.props.location ? String(e.props.location) : '',
|
|||
|
|
start: range?.start ? range.start * 1000 : 0,
|
|||
|
|
end: range?.end ? range.end * 1000 : 0,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
if (e.code !== 404) throw e;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return { available, albums: out };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function listAlbum(href) {
|
|||
|
|
if (!String(href).startsWith('/')) throw new Error('Ungültiges Album.');
|
|||
|
|
const entries = await propfind(origin(conn.server) + href, FILE_PROPS, '1');
|
|||
|
|
return entries.slice(1).map(toItem).filter((i) => !i.isDir);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------- Bild-Proxy nc://
|
|||
|
|
|
|||
|
|
// Vorschaubilder landen nur im Arbeitsspeicher (max. ca. 150 MB).
|
|||
|
|
const thumbCache = new Map();
|
|||
|
|
let thumbBytes = 0;
|
|||
|
|
const THUMB_LIMIT = 150 * 1024 * 1024;
|
|||
|
|
|
|||
|
|
function cachePut(key, entry) {
|
|||
|
|
if (thumbCache.has(key)) return;
|
|||
|
|
thumbCache.set(key, entry);
|
|||
|
|
thumbBytes += entry.buf.byteLength;
|
|||
|
|
while (thumbBytes > THUMB_LIMIT && thumbCache.size) {
|
|||
|
|
const [k, v] = thumbCache.entries().next().value;
|
|||
|
|
thumbCache.delete(k);
|
|||
|
|
thumbBytes -= v.buf.byteLength;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleNc(request) {
|
|||
|
|
if (!conn) return new Response('Nicht angemeldet', { status: 401 });
|
|||
|
|
const u = new URL(request.url);
|
|||
|
|
try {
|
|||
|
|
if (u.hostname === 'preview') {
|
|||
|
|
const id = u.searchParams.get('id');
|
|||
|
|
const s = Math.min(4096, Math.max(32, Number(u.searchParams.get('s')) || 256));
|
|||
|
|
const key = `${id}:${s}`;
|
|||
|
|
const hit = thumbCache.get(key);
|
|||
|
|
if (hit) {
|
|||
|
|
thumbCache.delete(key); thumbCache.set(key, hit); // LRU auffrischen
|
|||
|
|
return new Response(hit.buf, { headers: { 'Content-Type': hit.type } });
|
|||
|
|
}
|
|||
|
|
const res = await ncFetch(`${conn.server}/index.php/core/preview?fileId=${encodeURIComponent(id)}&x=${s}&y=${s}&a=1&forceIcon=0`);
|
|||
|
|
if (!res.ok) return new Response('', { status: res.status });
|
|||
|
|
const buf = Buffer.from(await res.arrayBuffer());
|
|||
|
|
const type = res.headers.get('content-type') || 'image/jpeg';
|
|||
|
|
if (s <= 1024) cachePut(key, { buf, type });
|
|||
|
|
return new Response(buf, { headers: { 'Content-Type': type } });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (u.hostname === 'file') {
|
|||
|
|
const href = u.searchParams.get('h') || '';
|
|||
|
|
if (!href.startsWith('/')) return new Response('', { status: 400 });
|
|||
|
|
const headers = {};
|
|||
|
|
const range = request.headers.get('range');
|
|||
|
|
if (range) headers.Range = range;
|
|||
|
|
const res = await ncFetch(origin(conn.server) + href, { headers });
|
|||
|
|
const out = new Headers();
|
|||
|
|
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'last-modified']) {
|
|||
|
|
const v = res.headers.get(h);
|
|||
|
|
if (v) out.set(h, v);
|
|||
|
|
}
|
|||
|
|
return new Response(res.body, { status: res.status, headers: out });
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
return new Response(String(e.message), { status: 502 });
|
|||
|
|
}
|
|||
|
|
return new Response('', { status: 404 });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------- IPC
|
|||
|
|
|
|||
|
|
function handle(channel, fn) {
|
|||
|
|
ipcMain.handle(channel, async (_e, ...args) => {
|
|||
|
|
try { return { ok: true, data: await fn(...args) }; }
|
|||
|
|
catch (e) { return { ok: false, error: e.message || String(e) }; }
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
handle('config', () => {
|
|||
|
|
const c = loadConfig();
|
|||
|
|
return { server: c.server || '', loginName: c.loginName || '', hasSecret: !!c.secret };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
handle('restore', async () => {
|
|||
|
|
const c = loadConfig();
|
|||
|
|
const pw = decryptSecret(c.secret);
|
|||
|
|
if (!c.server || !c.loginName || !pw) return null;
|
|||
|
|
return finishLogin(c.server, c.loginName, pw, true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
handle('login-password', (server, user, pass, remember) => finishLogin(server, user, pass, remember));
|
|||
|
|
handle('login-browser', (server, remember) => loginFlow(server, remember));
|
|||
|
|
handle('login-cancel', () => { if (loginFlowAbort) loginFlowAbort.aborted = true; return true; });
|
|||
|
|
|
|||
|
|
handle('logout', () => {
|
|||
|
|
conn = null;
|
|||
|
|
thumbCache.clear(); thumbBytes = 0;
|
|||
|
|
const c = loadConfig();
|
|||
|
|
delete c.secret;
|
|||
|
|
saveConfig(c);
|
|||
|
|
return true;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
handle('folder', (p) => listFolder(p));
|
|||
|
|
handle('timeline', (offset) => searchMedia(offset));
|
|||
|
|
handle('albums', () => listAlbums());
|
|||
|
|
handle('album', (href) => listAlbum(href));
|
|||
|
|
|
|||
|
|
handle('open-in-nextcloud', (fileId) => {
|
|||
|
|
if (!conn || !/^\d+$/.test(String(fileId))) return false;
|
|||
|
|
shell.openExternal(`${conn.server}/index.php/f/${fileId}`);
|
|||
|
|
return true;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
handle('toggle-fullscreen', () => {
|
|||
|
|
if (win) win.setFullScreen(!win.isFullScreen());
|
|||
|
|
return win?.isFullScreen();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------- Fenster
|
|||
|
|
|
|||
|
|
function createWindow() {
|
|||
|
|
win = new BrowserWindow({
|
|||
|
|
width: 1280,
|
|||
|
|
height: 820,
|
|||
|
|
minWidth: 640,
|
|||
|
|
minHeight: 420,
|
|||
|
|
backgroundColor: '#1f2022',
|
|||
|
|
title: 'Lichttisch',
|
|||
|
|
autoHideMenuBar: true,
|
|||
|
|
webPreferences: {
|
|||
|
|
preload: path.join(__dirname, 'preload.js'),
|
|||
|
|
contextIsolation: true,
|
|||
|
|
nodeIntegration: false,
|
|||
|
|
sandbox: true,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
|||
|
|
win.webContents.on('will-navigate', (e) => e.preventDefault());
|
|||
|
|
win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
app.whenReady().then(() => {
|
|||
|
|
Menu.setApplicationMenu(null);
|
|||
|
|
protocol.handle('nc', handleNc);
|
|||
|
|
createWindow();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.on('window-all-closed', () => app.quit());
|