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}`
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user