Initial scaffold: blob store server, CLI scripts, and PWA share target
Clipboard/screenshot sync across own devices via a single DEVICE_TOKEN-gated blob store, plus a small installable web app so a phone can push/pull clips and share screenshots via the OS share sheet.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
// scrot PWA — thin client mirroring cli/scrot-clip-push, scrot-clip-pull,
|
||||
// scrot-shot. Token lives in localStorage (used for every fetch here) and
|
||||
// mirrored into a cookie (read server-side by /manifest.webmanifest to bake
|
||||
// the token into the share_target action — see server/src/manifest.ts).
|
||||
|
||||
const TOKEN_KEY = 'scrot_token'
|
||||
|
||||
const $ = (id) => document.getElementById(id)
|
||||
const toastEl = $('toast')
|
||||
const onboardEl = $('onboard')
|
||||
const tokenInput = $('tokenInput')
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY) || ''
|
||||
}
|
||||
|
||||
function setToken(token) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
// 1 year, Lax is enough since this cookie is only ever read by our own
|
||||
// manifest route, never sent cross-site meaningfully.
|
||||
document.cookie = `scrot_token=${encodeURIComponent(token)}; path=/; max-age=31536000; samesite=lax`
|
||||
}
|
||||
|
||||
function toast(message) {
|
||||
toastEl.textContent = message
|
||||
toastEl.style.display = 'block'
|
||||
clearTimeout(toast._t)
|
||||
toast._t = setTimeout(() => { toastEl.style.display = 'none' }, 4000)
|
||||
}
|
||||
|
||||
function refreshOnboardVisibility() {
|
||||
const has = !!getToken()
|
||||
onboardEl.style.display = has ? 'none' : 'block'
|
||||
if (has) tokenInput.value = getToken()
|
||||
}
|
||||
|
||||
// --- onboarding: pick up #t=<token> from cli/scrot-onboard's QR/link ---
|
||||
function consumeHashToken() {
|
||||
const match = location.hash.match(/(?:^#|&)t=([^&]+)/)
|
||||
if (!match) return false
|
||||
const token = decodeURIComponent(match[1])
|
||||
setToken(token)
|
||||
history.replaceState(null, '', location.pathname + location.search)
|
||||
toast('Device token saved. Add scrot to your home screen to enable "Share to scrot".')
|
||||
return true
|
||||
}
|
||||
|
||||
const hadHashToken = consumeHashToken()
|
||||
if (!hadHashToken && getToken()) setToken(getToken()) // re-assert cookie in case it expired
|
||||
refreshOnboardVisibility()
|
||||
|
||||
$('saveToken').addEventListener('click', () => {
|
||||
const token = tokenInput.value.trim()
|
||||
if (!token) return
|
||||
setToken(token)
|
||||
refreshOnboardVisibility()
|
||||
toast('Device token saved.')
|
||||
})
|
||||
|
||||
$('editToken').addEventListener('click', () => {
|
||||
onboardEl.style.display = 'block'
|
||||
tokenInput.focus()
|
||||
})
|
||||
|
||||
// --- share-target redirect landing ---
|
||||
const params = new URLSearchParams(location.search)
|
||||
if (params.get('shared') === 'clip') toast('Shared text saved as a clip.')
|
||||
if (params.get('shared') === 'shot') toast('Shared screenshot uploaded.')
|
||||
if (params.get('shared') === 'empty') toast('Nothing shareable in that share (no text or image).')
|
||||
if (params.has('shared')) history.replaceState(null, '', location.pathname)
|
||||
|
||||
// --- push clip ---
|
||||
$('pasteFromClipboard').addEventListener('click', async () => {
|
||||
try {
|
||||
$('clipText').value = await navigator.clipboard.readText()
|
||||
} catch {
|
||||
toast('Clipboard read not available — paste manually instead.')
|
||||
}
|
||||
})
|
||||
|
||||
$('pushClip').addEventListener('click', async () => {
|
||||
const text = $('clipText').value
|
||||
if (!text) return
|
||||
const result = $('clipResult')
|
||||
result.textContent = 'Sending…'
|
||||
try {
|
||||
const res = await fetch('/api/blobs', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Device-Token': getToken(), 'X-Blob-Kind': 'clip', 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
body: text,
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
result.textContent = 'Sent.'
|
||||
} catch (err) {
|
||||
result.textContent = `Failed: ${err.message || err}`
|
||||
}
|
||||
})
|
||||
|
||||
// --- pull clip ---
|
||||
let lastPulled = ''
|
||||
$('pullClip').addEventListener('click', async () => {
|
||||
const result = $('pullResult')
|
||||
result.textContent = 'Fetching…'
|
||||
try {
|
||||
const res = await fetch('/api/clip/latest', { headers: { 'X-Device-Token': getToken() } })
|
||||
if (res.status === 204) {
|
||||
result.textContent = 'No clip yet.'
|
||||
$('copyPulled').disabled = true
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
lastPulled = await res.text()
|
||||
result.textContent = lastPulled
|
||||
$('copyPulled').disabled = false
|
||||
} catch (err) {
|
||||
result.textContent = `Failed: ${err.message || err}`
|
||||
}
|
||||
})
|
||||
|
||||
$('copyPulled').addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(lastPulled)
|
||||
toast('Copied.')
|
||||
} catch {
|
||||
toast('Clipboard write not available.')
|
||||
}
|
||||
})
|
||||
|
||||
// --- push shot ---
|
||||
$('pushShot').addEventListener('click', async () => {
|
||||
const file = $('shotFile').files[0]
|
||||
const result = $('shotResult')
|
||||
if (!file) {
|
||||
result.textContent = 'Pick an image first.'
|
||||
return
|
||||
}
|
||||
result.textContent = 'Uploading…'
|
||||
try {
|
||||
const res = await fetch('/api/blobs', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Device-Token': getToken(), 'X-Blob-Kind': 'shot', 'Content-Type': file.type || 'application/octet-stream' },
|
||||
body: file,
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const { url } = await res.json()
|
||||
const fullUrl = new URL(url, location.origin).toString()
|
||||
result.innerHTML = `<a href="${fullUrl}" target="_blank" rel="noopener">${fullUrl}</a>`
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullUrl)
|
||||
toast('Link copied to clipboard.')
|
||||
} catch { /* clipboard write may be unavailable; link is still shown above */ }
|
||||
} catch (err) {
|
||||
result.textContent = `Failed: ${err.message || err}`
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
||||
<rect width="192" height="192" rx="40" fill="#111318"/>
|
||||
<text x="96" y="128" font-family="system-ui, sans-serif" font-size="104" font-weight="700"
|
||||
fill="#6ee7b7" text-anchor="middle">s</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 274 B |
@@ -0,0 +1,104 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>scrot</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/icon.svg">
|
||||
<meta name="theme-color" content="#111318">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 1.25rem; padding-bottom: 3rem;
|
||||
background: #111318; color: #e6e8ec;
|
||||
font: 16px/1.4 system-ui, sans-serif;
|
||||
max-width: 32rem; margin-inline: auto;
|
||||
}
|
||||
h1 { font-size: 1.1rem; font-weight: 600; color: #9aa1ad; margin: 0 0 1.25rem; }
|
||||
section {
|
||||
background: #191c22; border: 1px solid #262b34; border-radius: 12px;
|
||||
padding: 1rem; margin-bottom: 1rem;
|
||||
}
|
||||
section h2 { font-size: 0.95rem; margin: 0 0 0.75rem; }
|
||||
textarea {
|
||||
width: 100%; min-height: 6rem; resize: vertical;
|
||||
background: #0d0f13; color: #e6e8ec; border: 1px solid #2c323d;
|
||||
border-radius: 8px; padding: 0.6rem; font: inherit;
|
||||
}
|
||||
input[type="text"], input[type="password"] {
|
||||
width: 100%; background: #0d0f13; color: #e6e8ec;
|
||||
border: 1px solid #2c323d; border-radius: 8px; padding: 0.6rem; font: inherit;
|
||||
}
|
||||
button {
|
||||
appearance: none; border: none; border-radius: 8px;
|
||||
background: #2c7a5f; color: #eafff5; font: inherit; font-weight: 600;
|
||||
padding: 0.6rem 1rem; margin-top: 0.6rem; margin-right: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.secondary { background: #262b34; color: #cfd4dc; }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
#clipResult, #shotResult, #toast { font-size: 0.9rem; color: #9aa1ad; margin-top: 0.6rem; word-break: break-all; }
|
||||
#shotResult a { color: #6ee7b7; }
|
||||
#toast { display: none; padding: 0.6rem; background: #1c3a2c; border-radius: 8px; color: #b9f5da; margin-bottom: 1rem; }
|
||||
#onboard { display: none; }
|
||||
label { display: block; font-size: 0.85rem; color: #9aa1ad; margin-bottom: 0.3rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>scrot</h1>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<section id="onboard">
|
||||
<h2>Device token</h2>
|
||||
<label for="tokenInput">Paste the token from <code>~/.config/scrot/env</code>, or open this page's link/QR from <code>cli/scrot-onboard</code>.</label>
|
||||
<input id="tokenInput" type="password" autocomplete="off" placeholder="device token">
|
||||
<div class="row">
|
||||
<button id="saveToken">Save</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Push clipboard</h2>
|
||||
<textarea id="clipText" placeholder="Paste or type text to sync to your other devices"></textarea>
|
||||
<div class="row">
|
||||
<button id="pasteFromClipboard" class="secondary">Paste from clipboard</button>
|
||||
<button id="pushClip">Send</button>
|
||||
</div>
|
||||
<div id="clipResult"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Pull latest clip</h2>
|
||||
<div class="row">
|
||||
<button id="pullClip">Fetch latest</button>
|
||||
<button id="copyPulled" class="secondary" disabled>Copy to clipboard</button>
|
||||
</div>
|
||||
<div id="pullResult"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Share screenshot</h2>
|
||||
<input id="shotFile" type="file" accept="image/*" capture="environment">
|
||||
<div class="row">
|
||||
<button id="pushShot">Upload</button>
|
||||
</div>
|
||||
<div id="shotResult"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Settings</h2>
|
||||
<div class="row">
|
||||
<button id="editToken" class="secondary">Change device token</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user