This commit is contained in:
cupcakearmy 2021-11-22 20:07:06 +01:00
parent ee29c0b25b
commit 1acc0d4244
No known key found for this signature in database
GPG Key ID: 3235314B4D31232F
17 changed files with 275 additions and 163 deletions

View File

@ -30,7 +30,8 @@
"content_scripts": [ "content_scripts": [
{ {
"matches": ["<all_urls>"], "matches": ["<all_urls>"],
"js": ["./src/client/index.js"] "js": ["./src/client/index.js"],
"css": ["./src/client/index.css"]
} }
], ],
"web_accessible_resources": ["./icons/watch.png", "./icons/watch-alt.png", "./src/dashboard/index.html"] "web_accessible_resources": ["./icons/watch.png", "./icons/watch-alt.png", "./src/dashboard/index.html"]

View File

@ -33,6 +33,7 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"joi": "^17.4.2", "joi": "^17.4.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"pretty-bytes": "^5.6.0",
"spectre.css": "^0.5.9", "spectre.css": "^0.5.9",
"svelte-spa-router": "^3.2.0", "svelte-spa-router": "^3.2.0",
"tailwindcss": "^1.9.6", "tailwindcss": "^1.9.6",

7
pnpm-lock.yaml generated
View File

@ -14,6 +14,7 @@ specifiers:
npm-run-all: ^4.1.5 npm-run-all: ^4.1.5
parcel: ^2.0.1 parcel: ^2.0.1
parcel-transformer-svelte: ^1.2.3 parcel-transformer-svelte: ^1.2.3
pretty-bytes: ^5.6.0
spectre.css: ^0.5.9 spectre.css: ^0.5.9
svelte: ^3.44.2 svelte: ^3.44.2
svelte-spa-router: ^3.2.0 svelte-spa-router: ^3.2.0
@ -29,6 +30,7 @@ dependencies:
file-saver: 2.0.5 file-saver: 2.0.5
joi: 17.4.2 joi: 17.4.2
lodash: 4.17.21 lodash: 4.17.21
pretty-bytes: 5.6.0
spectre.css: 0.5.9 spectre.css: 0.5.9
svelte-spa-router: 3.2.0 svelte-spa-router: 3.2.0
tailwindcss: 1.9.6 tailwindcss: 1.9.6
@ -5707,6 +5709,11 @@ packages:
engines: {node: '>=4'} engines: {node: '>=4'}
dev: true dev: true
/pretty-bytes/5.6.0:
resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==}
engines: {node: '>=6'}
dev: false
/pretty-hrtime/1.0.3: /pretty-hrtime/1.0.3:
resolution: {integrity: sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=} resolution: {integrity: sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}

View File

@ -48,5 +48,11 @@ setInterval(deleteOldLogs, 5 * 60 * 1000) // Delete old logs every 5 minutes
setInterval(log, frequency) setInterval(log, frequency)
browser.runtime.onMessage.addListener((message, sender, sendResponse) => { browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
return getUsageForHost(message).then((percentages) => percentagesToBool(percentages)) switch (message.type) {
case 'check':
return getUsageForHost(message.host).then((percentages) => percentagesToBool(percentages))
case 'report':
DB.settings.put({ key: 'lastActivity', value: new Date() })
break
}
}) })

18
src/client/index.css Normal file
View File

@ -0,0 +1,18 @@
.ora--wrapper {
display: none;
position: fixed;
color: #111;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: #eee;
z-index: 999999999;
}
@media (prefers-color-scheme: dark) {
.ora--wrapper {
background-color: #000000;
color: #eee;
}
}

View File

@ -1,20 +1,10 @@
import browser from 'webextension-polyfill' import browser from 'webextension-polyfill'
let wrapper let wrapper
let lastReported = 0
function init() { function init() {
wrapper = window.document.createElement('div') wrapper = window.document.createElement('div')
Object.assign(wrapper.style, {
display: 'none',
position: 'fixed',
color: '#000',
top: '0',
left: '0',
width: '100vw',
height: '100vh',
backgroundColor: '#ffffff',
zIndex: '999999999',
})
wrapper.classList.add('ora--wrapper') wrapper.classList.add('ora--wrapper')
wrapper.classList.add('hidden') wrapper.classList.add('hidden')
@ -35,10 +25,27 @@ function init() {
async function check() { async function check() {
if (window.document.hidden) return if (window.document.hidden) return
const isBlocked = await browser.runtime.sendMessage(window.location.host) const isBlocked = await browser.runtime.sendMessage({
type: 'check',
host: window.location.host,
})
wrapper.style.display = isBlocked ? 'initial' : 'none' wrapper.style.display = isBlocked ? 'initial' : 'none'
} }
init() init()
setInterval(check, 2000)
check() check()
setInterval(check, 2000)
function logActivity() {
const now = Date.now()
// Limit reports to once every second
if (now - lastReported < 1000) return
lastReported = now
browser.runtime.sendMessage({
type: 'report',
})
}
window.document.addEventListener('mousemove', logActivity, false)
window.document.addEventListener('keydown', logActivity, false)
window.document.addEventListener('scroll', logActivity, false)

View File

@ -0,0 +1,14 @@
<script>
export let value
</script>
{#if Array.isArray(value)}
<input type="number" class="form-input" placeholder="1" bind:value={value[0]} />
<select class="form-select" bind:value={value[1]}>
<option value="m">Minutes</option>
<option value="h">Hours</option>
<option value="d">Days</option>
<option value="w">Weeks</option>
<option value="M">Months</option>
</select>
{/if}

View File

@ -1,9 +1,10 @@
<footer> <footer>
<a href="https://github.com/cupcakearmy/ora" target="_blank" rel="noreferrer">Source Code</a> <small>
- v0.8 Made with ❤️ by
<br /> <a href="https://nicco.io" target="_blank" rel="noreferrer">🐘</a>
Made with ❤️ by — v0.8 —
<a href="https://nicco.io" target="_blank" rel="noreferrer">🐘</a> <a href="https://github.com/cupcakearmy/ora" target="_blank" rel="noreferrer">Source Code</a>
</small>
</footer> </footer>
<style> <style>

View File

@ -19,17 +19,23 @@
end = new Date() end = new Date()
} }
const intervals = [
{ label: 'Year', set: set('year', 1) },
{ label: 'Month', set: set('month', 1) },
{ label: 'Week', set: set('week', 1) },
{ label: '3 Days', set: set('day', 3) },
{ label: 'Today', set: set('day', 0) },
]
// Init // Init
onMount(() => set('day', 0)()) onMount(() => set('day', 0)())
</script> </script>
<div class="flex items-center"> <div class="flex items-center">
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-sm" on:click={all}>All</button> {#each intervals as interval (interval.label)}
<button class="btn btn-sm" on:click={set('month', 1)}>Month</button> <button class="btn btn-sm" on:click={interval.set}>{interval.label}</button>
<button class="btn btn-sm" on:click={set('week', 1)}>Week</button> {/each}
<button class="btn btn-sm" on:click={set('day', 3)}>3 Days</button>
<button class="btn btn-sm" on:click={set('day', 0)}>Today</button>
</div> </div>
<div class="spacer" /> <div class="spacer" />
<div class="input-group"> <div class="input-group">

View File

@ -3,11 +3,14 @@
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
import { DB } from '../../shared/db' import { DB } from '../../shared/db'
import { checkForErrors, LimitValidator } from '../../shared/validation'
import DurationInput from './DurationInput.svelte'
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const init = { limit: ['1', 'h'], every: [1, 'd'] } const init = { limit: ['1', 'h'], every: [1, 'd'] }
export let limit = null export let limit = null
export let error = null
$: active = limit !== null $: active = limit !== null
function add() { function add() {
@ -23,6 +26,11 @@
} }
async function save() { async function save() {
const errors = checkForErrors(LimitValidator, limit)
if (errors) {
error = errors
return
}
await DB.limits.put(limit) await DB.limits.put(limit)
dispatch('update') dispatch('update')
close() close()
@ -45,25 +53,11 @@
</label> </label>
<div class="form-label">Rules</div> <div class="form-label">Rules</div>
{#each limit.rules as { limit, every }, i} {#each limit.rules as rule, i}
<div class="input-group mb-3"> <div class="input-group mb-3">
<input type="text" class="form-input" placeholder="1" bind:value={limit[0]} /> <DurationInput bind:value={rule.limit} />
<select class="form-select" bind:value={limit[1]}>
<option value="m">Minutes</option>
<option value="h">Hours</option>
<option value="d">Days</option>
<option value="w">Weeks</option>
<option value="M">Months</option>
</select>
<span class="input-group-addon">every</span> <span class="input-group-addon">every</span>
<input type="text" class="form-input" bind:value={every[0]} /> <DurationInput bind:value={rule.every} />
<select class="form-select" bind:value={every[1]}>
<option value="m">Minutes</option>
<option value="h">Hours</option>
<option value="d">Days</option>
<option value="w">Weeks</option>
<option value="M">Months</option>
</select>
<button class="btn btn-error input-group-btn" on:click={del(i)}>X</button> <button class="btn btn-error input-group-btn" on:click={del(i)}>X</button>
</div> </div>
{/each} {/each}
@ -73,6 +67,9 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<div> <div>
{#if error}
<span class="text-error">{error}</span>
{/if}
<button on:click={close} class="btn">Cancel</button> <button on:click={close} class="btn">Cancel</button>
<button on:click={save} class="btn btn-primary">Save</button> <button on:click={save} class="btn btn-primary">Save</button>
</div> </div>

View File

@ -0,0 +1,17 @@
<script>
import pretty from 'pretty-bytes'
import { onMount } from 'svelte'
let usage = null
onMount(async () => {
const estimate = await window.navigator.storage.estimate()
usage = pretty(estimate.usage)
})
</script>
{#if usage === null}
<span class="loading" />
{:else}
<span>Storage used: <span class="font-mono">{usage}</span></span>
{/if}

View File

@ -1,5 +1,6 @@
<script> <script>
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { cloneDeep } from 'lodash'
import RulesEditor from '../components/RulesEditor.svelte' import RulesEditor from '../components/RulesEditor.svelte'
import Rules from '../components/Rules.svelte' import Rules from '../components/Rules.svelte'
@ -15,7 +16,7 @@
} }
function edit(id) { function edit(id) {
limit = limits.find((limit) => limit.id === id) limit = cloneDeep(limits.find((limit) => limit.id === id))
} }
async function load() { async function load() {

View File

@ -1,93 +1,7 @@
<script> <script>
import { onMount } from 'svelte' import YourData from '../views/YourData.svelte'
import browser from 'webextension-polyfill' import Settings from '../views/Settings.svelte'
import { saveAs } from 'file-saver'
import dj from 'dayjs'
import FileUpload from '../components/FileUpload.svelte'
import { dump as dumpDB, load as loadDB, clear as clearDB, validate } from '../../shared/db'
import { longPress } from '../../shared/lib'
const DEFAULT = {
retention: 90,
}
let settings = DEFAULT
let uploaded
let disabled = true
async function read() {
settings = {
...DEFAULT,
...(await browser.storage.local.get()),
}
}
function write() {
return browser.storage.local.set(settings)
}
async function reset() {
await browser.storage.local.clear()
await read()
}
async function dump() {
const data = await dumpDB()
const blob = new Blob([JSON.stringify(data)], { type: 'application/json;charset=utf-8' })
const filename = `Ora [${dj().format('YYYY-MM-DD HH-mm-ss')}].json`
saveAs(blob, filename)
}
async function clear() {
await clearDB()
alert('Done')
}
async function load() {
try {
await loadDB(uploaded)
alert('Imported')
} catch {
alert('Error importing')
}
}
$: {
disabled = !validate(uploaded)
}
onMount(read)
</script> </script>
<h2 class="mt-8 text-2xl">Settings</h2> <Settings />
<form class="mt-2" on:submit|preventDefault={write}> <YourData />
<div class="form-group">
<label class="form-label">
Retention
<small>(Days)</small>
<input
id="retention"
class="form-input"
type="number"
min="3"
max="365"
step="1"
bind:value={settings.retention}
/>
</label>
<div class="mt-2">
<button type="reset" class="btn" on:click={reset}>Reset</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</div>
</form>
<h2 class="mt-8 text-2xl">Your Data</h2>
<div class="mt-2">
<FileUpload bind:value={uploaded} />
<button class="btn btn-primary" on:click={load} {disabled}>Import</button>
<button class="btn btn-primary" on:click={dump}>Export</button>
<button class="btn btn-error tooltip" data-tooltip="Hold to delete" use:longPress={clear}>Delete all data</button>
</div>

View File

@ -0,0 +1,47 @@
<script>
import { onMount } from 'svelte'
import { DB } from '../../shared/db'
import { SettingsValidator } from '../../shared/validation'
let settings = null
async function load() {
const values = await DB.settings.toArray()
const fromDB = Object.fromEntries(values.map((v) => [v.key, v.value]))
settings = SettingsValidator.validate(fromDB).value
}
async function save() {
for (const [key, value] of Object.entries(settings)) {
await DB.settings.put({ key, value })
}
}
onMount(load)
</script>
<h2 class="mt-8 text-2xl">Settings</h2>
{#if settings}
<form class="mt-2" on:submit|preventDefault={save}>
<div class="form-group">
<label class="form-label">
Retention
<small>(Days)</small>
<input class="form-input" type="number" min="3" max="365" step="1" bind:value={settings.retention} />
</label>
<label class="form-label">
Idle Timeout
<small>(Minutes)</small>
<input class="form-input" type="number" min="0" step="1" bind:value={settings.idleTimeout} />
<p>Stop tracking after a certain period of idle behavior. <span class="font-mono">0</span> to disable.</p>
</label>
<div class="mt-2">
<button type="submit" class="btn btn-primary">Save</button>
</div>
</div>
</form>
{:else}
<div class="loading loading-lg" />
{/if}

View File

@ -0,0 +1,49 @@
<script>
import { saveAs } from 'file-saver'
import dj from 'dayjs'
import FileUpload from '../components/FileUpload.svelte'
import { dump, load, clear } from '../../shared/db'
import { checkForErrors, DBValidator } from '../../shared/validation'
import { longPress } from '../../shared/lib'
let uploaded
async function exportDB() {
const data = await dump()
const blob = new Blob([JSON.stringify(data)], { type: 'application/json;charset=utf-8' })
const filename = `Ora [${dj().format('YYYY-MM-DD HH-mm-ss')}].json`
saveAs(blob, filename)
}
async function clearDB() {
await clear()
window.location.reload()
}
async function importDB() {
try {
await load(uploaded)
alert('Imported')
} catch {
alert('Error importing')
}
}
$: disabled = uploaded && !checkForErrors(DBValidator, uploaded)
</script>
<h2 class="mt-8 text-2xl">Your Data</h2>
<div class="mt-2">
<FileUpload bind:value={uploaded} />
<button class="btn btn-primary" on:click={importDB} {disabled}>
{#if uploaded && disabled}
Invalid data
{:else}
Import
{/if}
</button>
<button class="btn btn-primary" on:click={exportDB}>Export</button>
<button class="btn btn-error tooltip" data-tooltip="Hold to delete" use:longPress={clearDB}>Delete all data</button>
</div>

View File

@ -2,17 +2,23 @@ import dj from 'dayjs'
import Dexie from 'dexie' import Dexie from 'dexie'
import RelativeTime from 'dayjs/plugin/relativeTime' import RelativeTime from 'dayjs/plugin/relativeTime'
import Duration from 'dayjs/plugin/duration' import Duration from 'dayjs/plugin/duration'
import Joi from 'joi'
import { checkForErrors, DBValidator } from './validation'
dj.extend(Duration) dj.extend(Duration)
dj.extend(RelativeTime) dj.extend(RelativeTime)
export const DB = new Dexie('ora') export const DB = new Dexie('ora')
DB.version(2).stores({ DB.version(2).stores({
logs: `++id, host, timestamp`, logs: `++id, host, timestamp`,
limits: `++id, host`, limits: `++id, host`,
}) })
DB.version(3).stores({
settings: `key, value`,
})
export function normalizeTimestamp(timestamp) { export function normalizeTimestamp(timestamp) {
// Normalize every dato to 15 minutes // Normalize every dato to 15 minutes
const t = dj(timestamp) const t = dj(timestamp)
@ -43,36 +49,8 @@ export async function dump() {
} }
} }
export function validate(data) {
const schema = Joi.object({
limits: Joi.array().items(
Joi.object({
host: Joi.string(),
id: Joi.number(),
rules: Joi.array().items(
Joi.object({
limit: Joi.array().items(Joi.string(), Joi.number()),
every: Joi.array().items(Joi.string(), Joi.number()),
})
),
})
),
logs: Joi.array().items(
Joi.object({
host: Joi.string(),
id: Joi.number(),
seconds: Joi.number(),
timestamp: Joi.string(),
})
),
})
const validated = schema.validate(data, { presence: 'required' })
return !validated.error
}
export async function load(data) { export async function load(data) {
if (!validate(data)) throw new Error('Invalid data') if (!checkForErrors(DBValidator, data)) throw new Error('Invalid data')
await clear() await clear()
await DB.limits.bulkAdd(data.limits) await DB.limits.bulkAdd(data.limits)
@ -83,3 +61,8 @@ export async function load(data) {
})) }))
) )
} }
export async function updateOrSet(table, key, value) {
// const updated = await table.update(key, value)
// if(updated === 0) await table.
}

43
src/shared/validation.js Normal file
View File

@ -0,0 +1,43 @@
import Joi from 'joi'
export const DurationTupleValidator = Joi.array().length(2).ordered(Joi.number(), Joi.string())
export const LimitValidator = Joi.object({
host: Joi.string().domain(),
id: Joi.number().optional(),
rules: Joi.array().items(
Joi.object({
limit: DurationTupleValidator,
every: DurationTupleValidator,
})
),
})
export const LogValidator = Joi.object({
host: Joi.string(),
id: Joi.number().optional(),
seconds: Joi.number(),
timestamp: Joi.date(),
})
export const SettingsValidator = Joi.object({
lastActivity: Joi.date()
.default(() => new Date())
.optional(), // Last user activity, to calculate idle time
retention: Joi.number().default(90).optional(), // Days to keep logs
idleTimeout: DurationTupleValidator.default(5).optional(), // Idle timeout in minutes
})
export const DBValidator = Joi.object({
limits: Joi.array().items(LimitValidator),
logs: Joi.array().items(LogValidator),
settings: SettingsValidator.optional(),
})
export function checkForErrors(validator, data) {
const validated = validator.validate(data, { presence: 'required' })
if (validated.error) {
console.error('Validation error', validated.error)
}
return validated.error
}