fixing wallpapers again and improving pipeline for release
Build and Release to Staging / Build Vision Start (push) Successful in 1m22s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m40s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 14s
Build and Release / build (push) Successful in 1m21s
Build and Release / virus-total-check (push) Successful in 1m29s
Build and Release / Build Vision Start Image (push) Successful in 2m42s
Build and Release / Deploy Vision Start (production) (push) Successful in 9s
Build and Release / Capture Vision Start Screenshots (push) Successful in 47s
Build and Release / release (push) Successful in 1m0s
Build and Release to Staging / Build Vision Start (push) Successful in 1m22s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m40s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 14s
Build and Release / build (push) Successful in 1m21s
Build and Release / virus-total-check (push) Successful in 1m29s
Build and Release / Build Vision Start Image (push) Successful in 2m42s
Build and Release / Deploy Vision Start (production) (push) Successful in 9s
Build and Release / Capture Vision Start Screenshots (push) Successful in 47s
Build and Release / release (push) Successful in 1m0s
This commit is contained in:
@@ -38,7 +38,7 @@ jobs:
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup required tools
|
||||
run: sudo apt-get install zip jq curl -y
|
||||
run: sudo apt-get install zip unzip jq curl -y
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
@@ -48,12 +48,12 @@ jobs:
|
||||
bash scripts/prepare_release.sh
|
||||
npm run build
|
||||
|
||||
- name: Prepare release
|
||||
- name: Prepare Chrome Web Store package
|
||||
run: |
|
||||
mkdir -p vision-start
|
||||
mv dist vision-start/
|
||||
mv extension vision-start/
|
||||
mv manifest.json vision-start/
|
||||
mkdir vision-start
|
||||
cp -a dist/. vision-start/
|
||||
cp -a extension vision-start/
|
||||
cp manifest.json vision-start/
|
||||
|
||||
- name: Set archive name
|
||||
id: set-archive
|
||||
@@ -70,7 +70,38 @@ jobs:
|
||||
- name: Create zip archive
|
||||
run: |
|
||||
cd vision-start
|
||||
zip -r "../${ARCHIVE_NAME}" *
|
||||
zip -r -X "../${ARCHIVE_NAME}" .
|
||||
|
||||
- name: Validate Chrome Web Store archive
|
||||
env:
|
||||
RELEASE_TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
unzip -t "$ARCHIVE_NAME"
|
||||
PACKAGE_DIR=$(mktemp -d)
|
||||
unzip -q "$ARCHIVE_NAME" -d "$PACKAGE_DIR"
|
||||
node --input-type=module - "$PACKAGE_DIR" "${RELEASE_TAG#v}" <<'NODE'
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { resolve, sep } from 'node:path';
|
||||
const [directory, version] = process.argv.slice(2);
|
||||
const manifest = JSON.parse(readFileSync(resolve(directory, 'manifest.json'), 'utf8'));
|
||||
assert.equal(manifest.manifest_version, 3);
|
||||
assert.equal(manifest.version, version);
|
||||
assert.match(version, /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/);
|
||||
const parts = version.split('.').map(Number);
|
||||
assert(parts.every(part => part <= 65535) && parts.some(part => part > 0), 'Invalid Chrome extension version');
|
||||
assert(typeof manifest.name === 'string' && manifest.name.length > 0);
|
||||
assert(typeof manifest.description === 'string' && manifest.description.length <= 132);
|
||||
assert.equal(manifest.chrome_url_overrides.newtab, 'index.html');
|
||||
assert(manifest.icons['128'], 'Missing store icon');
|
||||
for (const file of [manifest.chrome_url_overrides.newtab, ...Object.values(manifest.icons)]) {
|
||||
const path = resolve(directory, file);
|
||||
assert(path.startsWith(resolve(directory) + sep), `Invalid package path: ${file}`);
|
||||
assert(statSync(path).isFile(), `Missing packaged file: ${file}`);
|
||||
}
|
||||
console.log('Chrome Web Store archive structure and manifest validated');
|
||||
NODE
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
@@ -9,7 +9,7 @@ import CategoryGroup from './components/layout/CategoryGroup';
|
||||
import Wallpaper from './components/Wallpaper';
|
||||
import { ConfigurationService } from './components/services/ConfigurationService';
|
||||
import { getAlignmentClass } from './components/utils/styleUtils';
|
||||
import { getRandomWallpaperIndex } from './components/utils/wallpaperUtils';
|
||||
import { getRandomWallpaperIndex, loadWallpaperState, saveWallpaperState } from './components/utils/wallpaperUtils';
|
||||
import { PlusIcon } from './components/icons';
|
||||
|
||||
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
|
||||
@@ -62,17 +62,9 @@ const App: React.FC = () => {
|
||||
const names = config.currentWallpapers;
|
||||
if (names.length === 0) return;
|
||||
try {
|
||||
const state = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
|
||||
const current = typeof state.currentIndex === 'number' ? state.currentIndex : 0;
|
||||
const safeCurrent = current < 0 || current >= names.length ? 0 : current;
|
||||
const randomIndex = getRandomWallpaperIndex(names.length, safeCurrent);
|
||||
localStorage.setItem(
|
||||
'wallpaperState',
|
||||
JSON.stringify({
|
||||
lastWallpaperChange: new Date().toISOString(),
|
||||
currentIndex: randomIndex,
|
||||
}),
|
||||
);
|
||||
const { currentIndex } = loadWallpaperState(names);
|
||||
const randomIndex = getRandomWallpaperIndex(names.length, currentIndex);
|
||||
saveWallpaperState(names, randomIndex, Date.now());
|
||||
} catch (error) {
|
||||
console.error('Error randomizing wallpaper state', error);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
onWallpaperChange({ currentWallpapers: config.currentWallpapers });
|
||||
ConfigurationService.resetWallpaperState();
|
||||
}, [config.currentWallpapers]);
|
||||
|
||||
const handleClose = () => {
|
||||
|
||||
+39
-38
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { baseWallpapers } from './utils/baseWallpapers';
|
||||
import { Wallpaper as WallpaperType } from '../types';
|
||||
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
|
||||
import { getRandomWallpaperIndex, getWallpaperFrequencyMs } from './utils/wallpaperUtils';
|
||||
import { getRandomWallpaperIndex, getWallpaperFrequencyMs, loadWallpaperState, saveWallpaperState } from './utils/wallpaperUtils';
|
||||
|
||||
interface WallpaperProps {
|
||||
wallpaperNames: string[];
|
||||
@@ -64,41 +64,31 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let revision = 0;
|
||||
wallpaperUrlCache.clear();
|
||||
|
||||
const updateWallpaper = async () => {
|
||||
const request = ++revision;
|
||||
clearTimeout(timer);
|
||||
if (wallpaperNames.length === 0) {
|
||||
if (!cancelled) setImageUrl(undefined);
|
||||
localStorage.setItem(
|
||||
'wallpaperState',
|
||||
JSON.stringify({ lastWallpaperChange: new Date().toISOString(), currentIndex: 0 }),
|
||||
);
|
||||
setImageUrl(undefined);
|
||||
saveWallpaperState([], 0, loadWallpaperState([]).lastChange);
|
||||
return;
|
||||
}
|
||||
|
||||
const wallpaperState = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
|
||||
const lastChange = wallpaperState.lastWallpaperChange
|
||||
? new Date(wallpaperState.lastWallpaperChange).getTime()
|
||||
: 0;
|
||||
const now = Date.now();
|
||||
const { currentIndex, lastChange } = loadWallpaperState(wallpaperNames, now);
|
||||
const freqMs = getWallpaperFrequencyMs(wallpaperFrequency);
|
||||
|
||||
let storedIndex =
|
||||
typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
|
||||
if (storedIndex < 0 || storedIndex >= wallpaperNames.length) storedIndex = 0;
|
||||
|
||||
const shouldRotate = now - lastChange >= freqMs;
|
||||
const shouldRotate = wallpaperNames.length > 1 && now - lastChange >= freqMs;
|
||||
let resolvedIndex = shouldRotate
|
||||
? getRandomWallpaperIndex(wallpaperNames.length, storedIndex)
|
||||
: storedIndex;
|
||||
|
||||
const tried = new Set<number>();
|
||||
? getRandomWallpaperIndex(wallpaperNames.length, currentIndex)
|
||||
: currentIndex;
|
||||
let resolvedUrl: string | undefined;
|
||||
|
||||
for (let i = 0; i < wallpaperNames.length; i++) {
|
||||
if (tried.has(resolvedIndex)) break;
|
||||
tried.add(resolvedIndex);
|
||||
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
|
||||
if (cancelled) return;
|
||||
if (cancelled || request !== revision) return;
|
||||
if (url) {
|
||||
resolvedUrl = url;
|
||||
break;
|
||||
@@ -106,26 +96,37 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const nextLastChange = shouldRotate
|
||||
? new Date().toISOString()
|
||||
: wallpaperState.lastWallpaperChange || new Date().toISOString();
|
||||
|
||||
localStorage.setItem(
|
||||
'wallpaperState',
|
||||
JSON.stringify({
|
||||
lastWallpaperChange: nextLastChange,
|
||||
currentIndex: resolvedIndex,
|
||||
}),
|
||||
);
|
||||
|
||||
if (cancelled || request !== revision) return;
|
||||
const nextLastChange = shouldRotate || resolvedIndex !== currentIndex ? Date.now() : lastChange;
|
||||
saveWallpaperState(wallpaperNames, resolvedIndex, nextLastChange);
|
||||
setImageUrl(resolvedUrl);
|
||||
if (wallpaperNames.length > 1) {
|
||||
timer = setTimeout(refresh, Math.max(1, nextLastChange + freqMs - Date.now()));
|
||||
}
|
||||
};
|
||||
updateWallpaper();
|
||||
const refresh = () => {
|
||||
void updateWallpaper().catch(error => console.error('Error updating wallpaper', error));
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') refresh();
|
||||
};
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === 'wallpaperState' || event.key === 'userWallpapers' || event.key === null) {
|
||||
wallpaperUrlCache.clear();
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
refresh();
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
window.addEventListener('focus', refresh);
|
||||
window.addEventListener('storage', onStorage);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
window.removeEventListener('focus', refresh);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
};
|
||||
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
|
||||
|
||||
|
||||
@@ -205,14 +205,4 @@ export const ConfigurationService = {
|
||||
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
|
||||
};
|
||||
},
|
||||
|
||||
resetWallpaperState(): void {
|
||||
localStorage.setItem(
|
||||
'wallpaperState',
|
||||
JSON.stringify({
|
||||
lastWallpaperChange: new Date().toISOString(),
|
||||
currentIndex: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,3 +22,30 @@ export const getRandomWallpaperIndex = (wallpaperCount: number, currentIndex: nu
|
||||
const offset = Math.floor(Math.random() * (wallpaperCount - 1)) + 1;
|
||||
return (currentIndex + offset) % wallpaperCount;
|
||||
};
|
||||
|
||||
export const loadWallpaperState = (names: string[], now = Date.now()) => {
|
||||
let state: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
|
||||
if (parsed && typeof parsed === 'object') state = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
state = {};
|
||||
}
|
||||
const namedIndex = typeof state.currentName === 'string' ? names.indexOf(state.currentName) : -1;
|
||||
const index = namedIndex >= 0 ? namedIndex : typeof state.currentName === 'string' ? 0 : state.currentIndex;
|
||||
const currentIndex = typeof index === 'number' && Number.isInteger(index) && index >= 0 && index < names.length ? index : 0;
|
||||
const timestamp = typeof state.lastWallpaperChange === 'string' ? Date.parse(state.lastWallpaperChange) : NaN;
|
||||
const lastChange = Number.isFinite(timestamp) && timestamp <= now && timestamp >= 0 ? timestamp : now;
|
||||
return { currentIndex, lastChange };
|
||||
};
|
||||
|
||||
export const saveWallpaperState = (names: string[], currentIndex: number, lastChange: number): void => {
|
||||
const payload = JSON.stringify({
|
||||
currentIndex,
|
||||
currentName: names[currentIndex],
|
||||
lastWallpaperChange: new Date(lastChange).toISOString(),
|
||||
});
|
||||
if (localStorage.getItem('wallpaperState') !== payload) {
|
||||
localStorage.setItem('wallpaperState', payload);
|
||||
}
|
||||
};
|
||||
|
||||
+3
-3
@@ -183,7 +183,7 @@ Storage layout (browser-side):
|
||||
| `config` | `localStorage` | The full `Config` JSON |
|
||||
| `categories` | `localStorage` | `Category[]` JSON |
|
||||
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names plus URLs for remote wallpapers) |
|
||||
| `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex }` for rotation |
|
||||
| `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex, currentName? }` for rotation (legacy index-only state remains supported) |
|
||||
| `<wallpaperName>` | `chrome.storage.local` (when available) | Base64 image data for uploaded wallpaper files and legacy URL wallpapers |
|
||||
| `vision-start:icon:<encoded-source-url>` | `chrome.storage.local` (when available) | CORS-readable website icon data URL, capped at 256KiB |
|
||||
|
||||
@@ -225,7 +225,7 @@ Build, then combine `dist/` + `manifest.json` into a folder and "Load unpacked"
|
||||
`Dockerfile` builds in Node 22 Alpine (`npm ci` → runs `scripts/prepare_release.sh` → `npm run build`) and serves `/app/dist` + `manifest.json` via nginx:alpine on port 80.
|
||||
|
||||
### CI/CD (Gitea Actions)
|
||||
- `release.yaml` validates each `vX.Y.Z` tag and stamps the version into `manifest.json` (`"version": "0.0.0"` → the tag) in each build checkout before producing the extension archive and production image.
|
||||
- `release.yaml` validates each `vX.Y.Z` tag and stamps the version into `manifest.json` (`"version": "0.0.0"` → the tag) in each build checkout before producing the extension archive and production image. The release ZIP contains the contents of `dist/` at its root alongside `manifest.json` and `extension/icons/`, so the manifest's `index.html` new-tab path resolves directly. Before upload, the workflow tests ZIP integrity, extracts it, and validates the manifest version (no leading zeros, components at most 65535, not all zero), metadata, new-tab page, and icon paths. This is the Chrome Web Store upload ZIP; screenshots remain separate release assets.
|
||||
- **`pull-request.yaml`** — Triggers on pull request open, reopen, and synchronization. It builds and uploads a PR extension archive containing `dist/`, unpacks that archive in a separate Playwright job to generate the three demo screenshots, and uploads both artifacts (screenshots are retained for 30 days). 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 (retained for 30 days), 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`.
|
||||
@@ -243,7 +243,7 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
||||
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead, both of which share the `ModalShell` component.
|
||||
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
||||
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. Remote wallpaper URLs work without it because their URLs live in `userWallpapers`; file upload is gated off when unavailable, and `addWallpaperToChromeStorageLocal` throws if called without it.
|
||||
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, selects a random non-current wallpaper when the frequency window has elapsed, and writes its index back; the frequency is clamped to 1–48 hours, while older saved values like `1d`/`2d` still resolve to their hour equivalents. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Random Wallpaper" button in the Theme tab also picks a random non-current wallpaper and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
||||
- **Wallpaper rotation** uses a timeout scheduled from the persisted last-change timestamp, with checks on mount, focus, and return to a visible tab. Overdue backgrounds rotate once and start a new interval; frequency changes use elapsed time rather than restarting the countdown. Frequency is clamped to 1–48 hours and legacy `1d`/`2d` values remain supported. Invalid or future timestamps recover to the current time. State tracks the wallpaper name as well as its legacy index so reordering or shrinking the selection preserves the current wallpaper when possible. Missing data is skipped and an empty selection hides the background. Manual random changes exclude the current wallpaper and restart the interval. Opening settings does not reset rotation. Wallpaper state writes skip identical serialized values, and empty selections preserve the saved timestamp during refreshes to prevent repeated cross-tab updates. Storage events synchronize wallpaper changes across tabs; outdated asynchronous resolutions are discarded. URL caches are cleared when wallpaper inputs or stored wallpaper data change.
|
||||
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
|
||||
- **Website icon cache** is persistent only in `chrome.storage.local`; it is rebuilt from website icon URLs after configuration import. Only `image/*` responses no larger than 256KiB are cached. Trusted public TLDs use Google S2 and the CORS-open `icon.horse` service for favicon caching, while non-trusted TLDs (local TLDs, IP addresses, custom domains) fetch `${origin}/favicon.ico` directly. No site HTML is ever fetched for icon discovery.
|
||||
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
|
||||
|
||||
Reference in New Issue
Block a user