Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# QMD extension files are text-like but checksummed — force LF to prevent
# CRLF conversion on Windows from breaking SHA-512 validation.
*.qmd text eol=lf

# Keep shell scripts consistent
*.sh text eol=lf
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ A browser-based template editor for native reMarkable tablets. Browse, preview,

```bash
git clone https://github.com/cuttlefisch/RemarkableCustomTemplates
cd remarkable_templates
cd RemarkableCustomTemplates
docker compose up --build -d
```

Expand All @@ -23,6 +23,8 @@ Open **http://localhost:3000** in your browser. That's it.
> **Port conflict?** `PORT=3001 docker compose up --build -d`
> **Stop:** `docker compose down` · **Reset all data:** `docker compose down -v`

**Updating:** `git pull origin main && docker compose up --build -d` — your data is preserved across upgrades. See the [quickstart](docs/quickstart.md#updating-to-a-new-version) for details.

## What You Can Do

- **Browse & preview** all templates across reMarkable 1/2, Paper Pro, and Paper Pro Move resolutions
Expand Down
18 changes: 16 additions & 2 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Get remarkable-templates running and deploy custom templates to your reMarkable

```bash
git clone https://github.com/cuttlefisch/RemarkableCustomTemplates
cd remarkable_templates
cd RemarkableCustomTemplates
docker compose up --build -d
```

Expand Down Expand Up @@ -114,10 +114,24 @@ If something goes wrong, use the **Device & Sync** page to roll back:

**Stop:** `docker compose down`

**Data persistence:** Templates, device config, and SSH keys are stored in a Docker volume and persist across restarts.
**Data persistence:** Templates, device config, and SSH keys are stored in a Docker volume and persist across restarts and upgrades.

**Start fresh:** `docker compose down -v` removes the volume and all data.

## Updating to a new version

Pull the latest code and rebuild. Your data (templates, device config, SSH keys, backups) is stored in a Docker volume and is preserved automatically.

```bash
cd RemarkableCustomTemplates
git pull origin main
docker compose up --build -d
```

The `--build` flag rebuilds the Docker image with the latest code. The container restarts with the new version while keeping all your data intact. You can verify the build succeeded by checking for any errors in the build output — for example, xovi checksum validation runs during the build and will report any issues.

> **Troubleshooting an upgrade:** If you see unexpected behavior after updating, check the [release notes](https://github.com/cuttlefisch/RemarkableCustomTemplates/releases) for breaking changes. If your data appears corrupted, you can start fresh with `docker compose down -v && docker compose up --build -d` — but this removes all local data, so back up first (see [Back up your templates](#8-back-up-your-templates)).

---

## For developers
Expand Down
11 changes: 11 additions & 0 deletions docs/xovi-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,17 @@ When your reMarkable receives a firmware update:

## Troubleshooting

### Checksum validation errors during deploy

If you see checksum errors when deploying extensions, update to the latest version:

```bash
git pull origin main
docker compose up --build -d
```

This was fixed in [PR #8](https://github.com/cuttlefisch/RemarkableCustomTemplates/pull/8) — the root cause was line-ending conversion on Windows breaking file checksums. The fix normalizes line endings at all layers so checksums are consistent regardless of platform.

### "xovi not installed"

xovi and qt-resource-rebuilder must be installed on your device before this app can deploy extensions. If Vellum is on your device, click **Install xovi** in the app. Otherwise, install via SSH:
Expand Down
13 changes: 12 additions & 1 deletion scripts/generate-xovi-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,19 @@ const EXTENSION_DEFS = {
},
}

/** Normalize CRLF → LF so checksums are stable across platforms. */
function normalizeLF(buf: Buffer): Buffer {
if (!buf.includes(0x0d)) return buf
const out: number[] = []
for (let i = 0; i < buf.length; i++) {
if (buf[i] === 0x0d && i + 1 < buf.length && buf[i + 1] === 0x0a) continue
out.push(buf[i]!)
}
return Buffer.from(out)
}

function sha512(filePath: string): string {
const content = readFileSync(filePath)
const content = normalizeLF(readFileSync(filePath))
return createHash('sha512').update(content).digest('hex')
}

Expand Down
41 changes: 39 additions & 2 deletions scripts/validate-xovi-checksums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
* Validate SHA-512 checksums for bundled xovi extension QMD files.
* Exits with code 1 if any checksum mismatches.
*
* Normalizes line endings (CRLF → LF) before hashing so that checksums
* are stable across platforms even without .gitattributes protection.
*
* Usage: npx tsx scripts/validate-xovi-checksums.ts
*/

Expand All @@ -14,6 +17,24 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
const XOVI_DATA_DIR = resolve(__dirname, '../server/data/xovi-extensions')
const manifestPath = resolve(XOVI_DATA_DIR, 'manifest.json')

/** Normalize CRLF → LF so checksums are stable across platforms. */
function normalizeLF(buf: Buffer): Buffer {
// Fast path: no CR bytes at all
if (!buf.includes(0x0d)) return buf
// Strip \r from \r\n sequences
const out: number[] = []
for (let i = 0; i < buf.length; i++) {
if (buf[i] === 0x0d && i + 1 < buf.length && buf[i + 1] === 0x0a) continue
out.push(buf[i]!)
}
return Buffer.from(out)
}

/** Compute SHA-512 of a buffer after LF normalization. */
export function sha512Normalized(buf: Buffer): string {
return createHash('sha512').update(normalizeLF(buf)).digest('hex')
}

if (!existsSync(manifestPath)) {
console.error('manifest.json not found — run generate-xovi-manifest.ts first')
process.exit(1)
Expand All @@ -25,6 +46,7 @@ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as {

let ok = true
let checked = 0
let crlfDetected = false

for (const [relPath, expectedHash] of Object.entries(manifest.checksums)) {
const filePath = resolve(XOVI_DATA_DIR, relPath)
Expand All @@ -33,8 +55,9 @@ for (const [relPath, expectedHash] of Object.entries(manifest.checksums)) {
ok = false
continue
}
const content = readFileSync(filePath)
const actual = createHash('sha512').update(content).digest('hex')
const raw = readFileSync(filePath)
if (raw.includes(0x0d)) crlfDetected = true
const actual = sha512Normalized(raw)
if (actual !== expectedHash) {
console.error(`MISMATCH ${relPath}`)
console.error(` expected: ${expectedHash}`)
Expand All @@ -47,7 +70,21 @@ for (const [relPath, expectedHash] of Object.entries(manifest.checksums)) {

if (ok) {
console.log(`All ${checked} QMD file checksums verified.`)
if (crlfDetected) {
console.warn(
'WARNING: Some QMD files contain CRLF line endings (normalized before hashing).\n' +
'Run "git add --renormalize ." to fix, or ensure .gitattributes is present.',
)
}
} else {
console.error('\nChecksum validation failed.')
if (crlfDetected) {
console.error(
'HINT: CRLF line endings detected — this is a common cause of checksum mismatches.\n' +
'Ensure .gitattributes marks *.qmd with eol=lf, then re-checkout:\n' +
' git rm --cached server/data/xovi-extensions/**/*.qmd\n' +
' git checkout -- server/data/xovi-extensions/',
)
}
process.exit(1)
}
112 changes: 109 additions & 3 deletions server/__tests__/helpers/mockSshServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,13 @@ function handleExec(
return
}

if (command.includes('mkdir -p') && command.includes('.ssh')) {
const sshDir = mapPath(fsRoot, '/home/root/.ssh')
mkdirSync(sshDir, { recursive: true })
if (command.includes('mkdir -p')) {
// Extract the path argument
const mkdirMatch = command.match(/mkdir -p ([^\s;]+)/)
if (mkdirMatch) {
const dir = mapPath(fsRoot, mkdirMatch[1])
mkdirSync(dir, { recursive: true })
}
channel.exit(0)
channel.end()
return
Expand Down Expand Up @@ -227,6 +231,108 @@ function handleExec(
return
}

// Generic `test -f <path> && echo ok || echo missing` pattern
const testFileMatch = command.match(/test -f ([^\s&|]+)\s*&&\s*echo ok\s*\|\|\s*echo missing/)
if (testFileMatch) {
const localPath = mapPath(fsRoot, testFileMatch[1])
channel.write(existsSync(localPath) ? 'ok' : 'missing')
channel.exit(0)
channel.end()
return
}

// Generic `test -x <path> && echo ok || echo missing` pattern
const testExecMatch = command.match(/test -x ([^\s&|]+)\s*&&\s*echo ok\s*\|\|\s*echo missing/)
if (testExecMatch) {
const localPath = mapPath(fsRoot, testExecMatch[1])
channel.write(existsSync(localPath) ? 'ok' : 'missing')
channel.exit(0)
channel.end()
return
}

// Generic `test -f <path> && <cmd> || echo missing` — for vellum version check etc.
const testAndExecMatch = command.match(/test -f ([^\s&|]+)\s*&&/)
if (testAndExecMatch && !command.includes('echo ok')) {
const localPath = mapPath(fsRoot, testAndExecMatch[1])
if (!existsSync(localPath)) {
channel.write('missing')
channel.exit(1)
channel.end()
return
}
// File exists — fall through to more specific handlers below
}

// Vellum reenable status
if (command.includes('vellum') && command.includes('reenable status')) {
const marker = mapPath(fsRoot, '/home/root/.vellum/.reenable-needed')
channel.write(existsSync(marker) ? 'needed' : 'ok')
channel.exit(0)
channel.end()
return
}

// Vellum --version
if (command.includes('vellum') && command.includes('--version')) {
const vellumBin = mapPath(fsRoot, '/home/root/.vellum/bin/vellum')
channel.write(existsSync(vellumBin) ? '1.0.0' : 'missing')
channel.exit(existsSync(vellumBin) ? 0 : 1)
channel.end()
return
}

// Vellum add/del — always succeed (mock)
if (command.includes('vellum') && (command.includes(' add ') || command.includes(' del '))) {
channel.write('ok\n')
channel.exit(0)
channel.end()
return
}

// rebuild_hashtable
if (command.includes('rebuild_hashtable')) {
const bin = mapPath(fsRoot, '/home/root/xovi/rebuild_hashtable')
if (existsSync(bin)) {
channel.write('Hashtable rebuilt successfully\n')
channel.exit(0)
} else {
channel.stderr.write('rebuild_hashtable: not found\n')
channel.exit(1)
}
channel.end()
return
}

// rm -f <path> — delete a file (best effort)
const rmMatch = command.match(/rm -f ([^\s;]+)/)
if (rmMatch) {
const localPath = mapPath(fsRoot, rmMatch[1])
if (existsSync(localPath)) {
try { unlinkSync(localPath) } catch { /* best effort */ }
}
channel.exit(0)
channel.end()
return
}

// rm -f <glob> pattern via shell (e.g. rm -f /path/*.qmd)
if (command.includes('rm -f') && command.includes('*.qmd')) {
// Extract dir from the glob pattern
const globMatch = command.match(/rm -f ([^\s*]+)\*\.qmd/)
if (globMatch) {
const dir = mapPath(fsRoot, globMatch[1])
if (existsSync(dir)) {
for (const f of readdirSync(dir)) {
if (f.endsWith('.qmd')) {
try { unlinkSync(resolve(dir, f)) } catch { /* best effort */ }
}
}
}
}
// Fall through to default (will echo ok if piped)
}

// Default: succeed silently
channel.exit(0)
channel.end()
Expand Down
26 changes: 26 additions & 0 deletions server/__tests__/helpers/seedDeviceFs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,32 @@ export function seedMethodsTemplates(
)
}

/** Seed xovi/vellum filesystem structure for xovi integration tests. */
export function seedXoviFs(
fsRoot: string,
opts?: { xovi?: boolean; qtRebuilder?: boolean; vellum?: boolean; reenableNeeded?: boolean },
) {
const { xovi = true, qtRebuilder = true, vellum = true, reenableNeeded = false } = opts ?? {}
if (xovi) {
mkdirSync(resolve(fsRoot, 'home/root/xovi'), { recursive: true })
writeFileSync(resolve(fsRoot, 'home/root/xovi/xovi.so'), 'fake-xovi')
writeFileSync(resolve(fsRoot, 'home/root/xovi/rebuild_hashtable'), '#!/bin/sh\necho ok')
}
if (qtRebuilder) {
mkdirSync(resolve(fsRoot, 'home/root/xovi/extensions.d'), { recursive: true })
writeFileSync(resolve(fsRoot, 'home/root/xovi/extensions.d/qt-resource-rebuilder.so'), 'fake-qt')
mkdirSync(resolve(fsRoot, 'home/root/xovi/exthome/qt-resource-rebuilder'), { recursive: true })
}
if (vellum) {
mkdirSync(resolve(fsRoot, 'home/root/.vellum/bin'), { recursive: true })
writeFileSync(resolve(fsRoot, 'home/root/.vellum/bin/vellum'), '#!/bin/sh\necho 1.0.0')
}
// Marker file read by mock exec handler
if (reenableNeeded) {
writeFileSync(resolve(fsRoot, 'home/root/.vellum/.reenable-needed'), '')
}
}

/** Write classic templates (templates.json + .template files) to /usr/share/remarkable/templates/. */
export function seedClassicTemplates(
fsRoot: string,
Expand Down
Loading
Loading