Compare commits
5 Commits
552f27c002
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4e0243ac2 | ||
|
|
5d8fb9db55 | ||
|
|
d85fdc1101 | ||
| 34f63acf49 | |||
| e9f332171e |
@@ -57,6 +57,21 @@
|
||||
- [ ] 성능 측정(메모리, CPU, 대용량 큐)
|
||||
- [ ] macOS/Windows/Linux 빌드 파이프라인
|
||||
|
||||
### Phase 6 (신규): Native Messaging 안정화 전환
|
||||
- 목표 산출물: `브라우저 -> Native Host -> gdown` 경로를 표준 경로로 확정
|
||||
- Step 1: Native Host 스캐폴드(프로토콜/manifest/설치 스크립트) [진행]
|
||||
- Step 2: 확장 우클릭/자동 다운로드 경로를 Native Host로 연결 [진행]
|
||||
- Step 3: 앱 제어(다운로드 추가/포커스) 채널 정식화
|
||||
- Step 4: 링크 자동 후킹을 Native Host 경로로 이관
|
||||
- Step 5: 오류 복구/로깅/설정 UX 정리
|
||||
|
||||
### Phase 7 (신규): 범주(Category) 기능
|
||||
- Step 1: 범주 기본 데이터 + 설정 토글 + Add 모달 범주 선택/경로 반영
|
||||
- Step 2: 범주 CRUD UI(이름/아이콘/확장자 룰)
|
||||
- Step 3: 좌측 범주 패널(카운트/필터/접힘 상태) 연동
|
||||
- Step 4: 확장/외부 요청 자동 분류 규칙 정교화
|
||||
- Step 5: 성능/UX/오류 피드백 상용 수준 마감
|
||||
|
||||
## 5. 리스크 및 대응
|
||||
- aria2 바이너리 번들/서명: 플랫폼별 바이너리 동봉 규칙 문서화 + CI 검증
|
||||
- Electron API 차이: 기능별 대체표를 먼저 만들고 Tauri plugin으로 대응
|
||||
@@ -81,6 +96,8 @@
|
||||
- `src-tauri/src/engine.rs` + `src/lib/engineApi.ts`: 파일관리자에서 경로 열기 커맨드(`open_path_in_file_manager`) 추가
|
||||
- `src-tauri/src/engine.rs`: task summary에 `uri` 노출 추가(링크 복사용)
|
||||
- `src/App.vue`: Motrix `TaskActions`/`TaskItemActions` 기능 매핑에 맞춘 상단/항목 아이콘 동작 연결
|
||||
- `src-tauri/src/lib.rs`: macOS 앱 시작 시 Native Host manifest/runner 자동 설치(`org.gdown.nativehost`)
|
||||
- `src/App.vue` + `src/style.css`: 범주 기능 Step 1(기본 토글/선택/적용 폴더 프리뷰 + Add 시 경로 자동 반영)
|
||||
- `src-tauri/src/engine.rs`: aria2 프로세스 시작/중지/상태 조회
|
||||
- `src-tauri/src/engine.rs`: 바이너리 자동 탐지 + 에러 분류 + 작업 제어 RPC 커맨드
|
||||
- `src-tauri/src/lib.rs`: Tauri invoke handler 연결
|
||||
@@ -88,6 +105,6 @@
|
||||
- `src/App.vue`: Motrix 스타일 사이드바/목록/추가 모달/액션 버튼
|
||||
- `src/style.css`: 작업 액션 UI 스타일 보강
|
||||
- 다음 우선순위:
|
||||
1. Motrix `Task Detail` 동등 기능(파일/피어/트래커/활동) 구현
|
||||
2. 설정 저장소 도입(local persist + aria2 global option 적용)
|
||||
3. 선택 기반 배치 작업 액션 및 리스트 인터랙션 개선
|
||||
1. Native Messaging Host MVP 구축 (확장 연동 전 단계 산출물)
|
||||
2. Motrix `Task Detail` 동등 기능(파일/피어/트래커/활동) 구현
|
||||
3. 설정 저장소 도입(local persist + aria2 global option 적용)
|
||||
|
||||
81
docs/TODO.md
81
docs/TODO.md
@@ -7,32 +7,87 @@
|
||||
- 작업 후 반드시 이 파일과 `PORTING_PLAN.md` 동시 업데이트
|
||||
|
||||
## 현재 진행률
|
||||
- 전체: 약 30%
|
||||
- 엔진/RPC: 60%+
|
||||
- UI 동등성: 35~40%
|
||||
- 고급 기능: 10~20%
|
||||
- 전체: 약 45%
|
||||
- 엔진/RPC: 70%+
|
||||
- UI 동등성: 55~60%
|
||||
- 고급 기능: 40~50%
|
||||
|
||||
## In Progress
|
||||
- [ ] Task Detail 패널 1차 포팅
|
||||
- [ ] General
|
||||
- [ ] Activity
|
||||
- [ ] Files
|
||||
- [ ] Peers
|
||||
- [ ] Trackers
|
||||
- [~] yt-dlp 번들 내장/탐지
|
||||
- [x] 런타임 탐지에 번들 resources 경로 추가 (`src-tauri/resources/engine/**/yt-dlp`)
|
||||
- [x] 번들 동기화 스크립트 추가 (`npm run sync:ytdlp`)
|
||||
- [ ] Linux/Windows용 yt-dlp 바이너리 동기화 확장
|
||||
- [~] 범주(Category) 기능 단계 구현
|
||||
- [x] Step 1: 범주 기본 데이터/설정 토글/추가 모달 범주 선택 + 저장 경로 자동 반영
|
||||
- [ ] Step 2: 범주 관리 UI(추가/수정/삭제, 아이콘/확장자 규칙 편집)
|
||||
- [ ] Step 3: 좌측 범주 네비게이션/카운트/필터 연동
|
||||
- [ ] Step 4: 외부 연동/자동 캡처 시 범주 자동 매핑 고도화
|
||||
- [ ] Step 5: 범주별 통계/정렬/검색 UX 마무리
|
||||
- [~] Native Messaging 기반 브라우저 연동 전환 (Step-by-step)
|
||||
- [x] Step 1: Native Host 스캐폴드(프로토콜/설치 스크립트/템플릿 manifest)
|
||||
- [x] Step 2: 확장에서 Native Host 1차 연결(우클릭 + 자동 경로 공통 addUri)
|
||||
- [x] Step 3: gdown 앱 제어 채널 정식화(외부 요청 큐 + 앱 포커스 + Add 모달 선표시)
|
||||
- [~] Step 4: 링크 자동 후킹 안정화 (사이트별 트리거 차이/클릭 경합)
|
||||
- [ ] Step 5: 장애 복구/재시도/로깅 정리
|
||||
- [ ] Step 6: 설정 UI/배포 문서 정리
|
||||
- [ ] motrix-extension gdown 친화 리브랜딩/동작 정렬
|
||||
- [ ] 문구/알림/컨텍스트 메뉴의 `Motrix` 표기 제거 후 `gdown` 기준으로 통일
|
||||
- [ ] 확장 설정 문서/툴팁을 `gdown` 워크플로우(추가 모달 확인 후 시작)로 전면 수정
|
||||
- [ ] 불필요 fallback 옵션 정리 및 기본 동작을 gdown 표준 경로로 고정
|
||||
- [ ] 확장자별 분기 동작 정책 구현
|
||||
- [ ] `*.torrent`/`magnet:`: 토렌트 탭/파일 선택 UI 우선 진입
|
||||
- [ ] 일반 바이너리(`zip/exe/dmg/...`): URL 탭 + 다운로드 추가 모달 사전 채움
|
||||
- [ ] 미디어/문서(`mp4/mp3/pdf` 등): 사용자 정책(자동 캡처/브라우저 유지) 선택 가능
|
||||
- [ ] 확장자 룰셋 UI(허용/제외/모달 강제/자동시작 금지) 추가
|
||||
- [~] Task Detail 패널 1차 포팅
|
||||
- [x] General
|
||||
- [x] Activity
|
||||
- [x] Files
|
||||
- [x] Peers
|
||||
- [x] Trackers
|
||||
- [~] 하단 액션바/상세 인터랙션 Motrix 동등화
|
||||
- [x] 하단 액션바(재개/중지/삭제/폴더/링크)
|
||||
- [x] 탭 아이콘/간격/텍스트 밀도 디테일 튜닝
|
||||
- [x] 피어 %/클라이언트 파싱 고도화
|
||||
|
||||
## Next
|
||||
- [~] 설정 저장소(local) 연결
|
||||
- [x] RPC Port / Secret / Binary Path 저장/복원
|
||||
- [x] Download Dir / Split / Concurrent 저장/복원
|
||||
- [ ] 테마/고급 설정 키 저장/복원
|
||||
- [ ] aria2 global option 반영 커맨드
|
||||
- [ ] `changeGlobalOption` 대응
|
||||
- [x] 테마/고급 설정 키 저장/복원
|
||||
- [x] aria2 global option 반영 커맨드
|
||||
- [x] `changeGlobalOption` 대응
|
||||
- [ ] 리스트 인터랙션 강화
|
||||
- [ ] 행 선택 상태
|
||||
- [ ] 다중 선택
|
||||
- [ ] 선택 항목 일괄 액션
|
||||
- [ ] Task Detail 패널 1차 포팅 마무리
|
||||
- [ ] 하단 액션 세부 UX(Motrix 동등성 최종 조정)
|
||||
- [ ] 탭별 빈 상태/오류 상태 문구 정리
|
||||
|
||||
## Done
|
||||
- [x] Native Host `addUri` 처리에서 앱 포커스/전환 동작 제거(큐 적재 전용 무중단 모드)
|
||||
- [x] Native Host 포커스 로직에서 `osascript/System Events` 제거, `open` 기반 포커스만 사용(자동화 승인 팝업 회피)
|
||||
- [x] macOS 앱 시작 시 Native Host(`org.gdown.nativehost`) 자동 설치/갱신 로직 추가
|
||||
- [x] 완료 작업 분류 보정: `active/waiting/stopped` 전 구간에서 완료 조건(`done>=total`)을 `다운로드 완료`로 집계
|
||||
- [x] 외부 링크 캡처 시 즉시 시작 대신 `Add 모달 확인 후 시작` 흐름으로 전환
|
||||
- [x] `gdown://` 스킴 미등록 환경 대응: Native Host -> 로컬 큐(`~/.gdown/external_add_queue.jsonl`) -> 앱 폴링 처리
|
||||
- [x] Native Host 설치/삭제/스모크 스크립트 추가 (`tools/native-host/install-macos.sh`, `uninstall-macos.sh`, `smoke.mjs`)
|
||||
- [x] Native Messaging Host 1차 스캐폴드 추가 (`tools/native-host/*`)
|
||||
- [x] 앱 종료 시 aria2 종료 루틴 보강 (강제 정리 + 메인 윈도우 close 이벤트 훅)
|
||||
- [x] Browser extension 연동 UX: `gdown://focus` 딥링크 수신 시 앱 창 `show/unminimize/focus` 처리
|
||||
- [x] Add Task 모달 고급 옵션(접기/펼치기) 구현: 사용자 에이전트/권한 부여/리퍼러/쿠키/프록시/다운로드로 이동
|
||||
- [x] Add Task 고급 옵션 값의 aria2 전달 연결 (`header`, `all-proxy`, `user-agent`)
|
||||
- [x] aria2 addUri/addTorrent 요청에 `options`/`position` 확장 지원
|
||||
- [x] 작업 카드 `i` 버튼 우측 슬라이드 Task Info 패널 구현 (General/Activity/Trackers/Peers/Files)
|
||||
- [x] Task Detail API 확장: 토렌트 생성일/코멘트/트래커 목록 필드 추가
|
||||
- [x] 고급 설정 RPC 확장 연동 보조 기능 구현 (RPC URL/토큰 복사, RPC 연결 테스트)
|
||||
- [x] 엔진 실행 정책 정식화: 외부 aria2 자동 재사용 제거, 포트 충돌 시 명시적 오류 처리
|
||||
- [x] 작업 추가 임시 재시도 로직 제거, TLS 설정 기반 단일 경로로 정리
|
||||
- [x] 설정 화면 저장/적용 시 하단 중앙 토스트 피드백 추가 및 애니메이션 적용
|
||||
- [x] 다운로드 필터에 `다운로드 완료` 추가 및 완료 항목 자동 분류
|
||||
- [x] 다운로드 중 항목 삭제 신뢰성 개선 (`forceRemove -> remove -> removeDownloadResult`)
|
||||
- [x] 고급 설정 탭 1차 구현 (업데이트/프록시/트래커/RPC/포트/프로토콜/UA) + 런타임 즉시 반영
|
||||
- [x] Motrix `TaskActions.vue` / `TaskItemActions.vue` 분석 기반 아이콘 기능 매핑 적용
|
||||
- [x] 항목 아이콘 기능 확장: 폴더 열기(네이티브 파일관리자), 링크 복사(URI 우선), 정보 표시
|
||||
- [x] 다운로드 화면을 스크린샷 기준으로 재정렬(라이트 톤, 좌측 작업 패널, 상단 아이콘 툴바)
|
||||
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "gdown",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gdown",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.3",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"vue": "^3.5.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1103,6 +1104,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-deep-link": {
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.7.tgz",
|
||||
"integrity": "sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
|
||||
11
package.json
11
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gdown",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24 <25"
|
||||
@@ -10,13 +10,20 @@
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"pretauri:dev": "bash scripts/cleanup-stale-aria2.sh 16800",
|
||||
"tauri:dev": "tauri dev",
|
||||
"pretauri:build": "bash scripts/cleanup-stale-aria2.sh 16800",
|
||||
"tauri:build": "bash scripts/version-bump.sh && tauri build",
|
||||
"version:bump": "bash scripts/version-bump.sh",
|
||||
"sync:aria2": "bash scripts/sync-aria2-from-motrix.sh"
|
||||
"sync:aria2": "bash scripts/sync-aria2-from-motrix.sh",
|
||||
"sync:ytdlp": "bash scripts/sync-ytdlp-bundle.sh",
|
||||
"native-host:smoke": "cd tools/native-host && npm run smoke",
|
||||
"native-host:install": "bash tools/native-host/install-macos.sh makoclohjdpempbndoaljeadpngefhcf",
|
||||
"native-host:uninstall": "bash tools/native-host/uninstall-macos.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.3",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"vue": "^3.5.25"
|
||||
},
|
||||
|
||||
29
scripts/cleanup-stale-aria2.sh
Normal file
29
scripts/cleanup-stale-aria2.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${1:-16800}"
|
||||
|
||||
# Kill stale aria2 process bound to the target RPC port.
|
||||
PIDS="$(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t 2>/dev/null | sort -u || true)"
|
||||
if [[ -z "$PIDS" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for pid in $PIDS; do
|
||||
cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
|
||||
if [[ "$cmd" == *aria2c* ]]; then
|
||||
kill -15 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
sleep 0.3
|
||||
|
||||
PIDS="$(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t 2>/dev/null | sort -u || true)"
|
||||
if [[ -n "$PIDS" ]]; then
|
||||
for pid in $PIDS; do
|
||||
cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
|
||||
if [[ "$cmd" == *aria2c* ]]; then
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
28
scripts/sync-ytdlp-bundle.sh
Executable file
28
scripts/sync-ytdlp-bundle.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DST_BASE="$ROOT_DIR/src-tauri/resources/engine"
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$DST_BASE/darwin/arm64" "$DST_BASE/darwin/x64"
|
||||
|
||||
YTDLP_URL="${YTDLP_URL:-https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos}"
|
||||
TARGET_FILE="$TMP_DIR/yt-dlp"
|
||||
|
||||
echo "[sync-ytdlp] downloading: $YTDLP_URL"
|
||||
curl -fL "$YTDLP_URL" -o "$TARGET_FILE"
|
||||
chmod +x "$TARGET_FILE"
|
||||
|
||||
cp "$TARGET_FILE" "$DST_BASE/darwin/arm64/yt-dlp"
|
||||
cp "$TARGET_FILE" "$DST_BASE/darwin/x64/yt-dlp"
|
||||
chmod +x "$DST_BASE/darwin/arm64/yt-dlp" "$DST_BASE/darwin/x64/yt-dlp"
|
||||
|
||||
echo "[sync-ytdlp] copied to:"
|
||||
echo " - $DST_BASE/darwin/arm64/yt-dlp"
|
||||
echo " - $DST_BASE/darwin/x64/yt-dlp"
|
||||
117
src-tauri/Cargo.lock
generated
117
src-tauri/Cargo.lock
generated
@@ -77,7 +77,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"log",
|
||||
@@ -86,6 +86,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
]
|
||||
@@ -431,6 +432,26 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -520,6 +541,12 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -708,6 +735,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||
dependencies = [
|
||||
"const-random",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
@@ -1296,6 +1332,12 @@ dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -2247,6 +2289,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||
dependencies = [
|
||||
"dlv-list",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -3025,6 +3077,16 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.40.0"
|
||||
@@ -3787,6 +3849,27 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"plist",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"url",
|
||||
"windows-registry",
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
@@ -4034,6 +4117,15 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.2"
|
||||
@@ -4244,9 +4336,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
@@ -4867,6 +4971,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
@@ -24,5 +24,6 @@ log = "0.4"
|
||||
tauri = { version = "2.10.0", features = [] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls"] }
|
||||
base64 = "0.22"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:allow-open"
|
||||
"dialog:allow-open",
|
||||
"deep-link:default"
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
BIN
src-tauri/resources/engine/darwin/arm64/yt-dlp
Executable file
BIN
src-tauri/resources/engine/darwin/arm64/yt-dlp
Executable file
Binary file not shown.
BIN
src-tauri/resources/engine/darwin/x64/yt-dlp
Executable file
BIN
src-tauri/resources/engine/darwin/x64/yt-dlp
Executable file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,257 @@
|
||||
mod engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tauri::Manager;
|
||||
|
||||
use engine::{
|
||||
aria2_add_torrent, aria2_add_uri, aria2_list_tasks, aria2_pause_all, aria2_pause_task,
|
||||
aria2_remove_task, aria2_remove_task_record, aria2_resume_all, aria2_resume_task,
|
||||
detect_aria2_binary, engine_start, engine_status, engine_stop, load_torrent_file,
|
||||
open_path_in_file_manager, EngineState,
|
||||
aria2_add_torrent, aria2_add_uri, aria2_change_global_option, aria2_get_task_detail,
|
||||
aria2_list_tasks, aria2_pause_all, aria2_pause_task, aria2_remove_task,
|
||||
aria2_remove_task_record, aria2_resume_all, aria2_resume_task, detect_aria2_binary,
|
||||
engine_start, engine_status, engine_stop,
|
||||
load_torrent_file,
|
||||
open_path_in_file_manager, stop_engine_for_exit, yt_dlp_add_uri, EngineState,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
async fn focus_main_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
.ok_or_else(|| "메인 창을 찾을 수 없습니다.".to_string())?;
|
||||
|
||||
window
|
||||
.show()
|
||||
.map_err(|error| format!("창 표시 실패: {error}"))?;
|
||||
window
|
||||
.unminimize()
|
||||
.map_err(|error| format!("창 복원 실패: {error}"))?;
|
||||
window
|
||||
.set_focus()
|
||||
.map_err(|error| format!("창 포커스 실패: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExternalAddRequest {
|
||||
url: String,
|
||||
out: Option<String>,
|
||||
dir: Option<String>,
|
||||
referer: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
authorization: Option<String>,
|
||||
cookie: Option<String>,
|
||||
proxy: Option<String>,
|
||||
split: Option<u32>,
|
||||
extractor: Option<String>,
|
||||
format: Option<String>,
|
||||
}
|
||||
|
||||
fn default_extension_ids() -> Vec<String> {
|
||||
vec![
|
||||
"alaohbbicffclloghmknhlmfdbobcigc".to_string(),
|
||||
"makoclohjdpempbndoaljeadpngefhcf".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn collect_extension_ids() -> Vec<String> {
|
||||
let mut ids = default_extension_ids();
|
||||
for key in ["GDOWN_EXTENSION_ID", "GDOWN_EXTENSION_IDS"] {
|
||||
if let Ok(raw) = env::var(key) {
|
||||
for id in raw.split([',', ' ', '\n', '\t']) {
|
||||
let trimmed = id.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !ids.iter().any(|v| v == trimmed) {
|
||||
ids.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn merge_existing_allowed_origins(manifest_path: &Path, ids: &mut Vec<String>) {
|
||||
let content = match fs::read_to_string(manifest_path) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
let Some(origins) = parsed.get("allowed_origins").and_then(|v| v.as_array()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for origin in origins {
|
||||
let Some(value) = origin.as_str() else {
|
||||
continue;
|
||||
};
|
||||
let prefix = "chrome-extension://";
|
||||
if !value.starts_with(prefix) {
|
||||
continue;
|
||||
}
|
||||
let id = value.trim_start_matches(prefix).trim_end_matches('/');
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !ids.iter().any(|v| v == id) {
|
||||
ids.push(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_node_path() -> Option<PathBuf> {
|
||||
if let Ok(path_var) = env::var("PATH") {
|
||||
for dir in env::split_paths(&path_var) {
|
||||
let node = dir.join("node");
|
||||
if node.is_file() {
|
||||
return Some(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for candidate in ["/usr/local/bin/node", "/opt/homebrew/bin/node", "/usr/bin/node"] {
|
||||
let path = PathBuf::from(candidate);
|
||||
if path.is_file() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ensure_native_host_installed() -> Result<PathBuf, String> {
|
||||
let host_name = "org.gdown.nativehost";
|
||||
let home = env::var("HOME").map_err(|err| format!("HOME 경로 확인 실패: {err}"))?;
|
||||
let tools_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("tools")
|
||||
.join("native-host");
|
||||
let host_script = tools_dir.join("host.mjs");
|
||||
if !host_script.is_file() {
|
||||
return Err(format!("native host script not found: {}", host_script.display()));
|
||||
}
|
||||
|
||||
let node_path = resolve_node_path().ok_or_else(|| "node 경로를 찾을 수 없습니다.".to_string())?;
|
||||
let runtime_dir = tools_dir.join(".runtime");
|
||||
fs::create_dir_all(&runtime_dir).map_err(|err| format!("runtime 디렉터리 생성 실패: {err}"))?;
|
||||
|
||||
let runner_path = runtime_dir.join("run-host-macos.sh");
|
||||
let runner_content = format!(
|
||||
"#!/usr/bin/env bash\nset -euo pipefail\nexec \"{}\" \"{}\"\n",
|
||||
node_path.display(),
|
||||
host_script.display()
|
||||
);
|
||||
fs::write(&runner_path, runner_content).map_err(|err| format!("runner 생성 실패: {err}"))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perm = fs::Permissions::from_mode(0o755);
|
||||
fs::set_permissions(&runner_path, perm).map_err(|err| format!("runner 권한 설정 실패: {err}"))?;
|
||||
}
|
||||
|
||||
let chrome_hosts_dir = Path::new(&home)
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("Google")
|
||||
.join("Chrome")
|
||||
.join("NativeMessagingHosts");
|
||||
fs::create_dir_all(&chrome_hosts_dir).map_err(|err| format!("native host 디렉터리 생성 실패: {err}"))?;
|
||||
let manifest_path = chrome_hosts_dir.join(format!("{host_name}.json"));
|
||||
|
||||
let mut extension_ids = collect_extension_ids();
|
||||
merge_existing_allowed_origins(&manifest_path, &mut extension_ids);
|
||||
let allowed_origins = extension_ids
|
||||
.into_iter()
|
||||
.map(|id| format!("chrome-extension://{id}/"))
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
let manifest = serde_json::json!({
|
||||
"name": host_name,
|
||||
"description": "gdown Native Messaging Host",
|
||||
"path": runner_path,
|
||||
"type": "stdio",
|
||||
"allowed_origins": allowed_origins,
|
||||
});
|
||||
|
||||
let manifest_text = serde_json::to_string_pretty(&manifest)
|
||||
.map_err(|err| format!("manifest 직렬화 실패: {err}"))?;
|
||||
fs::write(&manifest_path, manifest_text).map_err(|err| format!("manifest 쓰기 실패: {err}"))?;
|
||||
|
||||
Ok(manifest_path)
|
||||
}
|
||||
|
||||
fn external_add_queue_path() -> Result<PathBuf, String> {
|
||||
let home = std::env::var("HOME").map_err(|err| format!("HOME 경로 확인 실패: {err}"))?;
|
||||
Ok(PathBuf::from(home).join(".gdown").join("external_add_queue.jsonl"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn take_external_add_requests() -> Result<Vec<ExternalAddRequest>, String> {
|
||||
let path = external_add_queue_path()?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| "큐 디렉터리 경로를 계산할 수 없습니다.".to_string())?;
|
||||
fs::create_dir_all(parent).map_err(|err| format!("큐 디렉터리 생성 실패: {err}"))?;
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path).map_err(|err| format!("큐 읽기 실패: {err}"))?;
|
||||
let mut requests: Vec<ExternalAddRequest> = Vec::new();
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(item) = serde_json::from_str::<ExternalAddRequest>(trimmed) {
|
||||
requests.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&path)
|
||||
.map_err(|err| format!("큐 초기화 실패: {err}"))?;
|
||||
file
|
||||
.write_all(b"")
|
||||
.map_err(|err| format!("큐 파일 쓰기 실패: {err}"))?;
|
||||
|
||||
Ok(requests)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(EngineState::default())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
if window.label() == "main" {
|
||||
let state = window.state::<EngineState>();
|
||||
stop_engine_for_exit(&state);
|
||||
}
|
||||
}
|
||||
})
|
||||
.setup(|app| {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Err(err) = ensure_native_host_installed() {
|
||||
eprintln!("[gdown] native host auto-install skipped: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
@@ -29,6 +268,9 @@ pub fn run() {
|
||||
detect_aria2_binary,
|
||||
aria2_add_torrent,
|
||||
aria2_add_uri,
|
||||
yt_dlp_add_uri,
|
||||
aria2_change_global_option,
|
||||
aria2_get_task_detail,
|
||||
aria2_list_tasks,
|
||||
aria2_pause_task,
|
||||
aria2_resume_task,
|
||||
@@ -37,7 +279,9 @@ pub fn run() {
|
||||
aria2_pause_all,
|
||||
aria2_resume_all,
|
||||
load_torrent_file,
|
||||
open_path_in_file_manager
|
||||
open_path_in_file_manager,
|
||||
focus_main_window,
|
||||
take_external_add_requests
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "gdown",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"identifier": "com.tauri.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -15,9 +15,9 @@
|
||||
"label": "main",
|
||||
"title": "gdown",
|
||||
"width": 1280,
|
||||
"height": 860,
|
||||
"height": 660,
|
||||
"minWidth": 1080,
|
||||
"minHeight": 720,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
@@ -39,5 +39,14 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": [
|
||||
"gdown"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1490
src/App.vue
1490
src/App.vue
File diff suppressed because it is too large
Load Diff
@@ -46,12 +46,76 @@ export interface Aria2TaskSnapshot {
|
||||
stopped: Aria2Task[]
|
||||
}
|
||||
|
||||
export interface Aria2TaskDetailFile {
|
||||
path: string
|
||||
length: string
|
||||
completedLength: string
|
||||
selected: string
|
||||
uris: string[]
|
||||
}
|
||||
|
||||
export interface Aria2TaskDetailPeer {
|
||||
peerId: string
|
||||
ip: string
|
||||
port: string
|
||||
bitfield: string
|
||||
amChoking: string
|
||||
peerChoking: string
|
||||
downloadSpeed: string
|
||||
uploadSpeed: string
|
||||
}
|
||||
|
||||
export interface Aria2TaskDetailServer {
|
||||
uri: string
|
||||
currentUri: string
|
||||
downloadSpeed: string
|
||||
}
|
||||
|
||||
export interface Aria2TaskDetail {
|
||||
gid: string
|
||||
status: string
|
||||
totalLength: string
|
||||
completedLength: string
|
||||
uploadLength: string
|
||||
downloadSpeed: string
|
||||
uploadSpeed: string
|
||||
numSeeders: string
|
||||
connections: string
|
||||
pieceLength: string
|
||||
numPieces: string
|
||||
dir: string
|
||||
infoHash: string
|
||||
creationDate: string
|
||||
comment: string
|
||||
errorCode: string
|
||||
errorMessage: string
|
||||
trackers: string[]
|
||||
files: Aria2TaskDetailFile[]
|
||||
peers: Aria2TaskDetailPeer[]
|
||||
servers: Aria2TaskDetailServer[]
|
||||
}
|
||||
|
||||
export interface AddUriPayload {
|
||||
rpc: Aria2RpcConfig
|
||||
uri: string
|
||||
out?: string
|
||||
dir?: string
|
||||
split?: number
|
||||
options?: Record<string, string | string[]>
|
||||
position?: number
|
||||
}
|
||||
|
||||
export interface YtDlpAddUriPayload {
|
||||
rpc: Aria2RpcConfig
|
||||
url: string
|
||||
out?: string
|
||||
dir?: string
|
||||
split?: number
|
||||
format?: string
|
||||
referer?: string
|
||||
userAgent?: string
|
||||
options?: Record<string, string | string[]>
|
||||
position?: number
|
||||
}
|
||||
|
||||
export interface AddTorrentPayload {
|
||||
@@ -60,6 +124,8 @@ export interface AddTorrentPayload {
|
||||
out?: string
|
||||
dir?: string
|
||||
split?: number
|
||||
options?: Record<string, string | string[]>
|
||||
position?: number
|
||||
}
|
||||
|
||||
export interface TaskCommandPayload {
|
||||
@@ -67,12 +133,31 @@ export interface TaskCommandPayload {
|
||||
gid: string
|
||||
}
|
||||
|
||||
export interface ChangeGlobalOptionPayload {
|
||||
rpc: Aria2RpcConfig
|
||||
options: Record<string, string>
|
||||
}
|
||||
|
||||
export interface TorrentFilePayload {
|
||||
name: string
|
||||
base64: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface ExternalAddRequest {
|
||||
url: string
|
||||
out?: string
|
||||
dir?: string
|
||||
referer?: string
|
||||
userAgent?: string
|
||||
authorization?: string
|
||||
cookie?: string
|
||||
proxy?: string
|
||||
split?: number
|
||||
extractor?: string
|
||||
format?: string
|
||||
}
|
||||
|
||||
export async function getEngineStatus(): Promise<EngineStatus> {
|
||||
return invoke<EngineStatus>('engine_status')
|
||||
}
|
||||
@@ -93,10 +178,18 @@ export async function listAria2Tasks(config: Aria2RpcConfig): Promise<Aria2TaskS
|
||||
return invoke<Aria2TaskSnapshot>('aria2_list_tasks', { config })
|
||||
}
|
||||
|
||||
export async function getAria2TaskDetail(payload: TaskCommandPayload): Promise<Aria2TaskDetail> {
|
||||
return invoke<Aria2TaskDetail>('aria2_get_task_detail', { request: payload })
|
||||
}
|
||||
|
||||
export async function addAria2Uri(payload: AddUriPayload): Promise<string> {
|
||||
return invoke<string>('aria2_add_uri', { request: payload })
|
||||
}
|
||||
|
||||
export async function ytDlpAddUri(payload: YtDlpAddUriPayload): Promise<string> {
|
||||
return invoke<string>('yt_dlp_add_uri', { request: payload })
|
||||
}
|
||||
|
||||
export async function addAria2Torrent(payload: AddTorrentPayload): Promise<string> {
|
||||
return invoke<string>('aria2_add_torrent', { request: payload })
|
||||
}
|
||||
@@ -129,6 +222,18 @@ export async function resumeAllAria2(config: Aria2RpcConfig): Promise<string> {
|
||||
return invoke<string>('aria2_resume_all', { config })
|
||||
}
|
||||
|
||||
export async function changeAria2GlobalOption(payload: ChangeGlobalOptionPayload): Promise<string> {
|
||||
return invoke<string>('aria2_change_global_option', { request: payload })
|
||||
}
|
||||
|
||||
export async function openPathInFileManager(path: string): Promise<void> {
|
||||
return invoke<void>('open_path_in_file_manager', { path })
|
||||
}
|
||||
|
||||
export async function focusMainWindow(): Promise<void> {
|
||||
return invoke<void>('focus_main_window')
|
||||
}
|
||||
|
||||
export async function takeExternalAddRequests(): Promise<ExternalAddRequest[]> {
|
||||
return invoke<ExternalAddRequest[]>('take_external_add_requests')
|
||||
}
|
||||
|
||||
438
src/style.css
438
src/style.css
@@ -1,7 +1,7 @@
|
||||
:root {
|
||||
--bg-app: #121317;
|
||||
--bg-sidebar: #0a0b0d;
|
||||
--bg-sidebar-active: #202227;
|
||||
--bg-sidebar: #2b1f57;
|
||||
--bg-sidebar-active: #473385;
|
||||
--bg-main: #15171c;
|
||||
--bg-card: #1f2127;
|
||||
--bg-card-soft: #2a2d34;
|
||||
@@ -57,7 +57,7 @@ body {
|
||||
|
||||
.sidebar {
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid #14161b;
|
||||
border-right: 1px solid #3f2f74;
|
||||
padding: 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -91,7 +91,7 @@ body {
|
||||
}
|
||||
|
||||
.side-icon:hover,
|
||||
.side-icon.add { background: #15181e; color: #d8def0; }
|
||||
.side-icon.add { background: #3d2f72; color: #ede8ff; }
|
||||
|
||||
.sidebar nav {
|
||||
margin-top: 6px;
|
||||
@@ -102,7 +102,7 @@ body {
|
||||
}
|
||||
|
||||
.sidebar nav a {
|
||||
color: #98a3b8;
|
||||
color: #d2caf3;
|
||||
text-decoration: none;
|
||||
font-size: 0.74rem;
|
||||
text-align: center;
|
||||
@@ -111,7 +111,7 @@ body {
|
||||
}
|
||||
|
||||
.sidebar nav a.active,
|
||||
.sidebar nav a:hover { background: var(--bg-sidebar-active); color: #ecf0fa; }
|
||||
.sidebar nav a:hover { background: var(--bg-sidebar-active); color: #f2edff; }
|
||||
|
||||
.sidebar-foot { margin-top: auto; }
|
||||
|
||||
@@ -235,6 +235,11 @@ body {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.downloads-main {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -294,6 +299,46 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
|
||||
border: 1px solid rgba(39, 180, 122, 0.3);
|
||||
}
|
||||
|
||||
.app-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 26px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 160;
|
||||
min-width: 240px;
|
||||
max-width: 420px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.28);
|
||||
pointer-events: none;
|
||||
animation: toast-in 180ms ease-out;
|
||||
}
|
||||
|
||||
.app-toast.success {
|
||||
background: #1f3a2f;
|
||||
border-color: #2e7050;
|
||||
color: #c2f1da;
|
||||
}
|
||||
|
||||
.app-toast.error {
|
||||
background: #4c2a31;
|
||||
border-color: #7a434f;
|
||||
color: #ffd3da;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -421,15 +466,198 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-info-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: min(640px, 100%);
|
||||
height: 100%;
|
||||
background: #2f333a !important;
|
||||
border: 1px solid #4a4f5b !important;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
}
|
||||
|
||||
.task-info-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #4a4f5b;
|
||||
}
|
||||
|
||||
.task-info-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: #d9deeb;
|
||||
}
|
||||
|
||||
.task-info-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #4a4f5b;
|
||||
}
|
||||
|
||||
.task-info-tabs button {
|
||||
min-width: 34px;
|
||||
height: 32px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #9da8be;
|
||||
border-radius: 6px;
|
||||
padding: 0;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.task-info-tabs button.active {
|
||||
color: #dfe3ff;
|
||||
border-color: #5b62f3;
|
||||
background: #3a3f73;
|
||||
}
|
||||
|
||||
.task-info-body {
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.task-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(0, 1fr);
|
||||
gap: 10px 12px;
|
||||
align-items: center;
|
||||
font-size: 0.84rem;
|
||||
color: #d3d9e8;
|
||||
}
|
||||
|
||||
.task-info-grid .k {
|
||||
color: #aab4c8;
|
||||
}
|
||||
|
||||
.task-info-divider {
|
||||
margin: 14px 0 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #4a4f5b;
|
||||
color: #bcc6da;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.inline-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.piece-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(24, 1fr);
|
||||
gap: 3px;
|
||||
padding: 8px;
|
||||
border: 1px solid #4a4f5b;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.piece-grid span {
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
background: #555b68;
|
||||
}
|
||||
|
||||
.piece-grid span.done {
|
||||
background: #35bf57;
|
||||
}
|
||||
|
||||
.task-info-progress-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.task-info-list {
|
||||
border: 1px solid #4a4f5b;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
background: #2b2f36;
|
||||
min-height: 240px;
|
||||
max-height: 560px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.task-info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid #4a4f5b;
|
||||
}
|
||||
|
||||
.task-info-table th,
|
||||
.task-info-table td {
|
||||
border-bottom: 1px solid #454a56;
|
||||
padding: 8px;
|
||||
color: #cfd7e8;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-info-table th {
|
||||
background: #252930;
|
||||
color: #aeb8cc;
|
||||
}
|
||||
|
||||
.task-info-files-meta {
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
color: #98a2b8;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.task-info-slide-enter-active,
|
||||
.task-info-slide-leave-active {
|
||||
transition: transform 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.task-info-slide-enter-from,
|
||||
.task-info-slide-leave-to {
|
||||
transform: translateX(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.task-info-footer {
|
||||
border-top: 1px solid #4a4f5b;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.task-info-actions-pill {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-start;
|
||||
background: #343844;
|
||||
border: 1px solid #505662;
|
||||
border-radius: 999px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.add-modal input,
|
||||
.add-modal select,
|
||||
.add-modal textarea {
|
||||
height: 33px;
|
||||
border: 1px solid #434955;
|
||||
border-radius: 7px;
|
||||
border-radius: 4px;
|
||||
padding: 7px 9px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-main);
|
||||
background: #242830;
|
||||
background: #20242c;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.add-modal textarea {
|
||||
@@ -439,10 +667,11 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
|
||||
}
|
||||
|
||||
.add-modal input:focus,
|
||||
.add-modal textarea:focus {
|
||||
.add-modal textarea:focus,
|
||||
.add-modal select:focus {
|
||||
outline: none;
|
||||
border-color: #656cf5;
|
||||
box-shadow: 0 0 0 2px rgba(94, 98, 243, 0.18);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
button {
|
||||
@@ -488,18 +717,22 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 90px;
|
||||
padding: 18px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.add-modal {
|
||||
width: min(680px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 36px);
|
||||
background: var(--bg-card-soft);
|
||||
border: 1px solid #434955;
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 22px 56px rgba(0, 0, 0, 0.44);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@@ -541,6 +774,9 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-body label {
|
||||
@@ -553,6 +789,65 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
.modal-body small { color: #919cb2; font-size: 0.74rem; }
|
||||
|
||||
.category-panel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border: 1px solid #3a404c;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
background: #20242c;
|
||||
}
|
||||
|
||||
.category-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.category-select-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.category-select-label select {
|
||||
width: 100%;
|
||||
height: 33px;
|
||||
border: 1px solid #3f4654;
|
||||
border-radius: 4px;
|
||||
padding: 7px 9px;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-main);
|
||||
background: #20242c;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.category-select-label select:focus {
|
||||
outline: none;
|
||||
border-color: #666ef5;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.category-select-label select option {
|
||||
color: #dce2f0;
|
||||
background: #1d2128;
|
||||
}
|
||||
|
||||
.category-row button.ghost.tiny {
|
||||
min-width: 34px;
|
||||
height: 33px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
background: #1d2128;
|
||||
border-color: #3f4654;
|
||||
color: #aeb8cc;
|
||||
}
|
||||
|
||||
.category-row button.ghost.tiny:hover:not(:disabled) {
|
||||
background: #242933;
|
||||
border-color: #4a5263;
|
||||
color: #d0d7e7;
|
||||
}
|
||||
|
||||
.torrent-drop-zone {
|
||||
border: 1px dashed #5a606f;
|
||||
border-radius: 8px;
|
||||
@@ -622,11 +917,79 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.modal-footer {
|
||||
border-top: 1px solid #484f5b;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-footer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-advanced {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border-top: 1px solid #3f4450;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.proxy-help-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.proxy-help-row a {
|
||||
color: #a8b2c8;
|
||||
font-size: 0.74rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.proxy-help-row a:hover {
|
||||
color: #d4dcf2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.modal-advanced-toggle {
|
||||
font-size: 0.8rem;
|
||||
color: #c5cee2 !important;
|
||||
}
|
||||
|
||||
.modal-advanced-toggle input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
@media (max-height: 760px) {
|
||||
.modal-backdrop {
|
||||
align-items: stretch;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.add-modal {
|
||||
width: min(680px, calc(100vw - 20px));
|
||||
max-height: calc(100vh - 20px);
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.expand-advanced-enter-active,
|
||||
.expand-advanced-leave-active {
|
||||
transition: opacity 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.expand-advanced-enter-from,
|
||||
.expand-advanced-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
@@ -776,7 +1139,8 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.settings-group input,
|
||||
.settings-group select {
|
||||
.settings-group select,
|
||||
.settings-group textarea {
|
||||
height: 33px;
|
||||
border: 1px solid #434955;
|
||||
border-radius: 4px;
|
||||
@@ -787,12 +1151,19 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.settings-group input:focus,
|
||||
.settings-group select:focus {
|
||||
.settings-group select:focus,
|
||||
.settings-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #656cf5;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.settings-group textarea {
|
||||
min-height: 66px;
|
||||
height: auto;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.settings-group select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
@@ -848,6 +1219,45 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
min-height: 33px;
|
||||
}
|
||||
|
||||
.inline-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-action-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.muted-line {
|
||||
color: #97a2ba;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.rpc-helper {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rpc-test-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rpc-ok {
|
||||
color: #8ad9af;
|
||||
}
|
||||
|
||||
.rpc-fail {
|
||||
color: #f0a3b1;
|
||||
}
|
||||
|
||||
.settings-placeholder {
|
||||
padding: 20px;
|
||||
background: #242831;
|
||||
|
||||
3
tools/native-host/.runtime/run-host-macos.sh
Executable file
3
tools/native-host/.runtime/run-host-macos.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec "/opt/homebrew/bin/node" "/Volumes/WD/Users/yommi/Work/tauri_projects/gdown/tools/native-host/host.mjs"
|
||||
24
tools/native-host/README.md
Normal file
24
tools/native-host/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# gdown Native Messaging Host (Step 1 MVP)
|
||||
|
||||
## Install (macOS + Chrome)
|
||||
|
||||
```bash
|
||||
cd tools/native-host
|
||||
bash install-macos.sh <EXTENSION_ID>
|
||||
```
|
||||
|
||||
If extension ID is omitted, default ID is used.
|
||||
|
||||
## Smoke test
|
||||
|
||||
```bash
|
||||
cd tools/native-host
|
||||
npm run smoke
|
||||
```
|
||||
|
||||
## Remove
|
||||
|
||||
```bash
|
||||
cd tools/native-host
|
||||
bash uninstall-macos.sh
|
||||
```
|
||||
151
tools/native-host/host.mjs
Normal file
151
tools/native-host/host.mjs
Normal file
@@ -0,0 +1,151 @@
|
||||
import { appendFile, mkdir } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const textEncoder = new TextEncoder()
|
||||
let readBuffer = Buffer.alloc(0)
|
||||
let pendingRequests = 0
|
||||
let stdinEnded = false
|
||||
|
||||
function sendMessage(payload) {
|
||||
const body = Buffer.from(JSON.stringify(payload), 'utf8')
|
||||
const len = Buffer.alloc(4)
|
||||
len.writeUInt32LE(body.length, 0)
|
||||
process.stdout.write(len)
|
||||
process.stdout.write(body)
|
||||
}
|
||||
|
||||
function parseHeaders(lines = []) {
|
||||
const map = new Map()
|
||||
for (const line of lines) {
|
||||
const idx = line.indexOf(':')
|
||||
if (idx <= 0) continue
|
||||
const key = line.slice(0, idx).trim().toLowerCase()
|
||||
const value = line.slice(idx + 1).trim()
|
||||
if (!key || !value) continue
|
||||
map.set(key, value)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function parseCookieHeader(lines = []) {
|
||||
const headers = parseHeaders(lines)
|
||||
return headers.get('cookie') || ''
|
||||
}
|
||||
|
||||
function queueFilePath() {
|
||||
return join(homedir(), '.gdown', 'external_add_queue.jsonl')
|
||||
}
|
||||
|
||||
async function enqueueExternalAdd(payload) {
|
||||
const filePath = queueFilePath()
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await appendFile(filePath, `${JSON.stringify(payload)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
function focusGdownApp() {
|
||||
return Promise.resolve({ ok: true, note: 'focus disabled by design' })
|
||||
}
|
||||
|
||||
async function handleRequest(message) {
|
||||
const action = String(message?.action || '').trim()
|
||||
|
||||
if (action === 'ping') {
|
||||
return {
|
||||
ok: true,
|
||||
version: '0.1.0',
|
||||
host: 'org.gdown.nativehost',
|
||||
capabilities: ['ping', 'addUri', 'focus', 'extractor:yt-dlp'],
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'addUri') {
|
||||
const url = String(message?.url || '').trim()
|
||||
if (!url) return { ok: false, error: 'url is required' }
|
||||
const referer = String(message?.referer || '').trim()
|
||||
const userAgent = String(message?.userAgent || '').trim()
|
||||
const out = String(message?.out || '').trim()
|
||||
const dir = String(message?.dir || '').trim()
|
||||
const cookie = String(message?.cookie || '').trim()
|
||||
const authorization = String(message?.authorization || '').trim()
|
||||
const proxy = String(message?.proxy || '').trim()
|
||||
const split = Number(message?.split || 0)
|
||||
const extractor = String(message?.extractor || '').trim()
|
||||
const format = String(message?.format || '').trim()
|
||||
|
||||
const parsedCookie = parseCookieHeader(Array.isArray(message?.headers) ? message.headers : [])
|
||||
const cookieValue = cookie || parsedCookie
|
||||
|
||||
await enqueueExternalAdd({
|
||||
url,
|
||||
referer: referer || undefined,
|
||||
userAgent: userAgent || undefined,
|
||||
out: out || undefined,
|
||||
dir: dir || undefined,
|
||||
cookie: cookieValue || undefined,
|
||||
authorization: authorization || undefined,
|
||||
proxy: proxy || undefined,
|
||||
split: Number.isFinite(split) && split > 0 ? Math.round(split) : undefined,
|
||||
extractor: extractor || undefined,
|
||||
format: format || undefined,
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
pending: true,
|
||||
mode: 'prompt',
|
||||
requestId: `pending-${Date.now()}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'focus') {
|
||||
return focusGdownApp()
|
||||
}
|
||||
|
||||
return { ok: false, error: `unknown action: ${action || '(empty)'}` }
|
||||
}
|
||||
|
||||
async function drainBuffer() {
|
||||
while (readBuffer.length >= 4) {
|
||||
const bodyLength = readBuffer.readUInt32LE(0)
|
||||
if (readBuffer.length < 4 + bodyLength) return
|
||||
const body = readBuffer.subarray(4, 4 + bodyLength)
|
||||
readBuffer = readBuffer.subarray(4 + bodyLength)
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(body.toString('utf8'))
|
||||
} catch (error) {
|
||||
sendMessage({ ok: false, error: `invalid JSON payload: ${String(error)}` })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
pendingRequests += 1
|
||||
const result = await handleRequest(payload)
|
||||
sendMessage(result)
|
||||
} catch (error) {
|
||||
sendMessage({ ok: false, error: String(error) })
|
||||
} finally {
|
||||
pendingRequests -= 1
|
||||
if (stdinEnded && pendingRequests === 0) {
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.on('data', async (chunk) => {
|
||||
readBuffer = Buffer.concat([readBuffer, chunk])
|
||||
await drainBuffer()
|
||||
})
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
stdinEnded = true
|
||||
if (pendingRequests === 0) {
|
||||
process.exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Emit one line for manual shell smoke visibility (not used by native messaging framing).
|
||||
process.stderr.write(`[native-host] started pid=${process.pid}\n`)
|
||||
41
tools/native-host/install-macos.sh
Executable file
41
tools/native-host/install-macos.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
EXTENSION_ID="${1:-makoclohjdpempbndoaljeadpngefhcf}"
|
||||
HOST_NAME="org.gdown.nativehost"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RUNNER_PATH="$SCRIPT_DIR/.runtime/run-host-macos.sh"
|
||||
TEMPLATE_PATH="$SCRIPT_DIR/manifest/${HOST_NAME}.json.template"
|
||||
CHROME_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
|
||||
OUT_PATH="$CHROME_DIR/${HOST_NAME}.json"
|
||||
NODE_PATH="$(command -v node || true)"
|
||||
|
||||
if [[ ! -f "$TEMPLATE_PATH" ]]; then
|
||||
echo "template not found: $TEMPLATE_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$NODE_PATH" ]]; then
|
||||
echo "node not found in current shell PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$CHROME_DIR"
|
||||
mkdir -p "$SCRIPT_DIR/.runtime"
|
||||
|
||||
cat > "$RUNNER_PATH" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec "$NODE_PATH" "$SCRIPT_DIR/host.mjs"
|
||||
EOF
|
||||
|
||||
chmod +x "$RUNNER_PATH"
|
||||
|
||||
sed \
|
||||
-e "s|__ABSOLUTE_HOST_PATH__|$RUNNER_PATH|g" \
|
||||
-e "s|__EXTENSION_ID__|$EXTENSION_ID|g" \
|
||||
"$TEMPLATE_PATH" > "$OUT_PATH"
|
||||
|
||||
echo "installed: $OUT_PATH"
|
||||
echo "extension id: $EXTENSION_ID"
|
||||
echo "node path: $NODE_PATH"
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "org.gdown.nativehost",
|
||||
"description": "gdown Native Messaging Host",
|
||||
"path": "__ABSOLUTE_HOST_PATH__",
|
||||
"type": "stdio",
|
||||
"allowed_origins": [
|
||||
"chrome-extension://__EXTENSION_ID__/"
|
||||
]
|
||||
}
|
||||
13
tools/native-host/package.json
Normal file
13
tools/native-host/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "gdown-native-host",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24 <25"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node ./host.mjs",
|
||||
"smoke": "node ./smoke.mjs"
|
||||
}
|
||||
}
|
||||
5
tools/native-host/run-host.sh
Executable file
5
tools/native-host/run-host.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec node "$SCRIPT_DIR/host.mjs"
|
||||
41
tools/native-host/smoke.mjs
Normal file
41
tools/native-host/smoke.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
function encodeMessage(payload) {
|
||||
const body = Buffer.from(JSON.stringify(payload), 'utf8')
|
||||
const len = Buffer.alloc(4)
|
||||
len.writeUInt32LE(body.length, 0)
|
||||
return Buffer.concat([len, body])
|
||||
}
|
||||
|
||||
function decodeMessages(buffer) {
|
||||
const messages = []
|
||||
let offset = 0
|
||||
while (offset + 4 <= buffer.length) {
|
||||
const len = buffer.readUInt32LE(offset)
|
||||
if (offset + 4 + len > buffer.length) break
|
||||
const body = buffer.subarray(offset + 4, offset + 4 + len)
|
||||
messages.push(JSON.parse(body.toString('utf8')))
|
||||
offset += 4 + len
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
const child = spawn(process.execPath, ['host.mjs'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
})
|
||||
|
||||
const chunks = []
|
||||
child.stdout.on('data', (chunk) => chunks.push(chunk))
|
||||
|
||||
child.stdin.write(encodeMessage({ action: 'ping' }))
|
||||
setTimeout(() => {
|
||||
child.stdin.end()
|
||||
}, 120)
|
||||
|
||||
child.on('exit', () => {
|
||||
const out = Buffer.concat(chunks)
|
||||
const messages = decodeMessages(out)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(messages, null, 2))
|
||||
})
|
||||
13
tools/native-host/uninstall-macos.sh
Executable file
13
tools/native-host/uninstall-macos.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HOST_NAME="org.gdown.nativehost"
|
||||
CHROME_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
|
||||
OUT_PATH="$CHROME_DIR/${HOST_NAME}.json"
|
||||
|
||||
if [[ -f "$OUT_PATH" ]]; then
|
||||
rm -f "$OUT_PATH"
|
||||
echo "removed: $OUT_PATH"
|
||||
else
|
||||
echo "not found: $OUT_PATH"
|
||||
fi
|
||||
Reference in New Issue
Block a user