From e6ee23aeac78c05033b6141b3ed90d457810d142 Mon Sep 17 00:00:00 2001 From: fish34851-hash <317297427+fish34851-hash@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:17:37 +0800 Subject: [PATCH 1/2] feat: add installable offline PWA --- CHANGELOG.md | 9 +++++ README.md | 9 +++++ ROADMAP.md | 6 ++-- assets/icon-192.png | Bin 0 -> 5704 bytes assets/icon-512.png | Bin 0 -> 8585 bytes assets/icon-maskable-512.png | Bin 0 -> 7337 bytes assets/icon-maskable.svg | 5 +++ assets/icon.svg | 4 +++ docs/ARCHITECTURE.md | 4 +++ index.html | 22 ++++++++---- manifest.webmanifest | 45 +++++++++++++++++++++++++ package.json | 4 +-- server.js | 2 ++ service-worker.js | 63 +++++++++++++++++++++++++++++++++++ src/app.js | 40 ++++++++++++++++++++++ src/styles.css | 21 ++++++++++++ tests/pwa.test.js | 54 ++++++++++++++++++++++++++++++ 17 files changed, 276 insertions(+), 12 deletions(-) create mode 100644 assets/icon-192.png create mode 100644 assets/icon-512.png create mode 100644 assets/icon-maskable-512.png create mode 100644 assets/icon-maskable.svg create mode 100644 assets/icon.svg create mode 100644 manifest.webmanifest create mode 100644 service-worker.js create mode 100644 tests/pwa.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 46591c8..88b99b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.3.0] - 2026-08-15 + +### Added + +- Installable Progressive Web App manifest and icons +- Offline app shell powered by a privacy-preserving service worker +- Install prompt and online/offline status indicator +- Automated checks for manifest and offline-cache completeness + ## [0.2.0] - 2026-08-15 ### Added diff --git a/README.md b/README.md index c6d3187..8c675c1 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ImageTool converts PNG, JPG, and WebP images locally. Your files are never uploa - Responsive interface for desktop and mobile - Zero runtime dependencies - Remove source metadata by re-encoding image pixels in the browser +- Install the app and use its core interface offline ## Try it locally @@ -35,6 +36,12 @@ Then open . No `npm install` step is required. +## Install for offline use + +Open the [live ImageTool site](https://fish34851-hash.github.io/ImageTool/) in Chrome or Edge. Use the **Install app** control in the browser or the in-page install button when it appears. + +After the first successful visit, the application shell can open without reaching the server. Image files and converted results remain temporary local browser data and are not added to the offline cache. + ## Check the code ```bash @@ -48,6 +55,8 @@ ImageTool uses the browser's Canvas API. Selected images stay on your device and Re-encoding through Canvas produces a new image from pixel data, so source EXIF and other file metadata are not copied to the output. +The offline service worker caches only same-origin ImageTool application files. It does not intercept, upload, or store selected images. + ## Roadmap - Offline installation as a Progressive Web App diff --git a/ROADMAP.md b/ROADMAP.md index c05c24f..a852e56 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,8 +10,6 @@ This roadmap describes intended directions, not guaranteed deadlines. Priorities - Quality controls and image preview - Privacy-first, zero-dependency browser architecture -## In progress - ### v0.2 — Batch workflow and maintainability - Batch conversion for up to 20 images @@ -19,7 +17,7 @@ This roadmap describes intended directions, not guaranteed deadlines. Priorities - Automated tests and pull-request checks - Security, contribution, and community documentation -## Planned +## In progress ### v0.3 — Offline installation @@ -28,6 +26,8 @@ This roadmap describes intended directions, not guaranteed deadlines. Priorities - Clear update behavior and cache controls - Browser-level offline tests +## Planned + ### v0.4 — Editing and access - Rotation and cropping diff --git a/assets/icon-192.png b/assets/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..0579bc95872e5e7814f9269c6933455f90b9f3bd GIT binary patch literal 5704 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 AB>(^b literal 0 HcmV?d00001 diff --git a/assets/icon-512.png b/assets/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..ac143f8a97faf4c59e16f3d0464819545c621ff5 GIT binary patch literal 8585 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 pH^D;HX%^((K-gxz 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 + + + + diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..fa77585 --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a209db9..b0cae5e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,6 +8,9 @@ ImageTool is a zero-dependency static web application. It can run from a local f - `src/app.js` owns browser interaction, image decoding, Canvas rendering, and downloads. - `src/core.js` contains deterministic formatting, filename, and resize calculations shared by the app and tests. - `src/styles.css` provides the responsive visual system. +- `manifest.webmanifest` describes the installable application experience. +- `service-worker.js` caches only the same-origin application shell for offline use. +- `assets/` contains source SVG and rasterized PWA icons. - `server.js` is an optional local development server built only with Node.js standard modules. ## Image data flow @@ -23,6 +26,7 @@ ImageTool is a zero-dependency static web application. It can run from a local f - Selected images must never be uploaded or included in a network request. - The application must not add analytics, advertising, remote scripts, or remote fonts. +- Offline caching must remain limited to same-origin application assets and must not cache selected images or converted results. - Output files are created from decoded pixels; source EXIF and other file metadata are not copied. - Features that would weaken these guarantees require prior public discussion and documentation. diff --git a/index.html b/index.html index 7e84485..423896b 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,8 @@ + + ImageTool — 私密的本地图片工具 @@ -15,12 +17,15 @@ I ImageTool -
- - 100% 本地处理 +
+ +
+ + 100% 本地处理 +
@@ -125,7 +130,10 @@

把图片拖到这里

ImageTool · 你的图片只属于你 - GitHub +
diff --git a/manifest.webmanifest b/manifest.webmanifest new file mode 100644 index 0000000..406c1e1 --- /dev/null +++ b/manifest.webmanifest @@ -0,0 +1,45 @@ +{ + "name": "ImageTool — Private Image Converter", + "short_name": "ImageTool", + "description": "Batch-convert and resize images privately in your browser.", + "lang": "zh-CN", + "id": "./", + "start_url": "./", + "scope": "./", + "display": "standalone", + "prefer_related_applications": false, + "background_color": "#f5f1e8", + "theme_color": "#2e4a37", + "icons": [ + { + "src": "./assets/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "./assets/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "./assets/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "./assets/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "./assets/icon-maskable.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "maskable" + } + ] +} diff --git a/package.json b/package.json index b2993ad..e4d1a7f 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "imagetool", "private": true, - "version": "0.2.0", + "version": "0.3.0", "description": "A private, browser-based image converter with zero dependencies.", "scripts": { "dev": "node server.js", - "check": "node --check src/core.js && node --check src/app.js && node --check server.js", + "check": "node --check src/core.js && node --check src/app.js && node --check service-worker.js && node --check server.js", "test": "node --test tests/*.test.js" }, "keywords": ["image", "converter", "privacy", "png", "jpeg", "webp"], diff --git a/server.js b/server.js index 3eea29f..1a6855d 100644 --- a/server.js +++ b/server.js @@ -9,6 +9,8 @@ const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', + '.webmanifest': 'application/manifest+json; charset=utf-8', + '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', diff --git a/service-worker.js b/service-worker.js new file mode 100644 index 0000000..a8f45ab --- /dev/null +++ b/service-worker.js @@ -0,0 +1,63 @@ +const CACHE_NAME = 'imagetool-v0.3.0' +const APP_SHELL = [ + './', + './index.html', + './manifest.webmanifest', + './assets/icon-192.png', + './assets/icon-512.png', + './assets/icon-maskable-512.png', + './assets/icon.svg', + './assets/icon-maskable.svg', + './src/styles.css', + './src/core.js', + './src/app.js', +] + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => cache.addAll(APP_SHELL)) + .then(() => self.skipWaiting()), + ) +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + Promise.all([ + caches.keys().then((keys) => Promise.all( + keys + .filter((key) => key.startsWith('imagetool-') && key !== CACHE_NAME) + .map((key) => caches.delete(key)), + )), + self.clients.claim(), + ]), + ) +}) + +self.addEventListener('fetch', (event) => { + const request = event.request + const url = new URL(request.url) + if (request.method !== 'GET' || url.origin !== self.location.origin) return + + if (request.mode === 'navigate') { + event.respondWith( + fetch(request) + .then((response) => { + const copy = response.clone() + caches.open(CACHE_NAME).then((cache) => cache.put('./index.html', copy)) + return response + }) + .catch(() => caches.match('./index.html')), + ) + return + } + + event.respondWith( + caches.match(request).then((cached) => cached || fetch(request).then((response) => { + if (!response.ok) return response + const copy = response.clone() + caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)) + return response + })), + ) +}) diff --git a/src/app.js b/src/app.js index d485c7b..17f5ade 100644 --- a/src/app.js +++ b/src/app.js @@ -12,8 +12,10 @@ const elements = { fileMeta: document.querySelector('#file-meta'), fileName: document.querySelector('#file-name'), formatButtons: [...document.querySelectorAll('[data-format]')], + installButton: document.querySelector('#install-button'), maxHeight: document.querySelector('#max-height'), maxWidth: document.querySelector('#max-width'), + networkStatus: document.querySelector('#network-status'), previewImage: document.querySelector('#preview-image'), quality: document.querySelector('#quality'), qualityOutput: document.querySelector('#quality-output'), @@ -32,6 +34,7 @@ let currentFiles = [] let previewUrl = '' let resultUrls = [] let outputFormat = 'image/webp' +let deferredInstallPrompt = null function showError(message = '') { elements.errorMessage.textContent = message @@ -268,6 +271,43 @@ elements.downloadAllButton.addEventListener('click', () => { links.forEach((link, index) => setTimeout(() => link.click(), index * 180)) }) +function updateNetworkStatus() { + const isOnline = navigator.onLine + elements.networkStatus.textContent = isOnline ? '在线' : '离线可用' + elements.networkStatus.classList.toggle('is-offline', !isOnline) +} + +window.addEventListener('beforeinstallprompt', (event) => { + event.preventDefault() + deferredInstallPrompt = event + elements.installButton.classList.remove('hidden') +}) + +elements.installButton.addEventListener('click', async () => { + if (!deferredInstallPrompt) return + deferredInstallPrompt.prompt() + await deferredInstallPrompt.userChoice + deferredInstallPrompt = null + elements.installButton.classList.add('hidden') +}) + +window.addEventListener('appinstalled', () => { + deferredInstallPrompt = null + elements.installButton.classList.add('hidden') +}) + +window.addEventListener('online', updateNetworkStatus) +window.addEventListener('offline', updateNetworkStatus) +updateNetworkStatus() + +if ('serviceWorker' in navigator && location.protocol !== 'file:') { + window.addEventListener('load', () => { + navigator.serviceWorker.register('./service-worker.js').catch((error) => { + console.warn('ImageTool offline support could not be enabled.', error) + }) + }) +} + window.addEventListener('beforeunload', () => { if (previewUrl) URL.revokeObjectURL(previewUrl) resultUrls.forEach((url) => URL.revokeObjectURL(url)) diff --git a/src/styles.css b/src/styles.css index cc5f0d5..60fb6ad 100644 --- a/src/styles.css +++ b/src/styles.css @@ -37,6 +37,21 @@ button, a { -webkit-tap-highlight-color: transparent; } justify-content: space-between; } +.header-actions { display: flex; align-items: center; gap: 9px; } + +.install-button { + padding: 8px 12px; + color: #fff; + border: 0; + border-radius: 999px; + background: #2e4a37; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.install-button:hover { background: #223e2c; } + .brand { display: inline-flex; gap: 10px; @@ -453,6 +468,11 @@ footer { } footer a { color: #59685b; font-weight: 600; text-decoration: none; } +.footer-links { display: flex; align-items: center; gap: 15px; } +.network-status { display: inline-flex; align-items: center; gap: 5px; } +.network-status::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #6e8a71; } +.network-status.is-offline { color: #52705a; font-weight: 600; } +.network-status.is-offline::before { background: #d49353; } .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } @media (max-width: 720px) { @@ -470,6 +490,7 @@ footer a { color: #59685b; font-weight: 600; text-decoration: none; } } @media (max-width: 430px) { + .install-button { padding: 8px 10px; font-size: 10px; } .privacy-pill span { display: none; } .privacy-pill { padding: 8px; } .tool-card { padding: 7px; border-radius: 20px; } diff --git a/tests/pwa.test.js b/tests/pwa.test.js new file mode 100644 index 0000000..697e1be --- /dev/null +++ b/tests/pwa.test.js @@ -0,0 +1,54 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const projectRoot = path.resolve(__dirname, '..') +const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, 'manifest.webmanifest'), 'utf8')) +const serviceWorker = fs.readFileSync(path.join(projectRoot, 'service-worker.js'), 'utf8') + +function readPngDimensions(filePath) { + const buffer = fs.readFileSync(filePath) + assert.equal(buffer.toString('ascii', 1, 4), 'PNG') + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) } +} + +test('web app manifest has installable app metadata', () => { + assert.equal(manifest.display, 'standalone') + assert.equal(manifest.start_url, './') + assert.equal(manifest.scope, './') + assert.equal(manifest.id, './') + assert.ok(manifest.name.includes('ImageTool')) + assert.ok(manifest.icons.some((icon) => icon.purpose === 'any')) + assert.ok(manifest.icons.some((icon) => icon.purpose === 'maskable')) + assert.ok(manifest.icons.some((icon) => icon.type === 'image/png' && icon.sizes === '192x192')) + assert.ok(manifest.icons.some((icon) => icon.type === 'image/png' && icon.sizes === '512x512')) +}) + +test('manifest icons exist in the repository', () => { + manifest.icons.forEach((icon) => { + const iconPath = icon.src.replace(/^\.\//, '') + assert.ok(fs.existsSync(path.join(projectRoot, iconPath)), `${iconPath} should exist`) + }) +}) + +test('raster icons have their declared dimensions', () => { + const rasterIcons = manifest.icons.filter((icon) => icon.type === 'image/png') + rasterIcons.forEach((icon) => { + const [declaredWidth, declaredHeight] = icon.sizes.split('x').map(Number) + const iconPath = path.join(projectRoot, icon.src.replace(/^\.\//, '')) + assert.deepEqual(readPngDimensions(iconPath), { width: declaredWidth, height: declaredHeight }) + }) +}) + +test('service worker pre-caches the complete app shell', () => { + const expectedAssets = [ + './index.html', + './manifest.webmanifest', + './src/styles.css', + './src/core.js', + './src/app.js', + ] + expectedAssets.forEach((asset) => assert.ok(serviceWorker.includes(`'${asset}'`), `${asset} should be cached`)) + assert.match(serviceWorker, /CACHE_NAME = 'imagetool-v0\.3\.0'/) +}) From 5d3e9f4197be36b152efe587b77dcb77570e2cf6 Mon Sep 17 00:00:00 2001 From: fish34851-hash <317297427+fish34851-hash@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:20:13 +0800 Subject: [PATCH 2/2] feat: add privacy-friendly feedback flow --- .github/ISSUE_TEMPLATE/user_feedback.yml | 36 ++++++++++++++++++++++++ CHANGELOG.md | 1 + README.md | 2 ++ index.html | 2 ++ src/app.js | 20 +++++++++++++ src/styles.css | 4 +++ 6 files changed, 65 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/user_feedback.yml diff --git a/.github/ISSUE_TEMPLATE/user_feedback.yml b/.github/ISSUE_TEMPLATE/user_feedback.yml new file mode 100644 index 0000000..2846b67 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/user_feedback.yml @@ -0,0 +1,36 @@ +name: User feedback +description: Tell us how ImageTool worked for you +title: "[Feedback]: " +body: + - type: markdown + attributes: + value: Thanks for using ImageTool. Real usage feedback helps decide what to build next. + - type: dropdown + id: task + attributes: + label: What did you use ImageTool for? + options: + - Convert image formats + - Compress images + - Resize images + - Remove image metadata + - Offline use + - Something else + validations: + required: true + - type: textarea + id: outcome + attributes: + label: What worked or did not work? + description: Do not attach private or sensitive images. + validations: + required: true + - type: textarea + id: improvement + attributes: + label: What would make ImageTool more useful? + - type: input + id: browser + attributes: + label: Browser and device + placeholder: Edge on Windows 11, Safari on iPhone, etc. diff --git a/CHANGELOG.md b/CHANGELOG.md index 88b99b8..f16fdfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Installable Progressive Web App manifest and icons - Offline app shell powered by a privacy-preserving service worker - Install prompt and online/offline status indicator +- Privacy-friendly system share control and structured user feedback form - Automated checks for manifest and offline-cache completeness ## [0.2.0] - 2026-08-15 diff --git a/README.md b/README.md index 8c675c1..d1058b2 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ The offline service worker caches only same-origin ImageTool application files. Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening an issue or pull request. +Used ImageTool for a real task? [Share feedback](https://github.com/fish34851-hash/ImageTool/issues/new/choose) without uploading any private images. Reports from real users guide the roadmap. + Project documentation: - [Roadmap](ROADMAP.md) diff --git a/index.html b/index.html index 423896b..468775e 100644 --- a/index.html +++ b/index.html @@ -132,6 +132,8 @@

把图片拖到这里

ImageTool · 你的图片只属于你 diff --git a/src/app.js b/src/app.js index 17f5ade..a3f04ef 100644 --- a/src/app.js +++ b/src/app.js @@ -27,6 +27,7 @@ const elements = { resultList: document.querySelector('#result-list'), resultSummary: document.querySelector('#result-summary'), selectionCount: document.querySelector('#selection-count'), + shareButton: document.querySelector('#share-button'), workspace: document.querySelector('#workspace'), } @@ -271,6 +272,25 @@ elements.downloadAllButton.addEventListener('click', () => { links.forEach((link, index) => setTimeout(() => link.click(), index * 180)) }) +elements.shareButton.addEventListener('click', async () => { + const shareData = { + title: 'ImageTool', + text: '无需上传图片的批量转换与压缩工具', + url: 'https://fish34851-hash.github.io/ImageTool/', + } + + try { + if (navigator.share) await navigator.share(shareData) + else { + await navigator.clipboard.writeText(shareData.url) + elements.shareButton.textContent = '链接已复制' + setTimeout(() => { elements.shareButton.textContent = '分享' }, 1800) + } + } catch (error) { + if (error.name !== 'AbortError') showError('暂时无法分享,请复制浏览器地址。') + } +}) + function updateNetworkStatus() { const isOnline = navigator.onLine elements.networkStatus.textContent = isOnline ? '在线' : '离线可用' diff --git a/src/styles.css b/src/styles.css index 60fb6ad..cbbcc50 100644 --- a/src/styles.css +++ b/src/styles.css @@ -469,6 +469,8 @@ footer { footer a { color: #59685b; font-weight: 600; text-decoration: none; } .footer-links { display: flex; align-items: center; gap: 15px; } +.footer-button { padding: 0; color: #59685b; border: 0; background: transparent; font-size: inherit; font-weight: 600; cursor: pointer; } +.footer-button:hover, footer a:hover { color: #2f503a; } .network-status { display: inline-flex; align-items: center; gap: 5px; } .network-status::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #6e8a71; } .network-status.is-offline { color: #52705a; font-weight: 600; } @@ -497,6 +499,8 @@ footer a { color: #59685b; font-weight: 600; text-decoration: none; } .drop-zone { min-height: 300px; padding: 22px; } .format-option span { display: none; } footer span { max-width: 190px; } + .network-status { display: none; } + .footer-links { gap: 10px; } } @media (prefers-reduced-motion: reduce) {