diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c446f84..664a395 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ jobs: cache: pnpm cache-dependency-path: pnpm-lock.yaml - run: pnpm install --frozen-lockfile + - run: pnpm run validate-xovi - run: pnpm lint - run: pnpm test - run: pnpm build diff --git a/.gitignore b/.gitignore index a040003..1083425 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,11 @@ public/templates/custom/ # Methods templates pulled from device public/templates/methods/ -# Server data (device config, SSH keys) +# Server data (device config, SSH keys — but not bundled xovi extensions) data/ +!**/server/data/ +server/data/* +!server/data/xovi-extensions/ # Server build output dist-server/ diff --git a/Dockerfile b/Dockerfile index 2506e26..aad6850 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,9 @@ RUN pnpm install --frozen-lockfile # Copy source COPY . . +# Validate xovi extension checksums before building +RUN pnpm run validate-xovi + # Build frontend and server RUN pnpm build diff --git a/docs/device-sync.md b/docs/device-sync.md index 116ec74..5f56fc5 100644 --- a/docs/device-sync.md +++ b/docs/device-sync.md @@ -285,6 +285,14 @@ For programmatic use: `POST /api/restore?mode=merge` (or `mode=replace` to overw --- +## xovi extensions + +If you have [xovi](https://github.com/asivery/xovi) installed on your device, you can deploy curated UI extensions that enhance the template experience — unlocking Methods templates without a Connect subscription, normalizing page dimensions across device families, and improving quicksheet behavior. Extensions are managed from the **xovi Extensions** card on the **Device & Sync** page. + +See the [xovi extensions guide](xovi-extensions.md) for the full list of available extensions, deployment instructions, and troubleshooting. + +--- + ## Caveats > [!WARNING] diff --git a/docs/quickstart.md b/docs/quickstart.md index 15a4136..0c204cc 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -85,7 +85,15 @@ Each deploy: > **Native vs PDF templates:** This app creates native `.template` files — vector-based pages that render instantly, use minimal battery, and zoom infinitely. PDF templates have their advantages (inter-page links, complex layouts) but are rasterized at fixed resolution and use more memory. -## 7. Back up your templates +## 7. Enhance with xovi extensions (optional) + +If you've installed [xovi](https://github.com/asivery/xovi) on your device, you can deploy curated UI extensions directly from the **Device & Sync** page. These extensions unlock Methods templates without a Connect subscription, normalize page sizes across devices, and improve quicksheet behavior. + +Scroll to the **xovi Extensions** card, click **Check xovi Status**, select your extensions, and deploy. See the [xovi extensions guide](xovi-extensions.md) for full details. + +> **Don't have xovi?** Everything else in this app works without it. xovi is only needed if you want the optional UI extensions described above. + +## 8. Back up your templates Click **↓ Backup** on the **Device & Sync** page to download a ZIP of all your custom templates. This preserves UUIDs needed for device sync continuity. @@ -93,7 +101,7 @@ To restore: click **↑ Restore** and select the backup ZIP. A preview shows wha ![Backup restore preview](images/backup-restore-preview.png) -## 8. Rollback +## 9. Rollback If something goes wrong, use the **Device & Sync** page to roll back: diff --git a/docs/xovi-extensions.md b/docs/xovi-extensions.md new file mode 100644 index 0000000..fee0e06 --- /dev/null +++ b/docs/xovi-extensions.md @@ -0,0 +1,169 @@ +# xovi Extensions + +Enhance your reMarkable experience with curated xovi extensions, deployed directly from the **Device & Sync** page. + +## What are xovi extensions? + +[xovi](https://github.com/asivery/xovi) is a community framework that lets you tweak the reMarkable UI without permanently modifying system files. It works by intercepting the Qt resource system at startup and applying small patch files (`.qmd`) that modify UI behavior. + +This app deploys a curated set of extensions that enhance the template experience — unlocking Methods templates without a subscription, normalizing page dimensions across devices, and improving quicksheet behavior. + +> If the [Vellum package manager](https://remarkable.guide/guide/software/vellum.html) is installed on your device, you can install xovi directly from this app. Otherwise, install Vellum first, then use the Install button. + +## Prerequisites + +1. **[Vellum](https://remarkable.guide/guide/software/vellum.html)** installed on your device (see the install guide for bootstrap instructions) +2. **xovi** — install it one of two ways: + - **From this app:** Click **Check xovi Status**, then click **Install xovi** (requires Vellum on the device) + - **Via SSH:** `vellum add qt-resource-rebuilder` (pulls in xovi + xovi-extensions as dependencies) +3. A configured device connection in this app (see [quickstart](quickstart.md)) + +## Important: warranty & risks + +- Modifying your device's software behavior **may void your warranty** +- Extensions are community-maintained and **not endorsed by reMarkable** +- All changes are **fully reversible** — remove the extensions and restart to restore stock behavior +- You accept responsibility for modifications to your device +- Extensions may need to be re-deployed after firmware updates + +## Available extensions + +### Unlock Methods Content (Essential) + +Bypasses the subscription check for using on-device Methods templates and documents. Without this extension, Methods templates (including any custom templates you deploy via rm_methods format) require an active Connect subscription. + +- **Works on:** reMarkable 1, reMarkable 2, Paper Pro, Paper Pro Move +- **Why you need it:** If you deploy custom templates via this app and don't have a Connect subscription, this extension ensures you can actually use them + +### Page Size Normalization (Essential — pick one) + +The three reMarkable device families have different screen dimensions: + +| Device | Resolution | +|--------|-----------| +| reMarkable 1 & 2 | 1404 x 1872 | +| Paper Pro | 1620 x 2160 | +| Paper Pro Move | 954 x 1696 | + +When you create a new page on one device, it's stamped with that device's dimensions. If you sync that page to a different device, it may appear zoomed in, zoomed out, or cropped. + +These extensions force new pages to use a consistent size regardless of which device you're on: + +| Your primary device | Install this | Why | +|---|---|---| +| reMarkable 1/2 | **Paper Pro Size** | Pages you create will render correctly on Paper Pro | +| Paper Pro | **RM2 Size** | Pages you create will render correctly on RM1/RM2 | +| Paper Pro Move | **Paper Pro Size** | Pages you create will render correctly on Paper Pro (most common sync target) | + +**Important:** These extensions only affect **new pages** you create after installation. They do not retroactively change existing pages or synced content. You can only install one of the two — they are mutually exclusive. + +### Prevent Notebook Zoom Out (Essential for Move) + +Forces all notebook pages to start at 1x zoom, preventing the default zoom-out behavior. This is especially important on the Paper Pro Move, where pages created on larger devices would otherwise display zoomed out. + +- **Best paired with** a page size extension for full cross-device consistency +- **Works on:** All devices, but designed primarily for Paper Pro Move + +### Quicksheet Use Template (Recommended) + +When you add a quicksheet page (quick-add at the bottom of a notebook), it normally uses the default blank template. This extension makes quicksheet pages inherit the template from the previous page — so if you're writing on a dot grid, your new page will also be a dot grid. + +## How to deploy + +1. Navigate to the **Device & Sync** page +2. Scroll to the **xovi Extensions** card +3. Click **Check xovi Status** to verify xovi is detected on your device +4. Select the extensions you want: + - Check/uncheck individual extensions + - Choose a page size option (RM2 Size, Paper Pro Size, or None) +5. Click **Deploy Selected** +6. Wait for the progress indicator to complete — the device UI will restart automatically + +## How to remove + +1. On the **xovi Extensions** card, click **Check xovi Status** +2. Click **Remove All Extensions** +3. Confirm the removal +4. The device UI restarts with stock behavior restored + +To remove individual extensions, uncheck them and deploy again — only checked extensions will be present after deployment. + +## After a firmware update + +When your reMarkable receives a firmware update: + +1. Extensions may stop working because the UI code they patch has changed +2. Open the app and click **Check xovi Status** to see the current state +3. If the new firmware version is supported, click **Deploy Selected** to re-deploy +4. If the new firmware version is not yet supported, you'll see a message — new versions are typically supported within a few days by the community + +## Troubleshooting + +### "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: + +```bash +# On your device (via SSH): +vellum add qt-resource-rebuilder +``` + +### "Vellum needs to be re-enabled" + +After a firmware update, Vellum needs to re-apply its system modifications. The app will show a warning when this is detected. SSH into your device and run: + +```bash +vellum reenable +``` + +Then check xovi status again in the app. Until reenable completes, package install/remove operations will fail. + +### "Extensions not available for firmware X.XX" + +Extension patch files are specific to each firmware version. If you've updated to a very new firmware version, the community may not have released compatible patches yet. Check the [xovi-qmd-extensions repository](https://github.com/rmitchellscott/xovi-qmd-extensions) for updates. + +### "rebuild_hashtable failed" + +Try restarting xochitl manually via SSH: + +```bash +ssh root@ "systemctl restart xochitl" +``` + +If the problem persists, try running the rebuild manually: + +```bash +ssh root@ "cd /home/root && ./xovi/rebuild_hashtable" +``` + +### Extensions deployed but no visible effect + +- Ensure xovi is running: `ssh root@ "test -f /home/root/xovi/xovi.so && echo ok"` +- Clear the QML cache and restart: `ssh root@ "rm -rf ~/.cache/remarkable/xochitl/qmlcache && systemctl restart xochitl"` + +## Technical details + +### How it works + +Extensions are `.qmd` (QML Diff) files — declarative patches that modify specific properties in the reMarkable UI at startup. The xovi framework's `qt-resource-rebuilder` component intercepts Qt's resource loading and applies these patches before the UI renders. + +### Device paths + +- Extension files: `/home/root/xovi/exthome/qt-resource-rebuilder/` +- After deploying extensions, the app runs `/home/root/xovi/rebuild_hashtable` and restarts xochitl + +### Source and integrity + +Extension files are sourced from the [xovi-qmd-extensions](https://github.com/rmitchellscott/xovi-qmd-extensions) repository by Mitchell Scott, licensed under MIT. SHA-512 checksums are verified at build time to ensure file integrity. + +### Manual deployment (via SSH) + +If you prefer to deploy extensions manually: + +```bash +# Copy the QMD file to the device +scp unlockMethodsContent.qmd root@:/home/root/xovi/exthome/qt-resource-rebuilder/ + +# Rebuild the hashtable and restart +ssh root@ "cd /home/root && ./xovi/rebuild_hashtable && systemctl restart xochitl" +``` diff --git a/package.json b/package.json index a6f4b4b..55a5d0a 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "generate-sample-icons": "tsx scripts/generate-sample-icons.ts" + "generate-sample-icons": "tsx scripts/generate-sample-icons.ts", + "validate-xovi": "tsx scripts/validate-xovi-checksums.ts", + "generate-xovi-manifest": "tsx scripts/generate-xovi-manifest.ts" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/scripts/generate-xovi-manifest.ts b/scripts/generate-xovi-manifest.ts new file mode 100644 index 0000000..439ae4a --- /dev/null +++ b/scripts/generate-xovi-manifest.ts @@ -0,0 +1,91 @@ +/** + * Generate manifest.json for bundled xovi extension QMD files. + * Walks server/data/xovi-extensions// directories, computes SHA-512 + * checksums, and writes the manifest with extension metadata. + * + * Usage: npx tsx scripts/generate-xovi-manifest.ts + */ + +import { createHash } from 'node:crypto' +import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const XOVI_DATA_DIR = resolve(__dirname, '../server/data/xovi-extensions') + +const EXTENSION_DEFS = { + unlockMethodsContent: { + filename: 'unlockMethodsContent.qmd', + displayName: 'Unlock Methods Content', + description: 'Bypasses subscription check for using on-device Methods templates and documents.', + tier: 1, + category: 'essential', + }, + createPagesRM2Size: { + filename: 'createPagesRM2Size.qmd', + displayName: 'Create Pages (RM2 Size)', + description: 'Forces new pages to use reMarkable 2 dimensions (1404\u00d71872) for cross-device consistency.', + tier: 1, + category: 'page-size', + exclusiveGroup: 'pageSize', + }, + createPagesPaperProSize: { + filename: 'createPagesPaperProSize.qmd', + displayName: 'Create Pages (Paper Pro Size)', + description: 'Forces new pages to use Paper Pro dimensions (1620\u00d72160) for cross-device consistency.', + tier: 1, + category: 'page-size', + exclusiveGroup: 'pageSize', + }, + preventNotebookZoomOut: { + filename: 'preventNotebookZoomOut.qmd', + displayName: 'Prevent Notebook Zoom Out', + description: 'Forces notebook pages to start at 1x zoom. Designed for Paper Pro Move.', + tier: 1, + category: 'essential', + }, + quicksheetUseTemplate: { + filename: 'quicksheetUseTemplate.qmd', + displayName: 'Quicksheet Use Template', + description: 'New quicksheet pages use the same template as the previous page in the notebook.', + tier: 2, + category: 'recommended', + }, +} + +function sha512(filePath: string): string { + const content = readFileSync(filePath) + return createHash('sha512').update(content).digest('hex') +} + +// Discover version directories +const versions = readdirSync(XOVI_DATA_DIR) + .filter(d => /^\d+\.\d+$/.test(d) && statSync(resolve(XOVI_DATA_DIR, d)).isDirectory()) + .sort((a, b) => { + const [aMaj, aMin] = a.split('.').map(Number) + const [bMaj, bMin] = b.split('.').map(Number) + return aMaj - bMaj || aMin - bMin + }) + +// Compute checksums +const checksums: Record = {} +for (const version of versions) { + const versionDir = resolve(XOVI_DATA_DIR, version) + const files = readdirSync(versionDir).filter(f => f.endsWith('.qmd')) + for (const file of files) { + const key = `${version}/${file}` + checksums[key] = sha512(resolve(versionDir, file)) + } +} + +const manifest = { + extensions: EXTENSION_DEFS, + checksums, + supportedVersions: versions, +} + +const outPath = resolve(XOVI_DATA_DIR, 'manifest.json') +writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n') +console.log(`Wrote ${outPath}`) +console.log(` ${versions.length} versions, ${Object.keys(checksums).length} files`) diff --git a/scripts/validate-xovi-checksums.ts b/scripts/validate-xovi-checksums.ts new file mode 100644 index 0000000..86158a4 --- /dev/null +++ b/scripts/validate-xovi-checksums.ts @@ -0,0 +1,53 @@ +/** + * Validate SHA-512 checksums for bundled xovi extension QMD files. + * Exits with code 1 if any checksum mismatches. + * + * Usage: npx tsx scripts/validate-xovi-checksums.ts + */ + +import { createHash } from 'node:crypto' +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +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') + +if (!existsSync(manifestPath)) { + console.error('manifest.json not found — run generate-xovi-manifest.ts first') + process.exit(1) +} + +const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { + checksums: Record +} + +let ok = true +let checked = 0 + +for (const [relPath, expectedHash] of Object.entries(manifest.checksums)) { + const filePath = resolve(XOVI_DATA_DIR, relPath) + if (!existsSync(filePath)) { + console.error(`MISSING ${relPath}`) + ok = false + continue + } + const content = readFileSync(filePath) + const actual = createHash('sha512').update(content).digest('hex') + if (actual !== expectedHash) { + console.error(`MISMATCH ${relPath}`) + console.error(` expected: ${expectedHash}`) + console.error(` actual: ${actual}`) + ok = false + } else { + checked++ + } +} + +if (ok) { + console.log(`All ${checked} QMD file checksums verified.`) +} else { + console.error('\nChecksum validation failed.') + process.exit(1) +} diff --git a/server/__tests__/deviceIntegration.test.ts b/server/__tests__/deviceIntegration.test.ts index 63a3b3b..85e1943 100644 --- a/server/__tests__/deviceIntegration.test.ts +++ b/server/__tests__/deviceIntegration.test.ts @@ -113,7 +113,7 @@ describe('device SSH integration', () => { expect(res.statusCode).toBe(200) const body = JSON.parse(res.body) expect(body.ok).toBe(true) - expect(body.deviceModel).toContain('reMarkable') + expect(body.deviceModel).toBe('rm') expect(body.firmwareVersion).toMatch(/\d+\.\d+/) expect(body.lastConnected).toBeTruthy() } finally { @@ -129,7 +129,7 @@ describe('device SSH integration', () => { const store = JSON.parse(readFileSync(config.deviceConfigPath, 'utf8')) const saved = store.devices.find((d: DeviceConfig) => d.id === 'test-dev-1') expect(saved.lastConnected).toBeTruthy() - expect(saved.deviceModel).toContain('reMarkable') + expect(saved.deviceModel).toBe('rm') } finally { await app.close() } diff --git a/server/__tests__/xoviExtensions.test.ts b/server/__tests__/xoviExtensions.test.ts new file mode 100644 index 0000000..8efdc4b --- /dev/null +++ b/server/__tests__/xoviExtensions.test.ts @@ -0,0 +1,145 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach } from 'vitest' +import { + mapFirmwareToQmdVersion, + getExtensionDefs, + getQmdFilePath, + validateExclusiveGroups, + getSupportedVersions, + _resetManifestCache, +} from '../lib/xoviExtensions.ts' + +beforeEach(() => { + _resetManifestCache() +}) + +describe('mapFirmwareToQmdVersion', () => { + it('maps a full firmware string to its minor version', () => { + expect(mapFirmwareToQmdVersion('3.26.1.2')).toBe('3.26') + }) + + it('maps a two-part version directly', () => { + expect(mapFirmwareToQmdVersion('3.24')).toBe('3.24') + }) + + it('returns null for unsupported versions', () => { + expect(mapFirmwareToQmdVersion('3.99.0.0')).toBeNull() + expect(mapFirmwareToQmdVersion('4.0.0')).toBeNull() + }) + + it('returns null for malformed input', () => { + expect(mapFirmwareToQmdVersion('abc')).toBeNull() + expect(mapFirmwareToQmdVersion('')).toBeNull() + expect(mapFirmwareToQmdVersion('3')).toBeNull() + }) + + it('handles all supported versions', () => { + for (const v of getSupportedVersions()) { + expect(mapFirmwareToQmdVersion(v)).toBe(v) + } + }) +}) + +describe('getExtensionDefs', () => { + it('returns all 5 curated extensions', () => { + const defs = getExtensionDefs() + expect(defs).toHaveLength(5) + const ids = defs.map(d => d.id).sort() + expect(ids).toEqual([ + 'createPagesPaperProSize', + 'createPagesRM2Size', + 'preventNotebookZoomOut', + 'quicksheetUseTemplate', + 'unlockMethodsContent', + ]) + }) + + it('each extension has required fields', () => { + for (const def of getExtensionDefs()) { + expect(def.id).toBeTruthy() + expect(def.filename).toMatch(/\.qmd$/) + expect(def.displayName).toBeTruthy() + expect(def.description).toBeTruthy() + expect([1, 2]).toContain(def.tier) + expect(def.category).toBeTruthy() + } + }) + + it('page size extensions have exclusiveGroup', () => { + const defs = getExtensionDefs() + const rm2 = defs.find(d => d.id === 'createPagesRM2Size')! + const pp = defs.find(d => d.id === 'createPagesPaperProSize')! + expect(rm2.exclusiveGroup).toBe('pageSize') + expect(pp.exclusiveGroup).toBe('pageSize') + }) + + it('non-exclusive extensions have no exclusiveGroup', () => { + const defs = getExtensionDefs() + const unlock = defs.find(d => d.id === 'unlockMethodsContent')! + expect(unlock.exclusiveGroup).toBeUndefined() + }) +}) + +describe('getQmdFilePath', () => { + it('resolves a valid extension + version to an absolute path', () => { + const path = getQmdFilePath('unlockMethodsContent', '3.26') + expect(path).toMatch(/server\/data\/xovi-extensions\/3\.26\/unlockMethodsContent\.qmd$/) + }) + + it('throws for unknown extension', () => { + expect(() => getQmdFilePath('nonexistent', '3.26')).toThrow('Unknown extension') + }) + + it('throws for unsupported version', () => { + expect(() => getQmdFilePath('unlockMethodsContent', '3.99')).toThrow('Unsupported QMD version') + }) + + it('throws for extension not available in a version (quicksheetUseTemplate in 3.22)', () => { + expect(() => getQmdFilePath('quicksheetUseTemplate', '3.22')).toThrow('QMD file not found') + }) + + it('resolves quicksheetUseTemplate in 3.25+', () => { + const path = getQmdFilePath('quicksheetUseTemplate', '3.25') + expect(path).toMatch(/3\.25\/quicksheetUseTemplate\.qmd$/) + }) +}) + +describe('validateExclusiveGroups', () => { + it('returns null for non-conflicting extensions', () => { + expect(validateExclusiveGroups(['unlockMethodsContent', 'createPagesRM2Size'])).toBeNull() + expect(validateExclusiveGroups(['unlockMethodsContent', 'preventNotebookZoomOut', 'quicksheetUseTemplate'])).toBeNull() + }) + + it('returns null for a single page-size extension', () => { + expect(validateExclusiveGroups(['createPagesRM2Size'])).toBeNull() + expect(validateExclusiveGroups(['createPagesPaperProSize'])).toBeNull() + }) + + it('returns error when both page-size extensions are selected', () => { + const err = validateExclusiveGroups(['createPagesRM2Size', 'createPagesPaperProSize']) + expect(err).toMatch(/mutually exclusive/) + expect(err).toMatch(/pageSize/) + }) + + it('returns error for unknown extension', () => { + const err = validateExclusiveGroups(['unknownExt']) + expect(err).toMatch(/Unknown extension/) + }) + + it('returns null for empty array', () => { + expect(validateExclusiveGroups([])).toBeNull() + }) +}) + +describe('getSupportedVersions', () => { + it('returns sorted version strings', () => { + const versions = getSupportedVersions() + expect(versions.length).toBeGreaterThanOrEqual(5) + expect(versions).toContain('3.22') + expect(versions).toContain('3.26') + // Verify sorted + for (let i = 1; i < versions.length; i++) { + expect(versions[i] > versions[i - 1]).toBe(true) + } + }) +}) diff --git a/server/__tests__/xoviRoutes.test.ts b/server/__tests__/xoviRoutes.test.ts new file mode 100644 index 0000000..6fdfcd0 --- /dev/null +++ b/server/__tests__/xoviRoutes.test.ts @@ -0,0 +1,239 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdirSync, rmSync } from 'node:fs' +import { resolve } from 'node:path' +import { tmpdir } from 'node:os' +import { createApp } from '../app.ts' +import { resolveConfig, type ServerConfig } from '../config.ts' +import { writeDeviceStore } from '../lib/deviceStore.ts' + +function makeConfig(): ServerConfig { + const base = resolve(tmpdir(), `xoviroutes-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(resolve(base, 'public/templates/custom'), { recursive: true }) + mkdirSync(resolve(base, 'public/templates/debug'), { recursive: true }) + mkdirSync(resolve(base, 'public/templates/methods'), { recursive: true }) + mkdirSync(resolve(base, 'public/templates/samples'), { recursive: true }) + mkdirSync(resolve(base, 'remarkable_official_templates'), { recursive: true }) + mkdirSync(resolve(base, 'rm-methods-dist'), { recursive: true }) + mkdirSync(resolve(base, 'rm-methods-backups'), { recursive: true }) + mkdirSync(resolve(base, 'data/ssh'), { recursive: true }) + return resolveConfig({ dataDir: base, port: 0, production: false }) +} + +function seedDevice(config: ServerConfig, overrides?: Partial<{ firmwareVersion: string }>) { + writeDeviceStore(config.deviceConfigPath, { + version: 2, + devices: [{ + id: 'dev-1', + nickname: 'Test RM', + deviceIp: '10.11.99.1', + sshPort: 22, + authMethod: 'password', + sshPassword: 'test', + firmwareVersion: overrides?.firmwareVersion ?? '3.26.1.2', + deviceModel: 'rmPP', + }], + activeDeviceId: 'dev-1', + }) +} + +describe('xovi routes', () => { + let config: ServerConfig + + beforeEach(() => { + config = makeConfig() + }) + + afterEach(() => { + rmSync(config.dataDir, { recursive: true, force: true }) + }) + + // ── xovi-status ────────────────────────────────────────────────────────── + + describe('POST /api/devices/:id/xovi-status', () => { + it('returns 400 when device is not configured', async () => { + const app = await createApp(config) + const res = await app.inject({ method: 'POST', url: '/api/devices/nonexistent/xovi-status' }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toMatch(/not configured/) + await app.close() + }) + }) + + // ── xovi-deploy ────────────────────────────────────────────────────────── + + describe('POST /api/devices/:id/xovi-deploy', () => { + it('returns 400 when no extensions selected', async () => { + seedDevice(config) + const app = await createApp(config) + const res = await app.inject({ + method: 'POST', + url: '/api/devices/dev-1/xovi-deploy', + payload: { extensionIds: [] }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toMatch(/No extensions selected/) + await app.close() + }) + + it('returns 400 for unknown extension IDs', async () => { + seedDevice(config) + const app = await createApp(config) + const res = await app.inject({ + method: 'POST', + url: '/api/devices/dev-1/xovi-deploy', + payload: { extensionIds: ['totallyFakeExtension'] }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toMatch(/Unknown extensions/) + await app.close() + }) + + it('returns 400 for conflicting exclusive groups', async () => { + seedDevice(config) + const app = await createApp(config) + const res = await app.inject({ + method: 'POST', + url: '/api/devices/dev-1/xovi-deploy', + payload: { extensionIds: ['createPagesRM2Size', 'createPagesPaperProSize'] }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toMatch(/mutually exclusive/) + await app.close() + }) + + it('returns 400 when firmware version is unknown', async () => { + seedDevice(config, { firmwareVersion: undefined as unknown as string }) + // Manually clear firmwareVersion + writeDeviceStore(config.deviceConfigPath, { + version: 2, + devices: [{ + id: 'dev-1', + nickname: 'Test RM', + deviceIp: '10.11.99.1', + sshPort: 22, + authMethod: 'password', + sshPassword: 'test', + }], + activeDeviceId: 'dev-1', + }) + const app = await createApp(config) + const res = await app.inject({ + method: 'POST', + url: '/api/devices/dev-1/xovi-deploy', + payload: { extensionIds: ['unlockMethodsContent'] }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toMatch(/firmware version unknown/) + await app.close() + }) + + it('returns 400 for unsupported firmware version', async () => { + seedDevice(config, { firmwareVersion: '9.99.0.0' }) + const app = await createApp(config) + const res = await app.inject({ + method: 'POST', + url: '/api/devices/dev-1/xovi-deploy', + payload: { extensionIds: ['unlockMethodsContent'] }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toMatch(/No extensions available/) + await app.close() + }) + + it('returns 400 when extension not available for firmware version', async () => { + seedDevice(config, { firmwareVersion: '3.22.0.0' }) + const app = await createApp(config) + const res = await app.inject({ + method: 'POST', + url: '/api/devices/dev-1/xovi-deploy', + payload: { extensionIds: ['quicksheetUseTemplate'] }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toMatch(/not available for firmware/) + await app.close() + }) + + it('returns 400 when device not configured', async () => { + const app = await createApp(config) + const res = await app.inject({ + method: 'POST', + url: '/api/devices/nonexistent/xovi-deploy', + payload: { extensionIds: ['unlockMethodsContent'] }, + }) + expect(res.statusCode).toBe(400) + await app.close() + }) + }) + + // ── xovi-remove ────────────────────────────────────────────────────────── + + describe('POST /api/devices/:id/xovi-remove', () => { + it('returns 400 when no extensions selected', async () => { + seedDevice(config) + const app = await createApp(config) + const res = await app.inject({ + method: 'POST', + url: '/api/devices/dev-1/xovi-remove', + payload: { extensionIds: [] }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toMatch(/No extensions selected/) + await app.close() + }) + + it('returns 400 for unknown extension IDs', async () => { + seedDevice(config) + const app = await createApp(config) + const res = await app.inject({ + method: 'POST', + url: '/api/devices/dev-1/xovi-remove', + payload: { extensionIds: ['fakeExtension'] }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toMatch(/Unknown extensions/) + await app.close() + }) + + it('returns 400 when device not configured', async () => { + const app = await createApp(config) + const res = await app.inject({ + method: 'POST', + url: '/api/devices/nonexistent/xovi-remove', + payload: { extensionIds: ['unlockMethodsContent'] }, + }) + expect(res.statusCode).toBe(400) + await app.close() + }) + }) + + // ── vellum-install-xovi ────────────────────────────────────────────────── + + describe('POST /api/devices/:id/vellum-install-xovi', () => { + it('returns 400 when device not configured', async () => { + const app = await createApp(config) + const res = await app.inject({ + method: 'POST', + url: '/api/devices/nonexistent/vellum-install-xovi', + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toMatch(/not configured/) + await app.close() + }) + }) + + // ── vellum-remove-xovi ─────────────────────────────────────────────────── + + describe('POST /api/devices/:id/vellum-remove-xovi', () => { + it('returns 400 when device not configured', async () => { + const app = await createApp(config) + const res = await app.inject({ + method: 'POST', + url: '/api/devices/nonexistent/vellum-remove-xovi', + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toMatch(/not configured/) + await app.close() + }) + }) +}) diff --git a/server/app.ts b/server/app.ts index 69a863b..103c949 100644 --- a/server/app.ts +++ b/server/app.ts @@ -22,6 +22,7 @@ import deviceRollbackRoutes from './routes/device/rollback.ts' import deviceBackupRoutes from './routes/device/backups.ts' import deviceRemoveAllRoutes from './routes/device/removeAll.ts' import deviceSyncStatusRoutes from './routes/device/syncStatus.ts' +import deviceXoviRoutes from './routes/device/xovi.ts' import sampleTemplateRoutes from './routes/sampleTemplates.ts' import { backfillAllIcons } from './lib/backfillIcons.ts' @@ -54,6 +55,7 @@ export async function createApp(config: ServerConfig) { deviceBackupRoutes(app, config) deviceRemoveAllRoutes(app, config) deviceSyncStatusRoutes(app, config) + deviceXoviRoutes(app, config) sampleTemplateRoutes(app, config) // Backfill iconData for any registry entries missing it diff --git a/server/data/xovi-extensions/3.22/createPagesPaperProSize.qmd b/server/data/xovi-extensions/3.22/createPagesPaperProSize.qmd new file mode 100644 index 0000000..086cf2f --- /dev/null +++ b/server/data/xovi-extensions/3.22/createPagesPaperProSize.qmd @@ -0,0 +1,55 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2025-11-03T00:39:35Z + +; Core page creation when adding pages +AFFECT [[11797611520953530268]] + TRAVERSE [[6502786168]]#[[496312708941723703]] + REBUILD [[14161682563420561297]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Page creation from document view +AFFECT [[1224665461898798997]] + TRAVERSE [[8397993708429497603]]#[[15793094956877902211]] + REBUILD [[233720993465423]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Template application and quicksheet creation +AFFECT [[8850026134298937527]] + TRAVERSE [[8397993708429497603]]#[[254540341572282132]] + REBUILD [[17550284043068430536]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE + + TRAVERSE [[8397993708429497603]]#[[254540341572282132]] > [[3215456617438411154]]#[[14771436684287383225]] + REBUILD [[4463636820780310028]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Handwriting conversion to text +AFFECT [[11265320901603507725]] + TRAVERSE [[8397993708429497603]]#[[8399273526924993769]] + REBUILD [[1152593634589442790]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.22/createPagesRM2Size.qmd b/server/data/xovi-extensions/3.22/createPagesRM2Size.qmd new file mode 100644 index 0000000..bd69a32 --- /dev/null +++ b/server/data/xovi-extensions/3.22/createPagesRM2Size.qmd @@ -0,0 +1,55 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2025-11-03T02:28:14Z + +; Core page creation when adding pages +AFFECT [[11797611520953530268]] + TRAVERSE [[6502786168]]#[[496312708941723703]] + REBUILD [[14161682563420561297]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Page creation from document view +AFFECT [[1224665461898798997]] + TRAVERSE [[8397993708429497603]]#[[15793094956877902211]] + REBUILD [[233720993465423]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Template application and quicksheet creation +AFFECT [[8850026134298937527]] + TRAVERSE [[8397993708429497603]]#[[254540341572282132]] + REBUILD [[17550284043068430536]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE + + TRAVERSE [[8397993708429497603]]#[[254540341572282132]] > [[3215456617438411154]]#[[14771436684287383225]] + REBUILD [[4463636820780310028]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Handwriting conversion to text +AFFECT [[11265320901603507725]] + TRAVERSE [[8397993708429497603]]#[[8399273526924993769]] + REBUILD [[1152593634589442790]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.22/preventNotebookZoomOut.qmd b/server/data/xovi-extensions/3.22/preventNotebookZoomOut.qmd new file mode 100644 index 0000000..e02bdad --- /dev/null +++ b/server/data/xovi-extensions/3.22/preventNotebookZoomOut.qmd @@ -0,0 +1,67 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT + +AFFECT [[11806562588218124596]] + TRAVERSE [[8397993708429497603]]#[[6504254477]] > [[254480451320573660]]#[[18007182163571081861]] > [[254502432014417618]]#[[6504391364]] + LOCATE AFTER ALL + INSERT { + /* Change the numerical value below to adjust horizontal offset. Negative = left, positive = right.*/ + readonly property ~&197088788&~ ~&12826966400763524666&~: -300 + + /* Handles initial page load and non-preloaded page turns (fires when view becomes Active)*/ + ~&17742136325200165396&~: { + ~&5972376&~ (~&6504254477&~.~&7713361680718652&~ && ~&6504254477&~.~&495572996767745525&~?.~&14593461789319363686&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~ && ~&6504391364&~.~&233748328658231&~) { + ~&5971598&~.~&254524858248187773&~(function() { + ~&5972376&~ (~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~ && ~&6504391364&~.~&233748328658231&~) { + ~&214622607920&~ ~&7082534202858&~ = ~&5971598&~.~&214638019283&~( + ~&6504391364&~.~&4161556510681018511&~.~&180993&~ + ~&6504391364&~.~&12826966400763524666&~, + ~&6504391364&~.~&4161556510681018511&~.~&180994&~ + ); + ~&6504391364&~.setFocalPoint(~&7082534202858&~, ~&6504391364&~.~&15777890623490054006&~); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } + }); + } + } + /* Handles page turns (fires when preloaded view becomes visible)*/ + ~&13733777459642361598&~: { + ~&5972376&~ (~&6504254477&~.~&7713361680718652&~ && ~&6504254477&~.~&495572996767745525&~?.~&14593461789319363686&~ && ~&6504391364&~.~&233748328658231&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~) { + ~&5971598&~.~&254524858248187773&~(function() { + ~&5972376&~ (~&6504391364&~.~&233748328658231&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~) { + ~&214622607920&~ ~&7082534202858&~ = ~&5971598&~.~&214638019283&~( + ~&6504391364&~.~&4161556510681018511&~.~&180993&~ + ~&6504391364&~.~&12826966400763524666&~, + ~&6504391364&~.~&4161556510681018511&~.~&180994&~ + ); + ~&6504391364&~.setFocalPoint(~&7082534202858&~, ~&6504391364&~.~&15777890623490054006&~); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } + }); + } + } + + /* Handles orientation changes: apply offset in portrait, reset in landscape*/ + ~&428051690305612204&~ { + ~&7083194890448&~: ~&6504254477&~.~&495572996767745525&~ + ~&233726547792244&~: ~&6504254477&~.~&7713361680718652&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ + function ~&4934706584521522300&~() { + ~&5971598&~.~&254524858248187773&~(function() { + ~&5972376&~ (~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~ && ~&6504391364&~.~&233748328658231&~) { + ~&5972376&~ (~&6504254477&~.~&495572996767745525&~?.~&14593461789319363686&~) { + /* Portrait: apply custom offset*/ + ~&214622607920&~ ~&7082534202858&~ = ~&5971598&~.~&214638019283&~( + ~&6504391364&~.~&4161556510681018511&~.~&180993&~ + ~&6504391364&~.~&12826966400763524666&~, + ~&6504391364&~.~&4161556510681018511&~.~&180994&~ + ); + ~&6504391364&~.setFocalPoint(~&7082534202858&~, ~&6504391364&~.~&15777890623490054006&~); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } ~&6503784146&~ { + ~&6504391364&~.setFocalPointToContent(); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } + } + }); + } + } + } + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.22/unlockMethodsContent.qmd b/server/data/xovi-extensions/3.22/unlockMethodsContent.qmd new file mode 100644 index 0000000..bfb8381 --- /dev/null +++ b/server/data/xovi-extensions/3.22/unlockMethodsContent.qmd @@ -0,0 +1,26 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT + +AFFECT [[13151316468101634456]] + TRAVERSE [[8397788359424131273]] + REPLACE [[6332993169457122379]] WITH { + readonly property bool ~&6332993169457122379&~: ~&214625660372&~ + } + END TRAVERSE +END AFFECT + +AFFECT [[1224665461898798997]] + TRAVERSE [[8397993708429497603]] + REPLACE [[13924027351468112124]] WITH { + property bool ~&13924027351468112124&~: ~&214625660372&~ + } + END TRAVERSE +END AFFECT + +AFFECT [[5376273845699449139]] + TRAVERSE [[4656948266548654837]] + REPLACE [[16638155029758307190]] WITH { + property bool ~&16638155029758307190&~: ~&214625660372&~ + } + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.23/createPagesPaperProSize.qmd b/server/data/xovi-extensions/3.23/createPagesPaperProSize.qmd new file mode 100644 index 0000000..ae54f6b --- /dev/null +++ b/server/data/xovi-extensions/3.23/createPagesPaperProSize.qmd @@ -0,0 +1,55 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2025-11-07T23:54:54Z + +; Core page creation when adding pages +AFFECT [[11797611520953530268]] + TRAVERSE [[6502786168]]#[[496312708941723703]] + REBUILD [[14161682563420561297]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Page creation from document view +AFFECT [[1224665461898798997]] + TRAVERSE [[8397993708429497603]]#[[15793094956877902211]] + REBUILD [[233720993465423]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Template application and quicksheet creation +AFFECT [[8850026134298937527]] + TRAVERSE [[8397993708429497603]]#[[254540341572282132]] + REBUILD [[17550284043068430536]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE + + TRAVERSE [[8397993708429497603]]#[[254540341572282132]] > [[3215456617438411154]]#[[14771436684287383225]] + REBUILD [[4463636820780310028]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Handwriting conversion to text +AFFECT [[11265320901603507725]] + TRAVERSE [[8397993708429497603]]#[[8399273526924993769]] + REBUILD [[1152593634589442790]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.23/createPagesRM2Size.qmd b/server/data/xovi-extensions/3.23/createPagesRM2Size.qmd new file mode 100644 index 0000000..046d41b --- /dev/null +++ b/server/data/xovi-extensions/3.23/createPagesRM2Size.qmd @@ -0,0 +1,55 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2025-11-07T23:54:54Z + +; Core page creation when adding pages +AFFECT [[11797611520953530268]] + TRAVERSE [[6502786168]]#[[496312708941723703]] + REBUILD [[14161682563420561297]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Page creation from document view +AFFECT [[1224665461898798997]] + TRAVERSE [[8397993708429497603]]#[[15793094956877902211]] + REBUILD [[233720993465423]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Template application and quicksheet creation +AFFECT [[8850026134298937527]] + TRAVERSE [[8397993708429497603]]#[[254540341572282132]] + REBUILD [[17550284043068430536]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE + + TRAVERSE [[8397993708429497603]]#[[254540341572282132]] > [[3215456617438411154]]#[[14771436684287383225]] + REBUILD [[4463636820780310028]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Handwriting conversion to text +AFFECT [[11265320901603507725]] + TRAVERSE [[8397993708429497603]]#[[8399273526924993769]] + REBUILD [[1152593634589442790]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.23/preventNotebookZoomOut.qmd b/server/data/xovi-extensions/3.23/preventNotebookZoomOut.qmd new file mode 100644 index 0000000..919d2bc --- /dev/null +++ b/server/data/xovi-extensions/3.23/preventNotebookZoomOut.qmd @@ -0,0 +1,68 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2025-11-07T23:54:54Z + +AFFECT [[11806562588218124596]] + TRAVERSE [[8397993708429497603]]#[[6504254477]] > [[254480451320573660]]#[[18007182163571081861]] > [[254502432014417618]]#[[6504391364]] + LOCATE AFTER ALL + INSERT { + /* Change the numerical value below to adjust horizontal offset. Negative = left, positive = right.*/ + readonly property ~&197088788&~ ~&12826966400763524666&~: -300 + + /* Handles initial page load and non-preloaded page turns (fires when view becomes Active)*/ + ~&17742136325200165396&~: { + ~&5972376&~ (~&6504254477&~.~&7713361680718652&~ && ~&6504254477&~.~&495572996767745525&~?.~&14593461789319363686&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~ && ~&6504391364&~.~&233748328658231&~) { + ~&5971598&~.~&254524858248187773&~(function() { + ~&5972376&~ (~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~ && ~&6504391364&~.~&233748328658231&~) { + ~&214622607920&~ ~&7082534202858&~ = ~&5971598&~.~&214638019283&~( + ~&6504391364&~.~&4161556510681018511&~.~&180993&~ + ~&6504391364&~.~&12826966400763524666&~, + ~&6504391364&~.~&4161556510681018511&~.~&180994&~ + ); + ~&6504391364&~.setFocalPoint(~&7082534202858&~, ~&6504391364&~.~&15777890623490054006&~); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } + }); + } + } + /* Handles page turns (fires when preloaded view becomes visible)*/ + ~&13733777459642361598&~: { + ~&5972376&~ (~&6504254477&~.~&7713361680718652&~ && ~&6504254477&~.~&495572996767745525&~?.~&14593461789319363686&~ && ~&6504391364&~.~&233748328658231&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~) { + ~&5971598&~.~&254524858248187773&~(function() { + ~&5972376&~ (~&6504391364&~.~&233748328658231&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~) { + ~&214622607920&~ ~&7082534202858&~ = ~&5971598&~.~&214638019283&~( + ~&6504391364&~.~&4161556510681018511&~.~&180993&~ + ~&6504391364&~.~&12826966400763524666&~, + ~&6504391364&~.~&4161556510681018511&~.~&180994&~ + ); + ~&6504391364&~.setFocalPoint(~&7082534202858&~, ~&6504391364&~.~&15777890623490054006&~); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } + }); + } + } + + /* Handles orientation changes: apply offset in portrait, reset in landscape*/ + ~&428051690305612204&~ { + ~&7083194890448&~: ~&6504254477&~.~&495572996767745525&~ + ~&233726547792244&~: ~&6504254477&~.~&7713361680718652&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ + function ~&4934706584521522300&~() { + ~&5971598&~.~&254524858248187773&~(function() { + ~&5972376&~ (~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~ && ~&6504391364&~.~&233748328658231&~) { + ~&5972376&~ (~&6504254477&~.~&495572996767745525&~?.~&14593461789319363686&~) { + /* Portrait: apply custom offset*/ + ~&214622607920&~ ~&7082534202858&~ = ~&5971598&~.~&214638019283&~( + ~&6504391364&~.~&4161556510681018511&~.~&180993&~ + ~&6504391364&~.~&12826966400763524666&~, + ~&6504391364&~.~&4161556510681018511&~.~&180994&~ + ); + ~&6504391364&~.setFocalPoint(~&7082534202858&~, ~&6504391364&~.~&15777890623490054006&~); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } ~&6503784146&~ { + ~&6504391364&~.setFocalPointToContent(); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } + } + }); + } + } + } + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.23/unlockMethodsContent.qmd b/server/data/xovi-extensions/3.23/unlockMethodsContent.qmd new file mode 100644 index 0000000..f2682a1 --- /dev/null +++ b/server/data/xovi-extensions/3.23/unlockMethodsContent.qmd @@ -0,0 +1,27 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2025-11-07T23:54:54Z + +AFFECT [[13151316468101634456]] + TRAVERSE [[8397788359424131273]] + REPLACE [[6332993169457122379]] WITH { + readonly property bool ~&6332993169457122379&~: ~&214625660372&~ + } + END TRAVERSE +END AFFECT + +AFFECT [[1224665461898798997]] + TRAVERSE [[8397993708429497603]] + REPLACE [[13924027351468112124]] WITH { + property bool ~&13924027351468112124&~: ~&214625660372&~ + } + END TRAVERSE +END AFFECT + +AFFECT [[5376273845699449139]] + TRAVERSE [[4656948266548654837]] + REPLACE [[16638155029758307190]] WITH { + property bool ~&16638155029758307190&~: ~&214625660372&~ + } + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.24/createPagesPaperProSize.qmd b/server/data/xovi-extensions/3.24/createPagesPaperProSize.qmd new file mode 100644 index 0000000..9a278b5 --- /dev/null +++ b/server/data/xovi-extensions/3.24/createPagesPaperProSize.qmd @@ -0,0 +1,55 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2025-12-04T00:54:47Z + +; Core page creation when adding pages +AFFECT [[11797611520953530268]] + TRAVERSE [[6502786168]]#[[496312708941723703]] + REBUILD [[14161682563420561297]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Page creation from document view +AFFECT [[1224665461898798997]] + TRAVERSE [[8397993708429497603]]#[[6504254477]] + REBUILD [[233720993465423]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Template application and quicksheet creation +AFFECT [[8850026134298937527]] + TRAVERSE [[8397993708429497603]]#[[254540341572282132]] + REBUILD [[17550284043068430536]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE + + TRAVERSE [[8397993708429497603]]#[[254540341572282132]] > [[3215456617438411154]]#[[14771436684287383225]] + REBUILD [[4463636820780310028]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Handwriting conversion to text +AFFECT [[11265320901603507725]] + TRAVERSE [[8397993708429497603]]#[[8399273526924993769]] + REBUILD [[1152593634589442790]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.24/createPagesRM2Size.qmd b/server/data/xovi-extensions/3.24/createPagesRM2Size.qmd new file mode 100644 index 0000000..3371f82 --- /dev/null +++ b/server/data/xovi-extensions/3.24/createPagesRM2Size.qmd @@ -0,0 +1,55 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2025-12-04T00:54:47Z + +; Core page creation when adding pages +AFFECT [[11797611520953530268]] + TRAVERSE [[6502786168]]#[[496312708941723703]] + REBUILD [[14161682563420561297]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Page creation from document view +AFFECT [[1224665461898798997]] + TRAVERSE [[8397993708429497603]]#[[6504254477]] + REBUILD [[233720993465423]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Template application and quicksheet creation +AFFECT [[8850026134298937527]] + TRAVERSE [[8397993708429497603]]#[[254540341572282132]] + REBUILD [[17550284043068430536]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE + + TRAVERSE [[8397993708429497603]]#[[254540341572282132]] > [[3215456617438411154]]#[[14771436684287383225]] + REBUILD [[4463636820780310028]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Handwriting conversion to text +AFFECT [[11265320901603507725]] + TRAVERSE [[8397993708429497603]]#[[8399273526924993769]] + REBUILD [[1152593634589442790]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.24/preventNotebookZoomOut.qmd b/server/data/xovi-extensions/3.24/preventNotebookZoomOut.qmd new file mode 100644 index 0000000..2d55dd3 --- /dev/null +++ b/server/data/xovi-extensions/3.24/preventNotebookZoomOut.qmd @@ -0,0 +1,68 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2025-12-04T00:54:47Z + +AFFECT [[11806562588218124596]] + TRAVERSE [[8397993708429497603]]#[[6504254477]] > [[254480451320573660]]#[[18007182163571081861]] > [[254502432014417618]]#[[6504391364]] + LOCATE AFTER ALL + INSERT { + /* Change the numerical value below to adjust horizontal offset. Negative = left, positive = right.*/ + readonly property ~&197088788&~ ~&12826966400763524666&~: -300 + + /* Handles initial page load and non-preloaded page turns (fires when view becomes Active)*/ + ~&17742136325200165396&~: { + ~&5972376&~ (~&6504254477&~.~&7713361680718652&~ && ~&6504254477&~.~&495572996767745525&~?.~&14593461789319363686&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~ && ~&6504391364&~.~&233748328658231&~) { + ~&5971598&~.~&254524858248187773&~(function() { + ~&5972376&~ (~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~ && ~&6504391364&~.~&233748328658231&~) { + ~&214622607920&~ ~&7082534202858&~ = ~&5971598&~.~&214638019283&~( + ~&6504391364&~.~&4161556510681018511&~.~&180993&~ + ~&6504391364&~.~&12826966400763524666&~, + ~&6504391364&~.~&4161556510681018511&~.~&180994&~ + ); + ~&6504391364&~.setFocalPoint(~&7082534202858&~, ~&6504391364&~.~&15777890623490054006&~); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } + }); + } + } + /* Handles page turns (fires when preloaded view becomes visible)*/ + ~&13733777459642361598&~: { + ~&5972376&~ (~&6504254477&~.~&7713361680718652&~ && ~&6504254477&~.~&495572996767745525&~?.~&14593461789319363686&~ && ~&6504391364&~.~&233748328658231&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~) { + ~&5971598&~.~&254524858248187773&~(function() { + ~&5972376&~ (~&6504391364&~.~&233748328658231&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~) { + ~&214622607920&~ ~&7082534202858&~ = ~&5971598&~.~&214638019283&~( + ~&6504391364&~.~&4161556510681018511&~.~&180993&~ + ~&6504391364&~.~&12826966400763524666&~, + ~&6504391364&~.~&4161556510681018511&~.~&180994&~ + ); + ~&6504391364&~.setFocalPoint(~&7082534202858&~, ~&6504391364&~.~&15777890623490054006&~); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } + }); + } + } + + /* Handles orientation changes: apply offset in portrait, reset in landscape*/ + ~&428051690305612204&~ { + ~&7083194890448&~: ~&6504254477&~.~&495572996767745525&~ + ~&233726547792244&~: ~&6504254477&~.~&7713361680718652&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ + function ~&4934706584521522300&~() { + ~&5971598&~.~&254524858248187773&~(function() { + ~&5972376&~ (~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~ && ~&6504391364&~.~&233748328658231&~) { + ~&5972376&~ (~&6504254477&~.~&495572996767745525&~?.~&14593461789319363686&~) { + /* Portrait: apply custom offset*/ + ~&214622607920&~ ~&7082534202858&~ = ~&5971598&~.~&214638019283&~( + ~&6504391364&~.~&4161556510681018511&~.~&180993&~ + ~&6504391364&~.~&12826966400763524666&~, + ~&6504391364&~.~&4161556510681018511&~.~&180994&~ + ); + ~&6504391364&~.setFocalPoint(~&7082534202858&~, ~&6504391364&~.~&15777890623490054006&~); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } ~&6503784146&~ { + ~&6504391364&~.setFocalPointToContent(); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } + } + }); + } + } + } + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.24/unlockMethodsContent.qmd b/server/data/xovi-extensions/3.24/unlockMethodsContent.qmd new file mode 100644 index 0000000..9c5c597 --- /dev/null +++ b/server/data/xovi-extensions/3.24/unlockMethodsContent.qmd @@ -0,0 +1,27 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2025-12-04T00:54:47Z + +AFFECT [[13151316468101634456]] + TRAVERSE [[8397788359424131273]] + REPLACE [[6332993169457122379]] WITH { + readonly property bool ~&6332993169457122379&~: ~&214625660372&~ + } + END TRAVERSE +END AFFECT + +AFFECT [[1224665461898798997]] + TRAVERSE [[8397993708429497603]] + REPLACE [[13924027351468112124]] WITH { + property bool ~&13924027351468112124&~: ~&214625660372&~ + } + END TRAVERSE +END AFFECT + +AFFECT [[5376273845699449139]] + TRAVERSE [[4656948266548654837]] + REPLACE [[16638155029758307190]] WITH { + property bool ~&16638155029758307190&~: ~&214625660372&~ + } + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.25/createPagesPaperProSize.qmd b/server/data/xovi-extensions/3.25/createPagesPaperProSize.qmd new file mode 100644 index 0000000..a1b6592 --- /dev/null +++ b/server/data/xovi-extensions/3.25/createPagesPaperProSize.qmd @@ -0,0 +1,59 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2026-01-31T02:09:24Z + +; Core page creation when adding pages +AFFECT [[11797611520953530268]] + TRAVERSE [[6502786168]]#[[496312708941723703]] + REBUILD [[14161682563420561297]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Page creation from document view +AFFECT [[1224665461898798997]] + TRAVERSE [[8397993708429497603]]#[[6504254477]] + REBUILD [[233720993465423]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Template application and quicksheet creation +AFFECT [[8850026134298937527]] + TRAVERSE [[8397993708429497603]]#[[6504254477]] + REBUILD [[17550284043068430536]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE + +END AFFECT + +; Notebook creation from create menu +AFFECT [[16334662581095534079]] + TRAVERSE [[7711468349764991]]#[[6504254477]] + REBUILD [[11689254259907176254]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Handwriting conversion to text +AFFECT [[11265320901603507725]] + TRAVERSE [[6502786168]]#[[6504254477]] + REBUILD [[1152593634589442790]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.25/createPagesRM2Size.qmd b/server/data/xovi-extensions/3.25/createPagesRM2Size.qmd new file mode 100644 index 0000000..e3862f2 --- /dev/null +++ b/server/data/xovi-extensions/3.25/createPagesRM2Size.qmd @@ -0,0 +1,59 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2026-01-31T02:09:24Z + +; Core page creation when adding pages +AFFECT [[11797611520953530268]] + TRAVERSE [[6502786168]]#[[496312708941723703]] + REBUILD [[14161682563420561297]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Page creation from document view +AFFECT [[1224665461898798997]] + TRAVERSE [[8397993708429497603]]#[[6504254477]] + REBUILD [[233720993465423]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Template application and quicksheet creation +AFFECT [[8850026134298937527]] + TRAVERSE [[8397993708429497603]]#[[6504254477]] + REBUILD [[17550284043068430536]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE + +END AFFECT + +; Notebook creation from create menu +AFFECT [[16334662581095534079]] + TRAVERSE [[7711468349764991]]#[[6504254477]] + REBUILD [[11689254259907176254]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Handwriting conversion to text +AFFECT [[11265320901603507725]] + TRAVERSE [[6502786168]]#[[6504254477]] + REBUILD [[1152593634589442790]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.25/preventNotebookZoomOut.qmd b/server/data/xovi-extensions/3.25/preventNotebookZoomOut.qmd new file mode 100644 index 0000000..21c9a32 --- /dev/null +++ b/server/data/xovi-extensions/3.25/preventNotebookZoomOut.qmd @@ -0,0 +1,68 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2026-01-31T02:09:24Z + +AFFECT [[11806562588218124596]] + TRAVERSE [[8397993708429497603]]#[[6504254477]] > [[254480451320573660]]#[[18007182163571081861]] > [[254502432014417618]]#[[6504391364]] + LOCATE AFTER ALL + INSERT { + /* Change the numerical value below to adjust horizontal offset. Negative = left, positive = right.*/ + readonly property ~&197088788&~ ~&12826966400763524666&~: -300 + + /* Handles initial page load and non-preloaded page turns (fires when view becomes Active)*/ + ~&17742136325200165396&~: { + ~&5972376&~ (~&6504254477&~.~&7713361680718652&~ && ~&6504254477&~.~&495572996767745525&~?.~&14593461789319363686&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~ && ~&6504391364&~.~&233748328658231&~) { + ~&5971598&~.~&254524858248187773&~(function() { + ~&5972376&~ (~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~ && ~&6504391364&~.~&233748328658231&~) { + ~&214622607920&~ ~&7082534202858&~ = ~&5971598&~.~&214638019283&~( + ~&6504391364&~.~&4161556510681018511&~.~&180993&~ + ~&6504391364&~.~&12826966400763524666&~, + ~&6504391364&~.~&4161556510681018511&~.~&180994&~ + ); + ~&6504391364&~.setFocalPoint(~&7082534202858&~, ~&6504391364&~.~&15777890623490054006&~); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } + }); + } + } + /* Handles page turns (fires when preloaded view becomes visible)*/ + ~&13733777459642361598&~: { + ~&5972376&~ (~&6504254477&~.~&7713361680718652&~ && ~&6504254477&~.~&495572996767745525&~?.~&14593461789319363686&~ && ~&6504391364&~.~&233748328658231&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~) { + ~&5971598&~.~&254524858248187773&~(function() { + ~&5972376&~ (~&6504391364&~.~&233748328658231&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~) { + ~&214622607920&~ ~&7082534202858&~ = ~&5971598&~.~&214638019283&~( + ~&6504391364&~.~&4161556510681018511&~.~&180993&~ + ~&6504391364&~.~&12826966400763524666&~, + ~&6504391364&~.~&4161556510681018511&~.~&180994&~ + ); + ~&6504391364&~.setFocalPoint(~&7082534202858&~, ~&6504391364&~.~&15777890623490054006&~); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } + }); + } + } + + /* Handles orientation changes: apply offset in portrait, reset in landscape*/ + ~&428051690305612204&~ { + ~&7083194890448&~: ~&6504254477&~.~&495572996767745525&~ + ~&233726547792244&~: ~&6504254477&~.~&7713361680718652&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ + function ~&4934706584521522300&~() { + ~&5971598&~.~&254524858248187773&~(function() { + ~&5972376&~ (~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~ && ~&6504391364&~.~&233748328658231&~) { + ~&5972376&~ (~&6504254477&~.~&495572996767745525&~?.~&14593461789319363686&~) { + /* Portrait: apply custom offset*/ + ~&214622607920&~ ~&7082534202858&~ = ~&5971598&~.~&214638019283&~( + ~&6504391364&~.~&4161556510681018511&~.~&180993&~ + ~&6504391364&~.~&12826966400763524666&~, + ~&6504391364&~.~&4161556510681018511&~.~&180994&~ + ); + ~&6504391364&~.setFocalPoint(~&7082534202858&~, ~&6504391364&~.~&15777890623490054006&~); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } ~&6503784146&~ { + ~&6504391364&~.setFocalPointToContent(); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } + } + }); + } + } + } + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.25/quicksheetUseTemplate.qmd b/server/data/xovi-extensions/3.25/quicksheetUseTemplate.qmd new file mode 100644 index 0000000..94260c3 --- /dev/null +++ b/server/data/xovi-extensions/3.25/quicksheetUseTemplate.qmd @@ -0,0 +1,13 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2026-03-03T01:12:56Z + +AFFECT [[8850026134298937527]] + TRAVERSE [[8397993708429497603]]#[[6504254477]] + REBUILD [[17550284043068430536]] + LOCATE BEFORE { ~&233703556595635&~.~&16254972084332612587&~ } + REMOVE LOCATED + INSERT { ~&7712934851008712&~.~&14140055321554169705&~(~&254543134825829310&~) } + END REBUILD + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.25/unlockMethodsContent.qmd b/server/data/xovi-extensions/3.25/unlockMethodsContent.qmd new file mode 100644 index 0000000..399afd0 --- /dev/null +++ b/server/data/xovi-extensions/3.25/unlockMethodsContent.qmd @@ -0,0 +1,27 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2026-01-31T02:09:24Z + +AFFECT [[13151316468101634456]] + TRAVERSE [[8397788359424131273]] + REPLACE [[6332993169457122379]] WITH { + readonly property bool ~&6332993169457122379&~: ~&214625660372&~ + } + END TRAVERSE +END AFFECT + +AFFECT [[1224665461898798997]] + TRAVERSE [[8397993708429497603]] + REPLACE [[13924027351468112124]] WITH { + property bool ~&13924027351468112124&~: ~&214625660372&~ + } + END TRAVERSE +END AFFECT + +AFFECT [[5376273845699449139]] + TRAVERSE [[4656948266548654837]] + REPLACE [[16638155029758307190]] WITH { + property bool ~&16638155029758307190&~: ~&214625660372&~ + } + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.26/createPagesPaperProSize.qmd b/server/data/xovi-extensions/3.26/createPagesPaperProSize.qmd new file mode 100644 index 0000000..69cfcf3 --- /dev/null +++ b/server/data/xovi-extensions/3.26/createPagesPaperProSize.qmd @@ -0,0 +1,58 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2026-03-20T02:00:38Z + +; Core page creation when adding pages +AFFECT [[11797611520953530268]] + TRAVERSE [[6502786168]]#[[496312708941723703]] + REBUILD [[14161682563420561297]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Page creation from document view +AFFECT [[1224665461898798997]] + TRAVERSE [[8397993708429497603]]#[[6504254477]] + REBUILD [[233720993465423]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Quicksheet creation +AFFECT [[6163493716185020606]] + TRAVERSE [[7081201431623]]#[[6504254477]] + REBUILD [[11925893266903827905]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Notebook creation from create menu +AFFECT [[16344100773210839301]] + TRAVERSE [[7711468349764991]]#[[6504254477]] + REBUILD [[11689254259907176254]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT + +; Handwriting conversion to text +AFFECT [[11265320901603507725]] + TRAVERSE [[6502786168]]#[[6504254477]] + REBUILD [[1152593634589442790]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1620, 2160) } + END REBUILD + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.26/createPagesRM2Size.qmd b/server/data/xovi-extensions/3.26/createPagesRM2Size.qmd new file mode 100644 index 0000000..1d1fa76 --- /dev/null +++ b/server/data/xovi-extensions/3.26/createPagesRM2Size.qmd @@ -0,0 +1,58 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2026-03-20T02:00:38Z + +; Core page creation when adding pages +AFFECT [[11797611520953530268]] + TRAVERSE [[6502786168]]#[[496312708941723703]] + REBUILD [[14161682563420561297]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Page creation from document view +AFFECT [[1224665461898798997]] + TRAVERSE [[8397993708429497603]]#[[6504254477]] + REBUILD [[233720993465423]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Quicksheet creation +AFFECT [[6163493716185020606]] + TRAVERSE [[7081201431623]]#[[6504254477]] + REBUILD [[11925893266903827905]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Notebook creation from create menu +AFFECT [[16344100773210839301]] + TRAVERSE [[7711468349764991]]#[[6504254477]] + REBUILD [[11689254259907176254]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT + +; Handwriting conversion to text +AFFECT [[11265320901603507725]] + TRAVERSE [[6502786168]]#[[6504254477]] + REBUILD [[1152593634589442790]] + LOCATE BEFORE { ~&4577806659545151269&~.~&254543146496699612&~ } + REMOVE LOCATED + INSERT { ~&5971598&~.~&6504284228&~(1404, 1872) } + END REBUILD + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.26/preventNotebookZoomOut.qmd b/server/data/xovi-extensions/3.26/preventNotebookZoomOut.qmd new file mode 100644 index 0000000..ff1742d --- /dev/null +++ b/server/data/xovi-extensions/3.26/preventNotebookZoomOut.qmd @@ -0,0 +1,68 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2026-03-20T02:00:38Z + +AFFECT [[11806562588218124596]] + TRAVERSE [[8397993708429497603]]#[[6504254477]] > [[254480451320573660]]#[[18007182163571081861]] > [[254502432014417618]]#[[6504391364]] + LOCATE AFTER ALL + INSERT { + /* Change the numerical value below to adjust horizontal offset. Negative = left, positive = right.*/ + readonly property ~&197088788&~ horizontalOffset: -300 + + /* Handles initial page load and non-preloaded page turns (fires when view becomes Active)*/ + ~&17742136325200165396&~: { + ~&5972376&~ (~&6504254477&~.~&7713361680718652&~ && ~&6504254477&~.~&495572996767745525&~?.~&14593461789319363686&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~ && ~&6504391364&~.~&233748328658231&~) { + ~&5971598&~.~&254524858248187773&~(function() { + ~&5972376&~ (~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~ && ~&6504391364&~.~&233748328658231&~) { + ~&214622607920&~ ~&7082534202858&~ = ~&5971598&~.~&214638019283&~( + ~&6504391364&~.~&4161556510681018511&~.~&180993&~ + ~&6504391364&~.horizontalOffset, + ~&6504391364&~.~&4161556510681018511&~.~&180994&~ + ); + ~&6504391364&~.~&10753335312005174244&~(~&7082534202858&~, ~&6504391364&~.~&15777890623490054006&~); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } + }); + } + } + /* Handles page turns (fires when preloaded view becomes visible)*/ + ~&13733777459642361598&~: { + ~&5972376&~ (~&6504254477&~.~&7713361680718652&~ && ~&6504254477&~.~&495572996767745525&~?.~&14593461789319363686&~ && ~&6504391364&~.~&233748328658231&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~) { + ~&5971598&~.~&254524858248187773&~(function() { + ~&5972376&~ (~&6504391364&~.~&233748328658231&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~) { + ~&214622607920&~ ~&7082534202858&~ = ~&5971598&~.~&214638019283&~( + ~&6504391364&~.~&4161556510681018511&~.~&180993&~ + ~&6504391364&~.horizontalOffset, + ~&6504391364&~.~&4161556510681018511&~.~&180994&~ + ); + ~&6504391364&~.~&10753335312005174244&~(~&7082534202858&~, ~&6504391364&~.~&15777890623490054006&~); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } + }); + } + } + + /* Handles orientation changes: apply offset in portrait, reset in landscape*/ + ~&428051690305612204&~ { + ~&7083194890448&~: ~&6504254477&~.~&495572996767745525&~ + ~&233726547792244&~: ~&6504254477&~.~&7713361680718652&~ && ~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ + function ~&4934706584521522300&~() { + ~&5971598&~.~&254524858248187773&~(function() { + ~&5972376&~ (~&6504391364&~.~&7083177691309&~ === ~&254502432014417618&~.~&7081201431845&~ && ~&6504391364&~.~&8399340108982949229&~ && ~&6504391364&~.~&233748328658231&~) { + ~&5972376&~ (~&6504254477&~.~&495572996767745525&~?.~&14593461789319363686&~) { + /* Portrait: apply custom offset*/ + ~&214622607920&~ ~&7082534202858&~ = ~&5971598&~.~&214638019283&~( + ~&6504391364&~.~&4161556510681018511&~.~&180993&~ + ~&6504391364&~.horizontalOffset, + ~&6504391364&~.~&4161556510681018511&~.~&180994&~ + ); + ~&6504391364&~.~&10753335312005174244&~(~&7082534202858&~, ~&6504391364&~.~&15777890623490054006&~); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } ~&6503784146&~ { + ~&6504391364&~.~&7243424524927826498&~(); + ~&6504254477&~.~&2491156027386016279&~ = ~&214625660372&~; + } + } + }); + } + } + } + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.26/quicksheetUseTemplate.qmd b/server/data/xovi-extensions/3.26/quicksheetUseTemplate.qmd new file mode 100644 index 0000000..ba3f789 --- /dev/null +++ b/server/data/xovi-extensions/3.26/quicksheetUseTemplate.qmd @@ -0,0 +1,13 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2026-03-20T02:00:38Z + +AFFECT [[6163493716185020606]] + TRAVERSE ?#[[6504254477]] + REBUILD [[11925893266903827905]] + LOCATE BEFORE { ~&"214583350385&~ } + REMOVE LOCATED + INSERT { ~&7712934851008712&~.~&14140055321554169705&~(~&254543134825829310&~) } + END REBUILD + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/3.26/unlockMethodsContent.qmd b/server/data/xovi-extensions/3.26/unlockMethodsContent.qmd new file mode 100644 index 0000000..52697ce --- /dev/null +++ b/server/data/xovi-extensions/3.26/unlockMethodsContent.qmd @@ -0,0 +1,27 @@ +; Mitchell Scott (https://github.com/rmitchellscott) +; SPDX-License-Identifier: MIT +; Modified: 2026-03-20T02:00:38Z + +AFFECT [[13151316468101634456]] + TRAVERSE [[8397788359424131273]] + REPLACE [[6332993169457122379]] WITH { + readonly property ~&6503679477&~ ~&6332993169457122379&~: ~&214625660372&~ + } + END TRAVERSE +END AFFECT + +AFFECT [[1224665461898798997]] + TRAVERSE [[8397993708429497603]] + REPLACE [[13924027351468112124]] WITH { + property ~&6503679477&~ ~&13924027351468112124&~: ~&214625660372&~ + } + END TRAVERSE +END AFFECT + +AFFECT [[5376273845699449139]] + TRAVERSE [[4656948266548654837]] + REPLACE [[16638155029758307190]] WITH { + property ~&6503679477&~ ~&16638155029758307190&~: ~&214625660372&~ + } + END TRAVERSE +END AFFECT diff --git a/server/data/xovi-extensions/manifest.json b/server/data/xovi-extensions/manifest.json new file mode 100644 index 0000000..092ed2e --- /dev/null +++ b/server/data/xovi-extensions/manifest.json @@ -0,0 +1,72 @@ +{ + "extensions": { + "unlockMethodsContent": { + "filename": "unlockMethodsContent.qmd", + "displayName": "Unlock Methods Content", + "description": "Bypasses subscription check for using on-device Methods templates and documents.", + "tier": 1, + "category": "essential" + }, + "createPagesRM2Size": { + "filename": "createPagesRM2Size.qmd", + "displayName": "Create Pages (RM2 Size)", + "description": "Forces new pages to use reMarkable 2 dimensions (1404×1872) for cross-device consistency.", + "tier": 1, + "category": "page-size", + "exclusiveGroup": "pageSize" + }, + "createPagesPaperProSize": { + "filename": "createPagesPaperProSize.qmd", + "displayName": "Create Pages (Paper Pro Size)", + "description": "Forces new pages to use Paper Pro dimensions (1620×2160) for cross-device consistency.", + "tier": 1, + "category": "page-size", + "exclusiveGroup": "pageSize" + }, + "preventNotebookZoomOut": { + "filename": "preventNotebookZoomOut.qmd", + "displayName": "Prevent Notebook Zoom Out", + "description": "Forces notebook pages to start at 1x zoom. Designed for Paper Pro Move.", + "tier": 1, + "category": "essential" + }, + "quicksheetUseTemplate": { + "filename": "quicksheetUseTemplate.qmd", + "displayName": "Quicksheet Use Template", + "description": "New quicksheet pages use the same template as the previous page in the notebook.", + "tier": 2, + "category": "recommended" + } + }, + "checksums": { + "3.22/createPagesPaperProSize.qmd": "5d36e170fb9e01d7d3aa4805f42c3fb62d8edb5a8e58e899c9060b86a1a85fb90653983ba511704dc6c626b7d63c105300a232a5112e6a72bdd21fbd4e75e953", + "3.22/createPagesRM2Size.qmd": "ef0ce85022fbe63958af209e7a8f27d37f57ef4e6e59b72915cd5d66ce2922003a3919f628fc9746ad65c64995fa9d8e74810386ec8575077825e3987b968574", + "3.22/preventNotebookZoomOut.qmd": "84622f7e1f25aba2f32b6dd6f95a731650ddc9fa981d06462fc7df2d7ae75dee94991995283f4476f4f1924385dfc6272bca6a9c5ff17f4e991bbf6b20e3dadf", + "3.22/unlockMethodsContent.qmd": "38e4f95fe12f46ff29a05da82bde12d0d856a4263c1e4f61d7a59416e1f2162d6d7d9374a32e5db041bf85590b640b8964e7df03ab64d8a9b28b57faea65c225", + "3.23/createPagesPaperProSize.qmd": "05b76484b9d2759dcc89bf264dcd76088520be282f716ff1648e44e55a33e7ae85b0e23fb372a5ff7267c32a9acbb318947bd148d8aac8426075fe64d1915bc8", + "3.23/createPagesRM2Size.qmd": "c6a44f7c727a96da197bc77f954c3b7338cc2962a78d3b28b6863b55f8e9aea3c4fd743934c4e9de13d02c48423f16c8088317cc6109d84cae504a1c1991f832", + "3.23/preventNotebookZoomOut.qmd": "72bce1d0b909ad7511d13a2bc38ed52b40a98e49bbe29b7cef43a72a554d9dd9a3f91ed643f221e534b47ed6d03aaad15c610998ec990ba99a2b7ee8de5f1a6c", + "3.23/unlockMethodsContent.qmd": "9f985a8dc3124d75e471a557bb8d45340422f0433904fe10fc0484e68749da4c26546e26470b7409fde13899f7c3d3437ce4369217532de3f1f2702b14939cd4", + "3.24/createPagesPaperProSize.qmd": "b0a19851ac8525e334be80258a520c07fe5aaa7e4e38bd2f85faf4dad1169dcc45d84ccc62ac44948e7bb1cf40a564f27ff118a2b51de85e0750480e7d0db599", + "3.24/createPagesRM2Size.qmd": "21cbbfa32dc11c31598b852c93c75f384b64009627a3ecaa04734c0f73ce2c5d7aa565e67e7da87288dcb49960abd860242d4715a98dd35668a51bff4ac40bd1", + "3.24/preventNotebookZoomOut.qmd": "a00501646d0c65b3bc6d37d8021252df523f5b61d48ed177fab91b8222c82ba213cd475dbc9bde262f1259369579a5a9e4f8b55adc2f8b701f5874d0e14b1327", + "3.24/unlockMethodsContent.qmd": "baf01c78f35a2684ddd749109543072c995f8e70066363ad90919774a8da7c8c949e369d74644ffb9d3144d19bfff855cc542943ba3867a1a233feda669e09fe", + "3.25/createPagesPaperProSize.qmd": "d964c00a32faec620bd389386d8469aa45d8020c426768cc926c6a796f2f21bbc590510ad56652e20d5f6fca461e999d8464fabfdb86dd14609c3f0171fb29c8", + "3.25/createPagesRM2Size.qmd": "c2fd6a9ff57dadb0c5a03917b350297fd22bc2d8c23e88e4d4491dcc7b67bb9f3d24a539794f4b00849214e440158b4f91b41041f218ffe581b8d72e1ed789f4", + "3.25/preventNotebookZoomOut.qmd": "74542be80f09042ae7e657f124fb04cca0ec1292b09fc4882a9729e6e658af67124eb80f08f7bec5285a57bdc8613471e1cbe5e7df09d1c33ee645c3140ea2df", + "3.25/quicksheetUseTemplate.qmd": "04b1974384b2e81c9258fa32eb280f7a9ea0339b1cb7f3b6dd856cc698f2b628c78c67a2a5c46573f9b83cbaa28f1db6bf312468a61ae41905c099f7012c1fcf", + "3.25/unlockMethodsContent.qmd": "d8a3350bfbddd2c17c417390117217f1ebfb13699483a6c9205d0fefc1fcfa2345aee0231c0a0bfec13d17ef07cf4869862e41d8e1ed08eacdc33d451450a7bc", + "3.26/createPagesPaperProSize.qmd": "23f0f72c264dff11d4c279bf7eed225232e2b4f8884e8333c8b0916f78cf623ba91ee6bfe5fa9bf606c5970d35183b539014d135c8d67cf3f7007af68dfbc8ff", + "3.26/createPagesRM2Size.qmd": "52cd62a7e8115f9cf1bf071e553e1788965932293a4c29a4c15ce32c617de28cd69c5acb8084d8f3079781f8c321f0059ac4f36c53928c6bf1965c1ab014c9d7", + "3.26/preventNotebookZoomOut.qmd": "0593bd35bdfd25ca638ca099f84ac06351d3b04163014a7bbd24c4f0a03796507e506cb837e3dc190686a1301dde27bb4934e49594ea69b91f54030a58ec079c", + "3.26/quicksheetUseTemplate.qmd": "7a1ca41028a15d438f02fe93bf5e6092baf7ce5b1adf8fd7f3bd213dbed6e651899058cdd9d3a8e576ca71cf5b3313a4be291f4f973f15295420886c69d3b015", + "3.26/unlockMethodsContent.qmd": "db4dadb1dc4a0f993c9f6831d93d2b115493b7b61dd74934c61b919175e3002d79cb6ea50cc0b7548afd44750d7ca682296dcebee3d5846d298f9dfe6b1cd8a9" + }, + "supportedVersions": [ + "3.22", + "3.23", + "3.24", + "3.25", + "3.26" + ] +} diff --git a/server/lib/xoviExtensions.ts b/server/lib/xoviExtensions.ts new file mode 100644 index 0000000..1ec891f --- /dev/null +++ b/server/lib/xoviExtensions.ts @@ -0,0 +1,233 @@ +/** + * xovi extension management — version mapping, extension definitions, + * device status checking, and validation. + * + * Pure logic (except checkXoviStatus which uses SSH/SFTP). + */ + +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Client } from 'ssh2' +import type { SFTPWrapper } from 'ssh2' +import { exec } from './ssh.ts' + +// ── paths ──────────────────────────────────────────────────────────────────── + +const __dirname = dirname(fileURLToPath(import.meta.url)) +export const XOVI_DATA_DIR = resolve(__dirname, '../data/xovi-extensions') + +/** Device-side paths */ +export const DEVICE_PATHS = { + xoviSo: '/home/root/xovi/xovi.so', + qtRebuilderSo: '/home/root/xovi/extensions.d/qt-resource-rebuilder.so', + qmdDir: '/home/root/xovi/exthome/qt-resource-rebuilder', + rebuildCmd: 'cd /home/root && echo | ./xovi/rebuild_hashtable', + restartCmd: 'systemctl restart xochitl', + vellumBin: '/home/root/.vellum/bin/vellum', +} as const + +// ── types ──────────────────────────────────────────────────────────────────── + +export interface XoviExtensionDef { + id: string + filename: string + displayName: string + description: string + tier: 1 | 2 + category: string + exclusiveGroup?: string +} + +export interface XoviExtensionStatus extends XoviExtensionDef { + installed: boolean + available: boolean +} + +export interface XoviDeviceStatus { + xoviInstalled: boolean + qtRebuilderInstalled: boolean + extensions: XoviExtensionStatus[] + firmwareVersion: string | null + qmdVersion: string | null + vellumInstalled: boolean + vellumVersion: string | null + vellumReenableNeeded: boolean +} + +interface Manifest { + extensions: Record> + checksums: Record + supportedVersions: string[] +} + +// ── manifest ───────────────────────────────────────────────────────────────── + +let _manifest: Manifest | null = null + +function loadManifest(): Manifest { + if (_manifest) return _manifest + const raw = readFileSync(resolve(XOVI_DATA_DIR, 'manifest.json'), 'utf-8') + _manifest = JSON.parse(raw) as Manifest + return _manifest +} + +/** For testing: clear the cached manifest so it reloads from disk. */ +export function _resetManifestCache(): void { + _manifest = null +} + +// ── public API ─────────────────────────────────────────────────────────────── + +/** + * Map a firmware version string (e.g. "3.26.1.2") to a QMD version directory + * (e.g. "3.26"). Returns null if no matching QMD version exists. + */ +export function mapFirmwareToQmdVersion(firmwareVersion: string): string | null { + const parts = firmwareVersion.split('.') + if (parts.length < 2) return null + const candidate = `${parts[0]}.${parts[1]}` + const manifest = loadManifest() + return manifest.supportedVersions.includes(candidate) ? candidate : null +} + +/** Return all extension definitions from the manifest. */ +export function getExtensionDefs(): XoviExtensionDef[] { + const manifest = loadManifest() + return Object.entries(manifest.extensions).map(([id, def]) => ({ id, ...def })) +} + +/** Return the set of supported firmware versions. */ +export function getSupportedVersions(): string[] { + return loadManifest().supportedVersions +} + +/** + * Resolve the local filesystem path for a QMD file. + * Throws if the extension or version is unknown, or the file doesn't exist. + */ +export function getQmdFilePath(extensionId: string, qmdVersion: string): string { + const manifest = loadManifest() + const def = manifest.extensions[extensionId] + if (!def) throw new Error(`Unknown extension: ${extensionId}`) + if (!manifest.supportedVersions.includes(qmdVersion)) { + throw new Error(`Unsupported QMD version: ${qmdVersion}`) + } + const filePath = resolve(XOVI_DATA_DIR, qmdVersion, def.filename) + if (!existsSync(filePath)) { + throw new Error(`QMD file not found: ${qmdVersion}/${def.filename}`) + } + return filePath +} + +/** + * Validate that no mutually-exclusive extensions are both selected. + * Returns an error message string if there's a conflict, or null if OK. + */ +export function validateExclusiveGroups(extensionIds: string[]): string | null { + const defs = getExtensionDefs() + const groups = new Map() + + for (const id of extensionIds) { + const def = defs.find(d => d.id === id) + if (!def) return `Unknown extension: ${id}` + if (def.exclusiveGroup) { + const existing = groups.get(def.exclusiveGroup) ?? [] + existing.push(id) + groups.set(def.exclusiveGroup, existing) + } + } + + for (const [group, ids] of groups) { + if (ids.length > 1) { + return `Extensions ${ids.join(' and ')} are mutually exclusive (${group} group)` + } + } + + return null +} + +/** + * Check xovi installation status on a connected device. + * Requires an active SSH client and SFTP session. + */ +export async function checkXoviStatus( + client: Client, + sftp: SFTPWrapper, + firmwareVersion: string | null, +): Promise { + // Check xovi core, qt-resource-rebuilder, and vellum in parallel + const [xoviResult, qtResult, vellumResult, vellumReenableResult] = await Promise.all([ + exec(client, `test -f ${DEVICE_PATHS.xoviSo} && echo ok || echo missing`), + exec(client, `test -f ${DEVICE_PATHS.qtRebuilderSo} && echo ok || echo missing`), + exec(client, `test -f ${DEVICE_PATHS.vellumBin} && ${DEVICE_PATHS.vellumBin} --version 2>/dev/null || echo missing`), + exec(client, `test -f ${DEVICE_PATHS.vellumBin} && ${DEVICE_PATHS.vellumBin} reenable status 2>/dev/null || echo missing`), + ]) + + const xoviInstalled = xoviResult.stdout.trim() === 'ok' + const qtRebuilderInstalled = qtResult.stdout.trim() === 'ok' + + const vellumOut = vellumResult.stdout.trim() + const vellumInstalled = vellumOut !== 'missing' && vellumOut !== '' + const vellumVersion = vellumInstalled ? vellumOut : null + + const reenableOut = vellumReenableResult.stdout.trim() + const vellumReenableNeeded = vellumInstalled && reenableOut === 'needed' + + // List installed QMD files + const installedFiles = new Set() + if (qtRebuilderInstalled) { + try { + const entries = await new Promise((resolve, reject) => { + sftp.readdir(DEVICE_PATHS.qmdDir, (err, list) => { + if (err) { + // Directory might not exist yet + if ((err as NodeJS.ErrnoException).code === '2' || err.message.includes('No such file')) { + resolve([]) + } else { + reject(err) + } + } else { + resolve(list.map(e => e.filename)) + } + }) + }) + for (const name of entries) { + if (name.endsWith('.qmd')) installedFiles.add(name) + } + } catch { + // If we can't read the directory, treat as empty + } + } + + const qmdVersion = firmwareVersion ? mapFirmwareToQmdVersion(firmwareVersion) : null + const defs = getExtensionDefs() + + const extensions: XoviExtensionStatus[] = defs.map(def => { + let available = false + if (qmdVersion) { + try { + getQmdFilePath(def.id, qmdVersion) + available = true + } catch { + available = false + } + } + return { + ...def, + installed: installedFiles.has(def.filename), + available, + } + }) + + return { + xoviInstalled, + qtRebuilderInstalled, + extensions, + firmwareVersion, + qmdVersion, + vellumInstalled, + vellumVersion, + vellumReenableNeeded, + } +} diff --git a/server/routes/device/config.ts b/server/routes/device/config.ts index d2ac8b5..0c327be 100644 --- a/server/routes/device/config.ts +++ b/server/routes/device/config.ts @@ -29,6 +29,21 @@ import { setActiveDevice, } from '../../lib/deviceStore.ts' +/** + * Map /sys/devices/soc0/machine codenames to normalized device model IDs. + * These IDs match the DEVICES record in src/lib/renderer.ts. + */ +const MACHINE_TO_MODEL: Record = { + 'reMarkable 1.0': 'rm', + 'reMarkable 2.0': 'rm', + 'reMarkable Merlot': 'rmPP', + 'reMarkable Chiappa': 'rmPPM', +} + +function normalizeDeviceModel(machine: string): string { + return MACHINE_TO_MODEL[machine] ?? machine +} + /** Redact password from a device config for API responses. */ function redact(device: DeviceConfig): Record { return { ...device, sshPassword: device.sshPassword ? '***' : undefined } @@ -165,7 +180,7 @@ export default function deviceConfigRoutes(app: FastifyInstance, config: ServerC ]) const now = new Date().toISOString() - const deviceModel = modelResult.stdout.trim() + const deviceModel = normalizeDeviceModel(modelResult.stdout.trim()) const firmwareVersion = fwResult.stdout.trim() || undefined // Update cached info on the saved device diff --git a/server/routes/device/xovi.ts b/server/routes/device/xovi.ts new file mode 100644 index 0000000..d4de4ff --- /dev/null +++ b/server/routes/device/xovi.ts @@ -0,0 +1,371 @@ +/** + * xovi extension management routes. + * + * POST /api/devices/:id/xovi-status — check xovi + extension status on device + * POST /api/devices/:id/xovi-deploy — deploy QMD extension files + * POST /api/devices/:id/xovi-remove — remove QMD extension files + * POST /api/devices/:id/vellum-install-xovi — install xovi via Vellum + * POST /api/devices/:id/vellum-remove-xovi — remove xovi via Vellum + */ + +import type { FastifyInstance } from 'fastify' +import type { ServerConfig } from '../../config.ts' +import { connect, exec } from '../../lib/ssh.ts' +import { getSftp, pushFile } from '../../lib/sftp.ts' +import { formatSshError } from '../../lib/sshErrors.ts' +import { createNdjsonStream } from '../../lib/ndjsonStream.ts' +import { readDevice } from '../../lib/deviceStore.ts' +import { + checkXoviStatus, + getExtensionDefs, + getQmdFilePath, + mapFirmwareToQmdVersion, + validateExclusiveGroups, + DEVICE_PATHS, +} from '../../lib/xoviExtensions.ts' + +export default function deviceXoviRoutes(app: FastifyInstance, _config: ServerConfig) { + // ── POST /api/devices/:id/xovi-status ────────────────────────────────────── + app.post<{ Params: { id: string } }>('/api/devices/:id/xovi-status', async (request, reply) => { + const { id } = request.params + const deviceConfig = readDevice(_config.deviceConfigPath, id) + if (!deviceConfig) { + return reply.status(400).send({ error: 'Device not configured' }) + } + + let client: Awaited> | null = null + try { + client = await connect(deviceConfig) + const sftp = await getSftp(client) + const status = await checkXoviStatus(client, sftp, deviceConfig.firmwareVersion ?? null) + return reply.send({ ok: true, ...status }) + } catch (err) { + const friendly = formatSshError(err as Error) + return reply.status(500).send({ error: friendly.message, hint: friendly.hint, rawError: friendly.rawError }) + } finally { + client?.end() + } + }) + + // ── POST /api/devices/:id/xovi-deploy ────────────────────────────────────── + app.post<{ Params: { id: string } }>('/api/devices/:id/xovi-deploy', async (request, reply) => { + const { id } = request.params + const deviceConfig = readDevice(_config.deviceConfigPath, id) + if (!deviceConfig) { + return reply.status(400).send({ error: 'Device not configured' }) + } + + const body = request.body as { extensionIds?: string[] } | undefined + const extensionIds = body?.extensionIds + if (!Array.isArray(extensionIds) || extensionIds.length === 0) { + return reply.status(400).send({ error: 'No extensions selected for deploy' }) + } + + // Validate extension IDs exist + const knownIds = new Set(getExtensionDefs().map(d => d.id)) + const unknownIds = extensionIds.filter(id => !knownIds.has(id)) + if (unknownIds.length > 0) { + return reply.status(400).send({ error: `Unknown extensions: ${unknownIds.join(', ')}` }) + } + + // Check exclusive groups + const exclusiveErr = validateExclusiveGroups(extensionIds) + if (exclusiveErr) { + return reply.status(400).send({ error: exclusiveErr }) + } + + // Map firmware version to QMD version + const fw = deviceConfig.firmwareVersion + if (!fw) { + return reply.status(400).send({ + error: 'Device firmware version unknown', + hint: 'Test the device connection first to detect firmware version.', + }) + } + const qmdVersion = mapFirmwareToQmdVersion(fw) + if (!qmdVersion) { + return reply.status(400).send({ + error: `No extensions available for firmware ${fw}`, + hint: 'Extensions may not yet support this firmware version.', + }) + } + + // Resolve local QMD file paths (validates availability) + const filePaths: { extensionId: string; localPath: string; filename: string }[] = [] + for (const extId of extensionIds) { + try { + const localPath = getQmdFilePath(extId, qmdVersion) + const def = getExtensionDefs().find(d => d.id === extId)! + filePaths.push({ extensionId: extId, localPath, filename: def.filename }) + } catch (err) { + return reply.status(400).send({ + error: `Extension ${extId} not available for firmware ${fw}`, + hint: (err as Error).message, + }) + } + } + + const stream = createNdjsonStream(reply) + let client: Awaited> | null = null + + try { + const steps: string[] = [] + + // Connect + stream.progress('Connecting to device...') + client = await connect(deviceConfig) + const sftp = await getSftp(client) + steps.push('Connected to device') + + // Check xovi prerequisites + stream.progress('Checking xovi prerequisites...') + const xoviCheck = await exec(client, `test -f ${DEVICE_PATHS.xoviSo} && echo ok || echo missing`) + if (xoviCheck.stdout.trim() !== 'ok') { + stream.error( + 'xovi is not installed on this device', + 'Install xovi via Vellum before deploying extensions: https://github.com/vellum-dev/vellum', + ) + return + } + + const qtCheck = await exec(client, `test -f ${DEVICE_PATHS.qtRebuilderSo} && echo ok || echo missing`) + if (qtCheck.stdout.trim() !== 'ok') { + stream.error( + 'qt-resource-rebuilder is not installed on this device', + 'Install via Vellum: the qt-resource-rebuilder package is a subpackage of xovi-extensions.', + ) + return + } + steps.push('xovi prerequisites verified') + + // Ensure QMD directory exists + await exec(client, `mkdir -p ${DEVICE_PATHS.qmdDir}`) + + // Deploy QMD files + for (let i = 0; i < filePaths.length; i++) { + const { extensionId, localPath, filename } = filePaths[i] + stream.progress(`Deploying ${extensionId}...`, i + 1, filePaths.length) + const remotePath = `${DEVICE_PATHS.qmdDir}/${filename}` + await pushFile(sftp, localPath, remotePath) + steps.push(`Deployed ${filename}`) + } + + // Rebuild hashtable + stream.progress('Rebuilding hashtable (this may take a minute — the device UI may restart during this process)...') + const rebuildResult = await exec(client, DEVICE_PATHS.rebuildCmd) + if (rebuildResult.code !== 0) { + stream.error( + 'rebuild_hashtable failed', + `Exit code ${rebuildResult.code}. Try running it manually: ${DEVICE_PATHS.rebuildCmd}`, + rebuildResult.stderr, + ) + return + } + steps.push('Rebuilt hashtable') + + // Restart xochitl + stream.progress('Restarting device UI (final restart)...') + await exec(client, DEVICE_PATHS.restartCmd) + steps.push('Restarted xochitl') + + stream.done({ steps, extensions: extensionIds, qmdVersion, log: rebuildResult.stdout }) + } catch (err) { + const friendly = formatSshError(err as Error) + stream.error(friendly.message, friendly.hint, friendly.rawError) + } finally { + client?.end() + } + }) + + // ── POST /api/devices/:id/xovi-remove ────────────────────────────────────── + app.post<{ Params: { id: string } }>('/api/devices/:id/xovi-remove', async (request, reply) => { + const { id } = request.params + const deviceConfig = readDevice(_config.deviceConfigPath, id) + if (!deviceConfig) { + return reply.status(400).send({ error: 'Device not configured' }) + } + + const body = request.body as { extensionIds?: string[] } | undefined + const extensionIds = body?.extensionIds + if (!Array.isArray(extensionIds) || extensionIds.length === 0) { + return reply.status(400).send({ error: 'No extensions selected for removal' }) + } + + // Validate extension IDs + const defs = getExtensionDefs() + const knownIds = new Set(defs.map(d => d.id)) + const unknownIds = extensionIds.filter(id => !knownIds.has(id)) + if (unknownIds.length > 0) { + return reply.status(400).send({ error: `Unknown extensions: ${unknownIds.join(', ')}` }) + } + + const stream = createNdjsonStream(reply) + let client: Awaited> | null = null + + try { + const steps: string[] = [] + let log = '' + + stream.progress('Connecting to device...') + client = await connect(deviceConfig) + steps.push('Connected to device') + + // Remove QMD files + for (let i = 0; i < extensionIds.length; i++) { + const extId = extensionIds[i] + const def = defs.find(d => d.id === extId)! + const remotePath = `${DEVICE_PATHS.qmdDir}/${def.filename}` + stream.progress(`Removing ${extId}...`, i + 1, extensionIds.length) + // rm -f: don't fail if file doesn't exist + await exec(client, `rm -f ${remotePath}`) + steps.push(`Removed ${def.filename}`) + } + + // Rebuild hashtable (if xovi is present) + stream.progress('Rebuilding hashtable (this may take a minute — the device UI may restart during this process)...') + const hasRebuilder = await exec(client, `test -x /home/root/xovi/rebuild_hashtable && echo ok || echo missing`) + if (hasRebuilder.stdout.trim() === 'ok') { + const rebuildResult = await exec(client, DEVICE_PATHS.rebuildCmd) + if (rebuildResult.code !== 0) { + stream.error( + 'rebuild_hashtable failed after removal', + `Exit code ${rebuildResult.code}`, + rebuildResult.stderr, + ) + return + } + steps.push('Rebuilt hashtable') + log = rebuildResult.stdout + } + + // Restart xochitl + stream.progress('Restarting device UI...') + await exec(client, DEVICE_PATHS.restartCmd) + steps.push('Restarted xochitl') + + stream.done({ steps, removed: extensionIds, log }) + } catch (err) { + const friendly = formatSshError(err as Error) + stream.error(friendly.message, friendly.hint, friendly.rawError) + } finally { + client?.end() + } + }) + + // ── POST /api/devices/:id/vellum-install-xovi ─────────────────────────────── + app.post<{ Params: { id: string } }>('/api/devices/:id/vellum-install-xovi', async (request, reply) => { + const { id } = request.params + const deviceConfig = readDevice(_config.deviceConfigPath, id) + if (!deviceConfig) { + return reply.status(400).send({ error: 'Device not configured' }) + } + + const stream = createNdjsonStream(reply) + let client: Awaited> | null = null + + try { + const steps: string[] = [] + + stream.progress('Connecting to device...') + client = await connect(deviceConfig) + steps.push('Connected to device') + + // Check vellum is installed + stream.progress('Checking Vellum...') + const vellumCheck = await exec(client, `test -f ${DEVICE_PATHS.vellumBin} && echo ok || echo missing`) + if (vellumCheck.stdout.trim() !== 'ok') { + stream.error( + 'Vellum is not installed on this device', + 'Install Vellum first: https://remarkable.guide/guide/software/vellum.html', + ) + return + } + + // Check reenable status — vellum add will fail if reenable is needed + const reenableResult = await exec(client, `${DEVICE_PATHS.vellumBin} reenable status 2>/dev/null`) + if (reenableResult.stdout.trim() === 'needed') { + stream.error( + 'Vellum needs to be re-enabled after a firmware update', + 'SSH into your device and run: vellum reenable', + ) + return + } + steps.push('Vellum ready') + + // Install all three packages explicitly for robustness + stream.progress('Installing xovi via Vellum (downloading packages)...') + const installResult = await exec(client, `${DEVICE_PATHS.vellumBin} add xovi xovi-extensions qt-resource-rebuilder 2>&1`) + if (installResult.code !== 0) { + stream.error( + 'Failed to install xovi via Vellum', + 'Check that the device has internet access and try again.', + installResult.stdout + installResult.stderr, + ) + return + } + steps.push('Installed xovi') + + stream.done({ steps, message: 'xovi installed successfully. Check status to verify.', stdout: installResult.stdout }) + } catch (err) { + const friendly = formatSshError(err as Error) + stream.error(friendly.message, friendly.hint, friendly.rawError) + } finally { + client?.end() + } + }) + + // ── POST /api/devices/:id/vellum-remove-xovi ──────────────────────────────── + app.post<{ Params: { id: string } }>('/api/devices/:id/vellum-remove-xovi', async (request, reply) => { + const { id } = request.params + const deviceConfig = readDevice(_config.deviceConfigPath, id) + if (!deviceConfig) { + return reply.status(400).send({ error: 'Device not configured' }) + } + + const stream = createNdjsonStream(reply) + let client: Awaited> | null = null + + try { + const steps: string[] = [] + + stream.progress('Connecting to device...') + client = await connect(deviceConfig) + steps.push('Connected to device') + + // Check vellum is installed + const vellumCheck = await exec(client, `test -f ${DEVICE_PATHS.vellumBin} && echo ok || echo missing`) + if (vellumCheck.stdout.trim() !== 'ok') { + stream.error( + 'Vellum is not installed on this device', + 'Cannot remove xovi without Vellum. Remove manually via SSH if needed.', + ) + return + } + + // Remove all three packages explicitly — vellum del doesn't cascade to dependencies + stream.progress('Removing xovi via Vellum...') + const removeResult = await exec(client, `${DEVICE_PATHS.vellumBin} del qt-resource-rebuilder xovi-extensions xovi 2>&1`) + if (removeResult.code !== 0) { + stream.error( + 'Failed to remove xovi via Vellum', + undefined, + removeResult.stdout + removeResult.stderr, + ) + return + } + steps.push('Removed xovi') + + // Restart xochitl to apply changes + stream.progress('Restarting device UI...') + await exec(client, DEVICE_PATHS.restartCmd) + steps.push('Restarted xochitl') + + stream.done({ steps, message: 'xovi removed successfully.' }) + } catch (err) { + const friendly = formatSshError(err as Error) + stream.error(friendly.message, friendly.hint, friendly.rawError) + } finally { + client?.end() + } + }) +} diff --git a/src/App.tsx b/src/App.tsx index 8f6c6e9..8d922b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,14 +7,14 @@ import { TemplatesPage } from './pages/TemplatesPage' import { DevicePage } from './pages/DevicePage' import { useRegistry, RegistryContext } from './hooks/useRegistry' import { ThemeContext, useThemeProvider } from './hooks/useTheme' -import { BusyContext } from './hooks/useBusy' +import { BusyContext, useBusyProvider } from './hooks/useBusy' import type { DeviceId } from './lib/renderer' export default function App() { const registryState = useRegistry() const themeState = useThemeProvider() const [deviceId, setDeviceId] = useState('rm') - const [isBusy, setBusy] = useState(false) + const { isBusy, setBusy } = useBusyProvider() return ( diff --git a/src/components/device/DeviceOpComponents.tsx b/src/components/device/DeviceOpComponents.tsx new file mode 100644 index 0000000..8943ad9 --- /dev/null +++ b/src/components/device/DeviceOpComponents.tsx @@ -0,0 +1,79 @@ +/** + * Shared React components for device operation UI (ProgressBar, OpButton). + * Separated from deviceOpHelpers.ts to satisfy react-refresh/only-export-components. + */ + +import { ErrorDetails } from './ErrorDetails' +import type { useDeviceOp, ProgressState } from './deviceOpHelpers' + +export function ProgressBar({ progress, label }: { progress: ProgressState | null; label?: string }) { + const phase = progress?.phase ?? label + const pct = progress?.current != null && progress?.total + ? Math.round((progress.current / progress.total) * 100) + : null + + return ( +
+
+ {phase} + {pct != null && ` ${progress!.current}/${progress!.total}`} +
+
+
+
+ {progress && ( +

+ Tip: Swipe or tap on your reMarkable screen to keep it awake — transfers go faster when the device isn't dozing. +

+ )} +
+ ) +} + +export function OpButton({ + label, + loadingLabel, + op, + variant = 'primary', + disabled = false, + title, + deviceModel, + firmwareVersion, +}: { + label: string + loadingLabel: string + op: ReturnType + variant?: 'primary' | 'secondary' | 'danger' + disabled?: boolean + title?: string + deviceModel?: string + firmwareVersion?: string +}) { + const cls = + variant === 'danger' + ? 'device-card-btn device-card-btn-danger' + : variant === 'secondary' + ? 'device-card-btn device-card-btn-secondary' + : 'device-card-btn' + return ( +
+ + {op.loading && ( + + )} + {op.result && !op.result.ok && ( + + )} + {op.result?.ok && ( +
+

{op.result.message}

+
+ )} +
+ ) +} diff --git a/src/components/device/DeviceSyncCard.tsx b/src/components/device/DeviceSyncCard.tsx index 856100a..71bf5cb 100644 --- a/src/components/device/DeviceSyncCard.tsx +++ b/src/components/device/DeviceSyncCard.tsx @@ -1,6 +1,9 @@ import { useState, useCallback, useEffect, useRef } from 'react' import { ErrorDetails } from './ErrorDetails' import { useBusy } from '../../hooks/useBusy' +import { readNdjsonStream, useDeviceOp } from './deviceOpHelpers' +import type { ProgressState } from './deviceOpHelpers' +import { ProgressBar, OpButton } from './DeviceOpComponents' interface Props { deviceId: string | null @@ -11,14 +14,6 @@ interface Props { onSyncComplete?: () => void } -type OpResult = { ok: true; message: string; steps?: string[] } | { ok: false; error: string; hint?: string; rawError?: string } - -interface ProgressState { - phase: string - current?: number - total?: number -} - // --------------------------------------------------------------------------- // Sync status types & hook // --------------------------------------------------------------------------- @@ -107,180 +102,6 @@ type RemoveAllPhase = 'idle' | 'loading-preview' | 'preview' | 'executing' | 'do interface RemoveAllPreview { count: number; templates: { uuid: string; name: string }[]; error?: string } interface RemoveAllResult { ok: boolean; steps?: string[]; backupFilename?: string; error?: string; hint?: string } -async function readNdjsonStream( - response: Response, - onProgress: (p: ProgressState) => void, -): Promise> { - const reader = response.body!.getReader() - const decoder = new TextDecoder() - let buffer = '' - let finalData: Record = {} - - for (;;) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - - const lines = buffer.split('\n') - buffer = lines.pop()! // keep incomplete line in buffer - - for (const line of lines) { - if (!line.trim()) continue - const event = JSON.parse(line) as Record - if (event.type === 'progress') { - onProgress({ - phase: event.phase as string, - current: event.current as number | undefined, - total: event.total as number | undefined, - }) - } else if (event.type === 'done') { - finalData = event - } else if (event.type === 'error') { - throw { error: event.error as string, hint: event.hint as string | undefined, rawError: event.rawError as string | undefined } - } - } - } - - return finalData -} - -function useDeviceOp(url: string, options?: { confirmMsg?: string; onSuccess?: () => void; bodyFn?: () => Record | undefined }) { - const [loading, setLoading] = useState(false) - const [result, setResult] = useState(null) - const [progress, setProgress] = useState(null) - - async function run() { - if (options?.confirmMsg && !window.confirm(options.confirmMsg)) return - setLoading(true) - setResult(null) - setProgress(null) - try { - const body = options?.bodyFn?.() - const fetchOptions: RequestInit = { method: 'POST' } - if (body) { - fetchOptions.headers = { 'Content-Type': 'application/json' } - fetchOptions.body = JSON.stringify(body) - } - const res = await fetch(url, fetchOptions) - const contentType = res.headers.get('content-type') ?? '' - - let data: Record - if (contentType.includes('application/x-ndjson')) { - data = await readNdjsonStream(res, setProgress) - } else { - data = (await res.json()) as Record - if (!res.ok) { - const hint = data.hint as string | undefined - const rawError = data.rawError as string | undefined - const error = (data.error as string) ?? `HTTP ${res.status}` - console.error('[device-op]', url, rawError ?? error) - setResult({ ok: false, error, hint, rawError }) - return - } - } - - const steps = data.steps as string[] | undefined - const count = data.count as number | undefined - const message = data.message as string | undefined - const restoredFrom = data.restoredFrom as string | undefined - const msg = - message ?? - ((steps ? steps.join(' \u2192 ') : '') || - (count !== undefined ? `Pulled ${count} templates` : '') || - (restoredFrom ? `Restored from ${restoredFrom}` : 'Done')) - setResult({ ok: true, message: msg, steps }) - options?.onSuccess?.() - } catch (e) { - if (e && typeof e === 'object' && 'error' in e) { - const streamErr = e as { error: string; hint?: string; rawError?: string } - console.error('[device-op]', url, streamErr.rawError ?? streamErr.error) - setResult({ ok: false, error: streamErr.error, hint: streamErr.hint, rawError: streamErr.rawError }) - } else { - const msg = e instanceof Error ? e.message : String(e) - console.error('[device-op]', url, msg) - setResult({ ok: false, error: msg, rawError: msg }) - } - } finally { - setLoading(false) - setProgress(null) - } - } - - return { loading, result, progress, run } -} - -function ProgressBar({ progress, label }: { progress: ProgressState | null; label?: string }) { - const phase = progress?.phase ?? label - const pct = progress?.current != null && progress?.total - ? Math.round((progress.current / progress.total) * 100) - : null - - return ( -
-
- {phase} - {pct != null && ` ${progress!.current}/${progress!.total}`} -
-
-
-
- {progress && ( -

- Tip: Swipe or tap on your reMarkable screen to keep it awake — transfers go faster when the device isn't dozing. -

- )} -
- ) -} - -function OpButton({ - label, - loadingLabel, - op, - variant = 'primary', - disabled = false, - title, - deviceModel, - firmwareVersion, -}: { - label: string - loadingLabel: string - op: ReturnType - variant?: 'primary' | 'secondary' | 'danger' - disabled?: boolean - title?: string - deviceModel?: string - firmwareVersion?: string -}) { - const cls = - variant === 'danger' - ? 'device-card-btn device-card-btn-danger' - : variant === 'secondary' - ? 'device-card-btn device-card-btn-secondary' - : 'device-card-btn' - return ( -
- - {op.loading && ( - - )} - {op.result && !op.result.ok && ( - - )} - {op.result?.ok && ( -
-

{op.result.message}

-
- )} -
- ) -} - function useRemoveAll(deviceId: string | null) { const [phase, setPhase] = useState('idle') const [preview, setPreview] = useState(null) @@ -721,7 +542,8 @@ export function DeviceSyncCard({ deviceId, deviceName, configured, deviceModel, // Block page navigation while an operation is in flight const { setBusy } = useBusy() useEffect(() => { - setBusy(anyOpRunning) + if (!anyOpRunning) return + setBusy(true) return () => setBusy(false) }, [anyOpRunning, setBusy]) diff --git a/src/components/device/DeviceXoviCard.tsx b/src/components/device/DeviceXoviCard.tsx new file mode 100644 index 0000000..516d391 --- /dev/null +++ b/src/components/device/DeviceXoviCard.tsx @@ -0,0 +1,680 @@ +/** + * DeviceXoviCard — deploy/manage curated xovi QMD extensions on the device. + * + * Requires xovi + qt-resource-rebuilder to be installed on the device (via Vellum). + * This component only manages QMD extension files. + */ + +import { useState, useCallback, useEffect, useMemo } from 'react' +import { useBusy } from '../../hooks/useBusy' +import { ErrorDetails } from './ErrorDetails' +import { readNdjsonStream } from './deviceOpHelpers' +import type { OpResult, ProgressState } from './deviceOpHelpers' +import { ProgressBar } from './DeviceOpComponents' + +function downloadLog(log: string, filename: string) { + const blob = new Blob([log], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +interface Props { + deviceId: string | null + deviceName: string + configured: boolean + deviceModel?: string + firmwareVersion?: string +} + +// ── Types mirroring server/lib/xoviExtensions.ts ───────────────────────────── + +interface XoviExtensionStatus { + id: string + displayName: string + description: string + tier: 1 | 2 + installed: boolean + available: boolean + exclusiveGroup?: string + filename: string +} + +interface XoviDeviceStatus { + xoviInstalled: boolean + qtRebuilderInstalled: boolean + extensions: XoviExtensionStatus[] + firmwareVersion: string | null + qmdVersion: string | null + vellumInstalled: boolean + vellumVersion: string | null + vellumReenableNeeded: boolean +} + +// ── Hooks ──────────────────────────────────────────────────────────────────── + +function useXoviStatus(deviceId: string | null) { + const [loading, setLoading] = useState(false) + const [status, setStatus] = useState(null) + const [error, setError] = useState<{ message: string; hint?: string; rawError?: string } | null>(null) + + const check = useCallback(async () => { + if (!deviceId) return + setLoading(true) + setError(null) + try { + const res = await fetch(`/api/devices/${deviceId}/xovi-status`, { method: 'POST' }) + const data = await res.json() as Record + if (!res.ok) { + setError({ + message: (data.error as string) ?? `HTTP ${res.status}`, + hint: data.hint as string | undefined, + rawError: data.rawError as string | undefined, + }) + setStatus(null) + } else { + setStatus(data as unknown as XoviDeviceStatus) + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + setError({ message: msg, rawError: msg }) + setStatus(null) + } finally { + setLoading(false) + } + }, [deviceId]) + + const clear = useCallback(() => { setStatus(null); setError(null) }, []) + useEffect(() => { clear() }, [deviceId, clear]) + + return { loading, status, error, check, clear } +} + +function useXoviOp(deviceId: string | null) { + const [loading, setLoading] = useState(false) + const [result, setResult] = useState(null) + const [progress, setProgress] = useState(null) + + async function run(endpoint: 'xovi-deploy' | 'xovi-remove', extensionIds: string[]) { + if (!deviceId) return + setLoading(true) + setResult(null) + setProgress(null) + try { + const res = await fetch(`/api/devices/${deviceId}/${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ extensionIds }), + }) + const contentType = res.headers.get('content-type') ?? '' + let data: Record + if (contentType.includes('application/x-ndjson')) { + data = await readNdjsonStream(res, setProgress) + } else { + data = (await res.json()) as Record + if (!res.ok) { + setResult({ ok: false, error: (data.error as string) ?? `HTTP ${res.status}`, hint: data.hint as string | undefined, rawError: data.rawError as string | undefined }) + return + } + } + const steps = data.steps as string[] | undefined + const log = data.log as string | undefined + setResult({ ok: true, message: steps?.join(' \u2192 ') ?? 'Done', steps, log }) + } catch (e) { + if (e && typeof e === 'object' && 'error' in e) { + const streamErr = e as { error: string; hint?: string; rawError?: string } + setResult({ ok: false, error: streamErr.error, hint: streamErr.hint, rawError: streamErr.rawError }) + } else { + const msg = e instanceof Error ? e.message : String(e) + setResult({ ok: false, error: msg, rawError: msg }) + } + } finally { + setLoading(false) + setProgress(null) + } + } + + return { loading, result, progress, run, clearResult: () => setResult(null) } +} + +function useVellumOp(deviceId: string | null) { + const [loading, setLoading] = useState(false) + const [result, setResult] = useState(null) + const [progress, setProgress] = useState(null) + + async function run(endpoint: 'vellum-install-xovi' | 'vellum-remove-xovi') { + if (!deviceId) return + setLoading(true) + setResult(null) + setProgress(null) + try { + const res = await fetch(`/api/devices/${deviceId}/${endpoint}`, { method: 'POST' }) + const contentType = res.headers.get('content-type') ?? '' + let data: Record + if (contentType.includes('application/x-ndjson')) { + data = await readNdjsonStream(res, setProgress) + } else { + data = (await res.json()) as Record + if (!res.ok) { + setResult({ ok: false, error: (data.error as string) ?? `HTTP ${res.status}`, hint: data.hint as string | undefined, rawError: data.rawError as string | undefined }) + return + } + } + const message = (data.message as string) ?? (data.steps as string[] | undefined)?.join(' \u2192 ') ?? 'Done' + setResult({ ok: true, message }) + } catch (e) { + if (e && typeof e === 'object' && 'error' in e) { + const streamErr = e as { error: string; hint?: string; rawError?: string } + setResult({ ok: false, error: streamErr.error, hint: streamErr.hint, rawError: streamErr.rawError }) + } else { + const msg = e instanceof Error ? e.message : String(e) + setResult({ ok: false, error: msg, rawError: msg }) + } + } finally { + setLoading(false) + setProgress(null) + } + } + + return { loading, result, progress, run, clearResult: () => setResult(null) } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + + +const DISCLAIMER_KEY = 'xoviDisclaimerAccepted' + +// ── Component ──────────────────────────────────────────────────────────────── + +export function DeviceXoviCard({ deviceId, deviceName, configured, deviceModel, firmwareVersion }: Props) { + const xoviStatus = useXoviStatus(deviceId) + const xoviOp = useXoviOp(deviceId) + const vellumOp = useVellumOp(deviceId) + const [helpOpen, setHelpOpen] = useState(false) + + // Compute default selections from status + const defaultExtensions = useMemo(() => { + if (!xoviStatus.status) return new Set() + const ids = new Set() + for (const ext of xoviStatus.status.extensions) { + if (!ext.exclusiveGroup && (ext.installed || ext.available)) { + ids.add(ext.id) + } + } + return ids + }, [xoviStatus.status]) + + const defaultPageSize = useMemo(() => { + if (!xoviStatus.status) return null + // Only pre-select if one is already deployed; default to None otherwise + const installedPS = xoviStatus.status.extensions.find(e => e.exclusiveGroup === 'pageSize' && e.installed) + return installedPS?.id ?? null + }, [xoviStatus.status]) + + // Extension selection state — reset to defaults when status changes + const [selectedExtensions, setSelectedExtensions] = useState>(new Set()) + const [selectedPageSize, setSelectedPageSize] = useState(null) + + useEffect(() => { + setSelectedExtensions(defaultExtensions) + }, [defaultExtensions]) + + useEffect(() => { + setSelectedPageSize(defaultPageSize) + }, [defaultPageSize]) + + function toggleExtension(id: string) { + setSelectedExtensions(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + function getDeployIds(): string[] { + const ids = [...selectedExtensions] + if (selectedPageSize) ids.push(selectedPageSize) + return ids + } + + async function handleDeploy() { + const ids = getDeployIds() + if (ids.length === 0) return + + // Disclaimer check + if (!localStorage.getItem(DISCLAIMER_KEY)) { + const accepted = window.confirm( + 'xovi extensions modify your device\'s UI behavior at runtime. ' + + 'These are community-maintained modifications, not endorsed by reMarkable. ' + + 'This may void your warranty. All changes are reversible by removing the extensions.\n\n' + + 'Extensions may need to be re-deployed after firmware updates.\n\n' + + 'Do you understand and wish to proceed?', + ) + if (!accepted) return + localStorage.setItem(DISCLAIMER_KEY, 'true') + } + + xoviOp.clearResult() + await xoviOp.run('xovi-deploy', ids) + xoviStatus.check() // refresh status after deploy + } + + async function handleRemoveAll() { + if (!xoviStatus.status) return + const installedIds = xoviStatus.status.extensions + .filter(e => e.installed) + .map(e => e.id) + if (installedIds.length === 0) return + + if (!window.confirm(`Remove all ${installedIds.length} extension(s) from ${deviceName}?`)) return + + xoviOp.clearResult() + await xoviOp.run('xovi-remove', installedIds) + xoviStatus.check() + } + + async function handleVellumInstall() { + vellumOp.clearResult() + await vellumOp.run('vellum-install-xovi') + xoviStatus.check() // refresh to show xovi as installed + } + + async function handleVellumRemove() { + if (!window.confirm( + `This will uninstall xovi, xovi-extensions (qt-resource-rebuilder), and all deployed QMD extensions from ${deviceName}. Continue?`, + )) return + vellumOp.clearResult() + await vellumOp.run('vellum-remove-xovi') + xoviStatus.check() + } + + const status = xoviStatus.status + const xoviReady = status?.xoviInstalled && status?.qtRebuilderInstalled + const hasAvailable = status?.extensions.some(e => e.available) ?? false + const hasInstalled = status?.extensions.some(e => e.installed) ?? false + const anyLoading = xoviStatus.loading || xoviOp.loading || vellumOp.loading + + // Block page navigation while an operation is running + const { setBusy } = useBusy() + useEffect(() => { + if (!anyLoading) return + setBusy(true) + return () => setBusy(false) + }, [anyLoading, setBusy]) + + return ( +
+

xovi Extensions

+ +
+ {/* Collapsible help */} + + + {helpOpen && ( +
+

+ xovi is a community framework that lets you tweak the reMarkable UI + without permanently modifying system files. Extensions are small patch files (.qmd) + that modify UI behavior at startup. +

+

+ This app deploys curated extensions to enhance your template experience: + unlocking Methods templates without a subscription, normalizing page dimensions + across devices, and improving quicksheet behavior. +

+

+ If you have the{' '} + + Vellum package manager + + {' '}on your device, you can install xovi directly from this app. Otherwise, + install Vellum first, then use the Install button or + run vellum add qt-resource-rebuilder via SSH. +

+
+ )} + + {/* Warning banner */} +

+ Extensions modify device UI behavior. Requires xovi on your device — + install it below via Vellum, or{' '} + + see the Vellum guide + . +

+ + {!configured && ( +

Connect a device to manage extensions.

+ )} + + {configured && ( + <> + {/* Status check */} +
+ + + {xoviStatus.error && ( + + )} +
+ + {status && ( + <> + {/* Infrastructure status */} +
+
+ + {status.xoviInstalled ? '\u2713' : '\u2717'} + + xovi core +
+
+ + {status.qtRebuilderInstalled ? '\u2713' : '\u2717'} + + qt-resource-rebuilder +
+
+ + {status.vellumInstalled ? '\u2713' : '\u2717'} + + Vellum{status.vellumVersion ? ` ${status.vellumVersion}` : ''} +
+ {status.firmwareVersion && ( +
+ + + Firmware {status.firmwareVersion} + {status.qmdVersion ? ` \u2192 extensions v${status.qmdVersion}` : ' (unsupported)'} + +
+ )} +
+ + {/* Vellum reenable warning */} + {status.vellumReenableNeeded && ( +
+

Firmware update detected. Vellum needs to be re-enabled before packages can be installed or updated.

+

SSH into your device and run:

+
vellum reenable
+

Then check status again.

+
+ )} + + {/* xovi not installed — interactive install or static guidance */} + {!xoviReady && !status.vellumReenableNeeded && ( +
+

+ xovi is not fully installed on {deviceName}. +

+ {status.vellumInstalled ? ( + <> +

+ Install xovi and qt-resource-rebuilder via Vellum. + Requires internet access on the device. +

+ + {vellumOp.loading && ( + + )} + {vellumOp.result && !vellumOp.result.ok && ( + + )} + {vellumOp.result?.ok && ( +
+

{vellumOp.result.message}

+
+ )} + + ) : ( + <> +

+ First,{' '} + + install the Vellum package manager + + {' '}on your device. Then SSH in and run: +

+
vellum add qt-resource-rebuilder
+

+ This installs xovi, xovi-extensions, and qt-resource-rebuilder. Restart your + device, then check status again. +

+ + )} +
+ )} + + {/* Extensions list */} + {xoviReady && !hasAvailable && status.qmdVersion === null && ( +

+ No extensions available for firmware {status.firmwareVersion}. Extensions may not yet support this version. +

+ )} + + {xoviReady && hasAvailable && ( +
+ {/* Tier 1: Essential */} +

Essential

+ {status.extensions + .filter(e => e.tier === 1 && !e.exclusiveGroup && e.available) + .map(ext => ( + + ))} + + {/* Page size radio group */} + {status.extensions.some(e => e.exclusiveGroup === 'pageSize' && e.available) && ( +
+
Page Size Normalization
+ + Only needed if you sync between different device types. + Forces new pages to match a different device's dimensions so + they render correctly on that device. Pick the size of the + device you sync to: RM2 Size for + a reMarkable 1/2, or Paper Pro Size for a Paper Pro. + Choose None if you only use one device or want + pages optimized for this device. + + {status.extensions + .filter(e => e.exclusiveGroup === 'pageSize' && e.available) + .map(ext => ( + + ))} + +
+ )} + + {/* Tier 2: Recommended */} + {status.extensions.some(e => e.tier === 2 && e.available) && ( + <> +

Recommended

+ {status.extensions + .filter(e => e.tier === 2 && e.available) + .map(ext => ( + + ))} + + )} + + {/* Actions */} +
+ + {hasInstalled && ( + + )} + {status.vellumInstalled && ( + + )} +
+ + {/* Vellum operation progress/result */} + {vellumOp.loading && ( + + )} + {vellumOp.result && !vellumOp.result.ok && ( + + )} + {vellumOp.result?.ok && ( +
+

{vellumOp.result.message}

+
+ )} + + {/* Progress */} + {xoviOp.loading && ( + + )} + + {/* Result */} + {xoviOp.result && !xoviOp.result.ok && ( + + )} + {xoviOp.result?.ok && ( +
+

+ {xoviOp.result.steps?.join(' \u2192 ') ?? 'Done'} + {xoviOp.result.log && ( + <> + {' \u2014 '} + + + )} +

+
+ )} +
+ )} + + {/* Firmware update warning */} +

+ Extensions may need to be re-deployed after a firmware update. Modifying device + behavior may void warranty. +

+ + )} + + )} +
+
+ ) +} diff --git a/src/components/device/deviceOpHelpers.ts b/src/components/device/deviceOpHelpers.ts new file mode 100644 index 0000000..138747f --- /dev/null +++ b/src/components/device/deviceOpHelpers.ts @@ -0,0 +1,124 @@ +/** + * Shared non-component utilities for device operation components. + * Types, NDJSON stream reader, and hooks. + */ + +import { useState } from 'react' + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type OpResult = + | { ok: true; message: string; steps?: string[]; log?: string } + | { ok: false; error: string; hint?: string; rawError?: string } + +export interface ProgressState { + phase: string + current?: number + total?: number +} + +// ── NDJSON stream reader ───────────────────────────────────────────────────── + +export async function readNdjsonStream( + response: Response, + onProgress: (p: ProgressState) => void, +): Promise> { + const reader = response.body!.getReader() + const decoder = new TextDecoder() + let buffer = '' + let finalData: Record = {} + + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + const lines = buffer.split('\n') + buffer = lines.pop()! // keep incomplete line in buffer + + for (const line of lines) { + if (!line.trim()) continue + const event = JSON.parse(line) as Record + if (event.type === 'progress') { + onProgress({ + phase: event.phase as string, + current: event.current as number | undefined, + total: event.total as number | undefined, + }) + } else if (event.type === 'done') { + finalData = event + } else if (event.type === 'error') { + throw { error: event.error as string, hint: event.hint as string | undefined, rawError: event.rawError as string | undefined } + } + } + } + + return finalData +} + +// ── useDeviceOp hook ───────────────────────────────────────────────────────── + +export function useDeviceOp(url: string, options?: { confirmMsg?: string; onSuccess?: () => void; bodyFn?: () => Record | undefined }) { + const [loading, setLoading] = useState(false) + const [result, setResult] = useState(null) + const [progress, setProgress] = useState(null) + + async function run() { + if (options?.confirmMsg && !window.confirm(options.confirmMsg)) return + setLoading(true) + setResult(null) + setProgress(null) + try { + const body = options?.bodyFn?.() + const fetchOptions: RequestInit = { method: 'POST' } + if (body) { + fetchOptions.headers = { 'Content-Type': 'application/json' } + fetchOptions.body = JSON.stringify(body) + } + const res = await fetch(url, fetchOptions) + const contentType = res.headers.get('content-type') ?? '' + + let data: Record + if (contentType.includes('application/x-ndjson')) { + data = await readNdjsonStream(res, setProgress) + } else { + data = (await res.json()) as Record + if (!res.ok) { + const hint = data.hint as string | undefined + const rawError = data.rawError as string | undefined + const error = (data.error as string) ?? `HTTP ${res.status}` + console.error('[device-op]', url, rawError ?? error) + setResult({ ok: false, error, hint, rawError }) + return + } + } + + const steps = data.steps as string[] | undefined + const count = data.count as number | undefined + const message = data.message as string | undefined + const restoredFrom = data.restoredFrom as string | undefined + const msg = + message ?? + ((steps ? steps.join(' \u2192 ') : '') || + (count !== undefined ? `Pulled ${count} templates` : '') || + (restoredFrom ? `Restored from ${restoredFrom}` : 'Done')) + setResult({ ok: true, message: msg, steps }) + options?.onSuccess?.() + } catch (e) { + if (e && typeof e === 'object' && 'error' in e) { + const streamErr = e as { error: string; hint?: string; rawError?: string } + console.error('[device-op]', url, streamErr.rawError ?? streamErr.error) + setResult({ ok: false, error: streamErr.error, hint: streamErr.hint, rawError: streamErr.rawError }) + } else { + const msg = e instanceof Error ? e.message : String(e) + console.error('[device-op]', url, msg) + setResult({ ok: false, error: msg, rawError: msg }) + } + } finally { + setLoading(false) + setProgress(null) + } + } + + return { loading, result, progress, run } +} diff --git a/src/hooks/useBusy.ts b/src/hooks/useBusy.ts index e2d2c60..e34b3cb 100644 --- a/src/hooks/useBusy.ts +++ b/src/hooks/useBusy.ts @@ -1,7 +1,8 @@ -import { createContext, useContext } from 'react' +import { createContext, useContext, useState, useCallback, useRef } from 'react' interface BusyState { isBusy: boolean + /** Increment/decrement the busy counter. Multiple callers can hold it busy simultaneously. */ setBusy: (busy: boolean) => void } @@ -10,3 +11,24 @@ export const BusyContext = createContext({ isBusy: false, setBusy: () export function useBusy() { return useContext(BusyContext) } + +/** + * Create ref-counted busy state for use in App.tsx. + * Multiple components can call setBusy(true) independently — + * isBusy stays true until all have called setBusy(false). + */ +export function useBusyProvider() { + const countRef = useRef(0) + const [isBusy, setIsBusy] = useState(false) + + const setBusy = useCallback((busy: boolean) => { + if (busy) { + countRef.current++ + } else { + countRef.current = Math.max(0, countRef.current - 1) + } + setIsBusy(countRef.current > 0) + }, []) + + return { isBusy, setBusy } +} diff --git a/src/pages/DevicePage.css b/src/pages/DevicePage.css index 749239b..cd30e1f 100644 --- a/src/pages/DevicePage.css +++ b/src/pages/DevicePage.css @@ -1001,3 +1001,234 @@ 0%, 70% { opacity: 1; } 100% { opacity: 0; } } + +/* ── xovi Extensions card ───────────────────────────────────────────────── */ + +.xovi-disclaimer { + font-size: 12px; + padding: 8px 12px; + margin: 8px 0; + border-radius: 6px; + background: var(--color-warning-bg); + color: var(--color-warning-text); + border: 1px solid var(--color-warning-border); + line-height: 1.5; +} + +.xovi-disclaimer a { + color: var(--color-link); + text-decoration: underline; +} + +.xovi-disclaimer code { + font-family: ui-monospace, monospace; + font-size: 11px; + background: var(--color-card-code-bg); + padding: 1px 5px; + border-radius: 3px; + color: var(--color-card-code-text); +} + +.xovi-status-section { + display: flex; + flex-direction: column; + gap: 6px; + margin: 12px 0; + padding: 10px 12px; + border-radius: 6px; + background: var(--color-help-bg); + border: 1px solid var(--color-help-border); +} + +.xovi-status-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; +} + +.xovi-status-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + font-size: 13px; + font-weight: 700; + border-radius: 50%; + flex-shrink: 0; +} + +.xovi-status-badge.installed { + color: var(--color-success-text); +} + +.xovi-status-badge.missing { + color: var(--color-error-text); +} + +.xovi-install-guidance { + padding: 12px; + margin: 12px 0; + border-radius: 6px; + background: var(--color-warning-bg); + border: 1px solid var(--color-warning-border); + font-size: 13px; + line-height: 1.5; +} + +.xovi-install-guidance p { + margin: 0 0 6px; +} + +.xovi-install-guidance p:last-child { + margin-bottom: 0; +} + +.xovi-install-guidance a { + color: var(--color-link); + text-decoration: underline; +} + +.xovi-reenable-warning { + padding: 12px; + margin: 12px 0; + border-radius: 6px; + background: var(--color-warning-bg); + border: 1px solid var(--color-warning-border); + color: var(--color-warning-text); + font-size: 13px; + line-height: 1.5; +} + +.xovi-reenable-warning p { + margin: 0 0 6px; +} + +.xovi-reenable-warning p:last-child { + margin-bottom: 0; +} + +.xovi-install-cmd { + font-family: ui-monospace, monospace; + font-size: 12px; + background: var(--color-card-code-bg); + color: var(--color-card-code-text); + padding: 6px 10px; + border-radius: 4px; + margin: 6px 0; + overflow-x: auto; +} + +.xovi-download-log-btn { + background: none; + border: none; + color: var(--color-link); + cursor: pointer; + font-size: inherit; + padding: 0; + text-decoration: underline; +} +.xovi-download-log-btn:hover { + opacity: 0.8; +} + +.xovi-extension-list { + margin: 12px 0 0; +} + +.xovi-tier-label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-muted); + margin: 12px 0 6px; +} + +.xovi-tier-label:first-child { + margin-top: 0; +} + +.xovi-extension-entry { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 8px; + align-items: start; + padding: 6px 0; + cursor: pointer; + font-size: 13px; +} + +.xovi-extension-entry input[type="checkbox"] { + margin-top: 2px; +} + +.xovi-extension-info { + display: flex; + align-items: center; + gap: 8px; +} + +.xovi-extension-name { + font-weight: 500; +} + +.xovi-extension-desc { + grid-column: 2; + font-size: 11px; + color: var(--color-text-muted); + line-height: 1.4; +} + +.xovi-deploy-badge { + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 8px; + white-space: nowrap; +} + +.xovi-deploy-badge.deployed { + background: var(--color-success-bg); + color: var(--color-success-text); + border: 1px solid var(--color-success-border); +} + +.xovi-deploy-badge.not-deployed { + background: var(--color-backup-entry-bg); + color: var(--color-text-muted); + border: 1px solid var(--color-backup-entry-border); +} + +.xovi-radio-group { + margin: 8px 0; + padding: 10px 12px; + border-radius: 6px; + background: var(--color-help-bg); + border: 1px solid var(--color-help-border); +} + +.xovi-radio-group > .xovi-extension-name { + font-size: 13px; + font-weight: 500; + margin-bottom: 2px; +} + +.xovi-radio-group > .xovi-extension-desc { + display: block; + margin-bottom: 8px; +} + +.xovi-radio-entry { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + cursor: pointer; + font-size: 13px; +} + +.xovi-radio-entry input[type="radio"] { + margin: 0; +} diff --git a/src/pages/DevicePage.tsx b/src/pages/DevicePage.tsx index 040b15c..82deef6 100644 --- a/src/pages/DevicePage.tsx +++ b/src/pages/DevicePage.tsx @@ -3,10 +3,19 @@ import { useRegistryContext } from '../hooks/useRegistry' import { useDevices } from '../hooks/useDevices' import { DeviceConnectionCard } from '../components/device/DeviceConnectionCard' import { DeviceSyncCard } from '../components/device/DeviceSyncCard' +import { DeviceXoviCard } from '../components/device/DeviceXoviCard' import { DeviceImportExportCard } from '../components/device/DeviceImportExportCard' import { DeviceBackupsCard } from '../components/device/DeviceBackupsCard' import './DevicePage.css' +function deviceModelLabel(model?: string): string { + if (!model) return '' + if (model === 'rm') return 'reMarkable 1/2' + if (model === 'rmPP') return 'Paper Pro' + if (model === 'rmPPM') return 'Paper Pro Move' + return model +} + export function DevicePage() { const { officialTemplatesAvailable, refreshRegistry } = useRegistryContext() const devicesState = useDevices() @@ -44,7 +53,7 @@ export function DevicePage() { > {d.nickname} {d.deviceModel && ( - {d.deviceModel} + {deviceModelLabel(d.deviceModel)} )} ))} @@ -53,6 +62,7 @@ export function DevicePage() { +