diff --git a/.gitea/workflows/pull-request.yaml b/.gitea/workflows/pull-request.yaml new file mode 100644 index 0000000..5aef2b8 --- /dev/null +++ b/.gitea/workflows/pull-request.yaml @@ -0,0 +1,168 @@ +name: Build Pull Request + +on: + pull_request: + types: + - opened + - reopened + - synchronize + +env: + ARCHIVE_NAME: vision-start-pr-${{ gitea.event.pull_request.number }}.zip + +jobs: + build: + name: Build Pull Request Archive + runs-on: ubuntu-amd64 + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Set up required tools + run: sudo apt-get install zip wget -y + + - name: Install JS dependencies + run: npm ci + + - name: Run build + run: | + bash scripts/prepare_release.sh + npm run build + + - name: Prepare archive + run: | + mv dist vision-start/ + mv extension vision-start/ + mv manifest.json vision-start/ + + - name: Create zip archive + run: | + cd vision-start + zip -r ../${{ env.ARCHIVE_NAME }} * + + - name: Upload build artifact + uses: actions/upload-artifact@v3 + with: + name: pr-release-zip + path: ${{ env.ARCHIVE_NAME }} + + capture_screenshots: + name: Capture Pull Request Screenshots + runs-on: ubuntu-amd64 + needs: build + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Set up required tools + run: sudo apt-get install unzip -y + + - name: Install JS dependencies + run: npm ci + + - name: Download build artifact + uses: actions/download-artifact@v3 + with: + name: pr-release-zip + + - name: Unpack build artifact + run: unzip -q ${{ env.ARCHIVE_NAME }} + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Capture screenshots + run: npm run capture:screenshots + + - name: Upload screenshot artifact + uses: actions/upload-artifact@v3 + with: + name: pr-screenshots + path: | + screenshots/home.png + screenshots/editing.png + screenshots/configuration.png + + publish_screenshot_preview: + name: Publish Pull Request Screenshot Preview + runs-on: ubuntu-amd64 + needs: capture_screenshots + if: ${{ gitea.event.pull_request.head.repo.full_name == gitea.repository }} + permissions: + actions: read + issues: write + steps: + - name: Set up required tools + run: sudo apt-get install curl jq -y + + - name: Download screenshot artifact + uses: actions/download-artifact@v3 + with: + name: pr-screenshots + path: screenshots + + - name: Update pull request screenshot preview + continue-on-error: true + env: + GITEA_API_URL: ${{ gitea.server_url }}/api/v1 + GITEA_TOKEN: ${{ gitea.token }} + REPOSITORY: ${{ gitea.repository }} + PR_NUMBER: ${{ gitea.event.pull_request.number }} + run: | + set -euo pipefail + OWNER="${REPOSITORY%%/*}" + REPO="${REPOSITORY#*/}" + MARKER='' + COMMENTS_URL="$GITEA_API_URL/repos/$OWNER/$REPO/issues/$PR_NUMBER/comments" + COMMENTS=$(curl -fsS -H "Authorization: token $GITEA_TOKEN" "$COMMENTS_URL") + COMMENT_ID=$(printf '%s' "$COMMENTS" | jq -r --arg marker "$MARKER" '.[] | select(.body | contains($marker)) | .id' | tail -n 1) + + if [ -z "$COMMENT_ID" ] || [ "$COMMENT_ID" = "null" ]; then + PAYLOAD=$(jq -n --arg body "$MARKER" '{body: $body}') + COMMENT_ID=$(curl -fsS -X POST \ + -H "Authorization: token $GITEA_TOKEN" \ + -H 'Content-Type: application/json' \ + --data "$PAYLOAD" \ + "$COMMENTS_URL" | jq -r '.id') + fi + + ASSETS_URL="$GITEA_API_URL/repos/$OWNER/$REPO/issues/comments/$COMMENT_ID/assets" + for ASSET_ID in $(curl -fsS -H "Authorization: token $GITEA_TOKEN" "$ASSETS_URL" | jq -r '.[].id'); do + curl -fsS -X DELETE -H "Authorization: token $GITEA_TOKEN" "$ASSETS_URL/$ASSET_ID" + done + + upload_asset() { + curl -fsS -X POST \ + -H "Authorization: token $GITEA_TOKEN" \ + -F "attachment=@$1;type=image/png" \ + "$ASSETS_URL" | jq -r '.browser_download_url' + } + + HOME_URL=$(upload_asset screenshots/home.png) + EDITING_URL=$(upload_asset screenshots/editing.png) + CONFIGURATION_URL=$(upload_asset screenshots/configuration.png) + BODY=$(printf '%s\n\n%s\n\n%s\n%s\n\n%s\n%s\n\n%s\n%s' \ + "$MARKER" \ + '## Visual preview' \ + '### Home' \ + "![Home]($HOME_URL)" \ + '### Editing' \ + "![Editing]($EDITING_URL)" \ + '### Configuration' \ + "![Configuration]($CONFIGURATION_URL)") + PAYLOAD=$(jq -n --arg body "$BODY" '{body: $body}') + curl -fsS -X PATCH \ + -H "Authorization: token $GITEA_TOKEN" \ + -H 'Content-Type: application/json' \ + --data "$PAYLOAD" \ + "$GITEA_API_URL/repos/$OWNER/$REPO/issues/comments/$COMMENT_ID" diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index c121c17..72bc5cf 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -102,7 +102,7 @@ jobs: release: runs-on: ubuntu-latest - needs: [build, virus-total-check] + needs: [build, virus-total-check, capture_screenshots] steps: - name: Check out repository code uses: actions/checkout@v4 @@ -110,6 +110,11 @@ jobs: uses: actions/download-artifact@v3 with: name: release-zip + - name: Download screenshot artifacts + uses: actions/download-artifact@v3 + with: + name: release-screenshots + path: release-screenshots - name: Release zip uses: akkuman/gitea-release-action@v1 with: @@ -120,7 +125,11 @@ jobs: **Virus Total Detection Ratio:** ${{ needs.virus-total-check.outputs.detection-ratio }} name: ${{ gitea.ref_name }} tag_name: ${{ gitea.ref_name }} - files: vision-start-${{ gitea.ref_name }}.zip + files: | + vision-start-${{ gitea.ref_name }}.zip + release-screenshots/home.png + release-screenshots/editing.png + release-screenshots/configuration.png build_vision_start: name: Build Vision Start Image @@ -184,3 +193,36 @@ jobs: cd ${{ secrets.PROD_DIR }} docker compose pull docker compose up -d --force-recreate + + capture_screenshots: + name: Capture Vision Start Screenshots + runs-on: ubuntu-amd64 + needs: deploy_vision_start + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install JS dependencies + run: npm ci + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Capture release screenshots + env: + SCREENSHOT_BASE_URL: http://vision-start.ivanch.me + run: npm run capture:screenshots + + - name: Upload release screenshots + uses: actions/upload-artifact@v3 + with: + name: release-screenshots + path: | + screenshots/home.png + screenshots/editing.png + screenshots/configuration.png diff --git a/package-lock.json b/package-lock.json index c4f339c..80864d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.5", "autoprefixer": "^10.4.21", + "playwright": "1.61.1", "postcss": "^8.5.6", "tailwindcss": "^4.1.11", "typescript": "~5.7.2", @@ -2387,6 +2388,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index 8b6af2b..4f68661 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "capture:screenshots": "node scripts/capture_screenshots.mjs" }, "dependencies": { "@hello-pangea/dnd": "^18.0.1", @@ -20,6 +21,7 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.5", "autoprefixer": "^10.4.21", + "playwright": "1.61.1", "postcss": "^8.5.6", "tailwindcss": "^4.1.11", "typescript": "~5.7.2", diff --git a/project-context.md b/project-context.md index 4aa5fb7..5c9b25b 100644 --- a/project-context.md +++ b/project-context.md @@ -40,6 +40,7 @@ Live instances / artifacts: | Container | Node 22 Alpine build stage → nginx Alpine serving `dist/` | | Extension packaging | Manifest V3 (`manifest.json`) consuming `dist/` + `manifest.json` zipped as `vision-start-.zip` | | CI/CD | Gitea Actions workflows (`.gitea/workflows/`) | +| Release screenshots | Playwright 1.61.1 + Chromium capture the deployed production page at a fixed 1280×800 viewport | Entry points: `index.html` → `index.tsx` → `App.tsx`. @@ -125,13 +126,16 @@ vision-start/ │ ├── favicon.ico │ └── icon-metadata.json # Dashboard Icons metadata (gitignored; fetched at release build) │ -├── screenshots/ # README screenshots (dark page, editing, configuration) +├── screenshots/ # README/release screenshots (home, editing, configuration; regenerated at 1280×800) ├── scripts/ +│ ├── capture_screenshots.mjs # Loads demoData, captures home/edit/configuration with Playwright, and can target a deployed URL +│ ├── demoData.json # Base64-encoded localStorage fixture used for release screenshots │ ├── prepare_release.sh # Downloads icon-metadata.json from homarr-labs/dashboard-icons │ └── check_virustotal.sh # Uploads the release zip to VirusTotal, waits for & reports the verdict │ ├── .gitea/workflows/ │ ├── main.yaml # On push to main: build, push staging Docker image, SSH-deploy to staging +│ ├── pull-request.yaml # On PR updates: build/zip, capture screenshots, and publish an inline visual preview │ └── release.yaml # On v* tag: build, zip, VirusTotal check, Gitea release, push latest image, SSH-deploy to prod │ ├── Dockerfile # Node 22 build → nginx serving dist/ (with gzip via nginx.conf) @@ -209,6 +213,8 @@ Build, then combine `dist/` + `manifest.json` into a folder and "Load unpacked" ### CI/CD (Gitea Actions) - `release.yaml` validates each `vX.Y.Z` tag and replaces tracked `v0.0.0`/`0.0.0` placeholders in each build checkout before producing the extension archive and production image. +- **`pull-request.yaml`** — Triggers on pull request open, reopen, and synchronization. It builds and uploads a PR extension archive, unpacks that archive in a separate Playwright job to generate the three demo screenshots, and uploads both artifacts. For same-repository PRs, it maintains one Gitea PR comment with inline image attachments; fork PRs retain artifacts but skip the comment because their workflow token is read-only. +- After `deploy_vision_start` succeeds, `release.yaml` uses Playwright Chromium against the deployed production page and seeds each browser context from `scripts/demoData.json`. It regenerates `home.png`, `editing.png`, and `configuration.png` at exactly 1280×800, uploads them as artifacts, and attaches them as individual Gitea release assets. - **`main.yaml`** — Triggers on push to `main` (and `workflow_dispatch`). Builds, pushes a `staging` multi-arch (amd64/arm64) image to `git.ivanch.me/ivanch/vision-start:staging`, then SSH-deploys on the staging host via `docker compose up -d --force-recreate`. - **`release.yaml`** — Triggers on `v*` tags. Builds, zips `dist/` + `manifest.json` as `vision-start-.zip`, runs `scripts/check_virustotal.sh` against it (publishes analysis URL + detection ratio on the release body), creates a Gitea release, pushes a `latest` multi-arch image, and SSH-deploys to production. @@ -248,7 +254,7 @@ External assets fetched at build time by `scripts/prepare_release.sh`: | Icon fetch / picker / metadata | `components/utils/iconService.ts`, `components/WebsiteEditModal.tsx`, `public/icon-metadata.json` | | chrome.storage.local access | `components/utils/StorageLocalManager.ts` | | Export/import config | `components/services/ConfigurationService.ts` (`exportConfig`, `importConfig`) | -| Release packaging | `scripts/prepare_release.sh`, `scripts/check_virustotal.sh`, `.gitea/workflows/release.yaml` | +| Build/release/PR pipelines | `scripts/prepare_release.sh`, `scripts/capture_screenshots.mjs`, `scripts/check_virustotal.sh`, `.gitea/workflows/pull-request.yaml`, `.gitea/workflows/release.yaml` | | Docker build | `Dockerfile` | --- diff --git a/scripts/capture_screenshots.mjs b/scripts/capture_screenshots.mjs new file mode 100644 index 0000000..23bee56 --- /dev/null +++ b/scripts/capture_screenshots.mjs @@ -0,0 +1,190 @@ +import { spawn } from 'node:child_process'; +import { mkdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { chromium } from 'playwright'; + +const viewport = { width: 1280, height: 800 }; +const host = '127.0.0.1'; +const port = 4173; +const configuredBaseUrl = process.env.SCREENSHOT_BASE_URL?.replace(/\/+$/, ''); +const baseUrl = configuredBaseUrl || `http://${host}:${port}`; +const outputDirectory = process.env.SCREENSHOT_OUTPUT_DIR || 'screenshots'; +const demoDataPath = process.env.SCREENSHOT_DEMO_DATA || 'scripts/demoData.json'; +const viteCli = path.resolve('node_modules/vite/bin/vite.js'); +const imgurImageCache = new Map(); + +const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const waitForServer = async (preview) => { + const deadline = Date.now() + 30_000; + let lastError; + + while (Date.now() < deadline) { + if (previewError) throw previewError; + + if (preview && preview.exitCode !== null) { + throw new Error(`Vite preview exited with code ${preview.exitCode}.`); + } + + try { + const response = await fetch(baseUrl); + if (response.ok) return; + lastError = new Error(`Vite preview returned ${response.status}.`); + } catch (error) { + lastError = error; + } + + await delay(250); + } + + throw new Error(`Screenshot target did not respond at ${baseUrl}. ${lastError?.message || ''}`.trim()); +}; + +const loadDemoData = async () => { + const encodedData = JSON.parse(await readFile(demoDataPath, 'utf8')); + + if (!encodedData || typeof encodedData !== 'object' || Array.isArray(encodedData)) { + throw new Error(`${demoDataPath} must contain an object of base64-encoded localStorage values.`); + } + + return Object.entries(encodedData).map(([key, value]) => { + if (typeof value !== 'string') { + throw new Error(`${demoDataPath} contains a non-string value for ${key}.`); + } + + return { key, value: Buffer.from(value, 'base64').toString('utf8') }; + }); +}; + +const fulfillImgurRequest = async (route) => { + const url = route.request().url(); + let image = imgurImageCache.get(url); + + if (!image) { + try { + const response = await fetch(url); + if (!response.ok) { + await route.continue(); + return; + } + + image = { + body: Buffer.from(await response.arrayBuffer()), + contentType: response.headers.get('content-type') || 'image/jpeg', + }; + imgurImageCache.set(url, image); + } catch { + await route.continue(); + return; + } + } + + await route.fulfill({ + status: 200, + headers: { 'content-type': image.contentType }, + body: image.body, + }); +}; + +const stopPreview = async (preview) => { + if (preview.exitCode !== null) return; + + const exited = new Promise((resolve) => preview.once('exit', resolve)); + preview.kill('SIGTERM'); + await Promise.race([exited, delay(5_000)]); + + if (preview.exitCode === null) preview.kill('SIGKILL'); +}; + +const assertPngDimensions = (image, filename) => { + const width = image.readUInt32BE(16); + const height = image.readUInt32BE(20); + + if (width !== viewport.width || height !== viewport.height) { + throw new Error(`${filename} was generated at ${width}x${height}, expected ${viewport.width}x${viewport.height}.`); + } +}; + +const capture = async (page, filename) => { + const image = await page.screenshot({ + path: path.join(outputDirectory, filename), + type: 'png', + fullPage: false, + scale: 'css', + animations: 'disabled', + }); + + assertPngDimensions(image, filename); +}; + +const loadPage = async (page) => { + await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); + await page.locator('main').waitFor({ state: 'visible' }); + await page.evaluate(() => document.fonts.ready); + await delay(1_500); +}; + +let previewError; +const preview = configuredBaseUrl + ? undefined + : spawn( + process.execPath, + [viteCli, 'preview', '--host', host, '--port', String(port), '--strictPort'], + { stdio: 'inherit' }, + ); + +if (preview) { + preview.once('error', (error) => { + previewError = error; + }); +} + +try { + await waitForServer(preview); + + await mkdir(outputDirectory, { recursive: true }); + const demoData = await loadDemoData(); + + const browser = await chromium.launch(); + const context = await browser.newContext({ + viewport, + screen: viewport, + deviceScaleFactor: 1, + locale: 'en-US', + timezoneId: 'America/Sao_Paulo', + userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + }); + await context.route('https://i.imgur.com/**', fulfillImgurRequest); + await context.addInitScript((storageItems) => { + storageItems.forEach(({ key, value }) => localStorage.setItem(key, value)); + }, demoData); + const page = await context.newPage(); + + try { + await loadPage(page); + await capture(page, 'home.png'); + + await page.getByRole('button', { name: 'Edit page' }).click(); + await capture(page, 'editing.png'); + + await loadPage(page); + await page.getByRole('button', { name: 'Open configuration' }).click(); + await page.getByRole('dialog').waitFor({ state: 'visible' }); + await page.waitForFunction( + () => { + const drawer = document.querySelector('.liquid-drawer'); + if (!drawer) return false; + const { left, right } = drawer.getBoundingClientRect(); + return left < window.innerWidth && right <= window.innerWidth; + }, + undefined, + { timeout: 5_000 }, + ); + await capture(page, 'configuration.png'); + } finally { + await context.close(); + await browser.close(); + } +} finally { + if (preview) await stopPreview(preview); +} diff --git a/scripts/demoData.json b/scripts/demoData.json new file mode 100644 index 0000000..6e2fa81 --- /dev/null +++ b/scripts/demoData.json @@ -0,0 +1,6 @@ +{ + "categories": "W3siaWQiOiIxIiwibmFtZSI6IlNlYXJjaCIsIndlYnNpdGVzIjpbeyJpZCI6IjEiLCJuYW1lIjoiR29vZ2xlIiwidXJsIjoiaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbSIsImljb24iOiJodHRwczovL3d3dy5nb29nbGUuY29tL3MyL2Zhdmljb25zP2RvbWFpbj1nb29nbGUuY29tJnN6PTEyOCIsImNhdGVnb3J5SWQiOiIxIn0seyJpZCI6IjE3NTMwNDQxODAxMDgiLCJuYW1lIjoiWW91VHViZSIsInVybCI6Imh0dHBzOi8vd3d3LnlvdXR1YmUuY29tLyIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy95b3V0dWJlLnN2ZyIsImNhdGVnb3J5SWQiOiIxIn0seyJpZCI6IjE3NTMwNDQyMjI2OTQiLCJuYW1lIjoiRHJpdmUiLCJ1cmwiOiJodHRwczovL2RyaXZlLmdvb2dsZS5jb20vZHJpdmUvdS8wL215LWRyaXZlIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2dvb2dsZS1kcml2ZS5zdmciLCJjYXRlZ29yeUlkIjoiMSJ9LHsiaWQiOiIxNzUzMDQ0NDE0ODIwIiwibmFtZSI6IkdtYWlsIiwidXJsIjoiaHR0cHM6Ly9tYWlsLmdvb2dsZS5jb20vbWFpbC91LzAvP3RhYj13bSNpbmJveCIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9nbWFpbC5zdmciLCJjYXRlZ29yeUlkIjoiMSJ9LHsiaWQiOiIxNzUzMDQ0NDI3NTYwIiwibmFtZSI6Ikxhc3QuZm0iLCJ1cmwiOiJodHRwczovL3d3dy5sYXN0LmZtL2hvbWUiLCJpY29uIjoiaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbS9zMi9mYXZpY29ucz9kb21haW49d3d3Lmxhc3QuZm0mc3o9MTI4IiwiY2F0ZWdvcnlJZCI6IjEifV19LHsiaWQiOiIxNzUzMDQ0NDQ2NzU1IiwibmFtZSI6IkNvZGUiLCJ3ZWJzaXRlcyI6W3siaWQiOiIxNzUzMDQ0NDU5MjcyIiwibmFtZSI6IkdpdGh1YiIsInVybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9mZWVkIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2dpdGh1Yi1saWdodC5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDQ0Njc1NSJ9LHsiaWQiOiIxNzgzNzI4ODcwNjgzIiwibmFtZSI6IkFXUyIsInVybCI6IiIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9hd3MtbGlnaHQuc3ZnIiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ0NDY3NTUifSx7ImlkIjoiMTc4MzcyODkxNTg1MyIsIm5hbWUiOiJEb2NrZXIgSHViIiwidXJsIjoiIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2RvY2tlci5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDQ0Njc1NSJ9LHsiaWQiOiIxNzgzNzI4OTkyMzkwIiwibmFtZSI6IkZpZ21hIiwidXJsIjoiIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2ZpZ21hLnN2ZyIsImNhdGVnb3J5SWQiOiIxNzUzMDQ0NDQ2NzU1In1dfSx7ImlkIjoiMTc1MzA0NDY2ODAxMiIsIm5hbWUiOiJNZWRpYSIsIndlYnNpdGVzIjpbeyJpZCI6IjE3NTMwNDQ3MDYwMjQiLCJuYW1lIjoiTmV0ZmxpeCIsInVybCI6Imh0dHA6Ly90di5oYXZlbi8iLCJpY29uIjoiaHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL2hvbWFyci1sYWJzL2Rhc2hib2FyZC1pY29ucy9zdmcvbmV0ZmxpeC5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDY2ODAxMiJ9LHsiaWQiOiIxNzUzMDQ0NzQwOTg2IiwibmFtZSI6IlByaW1lIiwidXJsIjoiaHR0cDovL29tdi5oYXZlbiIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9wcmltZS12aWRlby1hbHQtZGFyay5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDY2ODAxMiJ9LHsiaWQiOiIxNzgzNzI4Nzg0MjU2IiwibmFtZSI6IkhCTyIsInVybCI6IiIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9oYm8tbGlnaHQuc3ZnIiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ2NjgwMTIifSx7ImlkIjoiMTc1MzA0NDgyODg0MCIsIm5hbWUiOiJUcmFrdCIsInVybCI6Imh0dHBzOi8vYXBwLnRyYWt0LnR2LyIsImljb24iOiJodHRwczovL3d3dy5nb29nbGUuY29tL3MyL2Zhdmljb25zP2RvbWFpbj1hcHAudHJha3QudHYmc3o9MTI4IiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ2NjgwMTIifSx7ImlkIjoiMTc4MzcyODcyOTQyNCIsIm5hbWUiOiJMZXR0ZXJib3hkIiwidXJsIjoiaHR0cHM6Ly9sZXR0ZXJib3hkLmNvbS8iLCJpY29uIjoiaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbS9zMi9mYXZpY29ucz9kb21haW49bGV0dGVyYm94ZC5jb20mc3o9MTI4IiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ2NjgwMTIifV19XQ==", + "config": "eyJ0aXRsZSI6IiIsImN1cnJlbnRXYWxscGFwZXJzIjpbIkFic3RyYWN0IFJlZCJdLCJ3YWxscGFwZXJGcmVxdWVuY3kiOiIxZCIsIndhbGxwYXBlckJsdXIiOjAsIndhbGxwYXBlckJyaWdodG5lc3MiOjEwOCwid2FsbHBhcGVyT3BhY2l0eSI6MTAwLCJ0aXRsZVNpemUiOiJtZWRpdW0iLCJhbGlnbm1lbnQiOiJtaWRkbGUiLCJob3Jpem9udGFsQWxpZ25tZW50IjoibWlkZGxlIiwidGlsZVNpemUiOiJzbWFsbCIsImNsb2NrIjp7ImVuYWJsZWQiOnRydWUsInNpemUiOiJ0aW55IiwiZm9udCI6Im1vbm9zcGFjZSIsImZvcm1hdCI6Img6bW0gQSJ9LCJzZXJ2ZXJXaWRnZXQiOnsiZW5hYmxlZCI6dHJ1ZSwicGluZ0ZyZXF1ZW5jeSI6MTUsInNlcnZlcnMiOlt7ImlkIjoiMTc4MzcyOTE4MzU1MyIsIm5hbWUiOiJBZEd1YXJkIiwiYWRkcmVzcyI6Imh0dHBzOi8vZ29vZ2xlLmNvbSJ9LHsiaWQiOiIxNzgzNzI5MjI3NDQyIiwibmFtZSI6IlByb3htb3giLCJhZGRyZXNzIjoiaHR0cHM6Ly9nb29nbGUuY29tIn1dfX0=", + "userWallpapers": "W3sibmFtZSI6ImRhcmstYWJzdHJhY3QtMjU2MHgxNDQwLWNvbnRlbXBvcmFyeS1kZXNpZ24tc2xlZWstbGluZXMtMjY0MjYuanBnIn1d", + "wallpaperState": "eyJsYXN0V2FsbHBhcGVyQ2hhbmdlIjoiMjA0Ni0wNy0xMVQwMDoyMzozMS4zODdaIiwiY3VycmVudEluZGV4IjowfQ==" +} \ No newline at end of file