Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions App-plans.md
Original file line number Diff line number Diff line change
@@ -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` 裡肚連結佢:`<link rel="manifest" href="manifest.json">`
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 會係一隻當好个選擇。
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192x192.png">
<link rel="icon" type="image/png" sizes="512x512" href="android-chrome-512x512.png">
<link rel="shortcut icon" href="favicon.ico">
<link rel="manifest" href="manifest.json">
<link rel="stylesheet" href="style.css" />
<link href="https://tauhu.tw/tauhu-oo.css" rel="stylesheet" />

Expand Down
29 changes: 29 additions & 0 deletions info.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 年度)
Expand Down
22 changes: 22 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
120 changes: 92 additions & 28 deletions service-worker.js
Original file line number Diff line number Diff line change
@@ -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;
});
})
);
});
Loading