diff --git a/App-plans.md b/App-plans.md new file mode 100644 index 0000000..4dc970b --- /dev/null +++ b/App-plans.md @@ -0,0 +1,178 @@ +# App 化規劃建議書 + +這位係針對「客源翠 HakSpring」網站 App 化个規劃建議,會分幾個部分來討論。 + +## 總體建議 + +考量到這隻網站目前係用 Vanilla JavaScript 開發,而且有「離線優先」个設計,這表示大部分个功能都做在前端,這對 App 化非常有幫助。 + +## 1. 用 Tauri 來開發? + +Tauri 係一隻用 Rust 為後端、任何前端框架為介面个跨平台應用程式開發框架。 + +### 優點 + +* **性能**: 因為後端係用 Rust,性能比用 Electron (JavaScript 後端) 个方案還較好。 +* **檔案較細**: 打包出來个應用程式比 Electron 細非常多。 +* **安全性**: Tauri 在設計上有較多个安全性考量。 +* **跨平台**: 一次開發做得打包成 Windows, macOS, Linux 应用程式。 + +### 缺點 + +* **行動裝置支援**: Tauri 對行動裝置个支援還在實驗階段 (alpha),可能還無恁穩定,而且需要另外設定。 +* **學習曲線**: 若然無熟悉 Rust,後端个部分會需要學習。毋過,若然 App 个主要邏輯還係在前端,就較無這隻問題。 + +### 結論 + +若然主要目標係 **桌面應用程式**,Tauri 係一隻非常好个選擇。若然主要目標係 **行動應用程式**,可能愛考慮其他方案,或者等到 Tauri 在行動裝置个支援穩定下來。 + +--- + +## 2. 開發 Android App + +目前最主流个方法係用 WebView 將現有个網站包起來。 + +### 步驟 + +1. **設定開發環境**: + * 安裝 [Android Studio](https://developer.android.com/studio)。 + * 安裝 Java Development Kit (JDK)。 + * 設定 Android SDK 摎模擬器 (Emulator)。 +2. **建立專案**: + * 在 Android Studio 裡肚建立一隻新專案。 + * 選擇 "Empty Activity" 模板。 +3. **加入 WebView**: + * 在 `activity_main.xml` 版面檔案裡肚加入一隻 `WebView` 元件。 +4. **設定 WebView**: + * 在 `MainActivity.java` (或 `MainActivity.kt`) 裡肚,設定 WebView 來載入網站个 URL。 + * 啟用 JavaScript: `webView.getSettings().setJavaScriptEnabled(true);` + * 處理離線功能: 因為網站有離線功能,愛確定 WebView 有啟用本地儲存 (DOM Storage) 摎 IndexedDB。 + ```java + // 啟用 DOM Storage API,這係 localStorage 摎 sessionStorage 需要个 + webView.getSettings().setDomStorageEnabled(true); + + // 啟用 IndexedDB。setDatabaseEnabled() 主要係為著舊版个 Web SQL,這隻 API 目前既經棄用。 + // IndexedDB 在啟用 JavaScript 後通常就會自動支援。 + webView.getSettings().setDatabaseEnabled(true); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + // 這行係為著 API 19 (KitKat) 以前个版本設定 Web SQL 路徑,目前已罕見。 + webView.getSettings().setDatabasePath("/data/data/" + this.getPackageName() + "/databases/"); + } + ``` +5. **打包 App**: + * 產生一隻簽署過个 APK (Android Package) 或 AAB (Android App Bundle)。 +6. **上架**: + * 到 [Google Play Console](https://play.google.com/console) 註冊開發者帳號,然後上傳 App。 + +### 測試環境 + +* **Android 模擬器**: Android Studio 內建个模擬器做得模擬無共樣个裝置摎 Android 版本。 +* **實體裝置**: 直接用 USB 連到電腦,在實體手機上測試。 + +--- + +## 3. 開發 iOS App + +同 Android 相像,iOS 也係用 WebView (WKWebView) 來包裝。 + +### 步驟 + +1. **設定開發環境**: + * 需要一臺運行 macOS 个電腦。 + * 安裝 [Xcode](https://developer.apple.com/xcode/)。 + * 註冊 Apple Developer 帳號 (若愛上架到 App Store)。 +2. **建立專案**: + * 在 Xcode 裡肚建立一隻新專案。 + * 選擇 "App" 模板,介面用 "Storyboard"。 +3. **加入 WKWebView**: + * 打開 `Main.storyboard`,從元件庫拖一隻 "WebKit View" 到畫面上。 +4. **設定 WKWebView**: + * 在 `ViewController.swift` 裡肚,用程式碼控制 WKWebView 來載入網站。 + * 確定有處理本地資料个權限。iOS 个 WKWebView 預設就會支援 IndexedDB。 + ```swift + import UIKit + import WebKit + + class ViewController: UIViewController, WKUIDelegate { + + var webView: WKWebView! + + override func loadView() { + let webConfiguration = WKWebViewConfiguration() + webView = WKWebView(frame: .zero, configuration: webConfiguration) + webView.uiDelegate = self + view = webView + } + + override func viewDidLoad() { + super.viewDidLoad() + + // 用 guard let 安全地處理 URL,避免因無效 URL 造成閃退 + guard let myURL = URL(string:"https://gohakka.org/hak-ka-source-sui/") else { // 請換成網站个 URL + print("Error: Invalid URL") + return + } + + let myRequest = URLRequest(url: myURL) + webView.load(myRequest) + } + } + ``` +5. **打包 App**: + * 用 Xcode 來建置 (Build) 摎歸檔 (Archive) 專案。 +6. **上架**: + * 用 "Transporter" app 將建置好个檔案上傳到 [App Store Connect](https://appstoreconnect.apple.com/)。 + +### 測試環境 + +* **iOS 模擬器**: Xcode 內建个模擬器,做得模擬無共樣个 iPhone 摎 iPad。 +* **實體裝置**: 需要 Apple Developer 帳號正做得在實體 iPhone 上安裝測試。 + +--- + +## 4. 開發桌面 PWA (Progressive Web App) + +PWA 係分網站做得像原生 App 一樣安裝到電腦桌面或手機主畫面。這隻網站既經有 `service-worker.js`,表示佢有 PWA 个基礎。 + +### 必要性 + +**非常高**。因為: + +* **成本最低**: 無需要另外寫程式碼,淨愛確定 `manifest.json` (或相應个設定) 摎 `service-worker.js` 有設定好。 +* **跨平台**: 所有支援个瀏覽器 (Chrome, Edge, Safari) 都做得安裝。 +* **體驗當好**: 使用者做得直接從網站安裝,無需要透過 App 市集。安裝以後就同一般个應用程式共樣。 + +### 建議步驟 + +1. **檢查 Service Worker**: 確定 `service-worker.js` 有正確快取所有必要个檔案,提供良好个離線體驗。 +2. **建立 Web App Manifest**: 這隻專案目前無 `manifest.json`,愛加一隻。這隻檔案會定義 App 个名稱、圖示、啟動畫面等。 + * 建立一隻 `manifest.json` 檔案。 + * 在 `index.html` 裡肚連結佢:`` +3. **設定 `manifest.json`**: + ```json + { + "short_name": "客源翠", + "name": "客源翠 HakSpring", + "icons": [ + { + "src": "android-chrome-192x192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "android-chrome-512x512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#ffffff", + "background_color": "#ffffff" + } + ``` +4. **提供明確个安裝引導**: 在網站上加一隻按鈕或係說明,教使用者仰般安裝 PWA。 + +### 總結 + +PWA 係目前對這隻專案來講,投資報酬率最高个方案。建議優先完成 PWA 个設定,然後正來考慮用 WebView 包裝成 Android 摎 iOS App。若係未來有桌面應用程式个需求,Tauri 會係一隻當好个選擇。 \ No newline at end of file diff --git a/index.html b/index.html index d00f37f..d9fc87a 100644 --- a/index.html +++ b/index.html @@ -16,6 +16,7 @@ + diff --git a/info.md b/info.md index 6294acf..c7bd077 100644 --- a/info.md +++ b/info.md @@ -47,6 +47,35 @@ - 空白鍵:開始自動放送最新進度/暫停 - Esc:停止放送 +## 「安裝」客源翠 App + +這隻網站係一隻「漸進式網路應用程式 (PWA)」,你做得像安裝普通 App 共樣,將佢加到手機或電腦个主畫面,用起來會還較利便。 + +### Android (Chrome 瀏覽器) +1. 用 Chrome 打開客源翠網站。 +2. 點選瀏覽器右上角个「選單」撳鈕 (通常係三隻點)。 +3. 在跳出來个選單裡肚,尋並點選「**安裝應用程式**」或「**加到主畫面**」。 +4. 確認安裝,客源翠个圖示就會出現在你个應用程式列表。 + +### Android (Firefox 瀏覽器) +1. 用 Firefox 打開客源翠網站。 +2. 點選瀏覽器右下角或右上角个「選單」撳鈕 (三隻點)。 +3. 尋並點選「**安裝**」。 +4. 確認以後,圖示就會加到主畫面。 + +### iOS / iPadOS (Safari 瀏覽器) +雖然 iOS 無完整支援 PWA 个所有功能 (例如離線快取),但係你還係做得將佢加到主畫面,用起來像 App 共樣。 +1. 用 Safari 打開客源翠網站。 +2. 點選下底或頂項工具列个「**分享**」撳鈕 (一個有向上箭頭个四方框)。 +3. 在分享選單裡肚,向下滑動,尋並點選「**加到主畫面**」。 +4. 你做得自家改 App 个名仔,然後點「新增」,圖示就會出現在主畫面上。 + +### 電腦 (Chrome, Edge, 其他 Chromium 核心瀏覽器) +1. 用瀏覽器打開客源翠網站。 +2. 在網址列个右片,你會看著一隻有向下箭頭个「**安裝**」圖示。 +3. 點選該隻圖示,然後在跳出來个視窗點「安裝」。 +4. 客源翠就會變成一隻獨立个視窗應用程式,你做得將佢釘在工具列上。 + ## 資料來源 - 認證詞句文字:哈客網路學院 ODS 檔(2024 年度) diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..931f06c --- /dev/null +++ b/manifest.json @@ -0,0 +1,22 @@ +{ + "short_name": "客源翠", + "name": "客源翠 HakSpring", + "description": "客話詞典、認證詞彙分類放送學習網站", + "icons": [ + { + "src": "android-chrome-192x192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "android-chrome-512x512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "scope": "/", + "display": "standalone", + "theme_color": "#ffffff", + "background_color": "#ffffff" +} \ No newline at end of file diff --git a/service-worker.js b/service-worker.js index 8e3ffbe..400f371 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,52 +1,116 @@ // service-worker.js -const CACHE_NAME = 'audio-cache-v1'; +const STATIC_CACHE_NAME = 'hakspring-static-v2'; // Incremented version +const AUDIO_CACHE_NAME = 'hakspring-audio-v1'; +const CACHE_VERSION = 2; // Corresponds to static cache version + +// App Shell files +const STATIC_ASSETS = [ + '/', + 'index.html', + 'style.css', + 'main.js', + 'js/romanizer.js', + 'manifest.json', + 'apple-touch-icon.png', + 'favicon-32x32.png', + 'favicon-16x16.png', + 'android-chrome-192x192.png', + 'android-chrome-512x512.png', + 'favicon.ico', + 'og-image.png', + 'empty_category.mp3', + 'endOfPlay.mp3', + 'info.md', + 'whatsnew.md' +]; // --- Service Worker Lifecycle --- self.addEventListener('install', (event) => { - console.log('Service Worker: Installing...'); - // The Service Worker is installed. - // We don't need to pre-cache anything here, caching will be done on the fly. - event.waitUntil(self.skipWaiting()); + console.log(`Service Worker v${CACHE_VERSION}: Installing...`); + event.waitUntil( + caches.open(STATIC_CACHE_NAME).then((cache) => { + console.log('Service Worker: Caching static assets.'); + return cache.addAll(STATIC_ASSETS); + }).then(() => { + // Force the waiting service worker to become the active service worker. + return self.skipWaiting(); + }) + ); }); self.addEventListener('activate', (event) => { - console.log('Service Worker: Activating...'); - // Clean up old caches if necessary. - event.waitUntil(self.clients.claim()); + console.log(`Service Worker v${CACHE_VERSION}: Activating...`); + // Clean up old caches. + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + // Delete caches that are not the current static or audio cache + if (cacheName !== STATIC_CACHE_NAME && cacheName !== AUDIO_CACHE_NAME) { + console.log('Service Worker: Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }).then(() => { + // Tell the active service worker to take control of the page immediately. + return self.clients.claim(); + }) + ); }); // --- Fetch Event for Caching --- self.addEventListener('fetch', (event) => { - // We only want to cache audio files. - if (event.request.url.endsWith('.mp3')) { + const { request } = event; + const url = new URL(request.url); + + // Ignore non-GET requests + if (request.method !== 'GET') { + return; + } + + // Ignore requests to Google Analytics + if (url.hostname.includes('googletagmanager.com')) { + return; + } + + // Strategy for audio files: Cache on demand (cache-first) + if (url.pathname.endsWith('.mp3')) { event.respondWith( - caches.open(CACHE_NAME).then((cache) => { - return cache.match(event.request).then((response) => { - // If the file is already in the cache, serve it. - if (response) { - console.log('SW: Serving audio from cache:', event.request.url); - return response; + caches.open(AUDIO_CACHE_NAME).then((cache) => { + return cache.match(request).then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; } - // If not in cache, fetch from the network, cache it, and then return it. - return fetch(event.request).then((networkResponse) => { - console.log('SW: Caching new audio file:', event.request.url); - // We need to clone the response because a response can only be consumed once. - cache.put(event.request, networkResponse.clone()); + return fetch(request).then((networkResponse) => { + console.log('SW: Caching new audio file:', request.url); + cache.put(request, networkResponse.clone()); return networkResponse; - }).catch(error => { - console.error('SW: Fetching and caching audio failed:', error); - // If fetch fails, we can't do much, just let the browser handle the error. - throw error; }); }); }) ); - } else { - // For all other requests, just let the browser handle it. - event.respondWith(fetch(event.request)); + return; // Done with audio } + + // Strategy for all other requests: Cache-first, falling back to network, then caching the new response. + event.respondWith( + caches.match(request).then((cachedResponse) => { + return cachedResponse || fetch(request).then((networkResponse) => { + // Check if we received a valid response + if (networkResponse && networkResponse.status === 200) { + const responseToCache = networkResponse.clone(); + caches.open(STATIC_CACHE_NAME).then((cache) => { + cache.put(request, responseToCache); + console.log('SW: Cached new static resource:', request.url); + }); + } + return networkResponse; + }); + }) + ); }); \ No newline at end of file diff --git a/whatsnew.md b/whatsnew.md index 43677fc..433519f 100644 --- a/whatsnew.md +++ b/whatsnew.md @@ -1,12 +1,10 @@ - + [**<<< 回資訊主頁**](#show-info) # 「客源翠 HakSpring」新消息 -20251009 14:01 - -### 9/17 進前就有用个人,請改用 [hakspring.pages.dev](https://hakspring.pages.dev/) 網址斯好 lió -#### 嗨~大自家~客源翠的舊網址 `aiuanyu.github.io` 已經設計打包舊進度過來。而新網址的進度也會受到保護。這項遷移只需要做一次就好,之後大家可以直接用 [hakspring.pages.dev](https://hakspring.pages.dev/) 網址(可加入書籤/最愛或桌面捷徑)~ +20251014 +- [新] 做得直接在 Android、電腦瀏覽器「安裝」客源翠 app(iOS 係同網頁加去主畫面);[相關說明連去](#show-info) - 2025/10/09 [新] 對舊網址搬徙過來个時節,會保護新進度 - 2025/9/29 [整好] iOS「長按」客話例句个字詞 →「查讀音」正常運作 - (其他平台本旦斯有)