Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5ac13cfdc | ||
|
|
073e1413ab | ||
|
|
04b9aa18ec | ||
|
|
08fe4eef4e | ||
|
|
8f9bac9e6b | ||
|
|
bb17c7d48a | ||
|
|
75ffe70a4d | ||
|
|
36f9f65384 | ||
|
|
5f89ae3516 | ||
|
|
0cc2b690f8 | ||
|
|
b991482617 | ||
|
|
1f8906f952 | ||
|
|
0efc85d3c0 | ||
|
|
481675107f | ||
|
|
6568498850 | ||
|
|
67814348dc | ||
|
|
53d13dfe28 | ||
|
|
d511e66d56 | ||
|
|
16f2d30476 | ||
|
|
aafd039205 | ||
|
|
647e161431 |
No files matched your search
@@ -594,7 +594,7 @@ jobs:
|
|||||||
overwrite: true
|
overwrite: true
|
||||||
build-ios:
|
build-ios:
|
||||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}
|
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}
|
||||||
runs-on: macos-14
|
runs-on: macos-26
|
||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|||||||
@@ -6,15 +6,19 @@
|
|||||||
// What it checks:
|
// What it checks:
|
||||||
// - Every ARB file uses the repository's canonical two-space JSON formatting.
|
// - Every ARB file uses the repository's canonical two-space JSON formatting.
|
||||||
// - Every app_<locale>.arb file declares the matching @@locale value.
|
// - Every app_<locale>.arb file declares the matching @@locale value.
|
||||||
// - Message values are not empty.
|
// - Message values are not empty (warning only).
|
||||||
// - Every locale has exactly the same message keys as app_en.arb.
|
// - Every locale has the same message keys as app_en.arb (warning only).
|
||||||
// - Every translation preserves the placeholder variables used by app_en.arb.
|
// - Every translation preserves the placeholder variables used by app_en.arb
|
||||||
|
// (warning only).
|
||||||
//
|
//
|
||||||
// Notes:
|
// Notes:
|
||||||
// - This script does not judge translation quality.
|
// - This script does not judge translation quality.
|
||||||
// - It does not generate localization code or fully parse ICU MessageFormat.
|
// - It does not generate localization code or fully parse ICU MessageFormat.
|
||||||
// `flutter gen-l10n` runs immediately afterwards in CI to validate ICU syntax
|
// `flutter gen-l10n` runs immediately afterwards in CI to validate ICU syntax
|
||||||
// and generate app_localizations*.dart.
|
// and generate app_localizations*.dart.
|
||||||
|
// - The script exits with a failure only for malformed JSON, invalid ARB
|
||||||
|
// formatting, or a mismatched @@locale value. Translation completeness is
|
||||||
|
// reported as warnings; generation and analysis remain the compile-time gate.
|
||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
@@ -49,27 +53,24 @@ void main() {
|
|||||||
keys.where((key) => (messages[key] as String).trim().isEmpty).toList()
|
keys.where((key) => (messages[key] as String).trim().isEmpty).toList()
|
||||||
..sort();
|
..sort();
|
||||||
if (empty.isNotEmpty) {
|
if (empty.isNotEmpty) {
|
||||||
stderr.writeln('${file.path}: empty messages $empty');
|
_warning('${file.path}: empty messages $empty');
|
||||||
failed = true;
|
|
||||||
}
|
}
|
||||||
if (file.path == templateFile.path) continue;
|
if (file.path == templateFile.path) continue;
|
||||||
final missing = templateKeys.difference(keys);
|
final missing = templateKeys.difference(keys);
|
||||||
final extra = keys.difference(templateKeys);
|
final extra = keys.difference(templateKeys);
|
||||||
if (missing.isNotEmpty || extra.isNotEmpty) {
|
if (missing.isNotEmpty || extra.isNotEmpty) {
|
||||||
stderr.writeln(
|
_warning(
|
||||||
'${file.path}: missing ${missing.toList()..sort()}, extra ${extra.toList()..sort()}',
|
'${file.path}: missing ${missing.toList()..sort()}, extra ${extra.toList()..sort()}',
|
||||||
);
|
);
|
||||||
failed = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final key in templateKeys.intersection(keys)) {
|
for (final key in templateKeys.intersection(keys)) {
|
||||||
final expected = _placeholders(template[key] as String);
|
final expected = _placeholders(template[key] as String);
|
||||||
final actual = _placeholders(messages[key] as String);
|
final actual = _placeholders(messages[key] as String);
|
||||||
if (!_sameSet(expected, actual)) {
|
if (!_sameSet(expected, actual)) {
|
||||||
stderr.writeln(
|
_warning(
|
||||||
'${file.path}: $key placeholders $actual do not match $expected',
|
'${file.path}: $key placeholders $actual do not match $expected',
|
||||||
);
|
);
|
||||||
failed = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,3 +103,5 @@ Set<String> _placeholders(String message) => RegExp(
|
|||||||
/// Compares sets without depending on iteration order.
|
/// Compares sets without depending on iteration order.
|
||||||
bool _sameSet(Set<String> left, Set<String> right) =>
|
bool _sameSet(Set<String> left, Set<String> right) =>
|
||||||
left.length == right.length && left.containsAll(right);
|
left.length == right.length && left.containsAll(right);
|
||||||
|
|
||||||
|
void _warning(String message) => stderr.writeln('warning: $message');
|
||||||
@@ -213,7 +213,7 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ macos-14, ubuntu-latest ]
|
os: [ macos-26, ubuntu-latest ]
|
||||||
needs: [ test ]
|
needs: [ test ]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Connect Gopeed to an AI agent and manage downloads with natural language. For ex
|
|||||||
|
|
||||||
Gopeed 2.0.0 is currently in public beta, introducing a redesigned interface, a native communication architecture that connects desktop and mobile clients directly to the Go core through FFI, a more consistent cross-platform experience, improved task management, more flexible API support, and MCP-based AI agent integration. Some features may still be incomplete or unstable, so please try it and report any issues you encounter.
|
Gopeed 2.0.0 is currently in public beta, introducing a redesigned interface, a native communication architecture that connects desktop and mobile clients directly to the Go core through FFI, a more consistent cross-platform experience, improved task management, more flexible API support, and MCP-based AI agent integration. Some features may still be incomplete or unstable, so please try it and report any issues you encounter.
|
||||||
|
|
||||||
- [Download Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
|
- [Download Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||||
|
|
||||||
Once the features and stability meet our release standards, we will publish the official Gopeed 2.0.0 release. Beta users will be able to upgrade directly to the final release, while existing stable users will not be automatically moved onto the beta channel.
|
Once the features and stability meet our release standards, we will publish the official Gopeed 2.0.0 release. Beta users will be able to upgrade directly to the final release, while existing stable users will not be automatically moved onto the beta channel.
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Gopeed を AI Agent と連携すると、自然言語でダウンロードを管
|
|||||||
|
|
||||||
Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による AI Agent 連携機能を導入しています。一部の機能はまだ不完全または不安定な場合がありますので、ぜひお試しいただき、問題があればフィードバックをお寄せください。
|
Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による AI Agent 連携機能を導入しています。一部の機能はまだ不完全または不安定な場合がありますので、ぜひお試しいただき、問題があればフィードバックをお寄せください。
|
||||||
|
|
||||||
- [Gopeed 2.0.0 Beta 1 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
|
- [Gopeed 2.0.0 Beta 2 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||||
|
|
||||||
機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。
|
機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Sau khi kết nối Gopeed với AI Agent, bạn có thể quản lý tải xu
|
|||||||
|
|
||||||
Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai, với giao diện được thiết kế lại, kiến trúc giao tiếp native kết nối trực tiếp ứng dụng desktop và di động với lõi Go qua FFI, trải nghiệm đa nền tảng nhất quán hơn, khả năng quản lý tác vụ được cải thiện, API linh hoạt hơn và khả năng tích hợp AI Agent qua MCP. Một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định, vì vậy hãy dùng thử và gửi phản hồi nếu bạn gặp vấn đề.
|
Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai, với giao diện được thiết kế lại, kiến trúc giao tiếp native kết nối trực tiếp ứng dụng desktop và di động với lõi Go qua FFI, trải nghiệm đa nền tảng nhất quán hơn, khả năng quản lý tác vụ được cải thiện, API linh hoạt hơn và khả năng tích hợp AI Agent qua MCP. Một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định, vì vậy hãy dùng thử và gửi phản hồi nếu bạn gặp vấn đề.
|
||||||
|
|
||||||
- [Tải Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
|
- [Tải Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||||
|
|
||||||
Khi các tính năng và độ ổn định đáp ứng tiêu chuẩn phát hành, chúng tôi sẽ phát hành Gopeed 2.0.0 chính thức. Người dùng bản beta có thể nâng cấp trực tiếp lên bản chính thức, trong khi người dùng bản ổn định hiện tại sẽ không tự động được chuyển sang kênh beta.
|
Khi các tính năng và độ ổn định đáp ứng tiêu chuẩn phát hành, chúng tôi sẽ phát hành Gopeed 2.0.0 chính thức. Người dùng bản beta có thể nâng cấp trực tiếp lên bản chính thức, trong khi người dùng bản ổn định hiện tại sẽ không tự động được chuyển sang kênh beta.
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go
|
|||||||
|
|
||||||
Gopeed 2.0.0 目前处于公开 Beta 测试阶段,引入了全新界面、通过 FFI 直连 Go 核心的原生通信架构、更一致的跨平台体验、更完善的任务管理、更灵活的 API,以及用于 AI Agent 接入的 MCP 能力。部分功能仍可能不够完善或稳定,欢迎提前体验并向我们反馈问题。
|
Gopeed 2.0.0 目前处于公开 Beta 测试阶段,引入了全新界面、通过 FFI 直连 Go 核心的原生通信架构、更一致的跨平台体验、更完善的任务管理、更灵活的 API,以及用于 AI Agent 接入的 MCP 能力。部分功能仍可能不够完善或稳定,欢迎提前体验并向我们反馈问题。
|
||||||
|
|
||||||
- [下载 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
|
- [下载 Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||||
|
|
||||||
当功能完整度和稳定性达到正式发布标准后,我们会发布 Gopeed 2.0.0 正式版。已安装 Beta 版本的用户可以直接升级到最终正式版,现有稳定版用户则不会被自动切换到 Beta 渠道。
|
当功能完整度和稳定性达到正式发布标准后,我们会发布 Gopeed 2.0.0 正式版。已安装 Beta 版本的用户可以直接升级到最终正式版,现有稳定版用户则不会被自动切换到 Beta 渠道。
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、
|
|||||||
|
|
||||||
Gopeed 2.0.0 目前處於公開 Beta 測試階段,引入了全新介面、透過 FFI 直連 Go 核心的原生通訊架構、更一致的跨平台體驗、更完善的任務管理、更靈活的 API,以及透過 MCP 串接 AI Agent 的能力。部分功能仍可能不夠完善或穩定,歡迎搶先體驗並向我們回報問題。
|
Gopeed 2.0.0 目前處於公開 Beta 測試階段,引入了全新介面、透過 FFI 直連 Go 核心的原生通訊架構、更一致的跨平台體驗、更完善的任務管理、更靈活的 API,以及透過 MCP 串接 AI Agent 的能力。部分功能仍可能不夠完善或穩定,歡迎搶先體驗並向我們回報問題。
|
||||||
|
|
||||||
- [下載 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
|
- [下載 Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||||
|
|
||||||
當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。
|
當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 391 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 357 KiB |
@@ -6,9 +6,15 @@ package main
|
|||||||
|
|
||||||
typedef void (*TaskEventCallback)(char* payload);
|
typedef void (*TaskEventCallback)(char* payload);
|
||||||
|
|
||||||
|
typedef void (*InvokeResultCallback)(uint64_t request_id, int success, char* payload);
|
||||||
|
|
||||||
static void callTaskEventCallback(uintptr_t callback, char* payload) {
|
static void callTaskEventCallback(uintptr_t callback, char* payload) {
|
||||||
((TaskEventCallback)callback)(payload);
|
((TaskEventCallback)callback)(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void callInvokeResultCallback(uintptr_t callback, uint64_t request_id, int success, char* payload) {
|
||||||
|
((InvokeResultCallback)callback)(request_id, success, payload);
|
||||||
|
}
|
||||||
*/
|
*/
|
||||||
import "C"
|
import "C"
|
||||||
import (
|
import (
|
||||||
@@ -16,6 +22,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
|
nativebridge "github.com/GopeedLab/gopeed/bind/native"
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest"
|
"github.com/GopeedLab/gopeed/pkg/rest"
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
)
|
)
|
||||||
@@ -35,12 +42,13 @@ func Start(cfg *C.char) (int, *C.char) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, C.CString(err.Error())
|
return 0, C.CString(err.Error())
|
||||||
}
|
}
|
||||||
|
nativebridge.ResumeInvokes()
|
||||||
return realPort, nil
|
return realPort, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//export Stop
|
//export Stop
|
||||||
func Stop() {
|
func Stop() {
|
||||||
rest.Stop()
|
nativebridge.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
//export GetAPIServerState
|
//export GetAPIServerState
|
||||||
@@ -75,14 +83,39 @@ func apiServerResult(state *model.APIServerState, err error) string {
|
|||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
//export Invoke
|
//export InvokeAsync
|
||||||
func Invoke(method *C.char, path *C.char, query *C.char, body *C.char) *C.char {
|
func InvokeAsync(
|
||||||
return C.CString(rest.Invoke(
|
method *C.char,
|
||||||
|
path *C.char,
|
||||||
|
query *C.char,
|
||||||
|
body *C.char,
|
||||||
|
requestID C.ulonglong,
|
||||||
|
callback C.uintptr_t,
|
||||||
|
) {
|
||||||
|
if callback == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
complete := func(result string, err error) {
|
||||||
|
success := C.int(1)
|
||||||
|
payload := result
|
||||||
|
if err != nil {
|
||||||
|
success = 0
|
||||||
|
payload = err.Error()
|
||||||
|
}
|
||||||
|
C.callInvokeResultCallback(
|
||||||
|
callback,
|
||||||
|
C.uint64_t(requestID),
|
||||||
|
success,
|
||||||
|
C.CString(payload),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
nativebridge.InvokeAsync(
|
||||||
C.GoString(method),
|
C.GoString(method),
|
||||||
C.GoString(path),
|
C.GoString(path),
|
||||||
C.GoString(query),
|
C.GoString(query),
|
||||||
C.GoString(body),
|
C.GoString(body),
|
||||||
))
|
complete,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
//export SubscribeTaskEvents
|
//export SubscribeTaskEvents
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import "C"
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
|
nativebridge "github.com/GopeedLab/gopeed/bind/native"
|
||||||
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest"
|
"github.com/GopeedLab/gopeed/pkg/rest"
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
@@ -18,11 +19,15 @@ func Start(cfg string) (int, error) {
|
|||||||
config.ProductionMode = true
|
config.ProductionMode = true
|
||||||
config.NativeMode = true
|
config.NativeMode = true
|
||||||
applyWebViewProvider(&config)
|
applyWebViewProvider(&config)
|
||||||
return rest.Start(&config)
|
port, err := rest.Start(&config)
|
||||||
|
if err == nil {
|
||||||
|
nativebridge.ResumeInvokes()
|
||||||
|
}
|
||||||
|
return port, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func Stop() {
|
func Stop() {
|
||||||
rest.Stop()
|
nativebridge.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAPIServerState() string {
|
func GetAPIServerState() string {
|
||||||
@@ -50,8 +55,21 @@ func apiServerResult(state *model.APIServerState, err error) string {
|
|||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Invoke(method, path, query, body string) string {
|
type InvokeResultListener interface {
|
||||||
return rest.Invoke(method, path, query, body)
|
OnResult(requestID int64, success bool, payload string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InvokeAsync(method, path, query, body string, requestID int64, listener InvokeResultListener) {
|
||||||
|
if listener == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nativebridge.InvokeAsync(method, path, query, body, func(result string, err error) {
|
||||||
|
if err != nil {
|
||||||
|
listener.OnResult(requestID, false, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listener.OnResult(requestID, true, result)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskEventListener interface {
|
type TaskEventListener interface {
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// Package nativebridge contains transport-neutral behavior shared by the
|
||||||
|
// Desktop C FFI and gomobile bridge adapters.
|
||||||
|
package nativebridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/rest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StopTimeout bounds native shutdown even when an in-process request never returns.
|
||||||
|
const StopTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
var errNativeBridgeStopping = errors.New("native bridge is stopping")
|
||||||
|
|
||||||
|
type invokeRequest struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
query string
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
type invokeTask struct {
|
||||||
|
id uint64
|
||||||
|
request invokeRequest
|
||||||
|
complete func(result string, err error)
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *invokeTask) finish(result string, err error) {
|
||||||
|
t.once.Do(func() {
|
||||||
|
completeCallback(t.complete, result, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvokeAsync starts an in-process API request and completes it through the
|
||||||
|
// supplied callback. Desktop and mobile adapters share this executor.
|
||||||
|
func InvokeAsync(method, path, query, body string, callback func(result string, err error)) {
|
||||||
|
if callback == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task := invokeTask{
|
||||||
|
request: invokeRequest{
|
||||||
|
method: method,
|
||||||
|
path: path,
|
||||||
|
query: query,
|
||||||
|
body: body,
|
||||||
|
},
|
||||||
|
complete: callback,
|
||||||
|
}
|
||||||
|
if !getInvokeExecutor().submit(task) {
|
||||||
|
completeCallback(callback, "", errNativeBridgeStopping)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeInvokes enables request submission after the native runtime starts.
|
||||||
|
func ResumeInvokes() {
|
||||||
|
getInvokeExecutor().resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PauseInvokesAndWait prevents new submissions and gives running requests a
|
||||||
|
// bounded opportunity to finish before a native runtime is stopped.
|
||||||
|
// Requests still pending after StopTimeout are failed and detached so shutdown
|
||||||
|
// can continue without invoking their callbacks a second time.
|
||||||
|
func PauseInvokesAndWait() bool {
|
||||||
|
return getInvokeExecutor().pauseAndWait(StopTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop pauses native requests and shuts down the shared runtime, but never
|
||||||
|
// keeps the host application waiting longer than StopTimeout. Cleanup that
|
||||||
|
// cannot finish within the deadline is allowed to continue in the background.
|
||||||
|
func Stop() {
|
||||||
|
stopWithin(getInvokeExecutor(), rest.Stop, StopTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopWithin(executor *invokeExecutor, cleanup func(), timeout time.Duration) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
executor.pauseAndWait(time.Until(deadline))
|
||||||
|
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(stopped)
|
||||||
|
cleanup()
|
||||||
|
}()
|
||||||
|
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
if remaining <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(remaining)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-stopped:
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
invokeExecutorOnce sync.Once
|
||||||
|
sharedInvoker *invokeExecutor
|
||||||
|
)
|
||||||
|
|
||||||
|
func getInvokeExecutor() *invokeExecutor {
|
||||||
|
invokeExecutorOnce.Do(func() {
|
||||||
|
sharedInvoker = newInvokeExecutor(func(request invokeRequest) string {
|
||||||
|
return rest.Dispatch(request.method, request.path, request.query, request.body)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return sharedInvoker
|
||||||
|
}
|
||||||
|
|
||||||
|
// invokeExecutor starts every native API request in its own goroutine, matching
|
||||||
|
// net/http's request concurrency. It only tracks them to support bounded drain
|
||||||
|
// during shutdown; one stuck request must never prevent another from starting.
|
||||||
|
type invokeExecutor struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
accepting bool
|
||||||
|
nextID uint64
|
||||||
|
pending map[uint64]*invokeTask
|
||||||
|
drained chan struct{}
|
||||||
|
running sync.WaitGroup
|
||||||
|
handler func(invokeRequest) string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInvokeExecutor(handler func(invokeRequest) string) *invokeExecutor {
|
||||||
|
drained := make(chan struct{})
|
||||||
|
close(drained)
|
||||||
|
return &invokeExecutor{
|
||||||
|
handler: handler,
|
||||||
|
accepting: true,
|
||||||
|
pending: make(map[uint64]*invokeTask),
|
||||||
|
drained: drained,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *invokeExecutor) submit(task invokeTask) bool {
|
||||||
|
e.mu.Lock()
|
||||||
|
if e.closed || !e.accepting {
|
||||||
|
e.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(e.pending) == 0 {
|
||||||
|
e.drained = make(chan struct{})
|
||||||
|
}
|
||||||
|
taskID := e.nextID
|
||||||
|
e.nextID++
|
||||||
|
task.id = taskID
|
||||||
|
taskPtr := &task
|
||||||
|
e.pending[taskID] = taskPtr
|
||||||
|
e.running.Add(1)
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
go e.run(taskPtr)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *invokeExecutor) resume() {
|
||||||
|
e.mu.Lock()
|
||||||
|
e.accepting = true
|
||||||
|
e.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *invokeExecutor) pauseAndWait(timeout time.Duration) bool {
|
||||||
|
e.mu.Lock()
|
||||||
|
e.accepting = false
|
||||||
|
if len(e.pending) == 0 {
|
||||||
|
e.mu.Unlock()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
drained := e.drained
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-drained:
|
||||||
|
return true
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
|
||||||
|
e.mu.Lock()
|
||||||
|
if len(e.pending) == 0 {
|
||||||
|
e.mu.Unlock()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
pending := make([]*invokeTask, 0, len(e.pending))
|
||||||
|
for _, task := range e.pending {
|
||||||
|
pending = append(pending, task)
|
||||||
|
}
|
||||||
|
e.pending = make(map[uint64]*invokeTask)
|
||||||
|
close(e.drained)
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
for _, task := range pending {
|
||||||
|
task.finish("", errNativeBridgeStopping)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *invokeExecutor) close() {
|
||||||
|
e.mu.Lock()
|
||||||
|
e.closed = true
|
||||||
|
e.accepting = false
|
||||||
|
e.mu.Unlock()
|
||||||
|
e.running.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *invokeExecutor) run(task *invokeTask) {
|
||||||
|
defer e.running.Done()
|
||||||
|
result, err := e.execute(task.request)
|
||||||
|
task.finish(result, err)
|
||||||
|
e.mu.Lock()
|
||||||
|
if _, exists := e.pending[task.id]; exists {
|
||||||
|
delete(e.pending, task.id)
|
||||||
|
if len(e.pending) == 0 {
|
||||||
|
close(e.drained)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *invokeExecutor) execute(request invokeRequest) (result string, err error) {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
err = fmt.Errorf("invoke panic: %v", recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return e.handler(request), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeCallback(callback func(string, error), result string, err error) {
|
||||||
|
defer func() {
|
||||||
|
_ = recover()
|
||||||
|
}()
|
||||||
|
callback(result, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package nativebridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInvokeExecutorStartsEveryRequestIndependently(t *testing.T) {
|
||||||
|
const taskCount = 64
|
||||||
|
release := make(chan struct{})
|
||||||
|
started := make(chan struct{}, taskCount)
|
||||||
|
executor := newInvokeExecutor(func(request invokeRequest) string {
|
||||||
|
started <- struct{}{}
|
||||||
|
<-release
|
||||||
|
return request.path
|
||||||
|
})
|
||||||
|
|
||||||
|
var completed sync.WaitGroup
|
||||||
|
completed.Add(taskCount)
|
||||||
|
for i := range taskCount {
|
||||||
|
accepted := executor.submit(invokeTask{
|
||||||
|
request: invokeRequest{path: strconv.Itoa(i)},
|
||||||
|
complete: func(string, error) {
|
||||||
|
completed.Done()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !accepted {
|
||||||
|
t.Fatalf("request %d was rejected", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every handler must start before any of them is released. A worker pool
|
||||||
|
// would stall here once all of its workers were occupied.
|
||||||
|
for range taskCount {
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("a request waited behind other blocked requests")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
completed.Wait()
|
||||||
|
executor.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvokeExecutorConvertsPanicsToErrors(t *testing.T) {
|
||||||
|
executor := newInvokeExecutor(func(invokeRequest) string {
|
||||||
|
panic("boom")
|
||||||
|
})
|
||||||
|
completed := make(chan error, 1)
|
||||||
|
executor.submit(invokeTask{complete: func(_ string, err error) {
|
||||||
|
completed <- err
|
||||||
|
}})
|
||||||
|
select {
|
||||||
|
case err := <-completed:
|
||||||
|
if err == nil || err.Error() != "invoke panic: boom" {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("panic result was not completed")
|
||||||
|
}
|
||||||
|
executor.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvokeExecutorSurvivesCallbackPanic(t *testing.T) {
|
||||||
|
executor := newInvokeExecutor(func(request invokeRequest) string {
|
||||||
|
return request.path
|
||||||
|
})
|
||||||
|
executor.submit(invokeTask{complete: func(string, error) {
|
||||||
|
panic("callback failed")
|
||||||
|
}})
|
||||||
|
|
||||||
|
completed := make(chan string, 1)
|
||||||
|
executor.submit(invokeTask{
|
||||||
|
request: invokeRequest{path: "next"},
|
||||||
|
complete: func(result string, _ error) {
|
||||||
|
completed <- result
|
||||||
|
},
|
||||||
|
})
|
||||||
|
select {
|
||||||
|
case result := <-completed:
|
||||||
|
if result != "next" {
|
||||||
|
t.Fatalf("unexpected result: %q", result)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("worker stopped after callback panic")
|
||||||
|
}
|
||||||
|
executor.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvokeExecutorPausesAndDrains(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
started := make(chan struct{})
|
||||||
|
executor := newInvokeExecutor(func(request invokeRequest) string {
|
||||||
|
if request.path == "running" {
|
||||||
|
close(started)
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
return request.path
|
||||||
|
})
|
||||||
|
completed := make(chan struct{})
|
||||||
|
if !executor.submit(invokeTask{
|
||||||
|
request: invokeRequest{path: "running"},
|
||||||
|
complete: func(string, error) {
|
||||||
|
close(completed)
|
||||||
|
},
|
||||||
|
}) {
|
||||||
|
t.Fatal("initial task was rejected")
|
||||||
|
}
|
||||||
|
<-started
|
||||||
|
|
||||||
|
drained := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
executor.pauseAndWait(time.Second)
|
||||||
|
close(drained)
|
||||||
|
}()
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for {
|
||||||
|
executor.mu.Lock()
|
||||||
|
paused := !executor.accepting
|
||||||
|
executor.mu.Unlock()
|
||||||
|
if paused {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("executor did not pause")
|
||||||
|
}
|
||||||
|
runtime.Gosched()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-drained:
|
||||||
|
t.Fatal("pause returned before the running request completed")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if executor.submit(invokeTask{}) {
|
||||||
|
t.Fatal("paused executor accepted a new request")
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
<-completed
|
||||||
|
select {
|
||||||
|
case <-drained:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("pause did not return after pending requests drained")
|
||||||
|
}
|
||||||
|
|
||||||
|
executor.resume()
|
||||||
|
resumed := make(chan struct{})
|
||||||
|
if !executor.submit(invokeTask{complete: func(string, error) {
|
||||||
|
close(resumed)
|
||||||
|
}}) {
|
||||||
|
t.Fatal("resumed executor rejected a request")
|
||||||
|
}
|
||||||
|
<-resumed
|
||||||
|
executor.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvokeExecutorPauseTimesOutAndFailsPendingRequests(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
started := make(chan struct{})
|
||||||
|
executor := newInvokeExecutor(func(request invokeRequest) string {
|
||||||
|
started <- struct{}{}
|
||||||
|
<-release
|
||||||
|
return request.path
|
||||||
|
})
|
||||||
|
|
||||||
|
errors := make(chan error, 2)
|
||||||
|
for _, path := range []string{"first", "second"} {
|
||||||
|
if !executor.submit(invokeTask{
|
||||||
|
request: invokeRequest{path: path},
|
||||||
|
complete: func(_ string, err error) {
|
||||||
|
errors <- err
|
||||||
|
},
|
||||||
|
}) {
|
||||||
|
t.Fatalf("request %q was rejected", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for range 2 {
|
||||||
|
<-started
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = 30 * time.Millisecond
|
||||||
|
startedAt := time.Now()
|
||||||
|
if executor.pauseAndWait(timeout) {
|
||||||
|
t.Fatal("pause unexpectedly reported a complete drain")
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(startedAt); elapsed < timeout || elapsed > 10*timeout {
|
||||||
|
t.Fatalf("pause returned after %s, want close to %s", elapsed, timeout)
|
||||||
|
}
|
||||||
|
for range 2 {
|
||||||
|
select {
|
||||||
|
case err := <-errors:
|
||||||
|
if err == nil || err.Error() != errNativeBridgeStopping.Error() {
|
||||||
|
t.Fatalf("unexpected timeout error: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("pending request was not failed after the timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
executor.close()
|
||||||
|
select {
|
||||||
|
case err := <-errors:
|
||||||
|
t.Fatalf("request callback ran more than once: %v", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopWithinUsesOneDeadlineForRequestsAndCleanup(t *testing.T) {
|
||||||
|
releaseRequest := make(chan struct{})
|
||||||
|
requestStarted := make(chan struct{})
|
||||||
|
executor := newInvokeExecutor(func(invokeRequest) string {
|
||||||
|
close(requestStarted)
|
||||||
|
<-releaseRequest
|
||||||
|
return "done"
|
||||||
|
})
|
||||||
|
requestResult := make(chan error, 1)
|
||||||
|
executor.submit(invokeTask{complete: func(_ string, err error) {
|
||||||
|
requestResult <- err
|
||||||
|
}})
|
||||||
|
<-requestStarted
|
||||||
|
|
||||||
|
releaseCleanup := make(chan struct{})
|
||||||
|
cleanupStarted := make(chan struct{})
|
||||||
|
cleanup := func() {
|
||||||
|
close(cleanupStarted)
|
||||||
|
<-releaseCleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = 40 * time.Millisecond
|
||||||
|
startedAt := time.Now()
|
||||||
|
stopWithin(executor, cleanup, timeout)
|
||||||
|
if elapsed := time.Since(startedAt); elapsed < timeout || elapsed > 10*timeout {
|
||||||
|
t.Fatalf("stop returned after %s, want close to %s", elapsed, timeout)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-cleanupStarted:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("cleanup was not started after the request deadline")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-requestResult:
|
||||||
|
if err == nil || err.Error() != errNativeBridgeStopping.Error() {
|
||||||
|
t.Fatalf("unexpected request result: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed-out request was not failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
close(releaseRequest)
|
||||||
|
close(releaseCleanup)
|
||||||
|
executor.close()
|
||||||
|
}
|
||||||
@@ -459,6 +459,7 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
|||||||
if file.Name == "" || file.Name == "/" || file.Name == "." {
|
if file.Name == "" || file.Name == "/" || file.Name == "." {
|
||||||
file.Name = httpReq.URL.Hostname()
|
file.Name = httpReq.URL.Hostname()
|
||||||
}
|
}
|
||||||
|
file.Name = appendFilenameExtension(file.Name, resp.Header.Get("Content-Type"))
|
||||||
|
|
||||||
res.Files = append(res.Files, file)
|
res.Files = append(res.Files, file)
|
||||||
f.meta.Res = res
|
f.meta.Res = res
|
||||||
|
|||||||
@@ -221,6 +221,30 @@ func TestFetcher_Resolve(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFetcher_ResolveFilenameFromContentType(t *testing.T) {
|
||||||
|
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/webp")
|
||||||
|
w.WriteHeader(gohttp.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
fetcher := buildFetcher()
|
||||||
|
defer fetcher.Pause()
|
||||||
|
|
||||||
|
const opaqueName = "OIP-C.u5nEXTXND3PJF0gNTgw5YgHaFP"
|
||||||
|
err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + opaqueName}, &base.Options{
|
||||||
|
Name: test.DownloadName,
|
||||||
|
Path: test.Dir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := fetcher.meta.Res.Files[0].Name; got != opaqueName+".webp" {
|
||||||
|
t.Fatalf("resolved filename = %q, want %q", got, opaqueName+".webp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFetcher_ResolveWithHostHeader(t *testing.T) {
|
func TestFetcher_ResolveWithHostHeader(t *testing.T) {
|
||||||
listener := test.StartTestHostHeaderServer()
|
listener := test.StartTestHostHeaderServer()
|
||||||
defer listener.Close()
|
defer listener.Close()
|
||||||
|
|||||||
@@ -136,3 +136,52 @@ func TestUnescapeHTMLEntities(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAppendFilenameExtension(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filename string
|
||||||
|
contentType string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "adds extension from content type",
|
||||||
|
filename: "download",
|
||||||
|
contentType: "image/png",
|
||||||
|
want: "download.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parses MIME parameters",
|
||||||
|
filename: "download",
|
||||||
|
contentType: "image/png; charset=binary",
|
||||||
|
want: "download.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preserves existing extension",
|
||||||
|
filename: "download.zip",
|
||||||
|
contentType: "application/zip",
|
||||||
|
want: "download.zip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ignores invalid content type",
|
||||||
|
filename: "download",
|
||||||
|
contentType: "not a content type",
|
||||||
|
want: "download",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ignores unknown content type",
|
||||||
|
filename: "download",
|
||||||
|
contentType: "application/x-gopeed-unknown",
|
||||||
|
want: "download",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := appendFilenameExtension(tt.filename, tt.contentType)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("appendFilenameExtension() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -314,6 +315,28 @@ func parseFilename(contentDisposition string) string {
|
|||||||
return parseFilenameFallback(contentDisposition)
|
return parseFilenameFallback(contentDisposition)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appendFilenameExtension adds a MIME-derived extension when the resolved
|
||||||
|
// filename has no usable extension. Some image hosts use opaque path segments
|
||||||
|
// that look like extensions, even though the response Content-Type is the
|
||||||
|
// only reliable source for the file type.
|
||||||
|
func appendFilenameExtension(filename, contentType string) string {
|
||||||
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil {
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
|
extensions, err := mime.ExtensionsByType(mediaType)
|
||||||
|
if err != nil || len(extensions) == 0 {
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := path.Ext(filename)
|
||||||
|
if len(ext) > 1 && len(ext) <= 20 {
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
return filename + extensions[0]
|
||||||
|
}
|
||||||
|
|
||||||
// parseFilenameExtended handles RFC 5987 extended notation (filename*=)
|
// parseFilenameExtended handles RFC 5987 extended notation (filename*=)
|
||||||
// Format: filename*=charset'language'value (e.g., UTF-8”%E6%B5%8B%E8%AF%95.zip)
|
// Format: filename*=charset'language'value (e.g., UTF-8”%E6%B5%8B%E8%AF%95.zip)
|
||||||
func parseFilenameExtended(cd string) string {
|
func parseFilenameExtended(cd string) string {
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ const (
|
|||||||
TaskEventError = uint64(goapi.TaskEventError)
|
TaskEventError = uint64(goapi.TaskEventError)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Invoke dispatches a Core API request without using an HTTP listener. It is
|
// Dispatch executes one in-process API request. Native scheduling and callback
|
||||||
// the shared entry point for Desktop FFI and gomobile bindings.
|
// behavior belong to bind/native; this function only bridges into the shared
|
||||||
func Invoke(method, path, rawQuery, body string) string {
|
// API Service without opening an HTTP listener.
|
||||||
|
func Dispatch(method, path, rawQuery, body string) string {
|
||||||
|
runtimeMu.RLock()
|
||||||
service := APIService
|
service := APIService
|
||||||
|
runtimeMu.RUnlock()
|
||||||
if service == nil {
|
if service == nil {
|
||||||
return marshalInvokeResult(model.NewErrorResult("service not started"))
|
return marshalInvokeResult(model.NewErrorResult("service not started"))
|
||||||
}
|
}
|
||||||
@@ -29,6 +32,8 @@ func Invoke(method, path, rawQuery, body string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SubscribeTaskEvents(mask uint64, listener func(payload string)) {
|
func SubscribeTaskEvents(mask uint64, listener func(payload string)) {
|
||||||
|
runtimeMu.RLock()
|
||||||
|
defer runtimeMu.RUnlock()
|
||||||
if APIService == nil {
|
if APIService == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
@@ -27,7 +30,7 @@ func TestNativeInvokeWithoutRESTListener(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result model.Result[json.RawMessage]
|
var result model.Result[json.RawMessage]
|
||||||
if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
|
if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Code != model.CodeOk {
|
if result.Code != model.CodeOk {
|
||||||
@@ -35,6 +38,75 @@ func TestNativeInvokeWithoutRESTListener(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStartAPIServerIsNotBlockedByPendingDispatch(t *testing.T) {
|
||||||
|
requestStarted := make(chan struct{})
|
||||||
|
releaseRequest := make(chan struct{})
|
||||||
|
var releaseOnce sync.Once
|
||||||
|
release := func() { releaseOnce.Do(func() { close(releaseRequest) }) }
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
close(requestStarted)
|
||||||
|
<-releaseRequest
|
||||||
|
w.Header().Set("Content-Length", "0")
|
||||||
|
}))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
release()
|
||||||
|
upstream.Close()
|
||||||
|
Stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
requestBody, err := json.Marshal(&model.ResolveTask{Req: &base.Request{URL: upstream.URL}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dispatchDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(dispatchDone)
|
||||||
|
Dispatch(http.MethodPost, "/api/v1/resolve", "", string(requestBody))
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-requestStarted:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("resolve request did not start")
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := Downloader.GetConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
config.API = &base.APIServerConfig{Enable: true, Network: "tcp", Address: "127.0.0.1:0"}
|
||||||
|
if err := Downloader.PutConfig(config); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
started := make(chan *model.APIServerState, 1)
|
||||||
|
startErrors := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
state, err := StartAPIServer()
|
||||||
|
started <- state
|
||||||
|
startErrors <- err
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case state := <-started:
|
||||||
|
if err := <-startErrors; err != nil {
|
||||||
|
t.Fatalf("start API server failed: %v", err)
|
||||||
|
}
|
||||||
|
if !state.Running || state.RunningPort == 0 {
|
||||||
|
t.Fatalf("API server did not start: %#v", state)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("pending Resolve blocked API server startup")
|
||||||
|
}
|
||||||
|
|
||||||
|
release()
|
||||||
|
select {
|
||||||
|
case <-dispatchDone:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("resolve request did not finish after release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T) {
|
func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T) {
|
||||||
port, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem})
|
port, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -106,7 +178,7 @@ func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result model.Result[json.RawMessage]
|
var result model.Result[json.RawMessage]
|
||||||
if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
|
if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Code != model.CodeOk {
|
if result.Code != model.CodeOk {
|
||||||
@@ -153,7 +225,7 @@ func TestRestartAPIServerFailsFastAndLeavesListenerStopped(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result model.Result[json.RawMessage]
|
var result model.Result[json.RawMessage]
|
||||||
if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
|
if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Code != model.CodeOk {
|
if result.Code != model.CodeOk {
|
||||||
@@ -229,7 +301,7 @@ func TestNativeAPIServerAutoStartFailureKeepsCoreAvailable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result model.Result[json.RawMessage]
|
var result model.Result[json.RawMessage]
|
||||||
if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
|
if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Code != model.CodeOk {
|
if result.Code != model.CodeOk {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
runtimeMu sync.Mutex
|
runtimeMu sync.RWMutex
|
||||||
|
|
||||||
Downloader *download.Downloader
|
Downloader *download.Downloader
|
||||||
APIService *goapi.Service
|
APIService *goapi.Service
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ description: Design or revise Gopeed file-classification icons as one cohesive S
|
|||||||
|
|
||||||
Maintain Gopeed's file-classification visual language and its deterministic SVG → TTF → Flutter `IconData` pipeline.
|
Maintain Gopeed's file-classification visual language and its deterministic SVG → TTF → Flutter `IconData` pipeline.
|
||||||
|
|
||||||
The canonical editable materials live in `design/file-category-icons/`, outside the Flutter project. Read its `README.md` before changing any source. Do not add that directory to Flutter assets.
|
The canonical editable materials live in `design/file-category-icons/` as tooling-only sources inside the Flutter project. Read its `README.md` before changing any source. Do not add that directory to Flutter assets.
|
||||||
|
|
||||||
## Classification grammar
|
## Classification grammar
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ Use [assets/manifest.example.json](assets/manifest.example.json) and [assets/fol
|
|||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Existing codepoints are API: never renumber or reuse one unless the user explicitly approves a breaking migration.
|
- Existing codepoints are API: never renumber or reuse one unless the user explicitly approves a breaking migration.
|
||||||
- `design/file-category-icons/` is the sole editable source of truth. Flutter keeps only the generated runtime TTF and Dart constants.
|
- `design/file-category-icons/` is the sole editable source of truth. The packaged Flutter runtime uses only the generated TTF and Dart constants.
|
||||||
- Never add the design-material directory to `pubspec.yaml`; it must remain outside the packaged Flutter asset graph.
|
- Never add the design-material directory to `pubspec.yaml`; it must remain outside the packaged Flutter asset graph.
|
||||||
- The manifest is the source of truth. Do not hand-edit generated Dart constants or patch the binary font.
|
- The manifest is the source of truth. Do not hand-edit generated Dart constants or patch the binary font.
|
||||||
- Keep icon names lowercase `snake_case`; the generator emits lowerCamelCase Dart fields.
|
- Keep icon names lowercase `snake_case`; the generator emits lowerCamelCase Dart fields.
|
||||||
|
Before Width: | Height: | Size: 818 B After Width: | Height: | Size: 818 B |
@@ -109,6 +109,18 @@ Guidelines:
|
|||||||
- forbidden Material product-component usage
|
- forbidden Material product-component usage
|
||||||
- If a screen already uses `shadcn_flutter`, do not mix in Material product widgets for convenience. Replace them with `shadcn_flutter` equivalents or shared wrappers built on top of `shadcn_flutter`.
|
- If a screen already uses `shadcn_flutter`, do not mix in Material product widgets for convenience. Replace them with `shadcn_flutter` equivalents or shared wrappers built on top of `shadcn_flutter`.
|
||||||
|
|
||||||
|
### 3.3.1 Text Input Rules
|
||||||
|
|
||||||
|
- All product-facing editable text controls must use the shared `AppTextField` from `lib/shared/widgets/app_text_field.dart`.
|
||||||
|
- This rule applies to every input variant, including search fields, login and password fields, URL fields, numeric fields, multiline editors, settings controls, dialog inputs, and feature-specific form fields.
|
||||||
|
- Do not instantiate Flutter Material `TextField` or `shadcn_flutter`'s `TextField` directly in feature or page code. Direct construction is allowed only while implementing or extending a shared input primitive, with the reason documented in that primitive.
|
||||||
|
- `AppTextField` owns the cross-platform text-selection menu policy. Android, iOS, desktop, and Web must all use Flutter's adaptive native context menu so long-press and right-click selection actions follow platform behavior without `shadcn_flutter`'s full-screen context-menu overlay.
|
||||||
|
- Do not use `shadcn_flutter`'s editable-text context menu on any platform.
|
||||||
|
- Feature and page code must not override `contextMenuBuilder`. Any exception must be implemented centrally in `AppTextField`, justified by a platform requirement, and covered by Android/iOS and desktop tests.
|
||||||
|
- Preserve existing field behavior when migrating to `AppTextField`, including controllers, focus nodes, input formatters, autofill hints, password visibility, read-only state, input features, keyboard actions, and multiline sizing.
|
||||||
|
- Prefer stable keys or `AppTextField` when locating app inputs in widget tests. Do not couple product tests to the exact runtime type of the underlying `shadcn_flutter` implementation.
|
||||||
|
- Changes to the shared input policy must include regression coverage for Android long-press behavior and desktop right-click behavior. Web builds must keep the same shared native-menu policy.
|
||||||
|
|
||||||
## 3.4) Desktop Design Metrics
|
## 3.4) Desktop Design Metrics
|
||||||
|
|
||||||
Use the following desktop reference metrics unless a specific screen requires a documented exception:
|
Use the following desktop reference metrics unless a specific screen requires a documented exception:
|
||||||
|
|||||||
@@ -32,6 +32,12 @@
|
|||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme"
|
android:resource="@style/NormalTheme"
|
||||||
/>
|
/>
|
||||||
|
<!-- app_links and share_handler own external download intents. Letting
|
||||||
|
Flutter's Router handle the same URI races with the explicit
|
||||||
|
navigation to the create-task page. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutter_deeplinking_enabled"
|
||||||
|
android:value="false" />
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.gopeed.gopeed
|
package com.gopeed.gopeed
|
||||||
|
|
||||||
import com.gopeed.libgopeed.Libgopeed
|
import com.gopeed.libgopeed.Libgopeed
|
||||||
|
import com.gopeed.libgopeed.InvokeResultListener
|
||||||
import com.gopeed.libgopeed.TaskEventListener
|
import com.gopeed.libgopeed.TaskEventListener
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
@@ -38,13 +39,23 @@ class MainActivity : FlutterActivity() {
|
|||||||
"restartApiServer" -> result.success(Libgopeed.restartAPIServer())
|
"restartApiServer" -> result.success(Libgopeed.restartAPIServer())
|
||||||
"invoke" -> {
|
"invoke" -> {
|
||||||
try {
|
try {
|
||||||
result.success(
|
Libgopeed.invokeAsync(
|
||||||
Libgopeed.invoke(
|
|
||||||
call.argument<String>("method") ?: "",
|
call.argument<String>("method") ?: "",
|
||||||
call.argument<String>("path") ?: "",
|
call.argument<String>("path") ?: "",
|
||||||
call.argument<String>("query") ?: "",
|
call.argument<String>("query") ?: "",
|
||||||
call.argument<String>("body") ?: "",
|
call.argument<String>("body") ?: "",
|
||||||
),
|
call.argument<Number>("requestID")?.toLong() ?: 0L,
|
||||||
|
object : InvokeResultListener {
|
||||||
|
override fun onResult(requestID: Long, success: Boolean, payload: String?) {
|
||||||
|
runOnUiThread {
|
||||||
|
if (success) {
|
||||||
|
result.success(payload ?: "")
|
||||||
|
} else {
|
||||||
|
result.error("ERROR", payload ?: "InvokeAsync failed", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
result.error("ERROR", error.message, null)
|
result.error("ERROR", error.message, null)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 文件分类图标源物料
|
# 文件分类图标源物料
|
||||||
|
|
||||||
此目录是 Gopeed 文件分类图标的唯一源物料目录,位于 Flutter 工程之外,不得加入 `ui/flutter/pubspec.yaml` 的 `assets` 配置。
|
此目录是 Gopeed 文件分类图标的唯一源物料目录。它位于 Flutter 工程内,但只用于设计和生成流程,不得加入 `pubspec.yaml` 的 `assets` 配置。
|
||||||
|
|
||||||
## 维护限制
|
## 维护限制
|
||||||
|
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
|
|
||||||
## 生成产物
|
## 生成产物
|
||||||
|
|
||||||
- Flutter 字体:`ui/flutter/assets/fonts/GopeedIcons.ttf`
|
- Flutter 字体:`assets/fonts/GopeedIcons.ttf`
|
||||||
- Flutter 常量:`ui/flutter/lib/core/icons/gopeed_icons.dart`
|
- Flutter 常量:`lib/core/icons/gopeed_icons.dart`
|
||||||
|
|
||||||
所有修改都应遵循:
|
所有修改都应遵循:
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"fontFamily": "GopeedIcons",
|
"fontFamily": "GopeedIcons",
|
||||||
"fontOutput": "../../ui/flutter/assets/fonts/GopeedIcons.ttf",
|
"fontOutput": "../../assets/fonts/GopeedIcons.ttf",
|
||||||
"dartOutput": "../../ui/flutter/lib/core/icons/gopeed_icons.dart",
|
"dartOutput": "../../lib/core/icons/gopeed_icons.dart",
|
||||||
"previewOutput": "preview/gopeed_icons_preview.png",
|
"previewOutput": "preview/gopeed_icons_preview.png",
|
||||||
"dartClass": "GopeedIcons",
|
"dartClass": "GopeedIcons",
|
||||||
"codepointStart": "0xE900",
|
"codepointStart": "0xE900",
|
||||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 526 B After Width: | Height: | Size: 526 B |
|
Before Width: | Height: | Size: 387 B After Width: | Height: | Size: 387 B |
|
Before Width: | Height: | Size: 342 B After Width: | Height: | Size: 342 B |
|
Before Width: | Height: | Size: 323 B After Width: | Height: | Size: 323 B |
|
Before Width: | Height: | Size: 597 B After Width: | Height: | Size: 597 B |
|
Before Width: | Height: | Size: 495 B After Width: | Height: | Size: 495 B |
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 293 B |
|
Before Width: | Height: | Size: 355 B After Width: | Height: | Size: 355 B |
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 293 B |
|
Before Width: | Height: | Size: 395 B After Width: | Height: | Size: 395 B |
|
Before Width: | Height: | Size: 256 B After Width: | Height: | Size: 256 B |
|
Before Width: | Height: | Size: 262 B After Width: | Height: | Size: 262 B |
|
Before Width: | Height: | Size: 479 B After Width: | Height: | Size: 479 B |
|
Before Width: | Height: | Size: 357 B After Width: | Height: | Size: 357 B |