summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--LICENSE9
-rw-r--r--README.md6
-rw-r--r--doc/Design.md142
-rw-r--r--doc/Security.md32
-rw-r--r--doc/Specifications.md60
-rw-r--r--doc/Testing-Results.md27
-rw-r--r--manifest.json2
-rw-r--r--src/background/index.js32
-rw-r--r--src/background/menu-translation.js28
-rw-r--r--src/background/message-router.js151
-rw-r--r--src/background/settings-service.js9
-rw-r--r--src/background/translation-service.js319
-rw-r--r--src/popup/popup.css73
-rw-r--r--src/popup/popup.html56
-rw-r--r--src/popup/popup.js184
-rw-r--r--src/shared/constants.js18
-rw-r--r--tests/all-tests.js300
-rw-r--r--tests/ui-smoke-tests.js36
18 files changed, 1317 insertions, 167 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..724224a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,9 @@
+The MIT License (MIT)
+
+Copyright © 2026 <copyright holders>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file
diff --git a/README.md b/README.md
index 8c16128..040defa 100644
--- a/README.md
+++ b/README.md
@@ -90,4 +90,8 @@ ollama pull llama3.2
- `doc/Specifications.md` — 機能仕様
- `doc/Design.md` — 設計
- `doc/Security.md` — セキュリティ規定
-- `doc/Testing-Results.md` — テスト実行結果 \ No newline at end of file
+- `doc/Testing-Results.md` — テスト実行結果
+
+## ライセンス
+
+MIT \ No newline at end of file
diff --git a/doc/Design.md b/doc/Design.md
index 20de3e5..bc7f1ff 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -2,29 +2,32 @@
## 1. 設計概要
-本拡張は、Webページとの接点をContent Scriptに限定し、権限を伴うOllama通信と設定管理をBackgroundへ分離する。
+本拡張は、内容の翻訳接点をContent Script、権限の Ollama comunicación y administración de configuración を Background 内に分離する。Firefox内蔵PDF Viewerなどの content script 非対応ページおよび通常Webページの両方で、selectionベースの翻訳を context menuから実行できるようにする。
```text
-Web Page
- │
- │ selection
- ▼
-Content Script
- │ runtime.Port
- ▼
-Background
- │ fetch / HTTP
- ▼
-Ollama
- │ streaming
- ▼
-Background
- │ runtime.Port
- ▼
-Content Script
- │
- ▼
-Shadow DOM Popover
+Web Page / PDF Viewer
+ │
+ │ selection
+ ▼
+ selection経由
+ │
+ ├─ Content Script (通常ページのみ: 翻訳アイコン → Shadow DOM Popover)
+ ▼ │
+ ├─ menus API (任意ページ: PDF Viewer含むselection → context menu → Action Popup)
+ │ │
+ ▼ ▼
+ Background
+ │ fetch / HTTP
+ ▼
+ Ollama
+ │ streaming
+ ▼
+ Background + TranslationSession
+ │ runtime.Port / popup state
+ ▼
+ UI
+ ├─ Content Script: Shadow DOM Popover
+ └─ Action Popup: 翻訳結果モード
```
設計原則:
@@ -33,7 +36,7 @@ Shadow DOM Popover
- Ollama通信はBackgroundのみ(Ollama REST API + Fetch API + NDJSON streaming)。
- UI文字列はHTMLとして評価しない。
- 翻訳要求は常に1-shot。
-- 同時翻訳は1件に制限。
+- 同時翻訳は1件に制限(新規要求で旧要求abort)。
- 設定値はUIだけでなくBackgroundでも検証する。
- Ollama接続先は初期版ではlocalhost / 127.0.0.1 + 任意ポートのみ許可する。
@@ -47,11 +50,12 @@ Shadow DOM Popover
| `content/action-button` | 翻訳アイコン表示/非表示 |
| `content/translation-popover` | 翻訳結果UI、stream描画、copy/regenerate/close |
| `content/port-client` | Backgroundとの長期接続 |
-| `background/message-router` | Content Scriptからのrequest受信・validation |
+| `background/message-router` | Content Scriptからのrequest受信・validation(popup state 共有) |
+| `background/menu-controller` | context menu登録・selectionText取得・popup開き・selection経由翻訳 |
| `background/translation-session` | requestId、AbortController、active stream管理 |
| `background/ollama-client` | Ollama REST API呼び出し(fetch + NDJSON streaming) |
| `background/settings-service` | storage読書き、default、validation |
-| `popup` | 有効/無効、簡易設定、Options起動 |
+| `popup` | 設定UI + 翻訳結果モード(streaming表示、copy/regenerate、error表示) |
| `options` | 全設定UI |
| `shared/settings-schema` | 設定型、既定値、範囲 |
| `shared/messages` | runtime message型定義 |
@@ -113,7 +117,7 @@ Shadow DOM Popover
"manifest_version": 3,
"name": "Selected Text Translator",
"version": "0.1.0",
- "permissions": ["storage"],
+ "permissions": ["storage", "menus"],
"host_permissions": ["http://localhost/*", "http://127.0.0.1/*"],
"background": {
"scripts": ["background.js"],
@@ -143,7 +147,7 @@ Shadow DOM Popover
- `scripting` 権限は初期版では付与しない。
- `tabs` 権限は付与しない。
- localhost以外のhost permissionは初期版では付与しない。
-- Firefoxのmatch patternはポート番号指定に対応していない(Firefox bug 1362809 / 1468162)。`http://localhost/*` および `http://127.0.0.1/*` は任意ポートに一致するため、実際に通信してよい接続先はアプリ側Host validatorで `http://localhost:<port>` / `http://127.0.0.1:<port>` のみに限定する。
+- `menus` permission は selection context の翻訳のためのみに使用する。
---
@@ -250,6 +254,41 @@ ERROR
- Abortされた旧requestのchunkはrequestId不一致なら無視。
- Error時は再生成可能な状態に戻す。
+### 7.4 Action Popup 翻訳結果モード
+
+Pdf viewerやcontext menu経由翻訳では Action Popup が翻訳結果を表示する。状態機械と表示内容はPopoverと同様。
+
+状態:
+
+```text
+IDLE
+STARTING
+STREAMING
+COMPLETED
+ERROR
+CANCELLED
+```
+
+* `starting`: 翻訳 request を backgroundへ送信し、まだ started/chunk を受け取っていない状態
+* `streaming`: chunk受信中
+* `completed`: chunk完了(最後のchunkはdone:trueと推定)
+* `error`: ollamaエラーまたは request中止
+* `cancelled`: ユーザー cancel(request abort または新規 request abort)
+
+表示内容:
+
+| 要素 | 内容 |
+|---|---|
+| Header | 翻訳結果 |
+| TranslatedText | streaming翻訳本文 |
+| Meta | Model名 / Ollama |
+| Footer | コピー / 再生成 / 状態表示 / 「設定へ戻る」 |
+
+* popupが streaming中に開かれた場合でも、その時点での translation textを表示し、以降のchunkを継続受信する。
+* popupが閉じても requestを自動cancelしない。
+* 新しい翻訳request開始時に古いrequestは abortされる(既存方針)。
+* request sourceは backgroundで metadataとして識別してよい(例: `"content"` / `"context-menu"`)。ただし Ollama request生成処理は共通(prompt、settings、options生成)。
+
---
## 8. Runtime通信設計
@@ -365,6 +404,29 @@ Ollama streaming開始
### 9.1 古いchunk対策
受信chunkごとにactive requestIdを確認し、不一致なら破棄する。
+### 9.2 Translation state 共有(popup連携)
+
+Backgroundは context menu / content script いずれの経由でも translation stateを保持し、popupが開いた時に最新状態を取得できるようにする。
+
+```js
+// module internal state (background)
+translationState = {
+ requestId, // string
+ sourceText, // string (validated)
+ translatedText, // accumulated chunk text
+ status, // idle | starting | streaming | completed | error | cancelled
+ model, // settings.model when started
+ error, // error message (or null)
+ startedAt, // epoch ms
+ source // 'content' | 'context-menu'
+}
+```
+
+* popupからの `GET_POPUP_STATE` runtime messageでこのstateを返す。
+* `onChunk` ごとに state を更新(translatedText累積、status=streaming)。
+* completed/error/cancelledでもstateを更新する。popupが開き直されたときに最終状態を表示できる。
+* 新しい翻訳request開始時には state を初期化(古いrequestはabort)。
+
---
## 10. OllamaClient設計
@@ -742,6 +804,13 @@ BackgroundのOllamaClientからモデル一覧取得APIを呼び、Settings UIã�
- chat stream
- Abort
+### Phase 4.5: Context Menu 翻訳(PDF Viewer対応)
+- menus permission 追加
+- menu-controller.js(selection context menu 登録、selectionText取得、popup open)
+- message-routerへ request 生成共通化(port / menu / popup 経由の request が同じ設定・prompt・options生成を利用)
+- backgroundに translation state 共有(requestId/sourceText/translatedText/status/model/error)
+- Action Popup 翻訳結果モード表示
+
### Phase 5: Settings
- storage.local
- Basic settings
@@ -784,6 +853,8 @@ BackgroundのOllamaClientからモデル一覧取得APIを呼び、Settings UIã�
| prompt builder | fixed + user prompt結合 |
| message validation | malformed message拒否 |
| length limit | 境界値 |
+| selectionText validation | context menu selectionText の型/trim/空/最大長 |
+| popup state | status遷移、requestId/sourceText/translatedText/model/error更新 |
### 23.2 Integration
@@ -797,6 +868,17 @@ Content request
→ complete
```
+Context menu flow:
+
+```text
+menus selection (selectionText)
+→ validation
+→ translation request
+→ mock Ollama stream
+→ TranslationSession
+→ popup result state更新
+```
+
Abortテスト:
```text
@@ -814,6 +896,7 @@ B chunks only displayed
- 選択文字列にHTMLタグ表現を含むページ
- CSSが強く指定されたページ
- スクロール位置が深いページ
+- Firefox内蔵PDF Viewer(context menu翻訳)
確認:
- 選択位置近傍UI
@@ -825,6 +908,11 @@ B chunks only displayed
- close
- settings persistence
- Ollama down/up
+- context menu表示(通常ページ / PDF Viewer)
+- context menu翻訳→popup streaming表示
+- popup copy / regenerate
+- 新規翻訳時の旧request cancel
+- PDF上のHTML/script風文字列が実行されないこと
---
@@ -848,6 +936,8 @@ B chunks only displayed
- [ ] LLM出力への`innerHTML`なし
- [ ] 不要permissionsなし
- [ ] iframe初期対応なし
+- [ ] `menus` permission は selection context translationのみに使用
+- [ ] PDF Viewer内部DOM / PDF.js内部オブジェクトへのアクセスなし
---
diff --git a/doc/Security.md b/doc/Security.md
index 956f169..66dbde0 100644
--- a/doc/Security.md
+++ b/doc/Security.md
@@ -196,6 +196,7 @@ Use modern APIs where required:
| Extension storage | `storage.local` |
| Long-lived extension communication | `runtime.connect()` / `runtime.Port` |
| One-shot extension messages | `runtime.sendMessage()` |
+| Selection context menu translation | `menus` (selection context only) |
| Dynamic script injection, only if needed | `scripting.executeScript()` |
### 5.4 APIs / Patterns to Avoid
@@ -234,6 +235,7 @@ Expected initial permission set:
```text
storage
+menus
```
Expected host permission:
@@ -249,6 +251,8 @@ or the minimum equivalent required by the final Ollama-host design.
Page access MAY require content-script match patterns for HTTP/HTTPS pages because translation is initiated by selecting page text.
+The `menus` permission is used exclusively for the selection-context translation entry point (context menu "選択したテキストを翻訳"). It MUST NOT be used for any other purpose (no page action items, no tab items, no bookmark items, no launcher items).
+
Do not add broad privileged permissions for convenience.
Every newly introduced permission MUST be justified in code review.
@@ -260,11 +264,11 @@ Every newly introduced permission MUST be justified in code review.
The system has three primary trust zones.
```text
-Untrusted Web Page
+Untrusted Web Page / PDF Viewer selection
|
- | selected text
+ | selected text (content script or menus.selectionText)
v
-Content Script
+Content Script / menu-controller (background)
|
| validated extension messages
v
@@ -324,6 +328,25 @@ The background component owns privileged application operations:
- request cancellation;
- Ollama response parsing.
+### 7.4 Firefox PDF Viewer and Context Menu Input
+
+Firefox's built-in PDF Viewer is privileged browser UI:
+
+- The extension MUST NOT attempt to inject content scripts into it.
+- The extension MUST NOT access its internal DOM, PDF.js internal objects,
+ `wrappedJSObject`, `cloneInto`, `exportFunction`, or any Firefox-internal
+ URI/API to work around the injection restriction.
+- PDF translation is provided exclusively through the public `menus` API.
+
+`menus.OnClickData.selectionText` is UNTRUSTED input:
+
+- it MUST pass the same validation pipeline as content-script selections
+ (string type check, trim, empty rejection, maximum length, control
+ character handling) before being sent to Ollama;
+- it MUST NOT be treated as trusted control input or rendered as HTML;
+- the translation result MUST be rendered in the Action Popup with
+ `textContent`-style plain-text rendering only.
+
---
## 8. DOM and XSS Rules
@@ -743,6 +766,9 @@ Before declaring implementation complete, verify all of the following.
- [ ] No deprecated WebExtensions API used when a supported current alternative exists.
- [ ] Permissions are minimal.
- [ ] No unnecessary `tabs`, `scripting`, `webRequest`, `cookies`, or `nativeMessaging` permission.
+- [ ] `menus` permission used only for selection-context translation.
+- [ ] No content-script injection or internal DOM access into the Firefox PDF Viewer.
+- [ ] `menus.OnClickData.selectionText` validated before use.
- [ ] Firefox behavior manually verified in `about:debugging`.
### DOM / Page Boundary
diff --git a/doc/Specifications.md b/doc/Specifications.md
index ac6600d..617ddfd 100644
--- a/doc/Specifications.md
+++ b/doc/Specifications.md
@@ -57,7 +57,7 @@ Firefox上でユーザーがWebページ内の文字列を選択した際、選æ
| SEL-03 | 選択解除時は翻訳アイコンを非表示にすること |
| SEL-04 | 選択範囲の位置を取得し、その近傍に翻訳アイコンを表示すること |
| SEL-05 | 初期版ではトップレベル文書のみ対象とし、iframe内は対象外とすること |
-| SEL-06 | Firefox内部ページ、Reader View、view-source、内蔵PDF Viewer等、Content Scriptを利用できないページは対象外とすること |
+| SEL-06 | Firefox内部ページ、Reader View、view-source等、Content Scriptを利用できないページは対象外とすること。Firefox内蔵PDF Viewerはcontent scriptでなく menus APIで対応する(PDF-01) |
### 4.2 翻訳アイコン
@@ -104,6 +104,40 @@ Firefox上でユーザーがWebページ内の文字列を選択した際、選æ
| EN-01 | 拡張機能の翻訳機能を設定画面から有効/無効に切り替えられること |
| EN-02 | 無効時は選択文字列に対して翻訳アイコンを表示しないこと |
+### 4.6 PDF Selection Translation
+
+Firefox内蔵PDF Viewer上でテキストを選択し、右クリックで context menu の「選択したテキストを翻訳」を実行すると翻訳を開始できること。
+
+| ID | 要件 |
+|---|---|
+| PDF-01 | Firefox内蔵PDF Viewerでのみ、context menu経由で翻訳を開始できること |
+| PDF-02 | context menu翻訳の結果は Action Popup に streaming 表示すること |
+| PDF-03 | PDF Viewer への content script注入・translation popover表示は対象外 |
+| PDF-04 | 独自PDF Viewer・PDF.js同梱・外部変換サービスの利用は対象外 |
+
+### 4.7 Context Menu Translation
+
+通常Webページでも同じ context menu操作を使用可能とすること。
+
+| ID | 要件 |
+|---|---|
+| CTX-01 | 選択文字列の `menus.OnClickData.selectionText` を取得し translationを実行できること |
+| CTX-02 | 通常ページとFirefox PDF Viewerで同じ操作を利用できること |
+| CTX-03 | `menus` permission は selection context translationのみに使用すること |
+
+### 4.8 Popup 2モード化
+
+Action Popupに「設定モード」と「翻訳結果モード」を実装すること。
+
+| ID | 要件 |
+|---|---|
+| POP-01 | Action Popupが設定UIと翻訳結果UIの2モードを切り替えられること |
+| POP-02 | 翻訳結果モードはコピー・再生成・エラー表示・status表示を提供すること |
+| POP-03 | popupが閉じられた場合でも翻訳requestを自動cancelしないこと |
+| POP-04 | 新しい翻訳request開始時に進行中 requestを abortしてもよい |
+| POP-05 | popup 開いた時に最新の translation stateを取得できること |
+| POP-06 | streaming中にpopupが開かれた場合でも、その時点までのtranslation textを表示し、その後のchunkを継続受信できること |
+
---
## 5. 設定要件
@@ -352,6 +386,27 @@ Firefox上でユーザーがWebページ内の文字列を選択した際、選æ
13. ページ内のHTMLらしき文字列やscript文字列を選択してもDOM実行されない。
14. LLM出力にHTML文字列が含まれてもHTMLとして解釈されない。
+### 13.4 PDF Viewer実機動作確認
+
+以下の操作を実Firefox + 実Ollamaで確認すること。
+
+| # | 確認項目 |
+|---|---|
+| 1 | 通常Webページで文字列を選択する |
+| 2 | context menuに「選択したテキストを翻訳」が表示される |
+| 3 | menuからtranslationを開始できる |
+| 4 | Firefox内蔵PDF ViewerでPDFを開く |
+| 5 | PDF内のテキストを選択する |
+| 6 | context menuに翻訳項目が表示される |
+| 7 | `selectionText` が取得される |
+| 8 | Action Popupが開く |
+| 9 | Ollama streaming結果が逐次表示される |
+| 10 | Copyが動く |
+| 11 | Regenerateが動く |
+| 12 | Ollama停止時に安全なエラー表示になる |
+| 13 | HTML/script風のPDF文字列を選択しても実行されない |
+| 14 | 新しい翻訳開始時に古いrequestが正しくcancelされる |
+
---
## 14. 受け入れ条件
@@ -379,7 +434,6 @@ Firefox上でユーザーがWebページ内の文字列を選択した際、選æ
- Chrome/Edge完全互換
- iframe内翻訳
-- Firefox内蔵PDF Viewerへの対応
- 翻訳履歴管理
- チャット機能
- 複数同時翻訳
@@ -389,3 +443,5 @@ Firefox上でユーザーがWebページ内の文字列を選択した際、選æ
- Markdown/HTMLリッチレンダリング
- 自動翻訳(選択しただけでLLM実行)
- `tfs_z` / `mirostat*`
+- Firefox内蔵PDF Viewerへのページ内translation icon / popover表示(content script注入不可のため)
+- 独自PDF Viewer・外部PDF変換サービス
diff --git a/doc/Testing-Results.md b/doc/Testing-Results.md
index b2cf153..a9abf8a 100644
--- a/doc/Testing-Results.md
+++ b/doc/Testing-Results.md
@@ -6,7 +6,7 @@
実行環境: Firefox系ブラウザ(Geckoエンジン)で `tests/test.html` を実行。npm / Node.js 不使用の自作ランナー。
-**結果: 71 passed, 0 failed**
+**結果: 85 passed, 0 failed**
### カバレッジ
@@ -28,6 +28,10 @@
| toExtensionError | 2 | AbortError→REQUEST_ABORTED、デフォルトコード |
| TranslationSession | 2 | 新規要求で旧要求Abort+旧portへの通知、isActive/clear |
| settings service | 3 | 保存/復元ラウンドトリップ、不正値フォールバック、onChangedキャッシュ更新 |
+| selectionText validation | 1 | **context menu選択文字列のuntrusted validation**(型/trim/空/control文字/最大長) |
+| context menu flow + popup state | 11 | **menus選択→翻訳→popup state streaming/completed**、空選択拒否、streaming状態遷移、Ollama停止時error、新規翻訳で旧state置換、regenerateLast、**popup-state portライブ配信**、誤名port拒否、**初期状態のモデル自動選択(未設定→先頭モデルを自動永続化)**、Ollama停止時のOLLAMA_UNAVAILABLE報告、エラー詳細のユーザー安全化(URL除去・長さ制限・JSON除去) |
+| UI smoke (popup) | 1 | popup.js実DOM実行、2モードUI初期化、settings mode初期表示 |
+| UI smoke (options) | 1 | options.js実DOM実行、言語/モデル/host反映 |
| message router: port flow | 5 | started/chunk/completed伝送、**A→B新規要求でAがabort+stale chunk廃棄**、不正メッセージ破棄、OLLAMA_UNAVAILABLE、INVALID_SETTINGS |
| message router: listModels | 2 | 正常系、非信頼sender拒否 |
@@ -52,6 +56,27 @@
| 13 | ページ内のHTMLらしき文字列やscript文字列を選択してもDOM実行されない | ☐ 未実施 |
| 14 | LLM出力にHTML文字列が含まれてもHTMLとして解釈されない | ☐ 未実施 |
+## 2.1 PDF Viewer実機動作確認(context menu翻訳)
+
+以下はSpecifications.md §13.4の14項目です。実Firefox + 実Ollamaで確認してください。
+
+| # | 確認項目 | 結果 |
+|---|---|---|
+| 1 | 通常Webページで文字列を選択する | ☐ 未実施 |
+| 2 | context menuに「選択したテキストを翻訳」が表示される | ☐ 未実施 |
+| 3 | menuからtranslationを開始できる | ☐ 未実施 |
+| 4 | Firefox内蔵PDF ViewerでPDFを開く | ☐ 未実施 |
+| 5 | PDF内のテキストを選択する | ☐ 未実施 |
+| 6 | context menuに翻訳項目が表示される | ☐ 未実施 |
+| 7 | `selectionText` が取得される | ☐ 未実施 |
+| 8 | Action Popupが開く | ☐ 未実施 |
+| 9 | Ollama streaming結果が逐次表示される | ☐ 未実施 |
+| 10 | Copyが動く | ☐ 未実施 |
+| 11 | Regenerateが動く | ☐ 未実施 |
+| 12 | Ollama停止時に安全なエラー表示になる | ☐ 未実施 |
+| 13 | HTML/script風のPDF文字列を選択しても実行されない | ☐ 未実施 |
+| 14 | 新しい翻訳開始時に古いrequestが正しくcancelされる | ☐ 未実施 |
+
## 3. セキュリティチェック
| 項目 | 結果 |
diff --git a/manifest.json b/manifest.json
index 05aef1b..ef97f7c 100644
--- a/manifest.json
+++ b/manifest.json
@@ -13,7 +13,7 @@
"48": "icons/icon-48.png",
"96": "icons/icon-96.png"
},
- "permissions": ["storage"],
+ "permissions": ["storage", "menus"],
"host_permissions": [
"http://localhost/*",
"http://127.0.0.1/*"
diff --git a/src/background/index.js b/src/background/index.js
index 9c97c9d..8e3c5f1 100644
--- a/src/background/index.js
+++ b/src/background/index.js
@@ -5,8 +5,9 @@
* event page can be woken by any of them (MDN: background scripts).
*/
-import { registerRuntimeMessageHandler, handleConnection } from './message-router.js';
+import { registerRuntimeMessageHandler, handleConnection, handlePopupStateConnection } from './message-router.js';
import { loadSettings, watchSettings } from './settings-service.js';
+import { startFromSelection } from './menu-translation.js';
// One-shot messages from popup / options.
registerRuntimeMessageHandler();
@@ -14,6 +15,35 @@ registerRuntimeMessageHandler();
// Streaming connections from the content script.
browser.runtime.onConnect.addListener(handleConnection);
+// Live translation state updates for the popup (translation result mode).
+browser.runtime.onConnect.addListener(handlePopupStateConnection);
+
+// Selection context menu (works on normal pages and the PDF Viewer).
+if (browser.menus && typeof browser.menus.create === 'function') {
+ browser.menus.create({
+ id: 'translate-selection',
+ title: '選択したテキストを翻訳',
+ contexts: ['selection']
+ });
+ browser.menus.onClicked.addListener((info) => {
+ if (!info || info.menuItemId !== 'translate-selection') return;
+ const result = startFromSelection(info.selectionText);
+ if (result.ok) {
+ // Open the toolbar popup as the result UI. openPopup() must be called
+ // synchronously within the click user gesture. It can reject when the
+ // popup is already open or on unsupported platforms - ignore safely.
+ try {
+ const p = browser.action.openPopup();
+ if (p && typeof p.catch === 'function') {
+ p.catch(() => {});
+ }
+ } catch {
+ // openPopup unavailable; the user can open the popup manually
+ }
+ }
+ });
+}
+
// Keep the validated settings cache in sync and load initial settings.
watchSettings();
void loadSettings(); \ No newline at end of file
diff --git a/src/background/menu-translation.js b/src/background/menu-translation.js
new file mode 100644
index 0000000..1465c53
--- /dev/null
+++ b/src/background/menu-translation.js
@@ -0,0 +1,28 @@
+/**
+ * Context-menu translation entry (background).
+ *
+ * Receives the raw `menus.OnClickData.selectionText`, validates it through
+ * the shared pipeline and starts a translation without a sink. The popup
+ * observes results via the shared translation state (GET_POPUP_STATE).
+ */
+
+import { validateSelectionText, runTranslation, messageForCode } from './translation-service.js';
+import { createRequestId } from '../shared/messages.js';
+import { ERROR_CODES } from '../shared/constants.js';
+
+/**
+ * Start a translation from a context-menu selection.
+ * @param {*} selectionText raw value from menus.OnClickData
+ * @returns {{ok: boolean, requestId?: string, error?: string, message?: string}}
+ */
+export function startFromSelection(selectionText) {
+ const validated = validateSelectionText(selectionText);
+ if (!validated.ok) {
+ return { ok: false, error: validated.code, message: messageForCode(validated.code) };
+ }
+ const requestId = createRequestId();
+ // No sink: the popup polls GET_POPUP_STATE and receives streaming updates
+ // through the shared translation state.
+ void runTranslation({ text: validated.text, requestId, source: 'context-menu', sink: null });
+ return { ok: true, requestId };
+} \ No newline at end of file
diff --git a/src/background/message-router.js b/src/background/message-router.js
index 9428312..cb9b69c 100644
--- a/src/background/message-router.js
+++ b/src/background/message-router.js
@@ -3,43 +3,25 @@
*
* - runtime.Port ('translation-stream'): translate / cancel requests from
* the content script, streaming started/chunk/completed/error back.
- * - runtime.onMessage: one-shot listModels request for popup/options.
+ * - runtime.onMessage: one-shot requests for popup/options:
+ * - listModels
+ * - getPopupState (popup translation result mode)
+ * - regenerateLast
*
- * All inbound messages are validated; invalid ones are discarded
- * (Specifications.md SEC-06, Design.md §8.4).
+ * The actual translation pipeline (validation, prompt, Ollama request,
+ * session/abort, shared state) lives in translation-service.js and is used
+ * by BOTH the content-script port flow and the context-menu flow, so there
+ * is a single request-generation implementation (Design.md §9.2).
*/
import {
- CONTENT_TO_BG,
- BG_TO_CONTENT,
RUNTIME_MESSAGES,
- MAX_SELECTION_LENGTH,
ERROR_CODES
} from '../shared/constants.js';
-import { isValidTranslateRequest, isValidCancelRequest, isTrustedSender } from '../shared/messages.js';
+import { isValidTranslateRequest, isValidCancelRequest, isTrustedSender, createRequestId } from '../shared/messages.js';
import { getSettings } from './settings-service.js';
-import { TranslationSession } from './translation-session.js';
-import { chatStream, listModels } from './ollama-client.js';
-import { buildSystemPrompt } from '../shared/prompt.js';
-import { buildOptions, toApiThinking } from '../shared/settings-schema.js';
-
-const session = new TranslationSession();
-
-/** User-facing messages per error code (kept minimal; no stack traces). */
-const ERROR_MESSAGES = {
- [ERROR_CODES.INVALID_REQUEST]: '要求が不正です。',
- [ERROR_CODES.TEXT_EMPTY]: '選択文字列が空です。',
- [ERROR_CODES.TEXT_TOO_LONG]: `選択文字列が長すぎます(上限 ${MAX_SELECTION_LENGTH} 文字)。`,
- [ERROR_CODES.OLLAMA_UNAVAILABLE]: 'Ollamaに接続できません。Ollamaが起動しているか確認してください。',
- [ERROR_CODES.MODEL_NOT_FOUND]: '指定されたモデルが見つかりません。設定画面でモデルを選び直してください。',
- [ERROR_CODES.INVALID_SETTINGS]: '設定値が不正です。設定画面を確認してください。',
- [ERROR_CODES.REQUEST_ABORTED]: '翻訳を中断しました。',
- [ERROR_CODES.STREAM_ERROR]: '翻訳中にエラーが発生しました。',
- [ERROR_CODES.UNKNOWN_ERROR]: '不明なエラーが発生しました。'
-};
-
-/** Message shown when no model is configured at all. */
-const NO_MODEL_MESSAGE = 'モデルが設定されていません。ツールバーアイコンまたは設定画面でモデルを選択してください。';
+import { listModels } from './ollama-client.js';
+import { runTranslation, getSession, getTranslationState, addPopupPort, removePopupPort } from './translation-service.js';
/**
* Handle one content-script port connection.
@@ -52,12 +34,12 @@ export function handleConnection(port) {
if (!isTrustedSender(port.sender)) return;
if (isValidTranslateRequest(message)) {
- void runTranslation(port, message.requestId, message.text);
+ void runTranslation({ text: message.text, requestId: message.requestId, source: 'content', sink: port });
return;
}
if (isValidCancelRequest(message)) {
- if (session.isActive(message.requestId)) {
- session.clear(message.requestId);
+ if (getSession().isActive(message.requestId)) {
+ getSession().clear(message.requestId);
}
return;
}
@@ -65,84 +47,27 @@ export function handleConnection(port) {
});
port.onDisconnect.addListener(() => {
- // If the tab goes away, abort the translation owned by this port.
- if (session.hasActive() && session.active.port === port) {
- const requestId = session.active.requestId;
- session.clear(requestId);
+ // If the tab goes away, clear the session entry owned by this port.
+ // The popup-facing translation state keeps whatever was accumulated so
+ // reopening the popup still shows the latest text.
+ if (getSession().hasActive() && getSession().active.port === port) {
+ const requestId = getSession().active.requestId;
+ getSession().clear(requestId);
}
});
}
/**
- * Run one translation: settings -> prompt -> Ollama stream -> port messages.
- * @param {object} port
- * @param {string} requestId
- * @param {string} text
- */
-async function runTranslation(port, requestId, text) {
- const settings = await getSettings();
-
- if (!settings.enabled) {
- postError(port, requestId, ERROR_CODES.INVALID_REQUEST, 'Translation is disabled');
- return;
- }
- if (!settings.model) {
- postError(port, requestId, ERROR_CODES.INVALID_SETTINGS, NO_MODEL_MESSAGE);
- return;
- }
-
- const controller = session.start(requestId, port);
-
- port.postMessage({ type: BG_TO_CONTENT.STARTED, requestId, model: settings.model });
-
- try {
- await chatStream({
- host: settings.ollamaHost,
- model: settings.model,
- messages: [
- { role: 'system', content: buildSystemPrompt(settings) },
- { role: 'user', content: text }
- ],
- think: toApiThinking(settings),
- options: buildOptions(settings),
- signal: controller.signal,
- onChunk: (content) => {
- // Drop chunks if a newer request took over.
- if (!session.isActive(requestId)) return;
- port.postMessage({ type: BG_TO_CONTENT.CHUNK, requestId, text: content });
- }
- });
- if (session.isActive(requestId)) {
- port.postMessage({ type: BG_TO_CONTENT.COMPLETED, requestId });
- }
- } catch (error) {
- const code = (error && error.code) || ERROR_CODES.UNKNOWN_ERROR;
- // Aborted requests are reported by TranslationSession.abortActive();
- // avoid a duplicate error for the same requestId.
- if (code !== ERROR_CODES.REQUEST_ABORTED && session.isActive(requestId)) {
- postError(port, requestId, code, error && error.message ? error.message : '');
- }
- } finally {
- session.clear(requestId);
- }
-}
-
-/**
- * @param {object} port
- * @param {string} requestId
- * @param {string} code
- * @param {string} detail optional concrete reason shown to the user
+ * Handle one popup-state port connection. The popup subscribes to live
+ * translation state updates (Design.md §7.4, §9.2).
+ * @param {object} port runtime.Port
*/
-function postError(port, requestId, code, detail) {
- const base = ERROR_MESSAGES[code] || ERROR_MESSAGES[ERROR_CODES.UNKNOWN_ERROR];
- // Append the concrete server/detail reason when provided so the popover
- // shows WHY it failed (e.g. the Ollama error body for HTTP 400).
- const message = detail && detail !== base ? `${base}\n(${detail})` : base;
- try {
- port.postMessage({ type: BG_TO_CONTENT.ERROR, requestId, code, message });
- } catch {
- // port may be disconnected
- }
+export function handlePopupStateConnection(port) {
+ if (!port || port.name !== 'popup-state') return;
+ addPopupPort(port);
+ port.onDisconnect.addListener(() => {
+ removePopupPort(port);
+ });
}
/**
@@ -164,6 +89,24 @@ export function registerRuntimeMessageHandler(browserApi) {
}
})();
}
+ if (message && message.type === RUNTIME_MESSAGES.GET_POPUP_STATE) {
+ return Promise.resolve({ ok: true, state: getTranslationState() });
+ }
+ if (message && message.type === RUNTIME_MESSAGES.REGENERATE_LAST) {
+ return (async () => {
+ try {
+ const state = getTranslationState();
+ if (!state.sourceText) {
+ return { ok: false, error: ERROR_CODES.TEXT_EMPTY };
+ }
+ const requestId = createRequestId();
+ void runTranslation({ text: state.sourceText, requestId, source: state.source || 'context-menu', sink: null });
+ return { ok: true, requestId };
+ } catch (error) {
+ return { ok: false, error: (error && error.code) || ERROR_CODES.UNKNOWN_ERROR };
+ }
+ })();
+ }
return Promise.resolve({ ok: false, error: ERROR_CODES.INVALID_REQUEST });
});
}
diff --git a/src/background/settings-service.js b/src/background/settings-service.js
index 2687921..55d2a8f 100644
--- a/src/background/settings-service.js
+++ b/src/background/settings-service.js
@@ -79,4 +79,13 @@ export function watchSettings(browserApi) {
cache = sanitizeSettings(changes.settings.newValue);
}
});
+}
+
+/**
+ * Test-only: clear the module-level settings cache so each test starts from
+ * storage state instead of inheriting the previous test's cache.
+ * @visibleForTesting
+ */
+export function __resetCacheForTests() {
+ cache = null;
} \ No newline at end of file
diff --git a/src/background/translation-service.js b/src/background/translation-service.js
new file mode 100644
index 0000000..f443237
--- /dev/null
+++ b/src/background/translation-service.js
@@ -0,0 +1,319 @@
+/**
+ * Translation service (background): shared translation pipeline.
+ *
+ * Both entry points use this module so there is exactly one request
+ * generation / streaming / state implementation:
+ * - content script (runtime.Port streaming, Design.md §8)
+ * - context menu / popup (translation state, Design.md §9.2)
+ *
+ * No PDF-specific translation engine exists; `source` is metadata only.
+ */
+
+import { getSettings, saveSettings } from './settings-service.js';
+import { TranslationSession } from './translation-session.js';
+import { chatStream, listModels } from './ollama-client.js';
+import { buildSystemPrompt } from '../shared/prompt.js';
+import { buildOptions, toApiThinking, sanitizeSettings, defaultSettings } from '../shared/settings-schema.js';
+import { BG_TO_CONTENT, MAX_SELECTION_LENGTH, ERROR_CODES } from '../shared/constants.js';
+
+const session = new TranslationSession();
+
+/** User-facing messages per error code (kept minimal; no stack traces). */
+const ERROR_MESSAGES = {
+ [ERROR_CODES.INVALID_REQUEST]: '要求が不正です。',
+ [ERROR_CODES.TEXT_EMPTY]: '選択文字列が空です。',
+ [ERROR_CODES.TEXT_TOO_LONG]: `選択文字列が長すぎます(上限 ${MAX_SELECTION_LENGTH} 文字)。`,
+ [ERROR_CODES.OLLAMA_UNAVAILABLE]: 'Ollamaに接続できません。Ollamaが起動しているか確認してください。',
+ [ERROR_CODES.MODEL_NOT_FOUND]: '指定されたモデルが見つかりません。設定画面でモデルを選び直してください。',
+ [ERROR_CODES.INVALID_SETTINGS]: '設定値が不正です。設定画面を確認してください。',
+ [ERROR_CODES.REQUEST_ABORTED]: '翻訳を中断しました。',
+ [ERROR_CODES.STREAM_ERROR]: '翻訳中にエラーが発生しました。',
+ [ERROR_CODES.UNKNOWN_ERROR]: '不明なエラーが発生しました。'
+};
+
+const NO_MODEL_MESSAGE = 'モデルが設定されていません。ツールバーアイコンまたは設定画面でモデルを選択してください。';
+
+/**
+ * Background translation state (Design.md §9.2). Shared by popup and
+ * menu-controller; the popup reads it via GET_POPUP_STATE.
+ * @type {{requestId: string|null, sourceText: string, translatedText: string,
+ * status: string, model: string, error: string|null, startedAt: number|null,
+ * source: string}}
+ */
+const state = {
+ requestId: null,
+ sourceText: '',
+ translatedText: '',
+ status: 'idle',
+ model: '',
+ error: null,
+ startedAt: null,
+ source: ''
+};
+
+/**
+ * @returns {object} a copy of the current translation state.
+ */
+export function getTranslationState() {
+ return { ...state, translatedText: state.translatedText };
+}
+
+/**
+ * Reset the shared state for a new request.
+ * @param {string} requestId
+ * @param {string} text validated source text
+ * @param {string} source 'content' | 'context-menu'
+ */
+function beginState(requestId, text, source) {
+ state.requestId = requestId;
+ state.sourceText = text;
+ state.translatedText = '';
+ state.status = 'starting';
+ state.model = '';
+ state.error = null;
+ state.startedAt = Date.now();
+ state.source = source;
+}
+
+/**
+ * Validate an untrusted selection string (content script or
+ * menus.selectionText - both are untrusted, Security.md §7.4).
+ * @param {*} text
+ * @returns {{ok: true, text: string}|{ok: false, code: string}}
+ */
+export function validateSelectionText(text) {
+ if (typeof text !== 'string') {
+ return { ok: false, code: ERROR_CODES.INVALID_REQUEST };
+ }
+ const trimmed = text.trim();
+ if (trimmed.length === 0) {
+ return { ok: false, code: ERROR_CODES.TEXT_EMPTY };
+ }
+ // Strip control characters except tab/newline/CR.
+ const cleaned = trimmed.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
+ if (cleaned.length === 0) {
+ return { ok: false, code: ERROR_CODES.TEXT_EMPTY };
+ }
+ if (cleaned.length > MAX_SELECTION_LENGTH) {
+ return { ok: false, code: ERROR_CODES.TEXT_TOO_LONG };
+ }
+ return { ok: true, text: cleaned };
+}
+
+/**
+ * @param {string} code
+ * @returns {string} user-facing message for the code
+ */
+export function messageForCode(code) {
+ return ERROR_MESSAGES[code] || ERROR_MESSAGES[ERROR_CODES.UNKNOWN_ERROR];
+}
+
+/**
+ * Ensure a model is configured before translating. On a fresh install the
+ * user may translate straight from a context menu without ever opening the
+ * settings UI, so storage.model would still be ''. In that case pick the
+ * first model reported by the running Ollama (best-effort, one attempt per
+ * runTranslation call) and persist it so the choice is visible in the UI.
+ * This mirrors the popup/options auto-select behavior so every path agrees.
+ * @param {object} browserApi injectable for tests
+ * @returns {Promise<{settings: object, error: string|null}>}
+ */
+async function ensureModelConfigured(browserApi) {
+ let settings = await getSettings(browserApi);
+ if (settings.model) {
+ return { settings, error: null };
+ }
+ try {
+ const models = await listModels(settings.ollamaHost);
+ if (models.length > 0) {
+ const merged = sanitizeSettings({ ...settings, model: models[0] });
+ await saveSettings(merged, browserApi);
+ settings = merged;
+ return { settings, error: null };
+ }
+ return { settings, error: NO_MODEL_MESSAGE };
+ } catch (error) {
+ // Ollama unreachable while listing models: report a clear error.
+ const code = (error && error.code) || ERROR_CODES.OLLAMA_UNAVAILABLE;
+ return { settings, error: messageForCode(code) };
+ }
+}
+
+/**
+ * Popup state subscribers. The popup connects a 'popup-state' port and
+ * receives the shared state after every change, so an open popup shows
+ * live streaming progress even for sink-less (context-menu) requests.
+ * @type {Set<object>}
+ */
+const popupPorts = new Set();
+
+/**
+ * Register a popup port for live state broadcasts.
+ * @param {object} port runtime.Port (name 'popup-state')
+ */
+export function addPopupPort(port) {
+ popupPorts.add(port);
+ try {
+ port.postMessage({ type: 'popup-state', state: getTranslationState() });
+ } catch {
+ // ignore
+ }
+}
+
+/**
+ * Unregister a popup port.
+ * @param {object} port
+ */
+export function removePopupPort(port) {
+ popupPorts.delete(port);
+}
+
+/**
+ * Broadcast the current state to every connected popup port.
+ */
+function broadcastState() {
+ for (const port of popupPorts) {
+ try {
+ port.postMessage({ type: 'popup-state', state: getTranslationState() });
+ } catch {
+ popupPorts.delete(port);
+ }
+ }
+}
+
+/**
+ * Run one translation and notify a sink with started/chunk/completed/error.
+ *
+ * The sink is an object with an optional postMessage(message) method. The
+ * content-script port is one sink; the popup state is updated internally and
+ * broadcast to connected popups so they can render live progress.
+ *
+ * @param {object} params
+ * @param {string} params.text validated source text
+ * @param {string} params.requestId
+ * @param {string} params.source 'content' | 'context-menu'
+ * @param {object|null} [params.sink] optional message sink (port-like)
+ * @returns {Promise<void>}
+ */
+export async function runTranslation({ text, requestId, source, sink = null, browserApi = null }) {
+ let settings = await getSettings(browserApi);
+
+ if (!settings.enabled) {
+ state.status = 'error';
+ state.error = messageForCode(ERROR_CODES.INVALID_REQUEST);
+ broadcastState();
+ postToSink(sink, { type: BG_TO_CONTENT.ERROR, requestId, code: ERROR_CODES.INVALID_REQUEST, message: state.error });
+ return;
+ }
+
+ // Fresh-install path: pick the first model from the running Ollama so a
+ // user who never opened the settings UI can still translate.
+ const ensured = await ensureModelConfigured(browserApi);
+ if (ensured.error) {
+ state.status = 'error';
+ state.error = ensured.error;
+ broadcastState();
+ postToSink(sink, { type: BG_TO_CONTENT.ERROR, requestId, code: ERROR_CODES.INVALID_SETTINGS, message: ensured.error });
+ return;
+ }
+ settings = ensured.settings;
+
+ // Registering the new request aborts any previous one.
+ const controller = session.start(requestId, sink);
+
+ beginState(requestId, text, source);
+ state.model = settings.model;
+ broadcastState();
+
+ postToSink(sink, { type: BG_TO_CONTENT.STARTED, requestId, model: settings.model });
+
+ try {
+ await chatStream({
+ host: settings.ollamaHost,
+ model: settings.model,
+ messages: [
+ { role: 'system', content: buildSystemPrompt(settings) },
+ { role: 'user', content: text }
+ ],
+ think: toApiThinking(settings),
+ options: buildOptions(settings),
+ signal: controller.signal,
+ onChunk: (content) => {
+ // Drop chunks if a newer request took over.
+ if (!session.isActive(requestId)) return;
+ if (content) {
+ state.translatedText += content;
+ state.status = 'streaming';
+ broadcastState();
+ }
+ postToSink(sink, { type: BG_TO_CONTENT.CHUNK, requestId, text: content });
+ }
+ });
+ if (session.isActive(requestId)) {
+ state.status = 'completed';
+ broadcastState();
+ postToSink(sink, { type: BG_TO_CONTENT.COMPLETED, requestId });
+ }
+ } catch (error) {
+ const code = (error && error.code) || ERROR_CODES.UNKNOWN_ERROR;
+ if (code === ERROR_CODES.REQUEST_ABORTED) {
+ // Reported by TranslationSession.abortActive() to the old sink; the
+ // shared state marks it cancelled so the popup can show it.
+ state.status = 'cancelled';
+ state.error = messageForCode(code);
+ broadcastState();
+ } else if (session.isActive(requestId)) {
+ state.status = 'error';
+ // Fall back to the code's base message when the detail is empty or
+ // judged non-user-facing by detailForUser().
+ const detail = detailForUser(error);
+ state.error = detail ? `${messageForCode(code)}\n(${detail})` : messageForCode(code);
+ broadcastState();
+ postToSink(sink, { type: BG_TO_CONTENT.ERROR, requestId, code, detail: state.error });
+ }
+ } finally {
+ session.clear(requestId);
+ }
+}
+
+/**
+ * Build a short, user-safe detail string from an error. Low-level exception
+ * text and full server responses are not shown unbounded: keep the message
+ * short and drop anything that looks like an internal URL or a huge blob.
+ * @param {*} error
+ * @returns {string} '' when there is nothing worth showing
+ */
+function detailForUser(error) {
+ const base = messageForCode(ERROR_CODES.UNKNOWN_ERROR);
+ const raw = error && error.message ? String(error.message) : '';
+ if (!raw) return '';
+ // Skip raw network errors that leak no useful info beyond the code.
+ if (/failed to fetch|networkerror|load failed/i.test(raw)) return '';
+ // Strip URLs (internal host names should not be echoed to the UI).
+ const cleaned = raw.replace(/\bhttps?:\/\/\S+/gi, '').trim();
+ if (!cleaned) return '';
+ // Drop obviously non-user-facing payloads (full JSON responses etc.).
+ if (cleaned.startsWith('{') || cleaned.startsWith('[')) return '';
+ const short = cleaned.length > 120 ? `${cleaned.slice(0, 120)}…` : cleaned;
+ return `${short}`;
+}
+
+/**
+ * @param {object|null} sink
+ * @param {object} message
+ */
+function postToSink(sink, message) {
+ if (!sink) return;
+ try {
+ sink.postMessage(message);
+ } catch {
+ // sink may be disconnected (e.g. popup closed)
+ }
+}
+
+/** @returns {TranslationSession} the shared session (for tests / cancel). */
+export function getSession() {
+ return session;
+}
+
+export { ERROR_MESSAGES, NO_MODEL_MESSAGE }; \ No newline at end of file
diff --git a/src/popup/popup.css b/src/popup/popup.css
index 964454d..de287a9 100644
--- a/src/popup/popup.css
+++ b/src/popup/popup.css
@@ -8,9 +8,80 @@ body {
background: #ffffff;
}
+.popup-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 10px;
+}
+
h1 {
font-size: 14px;
- margin: 0 0 10px;
+ margin: 0;
+}
+
+.mode-nav {
+ display: flex;
+ gap: 4px;
+}
+
+.nav-btn {
+ border: 1px solid rgba(0, 0, 0, 0.25);
+ background: #fff;
+ border-radius: 4px;
+ padding: 2px 10px;
+ cursor: pointer;
+ font-size: 12px;
+}
+
+.nav-btn.active {
+ background: #1a73e8;
+ border-color: #1a73e8;
+ color: #fff;
+}
+
+.translation-text {
+ min-height: 60px;
+ max-height: 220px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ border: 1px solid rgba(0, 0, 0, 0.15);
+ border-radius: 4px;
+ padding: 6px 8px;
+ margin: 6px 0;
+ user-select: text;
+}
+
+.meta {
+ color: #5f6368;
+ font-size: 11px;
+ margin: 2px 0 0;
+}
+
+.actions {
+ display: flex;
+ gap: 6px;
+ margin-top: 8px;
+}
+
+.actions button {
+ flex: 1;
+ padding: 5px 8px;
+ border: 1px solid rgba(0, 0, 0, 0.25);
+ border-radius: 4px;
+ background: #fff;
+ cursor: pointer;
+}
+
+.actions button:hover:not([disabled]) {
+ background: #f0f4ff;
+}
+
+.actions button[disabled] {
+ opacity: 0.5;
+ cursor: default;
}
.row {
diff --git a/src/popup/popup.html b/src/popup/popup.html
index e51475f..06a4cf4 100644
--- a/src/popup/popup.html
+++ b/src/popup/popup.html
@@ -6,28 +6,50 @@
</head>
<body>
<main>
- <h1>Ollama Translations</h1>
+ <header class="popup-header">
+ <h1>Ollama Translations</h1>
+ <nav class="mode-nav" aria-label="表示切替">
+ <button id="nav-settings" type="button" class="nav-btn active" aria-pressed="true">設定</button>
+ <button id="nav-translation" type="button" class="nav-btn" aria-pressed="false">翻訳</button>
+ </nav>
+ </header>
- <section class="row toggle-row">
- <label class="toggle-label" for="enabled-toggle">翻訳機能</label>
- <input type="checkbox" id="enabled-toggle" role="switch">
- </section>
-
- <section class="row">
- <label for="model-select">モデル</label>
- <div class="model-row">
- <select id="model-select"></select>
- <button id="reload-models" type="button" title="モデル一覧を再取得">↻</button>
+ <!-- Translation result mode -->
+ <section id="translation-mode" class="mode" hidden>
+ <p id="translation-status" class="status"></p>
+ <p id="translation-meta" class="meta" hidden></p>
+ <div id="translation-text" class="translation-text" aria-live="polite"></div>
+ <p id="translation-error" class="status error" hidden></p>
+ <div class="actions">
+ <button id="copy-translation" type="button" disabled>コピー</button>
+ <button id="regenerate-translation" type="button" disabled>再生成</button>
+ <button id="open-options-from-translation" type="button">設定を開く</button>
</div>
- <p id="model-status" class="status" hidden></p>
</section>
- <section class="row">
- <label for="language-select">翻訳先言語</label>
- <select id="language-select"></select>
- </section>
+ <!-- Settings mode -->
+ <section id="settings-mode" class="mode">
+ <section class="row toggle-row">
+ <label class="toggle-label" for="enabled-toggle">翻訳機能</label>
+ <input type="checkbox" id="enabled-toggle" role="switch">
+ </section>
- <button id="open-options" type="button">詳細設定を開く</button>
+ <section class="row">
+ <label for="model-select">モデル</label>
+ <div class="model-row">
+ <select id="model-select"></select>
+ <button id="reload-models" type="button" title="モデル一覧を再取得">↻</button>
+ </div>
+ <p id="model-status" class="status" hidden></p>
+ </section>
+
+ <section class="row">
+ <label for="language-select">翻訳先言語</label>
+ <select id="language-select"></select>
+ </section>
+
+ <button id="open-options" type="button">詳細設定を開く</button>
+ </section>
</main>
<script type="module" src="popup.js"></script>
</body>
diff --git a/src/popup/popup.js b/src/popup/popup.js
index eb213f5..3bd0cd9 100644
--- a/src/popup/popup.js
+++ b/src/popup/popup.js
@@ -1,6 +1,11 @@
'use strict';
-import { LANGUAGES, ERROR_CODES, RUNTIME_MESSAGES } from '../shared/constants.js';
+import {
+ LANGUAGES,
+ ERROR_CODES,
+ RUNTIME_MESSAGES,
+ POPUP_STATUSES
+} from '../shared/constants.js';
import { defaultSettings, sanitizeSettings } from '../shared/settings-schema.js';
const $enabled = /** @type {HTMLInputElement} */ (document.getElementById('enabled-toggle'));
@@ -10,9 +15,171 @@ const $modelStatus = /** @type {HTMLParagraphElement} */ (document.getElementByI
const $language = /** @type {HTMLSelectElement} */ (document.getElementById('language-select'));
const $openOptions = /** @type {HTMLButtonElement} */ (document.getElementById('open-options'));
+// Mode navigation + translation result mode elements
+const $navSettings = /** @type {HTMLButtonElement} */ (document.getElementById('nav-settings'));
+const $navTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('nav-translation'));
+const $settingsMode = /** @type {HTMLElement} */ (document.getElementById('settings-mode'));
+const $translationMode = /** @type {HTMLElement} */ (document.getElementById('translation-mode'));
+const $translationStatus = /** @type {HTMLParagraphElement} */ (document.getElementById('translation-status'));
+const $translationMeta = /** @type {HTMLParagraphElement} */ (document.getElementById('translation-meta'));
+const $translationText = /** @type {HTMLDivElement} */ (document.getElementById('translation-text'));
+const $translationError = /** @type {HTMLParagraphElement} */ (document.getElementById('translation-error'));
+const $copyTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('copy-translation'));
+const $regenerateTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('regenerate-translation'));
+const $openOptionsFromTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('open-options-from-translation'));
+
/** @type {string[]} current model list for change detection */
let knownModels = [];
+// ---------------------------------------------------------------------
+// Mode switching
+// ---------------------------------------------------------------------
+
+/** @param {'settings'|'translation'} mode */
+function showMode(mode) {
+ const translation = mode === 'translation';
+ $settingsMode.hidden = translation;
+ $translationMode.hidden = !translation;
+ $navSettings.classList.toggle('active', !translation);
+ $navTranslation.classList.toggle('active', translation);
+ $navSettings.setAttribute('aria-pressed', String(!translation));
+ $navTranslation.setAttribute('aria-pressed', String(translation));
+}
+
+$navSettings.addEventListener('click', () => {
+ showMode('settings');
+ void persistMode('settings');
+});
+$navTranslation.addEventListener('click', () => {
+ showMode('translation');
+ void persistMode('translation');
+ void refreshTranslationState();
+});
+
+$openOptionsFromTranslation.addEventListener('click', () => {
+ browser.runtime.openOptionsPage();
+});
+
+/**
+ * Remember the tab the user picked so the next popup open restores it
+ * instead of always jumping to the translation tab.
+ * @param {'settings'|'translation'} mode
+ */
+async function persistMode(mode) {
+ try {
+ await browser.storage.local.set({ popupMode: mode });
+ } catch {
+ // non-fatal
+ }
+}
+
+// ---------------------------------------------------------------------
+// Translation result mode
+// ---------------------------------------------------------------------
+
+const STATUS_LABELS = {
+ [POPUP_STATUSES.IDLE]: '待機中',
+ [POPUP_STATUSES.STARTING]: '翻訳を準備中…',
+ [POPUP_STATUSES.STREAMING]: '受信中…',
+ [POPUP_STATUSES.COMPLETED]: '完了',
+ [POPUP_STATUSES.ERROR]: 'エラー',
+ [POPUP_STATUSES.CANCELLED]: '中断'
+};
+
+function renderTranslationState(state) {
+ if (!state || typeof state !== 'object') return;
+ const status = typeof state.status === 'string' ? state.status : POPUP_STATUSES.IDLE;
+ const busy = status === POPUP_STATUSES.STARTING || status === POPUP_STATUSES.STREAMING;
+
+ $translationStatus.textContent = STATUS_LABELS[status] || status;
+
+ if (state.model) {
+ $translationMeta.hidden = false;
+ $translationMeta.textContent = `Ollama / ${state.model}`;
+ } else {
+ $translationMeta.hidden = true;
+ }
+
+ // Plain-text rendering only (Security.md §7.4).
+ $translationText.textContent = typeof state.translatedText === 'string' ? state.translatedText : '';
+
+ if (state.error) {
+ $translationError.hidden = false;
+ $translationError.textContent = state.error;
+ } else {
+ $translationError.hidden = true;
+ }
+
+ $copyTranslation.disabled = busy || !(state.translatedText && state.translatedText.length > 0);
+ $regenerateTranslation.disabled = busy || !state.sourceText;
+}
+
+async function refreshTranslationState() {
+ try {
+ const response = await browser.runtime.sendMessage({ type: RUNTIME_MESSAGES.GET_POPUP_STATE });
+ if (response && response.ok && response.state) {
+ renderTranslationState(response.state);
+ return response.state;
+ }
+ } catch {
+ // background unavailable; leave the current view
+ }
+ return null;
+}
+
+// ---------------------------------------------------------------------
+// Live state updates: subscribe to the shared background state so an
+// open popup receives streaming chunks in real time (Design.md §7.4).
+// ---------------------------------------------------------------------
+
+try {
+ const statePort = browser.runtime.connect({ name: 'popup-state' });
+ statePort.onMessage.addListener((message) => {
+ if (message && message.type === 'popup-state' && message.state) {
+ renderTranslationState(message.state);
+ }
+ });
+ // onDisconnect: popup is closing or the background restarted; nothing to do.
+} catch {
+ // Port may be unavailable in non-extension test contexts; polling in
+ // refreshTranslationState() still covers the initial state.
+}
+
+$copyTranslation.addEventListener('click', () => {
+ const text = $translationText.textContent || '';
+ if (!text) return;
+ void (async () => {
+ try {
+ await navigator.clipboard.writeText(text);
+ $translationStatus.textContent = 'コピーしました';
+ } catch {
+ // No execCommand fallback (deprecated): show failure only.
+ $translationStatus.textContent = 'コピーできませんでした';
+ }
+ })();
+});
+
+$regenerateTranslation.addEventListener('click', () => {
+ void (async () => {
+ try {
+ const response = await browser.runtime.sendMessage({ type: RUNTIME_MESSAGES.REGENERATE_LAST });
+ if (response && response.ok) {
+ await refreshTranslationState();
+ } else {
+ $translationError.hidden = false;
+ $translationError.textContent = '再生成を開始できませんでした。';
+ }
+ } catch {
+ $translationError.hidden = false;
+ $translationError.textContent = '再生成を開始できませんでした。';
+ }
+ })();
+});
+
+// ---------------------------------------------------------------------
+// Settings mode (existing behavior)
+// ---------------------------------------------------------------------
+
function fillLanguages(selected) {
$language.textContent = '';
for (const lang of LANGUAGES) {
@@ -138,10 +305,23 @@ void (async () => {
// If the model list loaded and no model is saved yet, persist the
// auto-selected first model so the stored settings match the UI.
if (knownModels.length > 0) {
- const result = await browser.storage.local.get({ settings: null });
+ const result = await browser.storage.local.get({ settings: null, popupMode: null });
const stored = sanitizeSettings(result.settings || defaultSettings());
if (!stored.model) {
await persist({ model: $model.value });
}
+ // Decide the initial mode. Active/recent translation wins; otherwise
+ // restore the tab the user last picked (default: settings).
+ const state = await refreshTranslationState();
+ const active = state && (state.status === 'starting' || state.status === 'streaming');
+ if (active) {
+ showMode('translation');
+ } else if (result.popupMode === 'translation' && state && state.translatedText && state.translatedText.length > 0) {
+ showMode('translation');
+ } else {
+ showMode('settings');
+ }
+ } else {
+ showMode('settings');
}
})(); \ No newline at end of file
diff --git a/src/shared/constants.js b/src/shared/constants.js
index 76a679c..adae26d 100644
--- a/src/shared/constants.js
+++ b/src/shared/constants.js
@@ -67,5 +67,21 @@ export const BG_TO_CONTENT = {
/** Message types: one-shot runtime messages (popup/options -> background). */
export const RUNTIME_MESSAGES = {
- LIST_MODELS: 'listModels'
+ LIST_MODELS: 'listModels',
+ GET_POPUP_STATE: 'getPopupState',
+ START_CONTEXT_MENU_TRANSLATION: 'startContextMenuTranslation',
+ REGENERATE_LAST: 'regenerateLast'
+};
+
+/** Context menu item id (menus API). */
+export const MENU_ID_TRANSLATE_SELECTION = 'translate-selection';
+
+/** Popup translation result statuses (Design.md §7.4). */
+export const POPUP_STATUSES = {
+ IDLE: 'idle',
+ STARTING: 'starting',
+ STREAMING: 'streaming',
+ COMPLETED: 'completed',
+ ERROR: 'error',
+ CANCELLED: 'cancelled'
}; \ No newline at end of file
diff --git a/tests/all-tests.js b/tests/all-tests.js
index 2836203..d4430e2 100644
--- a/tests/all-tests.js
+++ b/tests/all-tests.js
@@ -618,6 +618,11 @@ function setupRouter({ settings, fetchResponses }) {
const runtime = fakeRuntime();
const browserApi = { storage, runtime };
return (async () => {
+ // Reset the shared settings cache so each test starts from its own
+ // storage state (the module-level cache would otherwise leak between
+ // tests and hide auto-select behavior).
+ const svc = await import('../src/background/settings-service.js');
+ svc.__resetCacheForTests();
await saveSettings(settings, browserApi);
if (fetchResponses) {
globalThis.fetch = fetchResponses;
@@ -787,4 +792,299 @@ describe('message router: listModels runtime message', () => {
});
});
+// ---------------------------------------------------------------------
+// Context menu translation (PDF Viewer path) + popup state
+// ---------------------------------------------------------------------
+
+import { POPUP_STATUSES } from '../src/shared/constants.js';
+
+describe('translation-service: selectionText validation', () => {
+ it('validates untrusted selection strings', async () => {
+ const { validateSelectionText } = await import('../src/background/translation-service.js');
+ const good = validateSelectionText(' hello world ');
+ assertEqual(good.ok, true);
+ assertEqual(good.text, 'hello world');
+
+ assertEqual(validateSelectionText(' ').ok, false);
+ assertEqual(validateSelectionText('').ok, false);
+ assertEqual(validateSelectionText(null).ok, false);
+ assertEqual(validateSelectionText(42).ok, false);
+ // control characters are stripped, leaving empty -> rejected
+ assertEqual(validateSelectionText('\u0000\u0001').ok, false);
+ // oversize selection rejected
+ const big = 'a'.repeat(MAX_SELECTION_LENGTH + 1);
+ assertEqual(validateSelectionText(big).ok, false);
+ });
+});
+
+describe('context menu translation flow + popup state', () => {
+ it('menu selection -> translation -> popup state streaming/completed', async () => {
+ const lines = [
+ JSON.stringify({ message: { content: 'こんばんは' } }),
+ JSON.stringify({ done: true })
+ ];
+ const { runtime, storage } = await setupRouter({
+ settings: { ...defaultSettings(), model: 'm1' },
+ fetchResponses: async () => ndjsonResponse(lines)
+ });
+ const { startFromSelection } = await import('../src/background/menu-translation.js');
+ const started = startFromSelection('good evening');
+ assertEqual(started.ok, true);
+ await new Promise((resolve) => setTimeout(resolve, 40));
+
+ const listener = runtime.onMessage.__listener();
+ const result = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' });
+ assertEqual(result.ok, true);
+ assertEqual(result.state.status, POPUP_STATUSES.COMPLETED);
+ assertEqual(result.state.translatedText, 'こんばんは');
+ assertEqual(result.state.model, 'm1');
+ assertEqual(result.state.source, 'context-menu');
+ assertEqual(result.state.sourceText, 'good evening');
+ assertEqual(result.state.error, null);
+ });
+
+ it('rejects empty selection without starting a request', async () => {
+ const { runtime } = await setupRouter({
+ settings: { ...defaultSettings(), model: 'm1' },
+ fetchResponses: async () => ndjsonResponse([])
+ });
+ const { startFromSelection } = await import('../src/background/menu-translation.js');
+ const result = startFromSelection(' ');
+ assertEqual(result.ok, false);
+ assertEqual(result.error, ERROR_CODES.TEXT_EMPTY);
+ globalThis.fetch = realFetch;
+ });
+
+ it('popup state shows streaming while chunks arrive and completed at end', async () => {
+ const lines = [
+ JSON.stringify({ message: { content: 'あ' } }),
+ JSON.stringify({ message: { content: 'い' } }),
+ JSON.stringify({ done: true })
+ ];
+ const { runtime } = await setupRouter({
+ settings: { ...defaultSettings(), model: 'm1' },
+ fetchResponses: async () => ndjsonResponse(lines, [1]) // 1-byte chunks: slow stream
+ });
+ const { startFromSelection } = await import('../src/background/menu-translation.js');
+ startFromSelection('test text');
+ // Poll state while streaming: at some point status must be streaming
+ let sawStreaming = false;
+ const listener = runtime.onMessage.__listener();
+ for (let i = 0; i < 30; i++) {
+ const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' });
+ if (s.state.status === POPUP_STATUSES.STREAMING) {
+ sawStreaming = true;
+ break;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ const final = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' });
+ assertTrue(sawStreaming || final.state.status === POPUP_STATUSES.COMPLETED, 'should reach streaming or completed');
+ assertEqual(final.state.status, POPUP_STATUSES.COMPLETED);
+ assertEqual(final.state.translatedText, 'あい');
+ globalThis.fetch = realFetch;
+ });
+
+ it('ollama unavailable surfaces an error into popup state', async () => {
+ globalThis.fetch = async () => {
+ throw new TypeError('Failed to fetch');
+ };
+ const { runtime } = await setupRouter({
+ settings: { ...defaultSettings(), model: 'm1' },
+ fetchResponses: null
+ });
+ const { startFromSelection } = await import('../src/background/menu-translation.js');
+ startFromSelection('hello');
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ const listener = runtime.onMessage.__listener();
+ const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' });
+ assertEqual(s.state.status, POPUP_STATUSES.ERROR);
+ assertTrue(typeof s.state.error === 'string' && s.state.error.length > 0);
+ globalThis.fetch = realFetch;
+ });
+
+ it('new menu translation aborts the previous one (state cancelled)', async () => {
+ // First stream: long-lived, manually closed.
+ let controllerA = null;
+ let callCount = 0;
+ globalThis.fetch = async () => {
+ callCount++;
+ if (callCount === 1) {
+ const stream = new ReadableStream({
+ start(c) {
+ controllerA = c;
+ const enc = new TextEncoder();
+ c.enqueue(enc.encode(JSON.stringify({ message: { content: 'A1' } }) + '\n'));
+ }
+ });
+ return new Response(stream, { status: 200 });
+ }
+ return ndjsonResponse([JSON.stringify({ message: { content: 'B' } }), JSON.stringify({ done: true })]);
+ };
+ const { runtime } = await setupRouter({
+ settings: { ...defaultSettings(), model: 'm1' },
+ fetchResponses: null
+ });
+ const { startFromSelection } = await import('../src/background/menu-translation.js');
+ startFromSelection('first');
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ startFromSelection('second');
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ if (controllerA) controllerA.close();
+
+ const listener = runtime.onMessage.__listener();
+ const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' });
+ // The latest request (B) must own the state.
+ assertEqual(s.state.sourceText, 'second');
+ assertEqual(s.state.translatedText, 'B');
+ assertTrue(s.state.status === POPUP_STATUSES.COMPLETED || s.state.status === POPUP_STATUSES.STREAMING);
+ globalThis.fetch = realFetch;
+ });
+
+ it('regenerateLast reuses the stored source text with a new requestId', async () => {
+ const lines = [JSON.stringify({ message: { content: 'OK' } }), JSON.stringify({ done: true })];
+ globalThis.fetch = async () => ndjsonResponse(lines);
+ const { runtime } = await setupRouter({
+ settings: { ...defaultSettings(), model: 'm1' },
+ fetchResponses: null
+ });
+ const { startFromSelection } = await import('../src/background/menu-translation.js');
+ startFromSelection('original text');
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ const listener = runtime.onMessage.__listener();
+ const before = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' });
+ const regen = await listener({ type: RUNTIME_MESSAGES.REGENERATE_LAST }, { id: 'ext' });
+ assertEqual(regen.ok, true);
+ assertTrue(regen.requestId !== before.state.requestId);
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ const after = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' });
+ assertEqual(after.state.sourceText, 'original text');
+ assertEqual(after.state.status, POPUP_STATUSES.COMPLETED);
+ assertEqual(after.state.translatedText, 'OK');
+ globalThis.fetch = realFetch;
+ });
+
+ it('popup-state port receives live state broadcasts while streaming', async () => {
+ const lines = [
+ JSON.stringify({ message: { content: 'あ' } }),
+ JSON.stringify({ message: { content: 'い' } }),
+ JSON.stringify({ done: true })
+ ];
+ const { runtime } = await setupRouter({
+ settings: { ...defaultSettings(), model: 'm1' },
+ fetchResponses: async () => ndjsonResponse(lines, [1])
+ });
+ // Register the popup-state handler (in production index.js registers
+ // both onConnect listeners; in tests we register explicitly).
+ const router = await import('../src/background/message-router.js');
+ const popupPort = fakePort();
+ popupPort.name = 'popup-state';
+ const received = [];
+ popupPort.postMessage = (m) => received.push(m);
+ router.handlePopupStateConnection(popupPort);
+
+ const { startFromSelection } = await import('../src/background/menu-translation.js');
+ startFromSelection('live test');
+ await new Promise((resolve) => setTimeout(resolve, 80));
+
+ // Initial state push + per-chunk broadcasts + final completed broadcast
+ assertTrue(received.length >= 2, 'should receive initial + live broadcasts');
+ const last = received[received.length - 1];
+ assertEqual(last.type, 'popup-state');
+ assertEqual(last.state.translatedText, 'あい');
+ assertTrue(
+ last.state.status === POPUP_STATUSES.COMPLETED || last.state.status === POPUP_STATUSES.STREAMING,
+ 'final broadcast should be completed (or streaming for the last chunk)'
+ );
+ globalThis.fetch = realFetch;
+ });
+
+ it('handlePopupStateConnection ignores ports with wrong name', async () => {
+ const { runtime } = await setupRouter({
+ settings: { ...defaultSettings(), model: 'm1' },
+ fetchResponses: async () => ndjsonResponse([JSON.stringify({ done: true })])
+ });
+ const router = await import('../src/background/message-router.js');
+ const wrongPort = fakePort();
+ wrongPort.name = 'translation-stream'; // content-script port, not popup
+ const received = [];
+ wrongPort.postMessage = (m) => received.push(m);
+ // Emit through popup-state handler: it must ignore non-popup ports.
+ router.handlePopupStateConnection(wrongPort);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ assertEqual(received.length, 0);
+ globalThis.fetch = realFetch;
+ });
+
+ it('auto-selects the first Ollama model when storage has none (fresh install)', async () => {
+ // settings.model is '' (defaultSettings) and Ollama responds with a list.
+ globalThis.fetch = async (url) => {
+ if (String(url).endsWith('/api/tags')) {
+ return new Response(JSON.stringify({ models: [{ name: 'first-model' }, { name: 'second-model' }] }), { status: 200 });
+ }
+ return ndjsonResponse([JSON.stringify({ message: { content: 'OK' } }), JSON.stringify({ done: true })]);
+ };
+ const { runtime, storage } = await setupRouter({
+ settings: defaultSettings(), // model: ''
+ fetchResponses: null
+ });
+ // runTranslation resolves the browser API from globalThis.browser when
+ // no browserApi is passed (production behavior); provide it for tests.
+ globalThis.browser = { storage, runtime };
+ const { startFromSelection } = await import('../src/background/menu-translation.js');
+ const started = startFromSelection('fresh install test');
+ assertEqual(started.ok, true);
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ const listener = runtime.onMessage.__listener();
+ const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' });
+ // The translation must have started with the auto-picked model.
+ assertEqual(s.state.model, 'first-model');
+ assertEqual(s.state.status, POPUP_STATUSES.COMPLETED);
+ // And the model must be persisted so the settings UI shows it.
+ const stored = await storage.local.get({ settings: null });
+ assertEqual(stored.settings && stored.settings.model, 'first-model');
+ globalThis.fetch = realFetch;
+ });
+
+ it('reports OLLAMA_UNAVAILABLE when no model configured and Ollama is down', async () => {
+ globalThis.fetch = async () => {
+ throw new TypeError('Failed to fetch');
+ };
+ const { runtime } = await setupRouter({
+ settings: defaultSettings(), // model: ''
+ fetchResponses: null
+ });
+ const { startFromSelection } = await import('../src/background/menu-translation.js');
+ startFromSelection('offline test');
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ const listener = runtime.onMessage.__listener();
+ const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' });
+ assertEqual(s.state.status, POPUP_STATUSES.ERROR);
+ // User-safe message: no raw exception text.
+ assertFalse(/Failed to fetch/i.test(s.state.error));
+ delete globalThis.browser;
+ globalThis.fetch = realFetch;
+ });
+
+ it('error detail is user-safe: URLs stripped, length capped, JSON dropped', async () => {
+ // Ollama returns a 400 with a JSON body including an internal URL.
+ globalThis.fetch = async () =>
+ new Response(JSON.stringify({ error: 'bad request see http://localhost:11434/internal for more {huge json' }), { status: 400 });
+ const { runtime } = await setupRouter({
+ settings: { ...defaultSettings(), model: 'm1' },
+ fetchResponses: null
+ });
+ const { startFromSelection } = await import('../src/background/menu-translation.js');
+ startFromSelection('error detail test');
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ const listener = runtime.onMessage.__listener();
+ const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' });
+ assertEqual(s.state.status, POPUP_STATUSES.ERROR);
+ assertFalse(/http:\/\//.test(s.state.error));
+ delete globalThis.browser;
+ globalThis.fetch = realFetch;
+ });
+});
+
run(); \ No newline at end of file
diff --git a/tests/ui-smoke-tests.js b/tests/ui-smoke-tests.js
index 84fe4c4..e88a811 100644
--- a/tests/ui-smoke-tests.js
+++ b/tests/ui-smoke-tests.js
@@ -93,22 +93,44 @@ async function runPageScript(scriptPath, markup, probe) {
describe('popup page script smoke', () => {
it('initializes language options and model select from markup', async () => {
const r = await runPageScript('../src/popup/popup.js', `
- <input type="checkbox" id="enabled-toggle">
- <select id="model-select"></select>
- <button id="reload-models"></button>
- <p id="model-status" hidden></p>
- <select id="language-select"></select>
- <button id="open-options"></button>
+ <header class="popup-header">
+ <h1>Ollama Translations</h1>
+ <nav class="mode-nav">
+ <button id="nav-settings" type="button" class="nav-btn active">設定</button>
+ <button id="nav-translation" type="button" class="nav-btn">翻訳</button>
+ </nav>
+ </header>
+ <section id="translation-mode" class="mode" hidden>
+ <p id="translation-status" class="status"></p>
+ <p id="translation-meta" class="meta" hidden></p>
+ <div id="translation-text" class="translation-text"></div>
+ <p id="translation-error" class="status error" hidden></p>
+ <div class="actions">
+ <button id="copy-translation" type="button" disabled>コピー</button>
+ <button id="regenerate-translation" type="button" disabled>再生成</button>
+ <button id="open-options-from-translation" type="button">設定を開く</button>
+ </div>
+ </section>
+ <section id="settings-mode" class="mode">
+ <input type="checkbox" id="enabled-toggle">
+ <select id="model-select"></select>
+ <button id="reload-models"></button>
+ <p id="model-status" hidden></p>
+ <select id="language-select"></select>
+ <button id="open-options"></button>
+ </section>
`, () => ({
langOptions: document.getElementById('language-select').options.length,
langValue: document.getElementById('language-select').value,
modelOptions: document.getElementById('model-select').options.length,
- firstModel: document.getElementById('model-select').options[0]?.textContent ?? null
+ firstModel: document.getElementById('model-select').options[0]?.textContent ?? null,
+ translationHidden: document.getElementById('translation-mode').hidden
}));
assertEqual(r.langOptions, LANGUAGES.length);
assertEqual(r.langValue, 'ja'); // default target language
assertTrue(r.modelOptions > 0, 'model select should be populated');
assertEqual(r.firstModel, 'fake-model');
+ assertEqual(r.translationHidden, true); // no translation yet: settings mode
});
});