🐛 fix(update/proxy): 稳定更新检查与代理运行时

- 更新检查前恢复持久化全局代理,避免重启后代理状态未进入运行时

- 将 DNS、EOF 和临时网络异常转为本地化错误,并对可重试网络错误做短重试

- 优先读取 GitHub asset digest,缺失时再回退 SHA256SUMS 校验文件

- 新增全局代理连接测试后端接口,支持草稿代理、已保存密码复用和清除密码意图

- 更新 Wails 绑定、浏览器 mock 与代理草稿序列化测试
This commit is contained in:
Syngnat
2026-07-08 17:36:42 +08:00
parent c792a30932
commit b94168957e
12 changed files with 783 additions and 56 deletions

View File

@@ -212,11 +212,13 @@ if (
const saveMockGlobalProxy = (input: any) => {
const nextPassword = String(input?.password ?? '');
const clearPassword = input?.clearPassword === true;
mockGlobalProxy = {
...mockGlobalProxy,
...input,
password: '',
hasPassword: nextPassword !== '' ? true : !!mockGlobalProxy.hasPassword,
hasPassword: clearPassword ? false : (nextPassword !== '' ? true : !!mockGlobalProxy.hasPassword),
clearPassword: undefined,
};
return cloneBrowserMockValue(mockGlobalProxy);
};
@@ -432,6 +434,21 @@ if (
},
SaveGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
ImportLegacyGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
TestGlobalProxyConnection: async (input: any) => {
const url = String(input?.url || 'https://api.github.com/').trim();
return {
success: true,
message: t('app.proxy.backend.message.test_success', { status: 200, duration: 18, url }),
data: {
url,
finalUrl: url,
statusCode: 200,
status: '200 OK',
durationMs: 18,
viaProxy: input?.proxy?.enabled === true,
},
};
},
SelectDataRootDirectory: async (currentPath: string) => ({ success: true, data: { ...mockDataRootInfo, path: currentPath || mockDataRootInfo.path } }),
ApplyDataRootDirectory: async (path: string) => {
const nextPath = String(path || mockDataRootInfo.defaultPath);

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { createGlobalProxyDraft, toPersistedGlobalProxy } from './globalProxyDraft';
import { createGlobalProxyDraft, toPersistedGlobalProxy, toSaveGlobalProxyInput } from './globalProxyDraft';
describe('global proxy draft', () => {
it('hydrates a secretless draft from backend metadata while keeping password input blank', () => {
@@ -32,4 +32,18 @@ describe('global proxy draft', () => {
expect('password' in persisted).toBe(false);
expect(persisted.hasPassword).toBe(true);
});
it('passes explicit saved password clearing intent to the backend save payload', () => {
const input = toSaveGlobalProxyInput({
enabled: true,
type: 'socks5',
host: '127.0.0.1',
port: 7890,
hasPassword: true,
clearPassword: true,
});
expect(input.password).toBe('');
expect(input.clearPassword).toBe(true);
});
});

View File

@@ -1,5 +1,9 @@
import { GlobalProxyConfig } from '../types';
type SaveGlobalProxyDraft = Partial<GlobalProxyConfig> & {
clearPassword?: boolean;
};
const toTrimmedString = (value: unknown): string => {
if (typeof value === 'string') {
return value.trim();
@@ -53,10 +57,11 @@ export function toPersistedGlobalProxy(value: Partial<GlobalProxyConfig> = {}):
};
}
export function toSaveGlobalProxyInput(value: Partial<GlobalProxyConfig> = {}): GlobalProxyConfig {
export function toSaveGlobalProxyInput(value: SaveGlobalProxyDraft = {}): GlobalProxyConfig & { clearPassword?: boolean } {
const draft = createGlobalProxyDraft(value);
return {
...draft,
password: typeof value.password === 'string' ? value.password : '',
clearPassword: value.clearPassword === true || undefined,
};
}

View File

@@ -372,6 +372,8 @@ export function StartSecurityUpdate(arg1:app.StartSecurityUpdateRequest):Promise
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function TestGlobalProxyConnection(arg1:connection.TestGlobalProxyInput):Promise<connection.QueryResult>;
export function TestJVMConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;

View File

@@ -734,6 +734,10 @@ export function TestConnection(arg1) {
return window['go']['app']['App']['TestConnection'](arg1);
}
export function TestGlobalProxyConnection(arg1) {
return window['go']['app']['App']['TestGlobalProxyConnection'](arg1);
}
export function TestJVMConnection(arg1) {
return window['go']['app']['App']['TestJVMConnection'](arg1);
}

View File

@@ -1161,6 +1161,7 @@ export namespace connection {
port: number;
user?: string;
password?: string;
clearPassword?: boolean;
static createFrom(source: any = {}) {
return new SaveGlobalProxyInput(source);
@@ -1174,6 +1175,7 @@ export namespace connection {
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
this.clearPassword = source["clearPassword"];
}
}
export class SavedConnectionInput {
@@ -1360,6 +1362,40 @@ export namespace connection {
return a;
}
}
export class TestGlobalProxyInput {
proxy: SaveGlobalProxyInput;
url: string;
timeoutSeconds?: number;
static createFrom(source: any = {}) {
return new TestGlobalProxyInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.proxy = this.convertValues(source["proxy"], SaveGlobalProxyInput);
this.url = source["url"];
this.timeoutSeconds = source["timeoutSeconds"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
@@ -1555,4 +1591,3 @@ export namespace sync {
}
}

View File

@@ -5,9 +5,11 @@ import (
"crypto/x509"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
@@ -139,6 +141,18 @@ func currentGlobalProxyView() connection.GlobalProxyView {
}
func (a *App) GetGlobalProxyConfig() connection.QueryResult {
if strings.TrimSpace(a.configDir) == "" {
a.configDir = resolveAppConfigDir()
}
if view, err := a.loadStoredGlobalProxyView(); err == nil {
return connection.QueryResult{
Success: true,
Message: "OK",
Data: sanitizeGlobalProxyView(view),
}
} else if !os.IsNotExist(err) {
logger.Error(err, "加载全局代理元数据失败")
}
return connection.QueryResult{
Success: true,
Message: "OK",
@@ -186,6 +200,33 @@ func newHTTPClientWithGlobalProxy(timeout time.Duration) *http.Client {
return client
}
func (a *App) ensurePersistedGlobalProxyRuntime() {
if currentGlobalProxyConfig().Enabled {
return
}
if strings.TrimSpace(a.configDir) == "" {
a.configDir = resolveAppConfigDir()
}
view, err := a.loadStoredGlobalProxyView()
if err != nil {
if !os.IsNotExist(err) {
logger.Error(err, "加载全局代理元数据失败")
}
return
}
if !view.Enabled {
return
}
proxyConfig, err := a.resolveStoredGlobalProxyRuntimeConfig(view)
if err != nil {
logger.Error(err, "恢复全局代理运行时配置失败")
return
}
if _, err := setGlobalProxyConfig(true, proxyConfig); err != nil {
logger.Error(err, "恢复全局代理运行时配置失败")
}
}
func buildHTTPTransportWithGlobalProxy() http.RoundTripper {
baseTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok || baseTransport == nil {
@@ -199,16 +240,29 @@ func buildHTTPTransportWithGlobalProxy() http.RoundTripper {
return transport
}
proxyURL, err := buildProxyURLFromConfig(snapshot.Proxy)
transportWithProxy, err := buildHTTPTransportForProxyConfig(snapshot.Proxy)
if err != nil {
logger.Warnf("全局代理配置无效,回退系统代理:%v", err)
transport.Proxy = http.ProxyFromEnvironment
return transport
}
return transportWithProxy
}
func buildHTTPTransportForProxyConfig(proxyConfig connection.ProxyConfig) (http.RoundTripper, error) {
baseTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok || baseTransport == nil {
return nil, fmt.Errorf("default HTTP transport unavailable")
}
transport := baseTransport.Clone()
proxyURL, err := buildProxyURLFromConfig(proxyConfig)
if err != nil {
return nil, err
}
transport.Proxy = http.ProxyURL(proxyURL)
if !isLoopbackProxyHost(snapshot.Proxy.Host) {
return transport
if !isLoopbackProxyHost(proxyConfig.Host) {
return transport, nil
}
fallbackTransport := transport.Clone()
@@ -217,7 +271,7 @@ func buildHTTPTransportWithGlobalProxy() http.RoundTripper {
primary: transport,
fallback: fallbackTransport,
proxyEndpoint: proxyURL.Redacted(),
}
}, nil
}
func (t *localProxyTLSFallbackTransport) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -328,3 +382,156 @@ func buildProxyURLFromConfig(proxyConfig connection.ProxyConfig) (*url.URL, erro
return proxyURL, nil
}
func (a *App) TestGlobalProxyConnection(input connection.TestGlobalProxyInput) connection.QueryResult {
started := time.Now()
targetURL, err := normalizeGlobalProxyTestURL(input.URL)
if err != nil {
return connection.QueryResult{Success: false, Message: a.localizedGlobalProxyTestError(err)}
}
timeoutSeconds := input.TimeoutSeconds
if timeoutSeconds <= 0 {
timeoutSeconds = 8
}
if timeoutSeconds > 30 {
timeoutSeconds = 30
}
client := &http.Client{Timeout: time.Duration(timeoutSeconds) * time.Second}
if input.Proxy.Enabled {
proxyPassword, err := a.resolveGlobalProxyPasswordForInput(input.Proxy)
if err != nil {
return connection.QueryResult{
Success: false,
Message: a.appText("app.proxy.backend.message.test_failed", map[string]any{
"url": targetURL,
"detail": err.Error(),
}),
}
}
transport, err := buildHTTPTransportForProxyConfig(connection.ProxyConfig{
Type: input.Proxy.Type,
Host: input.Proxy.Host,
Port: input.Proxy.Port,
User: input.Proxy.User,
Password: proxyPassword,
})
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
client.Transport = transport
}
req, err := http.NewRequest(http.MethodGet, targetURL, nil)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
req.Header.Set("User-Agent", "GoNavi-Proxy-Test")
req.Header.Set("Accept", "*/*")
req.Header.Set("Range", "bytes=0-0")
resp, err := client.Do(req)
durationMs := time.Since(started).Milliseconds()
if err != nil {
return connection.QueryResult{
Success: false,
Message: a.appText("app.proxy.backend.message.test_failed", map[string]any{
"url": targetURL,
"detail": err.Error(),
}),
Data: connection.GlobalProxyTestResult{
URL: targetURL,
DurationMs: durationMs,
ViaProxy: input.Proxy.Enabled,
},
}
}
defer resp.Body.Close()
_, _ = io.CopyN(io.Discard, resp.Body, 512)
result := connection.GlobalProxyTestResult{
URL: targetURL,
FinalURL: resp.Request.URL.String(),
StatusCode: resp.StatusCode,
Status: resp.Status,
DurationMs: durationMs,
ViaProxy: input.Proxy.Enabled,
}
messageKey := "app.proxy.backend.message.test_success"
if resp.StatusCode >= http.StatusBadRequest {
messageKey = "app.proxy.backend.message.test_http_status"
}
return connection.QueryResult{
Success: true,
Message: a.appText(messageKey, map[string]any{
"status": resp.StatusCode,
"duration": durationMs,
"url": targetURL,
}),
Data: result,
}
}
func (a *App) resolveGlobalProxyPasswordForInput(input connection.SaveGlobalProxyInput) (string, error) {
if strings.TrimSpace(input.Password) != "" {
return input.Password, nil
}
if input.ClearPassword {
return "", nil
}
if strings.TrimSpace(a.configDir) == "" {
a.configDir = resolveAppConfigDir()
}
existing, err := a.loadStoredGlobalProxyView()
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
if !existing.HasPassword {
return "", nil
}
bundle, err := a.loadGlobalProxySecretBundle(existing)
if err != nil {
return "", err
}
return bundle.Password, nil
}
func normalizeGlobalProxyTestURL(rawURL string) (string, error) {
trimmed := strings.TrimSpace(rawURL)
if trimmed == "" {
return "", localizedUpdateError{key: "app.proxy.backend.error.test_url_empty"}
}
if !strings.Contains(trimmed, "://") {
trimmed = "https://" + trimmed
}
parsed, err := url.Parse(trimmed)
if err != nil {
return "", localizedUpdateError{
key: "app.proxy.backend.error.test_url_invalid",
params: map[string]any{"detail": err.Error()},
}
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https":
default:
return "", localizedUpdateError{
key: "app.proxy.backend.error.test_scheme_unsupported",
params: map[string]any{"scheme": parsed.Scheme},
}
}
if strings.TrimSpace(parsed.Host) == "" {
return "", localizedUpdateError{key: "app.proxy.backend.error.test_host_missing"}
}
return parsed.String(), nil
}
func (a *App) localizedGlobalProxyTestError(err error) string {
var localized localizedUpdateError
if errors.As(err, &localized) {
return a.appText(localized.key, localized.params)
}
return err.Error()
}

View File

@@ -47,7 +47,7 @@ func (a *App) saveGlobalProxy(input connection.SaveGlobalProxyInput) (connection
bundle := globalProxySecretBundle{}
if strings.TrimSpace(input.Password) != "" {
bundle.Password = input.Password
} else if existing.HasPassword {
} else if existing.HasPassword && !input.ClearPassword {
existingBundle, loadErr := a.loadGlobalProxySecretBundle(existing)
if loadErr != nil {
return connection.GlobalProxyView{}, loadErr
@@ -55,20 +55,6 @@ func (a *App) saveGlobalProxy(input connection.SaveGlobalProxyInput) (connection
bundle = existingBundle
}
if !view.Enabled {
if deleteErr := a.dailySecretStore().DeleteGlobalProxy(); deleteErr != nil {
return connection.GlobalProxyView{}, deleteErr
}
view = connection.GlobalProxyView{Enabled: false}
if err := a.persistGlobalProxyView(view); err != nil {
return connection.GlobalProxyView{}, err
}
if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil {
return connection.GlobalProxyView{}, err
}
return view, nil
}
if strings.TrimSpace(bundle.Password) != "" {
if storeErr := a.dailySecretStore().PutGlobalProxy(toDailyGlobalProxyBundle(bundle)); storeErr != nil {
return connection.GlobalProxyView{}, storeErr
@@ -86,6 +72,12 @@ func (a *App) saveGlobalProxy(input connection.SaveGlobalProxyInput) (connection
if err := a.persistGlobalProxyView(view); err != nil {
return connection.GlobalProxyView{}, err
}
if !view.Enabled {
if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil {
return connection.GlobalProxyView{}, err
}
return sanitizeGlobalProxyView(view), nil
}
if _, err := setGlobalProxyConfig(true, connection.ProxyConfig{
Type: view.Type,
Host: view.Host,
@@ -139,6 +131,28 @@ func (a *App) loadGlobalProxySecretBundle(view connection.GlobalProxyView) (glob
return globalProxySecretBundle{}, os.ErrNotExist
}
func (a *App) resolveStoredGlobalProxyRuntimeConfig(view connection.GlobalProxyView) (connection.ProxyConfig, error) {
proxyConfig := connection.ProxyConfig{
Type: view.Type,
Host: view.Host,
Port: view.Port,
User: view.User,
}
if !view.HasPassword {
return proxyConfig, nil
}
bundle, err := a.loadGlobalProxySecretBundle(view)
if err != nil {
if os.IsNotExist(err) {
logger.Warnf("全局代理标记了已保存密码,但密文不存在,将尝试按无认证代理恢复")
return proxyConfig, nil
}
return connection.ProxyConfig{}, err
}
proxyConfig.Password = bundle.Password
return proxyConfig, nil
}
func (a *App) loadGlobalProxySecretBundleFromStore(view connection.GlobalProxyView) (globalProxySecretBundle, error) {
if a.secretStore == nil {
return globalProxySecretBundle{}, fmt.Errorf("secret store unavailable")
@@ -195,20 +209,17 @@ func (a *App) loadPersistedGlobalProxy() {
}
return
}
proxyConfig := connection.ProxyConfig{
Type: view.Type,
Host: view.Host,
Port: view.Port,
User: view.User,
}
if view.HasPassword {
bundle, loadErr := a.loadGlobalProxySecretBundle(view)
if loadErr != nil {
logger.Error(loadErr, "加载全局代理密码失败")
return
if !view.Enabled {
if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil {
logger.Error(err, "恢复全局代理关闭状态失败")
}
proxyConfig.Password = bundle.Password
return
}
proxyConfig, err := a.resolveStoredGlobalProxyRuntimeConfig(view)
if err != nil {
logger.Error(err, "加载全局代理密码失败")
return
}
if _, err := setGlobalProxyConfig(view.Enabled, proxyConfig); err != nil {
logger.Error(err, "恢复全局代理配置失败")

View File

@@ -1,7 +1,13 @@
package app
import (
"encoding/base64"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"testing"
@@ -67,6 +73,202 @@ func TestGetGlobalProxyConfigReturnsSecretlessView(t *testing.T) {
}
}
func TestSaveGlobalProxyClearPasswordRemovesStoredSecret(t *testing.T) {
store := newFakeAppSecretStore()
app := NewAppWithSecretStore(store)
app.configDir = t.TempDir()
if _, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
Password: "proxy-secret",
}); err != nil {
t.Fatalf("saveGlobalProxy with password returned error: %v", err)
}
view, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
ClearPassword: true,
})
if err != nil {
t.Fatalf("saveGlobalProxy clear password returned error: %v", err)
}
if view.HasPassword {
t.Fatal("expected global proxy password marker to be cleared")
}
if secret, ok, err := app.dailySecretStore().GetGlobalProxy(); err != nil {
t.Fatalf("GetGlobalProxy returned error: %v", err)
} else if ok || secret.Password != "" {
t.Fatalf("expected global proxy secret to be deleted, got %#v ok=%v", secret, ok)
}
snapshot := currentGlobalProxyConfig()
if snapshot.Proxy.Password != "" {
t.Fatalf("expected runtime proxy password to be cleared, got %q", snapshot.Proxy.Password)
}
}
func TestSaveGlobalProxyDisabledKeepsDraftAndStoredPassword(t *testing.T) {
store := newFakeAppSecretStore()
app := NewAppWithSecretStore(store)
app.configDir = t.TempDir()
if _, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
Password: "proxy-secret",
}); err != nil {
t.Fatalf("saveGlobalProxy with password returned error: %v", err)
}
view, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{
Enabled: false,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
})
if err != nil {
t.Fatalf("saveGlobalProxy disabled draft returned error: %v", err)
}
if view.Enabled {
t.Fatal("expected saved proxy view to stay disabled")
}
if view.Host != "127.0.0.1" || view.Port != 8080 || view.User != "ops" {
t.Fatalf("expected disabled draft fields to be retained, got %#v", view)
}
if !view.HasPassword {
t.Fatal("expected disabled draft to keep saved password marker")
}
if snapshot := currentGlobalProxyConfig(); snapshot.Enabled {
t.Fatalf("expected runtime global proxy to be disabled, got %#v", snapshot)
}
if secret, ok, err := app.dailySecretStore().GetGlobalProxy(); err != nil {
t.Fatalf("GetGlobalProxy returned error: %v", err)
} else if !ok || secret.Password != "proxy-secret" {
t.Fatalf("expected saved proxy password to be retained, got %#v ok=%v", secret, ok)
}
result := app.GetGlobalProxyConfig()
stored, ok := result.Data.(connection.GlobalProxyView)
if !ok {
t.Fatalf("expected GlobalProxyView, got %T", result.Data)
}
if stored.Enabled || stored.Host != "127.0.0.1" || !stored.HasPassword {
t.Fatalf("expected GetGlobalProxyConfig to return disabled persisted draft, got %#v", stored)
}
}
func TestTestGlobalProxyConnectionUsesDraftHTTPProxy(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
proxyCalled := false
proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyCalled = true
if !r.URL.IsAbs() {
t.Fatalf("expected proxy request URL to be absolute, got %q", r.URL.String())
}
if r.URL.String() != "http://example.com/probe" {
t.Fatalf("unexpected target URL through proxy: %s", r.URL.String())
}
w.WriteHeader(http.StatusNoContent)
}))
defer proxyServer.Close()
host, port := parseTestServerHostPort(t, proxyServer.URL)
result := app.TestGlobalProxyConnection(connection.TestGlobalProxyInput{
Proxy: connection.SaveGlobalProxyInput{
Enabled: true,
Type: "http",
Host: host,
Port: port,
},
URL: "http://example.com/probe",
TimeoutSeconds: 2,
})
if !result.Success {
t.Fatalf("expected proxy test success, got %#v", result)
}
if !proxyCalled {
t.Fatal("expected draft proxy to receive the test request")
}
data, ok := result.Data.(connection.GlobalProxyTestResult)
if !ok {
t.Fatalf("expected GlobalProxyTestResult, got %T", result.Data)
}
if data.StatusCode != http.StatusNoContent || !data.ViaProxy {
t.Fatalf("unexpected proxy test data: %#v", data)
}
}
func TestTestGlobalProxyConnectionReusesSavedPasswordWhenDraftPasswordBlank(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
if _, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 8080,
User: "ops",
Password: "proxy-secret",
}); err != nil {
t.Fatalf("saveGlobalProxy returned error: %v", err)
}
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("ops:proxy-secret"))
proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Proxy-Authorization"); got != wantAuth {
t.Fatalf("expected saved proxy password auth %q, got %q", wantAuth, got)
}
w.WriteHeader(http.StatusOK)
}))
defer proxyServer.Close()
host, port := parseTestServerHostPort(t, proxyServer.URL)
result := app.TestGlobalProxyConnection(connection.TestGlobalProxyInput{
Proxy: connection.SaveGlobalProxyInput{
Enabled: true,
Type: "http",
Host: host,
Port: port,
User: "ops",
},
URL: "http://example.com/probe",
TimeoutSeconds: 2,
})
if !result.Success {
t.Fatalf("expected proxy test to reuse saved password, got %#v", result)
}
}
func parseTestServerHostPort(t *testing.T, rawURL string) (string, int) {
t.Helper()
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("url.Parse returned error: %v", err)
}
host, portText, err := net.SplitHostPort(parsed.Host)
if err != nil {
t.Fatalf("SplitHostPort returned error: %v", err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatalf("Atoi returned error: %v", err)
}
return host, port
}
func TestLoadPersistedGlobalProxyOnDarwinUsesInlinePassword(t *testing.T) {
if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil {
t.Fatalf("setGlobalProxyConfig returned error: %v", err)

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"math"
"net"
"net/http"
urlpkg "net/url"
"os"
@@ -31,6 +32,7 @@ const (
updateDevAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/tags/" + updateDevReleaseTag
updateChecksumAsset = "SHA256SUMS"
updateDownloadProgressEvent = "update:download-progress"
updateNetworkRetryDelay = 250 * time.Millisecond
)
var (
@@ -150,6 +152,7 @@ func (a *App) CheckForUpdatesSilently() connection.QueryResult {
}
func (a *App) checkForUpdates(logFailure bool) connection.QueryResult {
a.ensurePersistedGlobalProxyRuntime()
channel := a.currentUpdateChannel()
info, err := fetchLatestUpdateInfo(channel)
if err != nil {
@@ -477,11 +480,14 @@ func fetchLatestUpdateInfo(channel updateChannel) (UpdateInfo, error) {
return UpdateInfo{}, err
}
hashMap, err := updateFetchReleaseSHA256(release.Assets)
if err != nil {
return UpdateInfo{}, err
sha256Value := normalizeGitHubAssetSHA256(asset.Digest)
if sha256Value == "" {
hashMap, err := updateFetchReleaseSHA256(release.Assets)
if err != nil {
return UpdateInfo{}, err
}
sha256Value = strings.TrimSpace(hashMap[assetName])
}
sha256Value := strings.TrimSpace(hashMap[assetName])
if sha256Value == "" {
return UpdateInfo{}, localizedUpdateError{key: "app.update.backend.error.sha256_missing_current_package"}
}
@@ -566,7 +572,7 @@ func fetchReleaseByURL(apiURL string) (*githubRelease, error) {
req.Header.Set("User-Agent", "GoNavi-Updater")
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
resp, err := doUpdateRequest(client, req)
if err != nil {
return nil, err
}
@@ -581,7 +587,7 @@ func fetchReleaseByURL(apiURL string) (*githubRelease, error) {
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
return nil, wrapUpdateNetworkError(err)
}
return &release, nil
}
@@ -662,6 +668,20 @@ func findReleaseAsset(assets []githubAsset, name string) (*githubAsset, error) {
}
}
func normalizeGitHubAssetSHA256(digest string) string {
digest = strings.TrimSpace(digest)
if digest == "" {
return ""
}
if algorithm, value, ok := strings.Cut(digest, ":"); ok {
if !strings.EqualFold(strings.TrimSpace(algorithm), "sha256") {
return ""
}
digest = strings.TrimSpace(value)
}
return strings.ToLower(digest)
}
func fetchReleaseSHA256(assets []githubAsset) (map[string]string, error) {
var checksumURL string
for _, asset := range assets {
@@ -680,7 +700,7 @@ func fetchReleaseSHA256(assets []githubAsset) (map[string]string, error) {
return nil, err
}
req.Header.Set("User-Agent", "GoNavi-Updater")
resp, err := client.Do(req)
resp, err := doUpdateRequest(client, req)
if err != nil {
return nil, err
}
@@ -695,7 +715,7 @@ func fetchReleaseSHA256(assets []githubAsset) (map[string]string, error) {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
return nil, wrapUpdateNetworkError(err)
}
return parseSHA256Sums(string(body)), nil
@@ -765,7 +785,7 @@ func downloadFileWithHashWithTimeout(url, filePath string, onProgress func(downl
req.Header.Set("Accept", "application/octet-stream")
}
resp, err := client.Do(req)
resp, err := doUpdateRequest(client, req)
if err != nil {
return "", err
}
@@ -810,7 +830,7 @@ func downloadFileWithHashWithTimeout(url, filePath string, onProgress func(downl
}
if _, err := io.Copy(io.MultiWriter(writers...), resp.Body); err != nil {
out.Close()
return "", err
return "", wrapUpdateNetworkError(err)
}
if onProgress != nil {
onProgress(progressWriter.written, total)
@@ -828,6 +848,76 @@ func downloadFileWithHashWithTimeout(url, filePath string, onProgress func(downl
return hex.EncodeToString(hasher.Sum(nil)), nil
}
func doUpdateRequest(client *http.Client, req *http.Request) (*http.Response, error) {
resp, err := client.Do(req)
if err == nil {
return resp, nil
}
if !shouldRetryUpdateNetworkError(err) {
return nil, wrapUpdateNetworkError(err)
}
time.Sleep(updateNetworkRetryDelay)
retryReq := req.Clone(req.Context())
resp, err = client.Do(retryReq)
if err != nil {
return nil, wrapUpdateNetworkError(err)
}
return resp, nil
}
func shouldRetryUpdateNetworkError(err error) bool {
if err == nil {
return false
}
if isUpdateEOFError(err) {
return true
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
lower := strings.ToLower(err.Error())
return strings.Contains(lower, "connection reset by peer") ||
strings.Contains(lower, "connection refused") ||
strings.Contains(lower, "server closed idle connection")
}
func wrapUpdateNetworkError(err error) error {
if err == nil {
return nil
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
host := strings.TrimSpace(dnsErr.Name)
if host == "" {
host = "api.github.com"
}
return localizedUpdateError{
key: "app.update.backend.error.network_dns",
params: map[string]any{
"host": host,
"detail": err.Error(),
},
}
}
if isUpdateEOFError(err) {
return localizedUpdateError{
key: "app.update.backend.error.network_eof",
params: map[string]any{"detail": err.Error()},
}
}
return localizedUpdateError{
key: "app.update.backend.error.network_failed",
params: map[string]any{"detail": err.Error()},
}
}
func isUpdateEOFError(err error) bool {
return errors.Is(err, io.EOF) ||
errors.Is(err, io.ErrUnexpectedEOF) ||
strings.Contains(strings.ToLower(err.Error()), "eof")
}
func isGitHubReleaseAssetAPIURL(urlText string) bool {
parsed, err := urlpkg.Parse(strings.TrimSpace(urlText))
if err != nil {

View File

@@ -1,12 +1,17 @@
package app
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
stdRuntime "runtime"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestFetchLatestUpdateInfoSkipsChecksumWhenCurrentVersionIsAlreadyLatest(t *testing.T) {
@@ -64,7 +69,59 @@ func TestFetchLatestUpdateInfoSkipsChecksumWhenCurrentVersionIsAlreadyLatest(t *
}
}
func TestFetchLatestUpdateInfoFetchesChecksumWhenUpdateIsAvailable(t *testing.T) {
func TestFetchLatestUpdateInfoUsesAssetDigestWhenUpdateIsAvailable(t *testing.T) {
assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, "v0.6.5")
if err != nil {
t.Fatalf("expectedAssetName returned error: %v", err)
}
digest := strings.Repeat("A", 64)
originalVersion := AppVersion
AppVersion = "0.6.4"
defer func() {
AppVersion = originalVersion
}()
restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
return &githubRelease{
TagName: "v0.6.5",
Name: "v0.6.5",
HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/v0.6.5",
Assets: []githubAsset{
{
Name: assetName,
BrowserDownloadURL: "https://example.com/" + assetName,
Digest: "sha256:" + digest,
Size: 4096,
},
},
}, nil
})
defer restoreRelease()
checksumCalled := false
restoreChecksum := swapUpdateFetchReleaseSHA256(func([]githubAsset) (map[string]string, error) {
checksumCalled = true
return nil, errors.New("checksum should not be fetched when asset digest is available")
})
defer restoreChecksum()
info, err := fetchLatestUpdateInfo(updateChannelLatest)
if err != nil {
t.Fatalf("fetchLatestUpdateInfo returned error: %v", err)
}
if checksumCalled {
t.Fatal("expected SHA256SUMS fetch to be skipped when asset digest is available")
}
if !info.HasUpdate {
t.Fatalf("expected HasUpdate=true, got %#v", info)
}
if info.SHA256 != strings.ToLower(digest) || info.AssetName != assetName {
t.Fatalf("unexpected update info: %#v", info)
}
}
func TestFetchLatestUpdateInfoFallsBackToChecksumFileWhenAssetDigestMissing(t *testing.T) {
assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, "v0.6.5")
if err != nil {
t.Fatalf("expectedAssetName returned error: %v", err)
@@ -106,7 +163,7 @@ func TestFetchLatestUpdateInfoFetchesChecksumWhenUpdateIsAvailable(t *testing.T)
t.Fatalf("fetchLatestUpdateInfo returned error: %v", err)
}
if !checksumCalled {
t.Fatal("expected SHA256SUMS fetch when update is available")
t.Fatal("expected SHA256SUMS fetch when asset digest is missing")
}
if !info.HasUpdate {
t.Fatalf("expected HasUpdate=true, got %#v", info)
@@ -117,7 +174,7 @@ func TestFetchLatestUpdateInfoFetchesChecksumWhenUpdateIsAvailable(t *testing.T)
}
func TestCheckForUpdatesLogsFailuresForManualChecks(t *testing.T) {
app := &App{}
app := &App{configDir: t.TempDir()}
restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
return nil, errors.New("request timed out")
@@ -140,7 +197,7 @@ func TestCheckForUpdatesLogsFailuresForManualChecks(t *testing.T) {
}
func TestCheckForUpdatesSilentlySkipsFailureLogs(t *testing.T) {
app := &App{}
app := &App{configDir: t.TempDir()}
restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
return nil, errors.New("request timed out")
@@ -162,6 +219,73 @@ func TestCheckForUpdatesSilentlySkipsFailureLogs(t *testing.T) {
}
}
func TestCheckForUpdatesRestoresPersistedGlobalProxyRuntime(t *testing.T) {
previousProxy := currentGlobalProxyConfig()
t.Cleanup(func() {
_, _ = setGlobalProxyConfig(previousProxy.Enabled, previousProxy.Proxy)
})
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
proxyCalled := false
proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyCalled = true
if !r.URL.IsAbs() {
t.Fatalf("expected update request through HTTP proxy to use absolute URL, got %q", r.URL.String())
}
if r.URL.Host != "api.github.invalid" {
t.Fatalf("expected proxied GitHub API host api.github.invalid, got %q", r.URL.Host)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(githubRelease{
TagName: updateDevReleaseTag,
Name: "Dev Build (dev-proxy123)",
HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/dev-latest",
}); err != nil {
t.Fatalf("Encode returned error: %v", err)
}
}))
defer proxyServer.Close()
host, port := parseTestServerHostPort(t, proxyServer.URL)
if _, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{
Enabled: true,
Type: "http",
Host: host,
Port: port,
}); err != nil {
t.Fatalf("saveGlobalProxy returned error: %v", err)
}
if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil {
t.Fatalf("setGlobalProxyConfig reset returned error: %v", err)
}
originalVersion := AppVersion
AppVersion = "dev-proxy123"
defer func() {
AppVersion = originalVersion
}()
restoreRelease := swapUpdateFetchDevRelease(func() (*githubRelease, error) {
return fetchReleaseByURL("http://api.github.invalid/repos/Syngnat/GoNavi/releases/tags/dev-latest")
})
defer restoreRelease()
setChannelResult := app.SetUpdateChannel(string(updateChannelDev))
if !setChannelResult.Success {
t.Fatalf("SetUpdateChannel returned failure: %#v", setChannelResult)
}
result := app.CheckForUpdates()
if !result.Success {
t.Fatalf("expected update check through restored proxy to succeed, got %#v", result)
}
if !proxyCalled {
t.Fatal("expected persisted global proxy to receive the update check request")
}
}
func TestFetchLatestUpdateInfoForDevChannelUsesReleaseBuildVersion(t *testing.T) {
assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, "dev-a1b2c3d")
if err != nil {

View File

@@ -42,12 +42,28 @@ type SavedConnectionView struct {
type LegacySavedConnection = SavedConnectionInput
type SaveGlobalProxyInput struct {
Enabled bool `json:"enabled"`
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
Enabled bool `json:"enabled"`
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
ClearPassword bool `json:"clearPassword,omitempty"`
}
type TestGlobalProxyInput struct {
Proxy SaveGlobalProxyInput `json:"proxy"`
URL string `json:"url"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
}
type GlobalProxyTestResult struct {
URL string `json:"url"`
FinalURL string `json:"finalUrl,omitempty"`
StatusCode int `json:"statusCode,omitempty"`
Status string `json:"status,omitempty"`
DurationMs int64 `json:"durationMs"`
ViaProxy bool `json:"viaProxy"`
}
type GlobalProxyView struct {