Skip to content

Commit ff8bdab

Browse files
lcompleteclaude
andcommitted
feat(extension): restrict attachments to images only and add DeepSeek V4 models
- Limit file attachments to images (by MIME type or extension), updating drag-and-drop, paste, and file picker to filter non-image files - Add DeepSeek V4 Flash and V4 Pro preset models to the provider registry - Improve CI pipeline: add typecheck step, use yarn zip, verify zip package - Bump extension version to 0.5.5 - Fix TypeScript types for chrome.scripting and context menu APIs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b53182a commit ff8bdab

19 files changed

Lines changed: 243 additions & 86 deletions

.github/workflows/extension-release.yml

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,26 +66,58 @@ jobs:
6666
cache-dependency-path: "app/extension/yarn.lock"
6767

6868
- name: Setup yarn
69-
run: npm install -g yarn --version 1.22.19
69+
run: npm install -g yarn@1.22.19
7070

7171
- name: Install extension dependencies
7272
run: |
7373
cd app/extension
74-
yarn install
74+
yarn install --frozen-lockfile
7575
76-
- name: Create extension bundle (Chrome)
76+
- name: Typecheck extension
7777
run: |
7878
cd app/extension
79-
yarn build
79+
yarn typecheck
80+
81+
- name: Create extension package (Chrome)
82+
run: |
83+
cd app/extension
84+
yarn zip
8085
env:
8186
CI: false
8287
EXTENSION_VERSION: ${{ needs.create-release.outputs.version }}
8388

89+
- name: Verify extension package
90+
run: |
91+
python3 - <<'PY'
92+
import json
93+
import os
94+
import zipfile
95+
96+
version = os.environ["EXTENSION_VERSION"]
97+
manifest_path = "app/extension/dist/manifest.json"
98+
zip_path = f"app/extension/dist/huntly-{version}-chrome.zip"
99+
100+
with open(manifest_path, encoding="utf-8") as manifest_file:
101+
manifest = json.load(manifest_file)
102+
103+
if manifest.get("version") != version:
104+
raise SystemExit(
105+
f"Manifest version {manifest.get('version')} does not match tag version {version}"
106+
)
107+
108+
with zipfile.ZipFile(zip_path) as archive:
109+
names = set(archive.namelist())
110+
if "manifest.json" not in names:
111+
raise SystemExit("Extension zip must contain manifest.json at the archive root")
112+
PY
113+
env:
114+
EXTENSION_VERSION: ${{ needs.create-release.outputs.version }}
115+
84116
- name: Package release files
85117
run: |
86118
mkdir -p release
87-
cd app/extension
88-
zip -r ../../release/huntly-chrome-extension-${{ needs.create-release.outputs.version }}.zip ./dist/*
119+
cp app/extension/dist/huntly-${{ needs.create-release.outputs.version }}-chrome.zip \
120+
release/huntly-chrome-extension-${{ needs.create-release.outputs.version }}.zip
89121
90122
- name: Upload Chrome extension to release
91123
uses: actions/upload-release-asset@v1

app/extension/entrypoints/background.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
import { defineBackground } from "wxt/utils/define-background";
22
import { initBackground } from "../src/background";
33

4+
type RegisteredContentScript = {
5+
id: string;
6+
matches: string[];
7+
js: string[];
8+
runAt: "document_start" | "document_end" | "document_idle";
9+
};
10+
11+
type DynamicScriptingApi = typeof chrome.scripting & {
12+
getRegisteredContentScripts?: () => Promise<Array<{ id: string }>>;
13+
registerContentScripts?: (scripts: RegisteredContentScript[]) => Promise<void>;
14+
};
15+
416
/**
517
* In WXT dev mode, content scripts are not declared in the manifest.
618
* They are dynamically registered via the dev server WebSocket.
@@ -18,15 +30,16 @@ async function ensureContentScriptsRegistered(): Promise<void> {
1830
}
1931

2032
// Dev build without content_scripts in manifest - register them dynamically
21-
if (!chrome.scripting?.registerContentScripts) {
33+
const scripting = chrome.scripting as DynamicScriptingApi | undefined;
34+
if (!scripting?.registerContentScripts || !scripting.getRegisteredContentScripts) {
2235
return; // API not available
2336
}
2437

2538
try {
26-
const registered = await chrome.scripting.getRegisteredContentScripts();
39+
const registered = await scripting.getRegisteredContentScripts();
2740
const registeredIds = new Set(registered.map((cs) => cs.id));
2841

29-
const scriptsToRegister: chrome.scripting.RegisteredContentScript[] = [];
42+
const scriptsToRegister: RegisteredContentScript[] = [];
3043

3144
if (!registeredIds.has("huntly:content")) {
3245
scriptsToRegister.push({
@@ -47,7 +60,7 @@ async function ensureContentScriptsRegistered(): Promise<void> {
4760
}
4861

4962
if (scriptsToRegister.length > 0) {
50-
await chrome.scripting.registerContentScripts(scriptsToRegister);
63+
await scripting.registerContentScripts(scriptsToRegister);
5164
console.log("[Huntly] Registered content scripts as fallback:", scriptsToRegister.map((s) => s.id));
5265
}
5366
} catch (error) {

app/extension/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "huntly",
3-
"version": "1.0.0",
3+
"version": "0.5.5",
44
"description": "Huntly - Automatic saving browsed contents",
55
"main": "index.js",
66
"scripts": {

app/extension/src/__tests__/Composer.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,13 @@ function renderComposer(
112112
throw new Error("Composer textarea not found");
113113
}
114114

115+
const fileInput = container.querySelector('input[type="file"]');
116+
if (!(fileInput instanceof HTMLInputElement)) {
117+
throw new Error("Composer file input not found");
118+
}
119+
115120
return {
121+
fileInput,
116122
textarea,
117123
cleanup: () => {
118124
act(() => {
@@ -179,6 +185,40 @@ describe("Composer", () => {
179185
cleanup();
180186
});
181187

188+
it("limits the upload picker to images", () => {
189+
const { fileInput, cleanup } = renderComposer();
190+
191+
expect(fileInput.accept).toBe("image/*");
192+
193+
cleanup();
194+
});
195+
196+
it("keeps non-image file paste behavior unchanged", () => {
197+
const onAttachmentFiles = jest.fn();
198+
const pastedDocument = new File(["document-bytes"], "notes.pdf", {
199+
type: "application/pdf",
200+
});
201+
const { textarea, cleanup } = renderComposer({ onAttachmentFiles });
202+
203+
const pasteEvent = createClipboardEvent({
204+
items: createDataTransferItemList([
205+
{
206+
kind: "file",
207+
type: pastedDocument.type,
208+
getAsFile: () => pastedDocument,
209+
} as DataTransferItem,
210+
]),
211+
files: createFileList([pastedDocument]),
212+
});
213+
214+
const dispatchResult = textarea.dispatchEvent(pasteEvent);
215+
216+
expect(onAttachmentFiles).not.toHaveBeenCalled();
217+
expect(dispatchResult).toBe(true);
218+
219+
cleanup();
220+
});
221+
182222
it("keeps normal text paste behavior unchanged", () => {
183223
const onAttachmentFiles = jest.fn();
184224
const { textarea, cleanup } = renderComposer({ onAttachmentFiles });

app/extension/src/__tests__/providers.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
getOllamaBaseUrl,
44
getOllamaOpenAIBaseUrl,
55
} from "../ai/openAICompatibleProviders";
6-
import { getEffectiveApiFormat } from "../ai/types";
6+
import { getEffectiveApiFormat, PROVIDER_REGISTRY } from "../ai/types";
77

88
describe("providers helpers", () => {
99
it("returns configured or default OpenAI-compatible base url", () => {
@@ -64,4 +64,13 @@ describe("providers helpers", () => {
6464
getEffectiveApiFormat({ type: "openai", apiFormat: "anthropic" })
6565
).toBe("openai");
6666
});
67+
68+
it("includes DeepSeek V4 preset models without changing the existing default", () => {
69+
expect(PROVIDER_REGISTRY.deepseek.defaultModels.map((model) => model.id)).toEqual([
70+
"deepseek-chat",
71+
"deepseek-reasoner",
72+
"deepseek-v4-flash",
73+
"deepseek-v4-pro",
74+
]);
75+
});
6776
});

app/extension/src/ai/types.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,18 +115,21 @@ export const PROVIDER_REGISTRY: Record<ProviderType, ProviderMeta> = {
115115
deepseek: {
116116
type: 'deepseek',
117117
displayName: 'DeepSeek',
118-
description: 'DeepSeek V3.2 (chat and reasoner modes)',
118+
description: 'DeepSeek Chat, Reasoner, V4 Flash, and V4 Pro',
119119
icon: 'deepseek',
120120
requiresApiKey: true,
121121
supportsCustomUrl: true,
122122
defaultBaseUrl: 'https://api.deepseek.com',
123123
nativeApiFormat: 'openai',
124124
defaultModels: [
125-
// DeepSeek official API only supports these two model IDs
126-
// deepseek-chat: Non-thinking mode (V3.2)
127-
// deepseek-reasoner: Thinking mode (V3.2 R1)
125+
// Keep the existing defaults first so new installs preserve the current
126+
// default selection, then expose the newer V4 presets.
127+
// deepseek-chat: Chat mode
128+
// deepseek-reasoner: Reasoning mode
128129
{ id: 'deepseek-chat' },
129130
{ id: 'deepseek-reasoner' },
131+
{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
132+
{ id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
130133
],
131134
},
132135
groq: {

app/extension/src/background.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ type PendingSidepanelContextCommand = {
6464
page: PendingSelectionPageContext;
6565
}
6666
);
67+
type SidepanelContextCommandResponse = {
68+
success?: boolean;
69+
commandId?: string | null;
70+
};
6771
const pendingSidepanelContextCommands =
6872
new Map<number, PendingSidepanelContextCommand[]>();
6973
const SAVED_BADGE_TEXT = "✓";
@@ -903,17 +907,17 @@ const CONTEXT_MENU_SIDE_PANEL_PAGE = "huntly_side_panel_page";
903907
const CONTEXT_MENU_SIDE_PANEL_IMAGE = "huntly_side_panel_image";
904908
const CONTEXT_MENU_READING_MODE_ACTION = "huntly_reading_mode_action";
905909
const CONTEXT_MENU_SIDE_PANEL_ACTION = "huntly_side_panel_action";
906-
const CONTEXT_MENU_PAGE_CONTEXTS: chrome.contextMenus.ContextType[] = [
910+
const CONTEXT_MENU_PAGE_CONTEXTS: string[] = [
907911
"page",
908912
"selection",
909913
];
910-
const CONTEXT_MENU_IMAGE_CONTEXTS: chrome.contextMenus.ContextType[] = [
914+
const CONTEXT_MENU_IMAGE_CONTEXTS: string[] = [
911915
"image",
912916
];
913-
const CONTEXT_MENU_ACTION_CONTEXTS: chrome.contextMenus.ContextType[] = [
917+
const CONTEXT_MENU_ACTION_CONTEXTS: string[] = [
914918
"action",
915919
];
916-
const CONTEXT_MENU_PAGE_AND_IMAGE_CONTEXTS: chrome.contextMenus.ContextType[] = [
920+
const CONTEXT_MENU_PAGE_AND_IMAGE_CONTEXTS: string[] = [
917921
...CONTEXT_MENU_PAGE_CONTEXTS,
918922
...CONTEXT_MENU_IMAGE_CONTEXTS,
919923
];
@@ -1073,13 +1077,13 @@ async function handleImageSidePanelContextMenuClick(
10731077
}
10741078

10751079
try {
1076-
const response = await chrome.runtime.sendMessage({
1080+
const response = (await chrome.runtime.sendMessage({
10771081
type: "sidepanel_context_menu_command",
10781082
payload: {
10791083
command,
10801084
windowId: targetTab.windowId,
10811085
},
1082-
});
1086+
})) as unknown as SidepanelContextCommandResponse | undefined;
10831087

10841088
if (response?.success && response.commandId === command.id) {
10851089
return;
@@ -1147,13 +1151,13 @@ async function handlePageSidePanelContextMenuClick(
11471151
}
11481152

11491153
try {
1150-
const response = await chrome.runtime.sendMessage({
1154+
const response = (await chrome.runtime.sendMessage({
11511155
type: "sidepanel_context_menu_command",
11521156
payload: {
11531157
command,
11541158
windowId: targetTab.windowId,
11551159
},
1156-
});
1160+
})) as unknown as SidepanelContextCommandResponse | undefined;
11571161

11581162
if (response?.success && response.commandId === command.id) {
11591163
return;

app/extension/src/globals.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/// <reference types="chrome" />
2+
13
export {}
24

35
declare global {

app/extension/src/popup.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ import {
5858
} from "./services";
5959
import { LibrarySaveStatus } from "./model/librarySaveStatus";
6060
import { PageOperateResult } from "./model/pageOperateResult";
61-
import { detectRssFeed } from "./rss";
61+
import { detectRssFeed } from "./rss/rssDetection";
6262
import type { ShortcutItem, ModelItem } from "./components/AIToolbar";
6363

6464
// Parser selector component - only shows the alternative parser option

app/extension/src/settings.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, {useEffect, useState} from "react";
22
import './options.css';
33
import {Alert, Button, Divider, FormControlLabel, IconButton, Snackbar, Switch, TextField} from "@mui/material";
44
import * as yup from 'yup';
5-
import {FieldArray, Form, Formik, getIn} from "formik";
5+
import {FieldArray, Formik, getIn} from "formik";
66
import DeleteIcon from "@mui/icons-material/Delete";
77
import AddIcon from '@mui/icons-material/Add';
88
import {ContentParserType, readSyncStorageSettings, ServerUrlItem, StorageSettings, DefaultStorageSettings} from "./storage";
@@ -94,8 +94,8 @@ export const Settings = ({onOptionsChange}: SettingsProps) => {
9494
}
9595
);
9696
}}>
97-
{({values, touched, errors, handleChange, handleBlur, isValid}) => (
98-
<Form noValidate autoComplete="off">
97+
{({values, touched, errors, handleChange, handleBlur, handleSubmit, isValid}) => (
98+
<form noValidate autoComplete="off" onSubmit={handleSubmit}>
9999
<FieldArray name="settings">
100100
{({push, remove}) => (
101101
<div>
@@ -192,7 +192,7 @@ export const Settings = ({onOptionsChange}: SettingsProps) => {
192192
>
193193
save
194194
</Button>
195-
</Form>
195+
</form>
196196
)}
197197
</Formik>}
198198

0 commit comments

Comments
 (0)