Compare commits
5
Commits
v0.6.5
...
dc3e9beb14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc3e9beb14 | ||
|
|
9d7d4c8287 | ||
|
|
469106d502 | ||
|
|
e61d4fedd8 | ||
|
|
1886d9b07b |
@@ -11,12 +11,18 @@ GO = go
|
|||||||
CGO_ENABLED = 1
|
CGO_ENABLED = 1
|
||||||
WINDRES ?= x86_64-w64-mingw32-windres
|
WINDRES ?= x86_64-w64-mingw32-windres
|
||||||
MINGW_CC ?= x86_64-w64-mingw32-gcc
|
MINGW_CC ?= x86_64-w64-mingw32-gcc
|
||||||
SEMVER ?= 0.6.5
|
SEMVER ?= 0.6.8
|
||||||
GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
VERSION = $(SEMVER)-$(GIT_HASH)
|
VERSION = $(SEMVER)-$(GIT_HASH)
|
||||||
LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION)
|
LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION)
|
||||||
|
|
||||||
.PHONY: all build app run daemon clean vet tidy fmt icon icon-windows build-windows installer-windows
|
# Code signing (optional). Set these to sign the .app bundle with a
|
||||||
|
# Developer ID certificate, enabling biometric (Touch ID) keychain access.
|
||||||
|
# Example: make app CODESIGN_IDENTITY="Developer ID Application: Name (ABCDE12345)" TEAM_ID=ABCDE12345
|
||||||
|
CODESIGN_IDENTITY ?=
|
||||||
|
TEAM_ID ?=
|
||||||
|
|
||||||
|
.PHONY: all build app run daemon clean vet tidy fmt icon icon-windows build-windows installer-windows sign
|
||||||
|
|
||||||
## all: build the .app bundle (default)
|
## all: build the .app bundle (default)
|
||||||
all: app
|
all: app
|
||||||
@@ -41,8 +47,39 @@ app: build
|
|||||||
@if [ -f resources/icon.icns ]; then \
|
@if [ -f resources/icon.icns ]; then \
|
||||||
cp resources/icon.icns $(APP_BUNDLE)/Contents/Resources/icon.icns; \
|
cp resources/icon.icns $(APP_BUNDLE)/Contents/Resources/icon.icns; \
|
||||||
else echo " (no icon.icns found, skipping icon)"; fi
|
else echo " (no icon.icns found, skipping icon)"; fi
|
||||||
|
@if [ -n "$(CODESIGN_IDENTITY)" ]; then $(MAKE) sign; \
|
||||||
|
else echo " (skipping code signing - set CODESIGN_IDENTITY and TEAM_ID to enable Touch ID keychain)"; fi
|
||||||
@echo "Built $(APP_BUNDLE)"
|
@echo "Built $(APP_BUNDLE)"
|
||||||
|
|
||||||
|
## sign: code-sign the .app bundle with a Developer ID certificate
|
||||||
|
## Requires CODESIGN_IDENTITY and TEAM_ID to be set.
|
||||||
|
sign:
|
||||||
|
@if [ -z "$(CODESIGN_IDENTITY)" ]; then \
|
||||||
|
echo "ERROR: CODESIGN_IDENTITY not set."; \
|
||||||
|
echo "Usage: make sign CODESIGN_IDENTITY='Developer ID Application: Name (TEAMID)' TEAM_ID=TEAMID"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@if [ -z "$(TEAM_ID)" ]; then \
|
||||||
|
echo "ERROR: TEAM_ID not set."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@tmp=$$(mktemp LMVPN.entitlements.XXXXXX); \
|
||||||
|
sed 's/\$$(AppIdentifierPrefix)/$(TEAM_ID)./' resources/LMVPN.entitlements > $$tmp; \
|
||||||
|
codesign --force --options runtime \
|
||||||
|
--sign "$(CODESIGN_IDENTITY)" \
|
||||||
|
--entitlements $$tmp \
|
||||||
|
$(APP_BUNDLE)/Contents/MacOS/$(GUI_BIN); \
|
||||||
|
codesign --force --options runtime \
|
||||||
|
--sign "$(CODESIGN_IDENTITY)" \
|
||||||
|
--entitlements $$tmp \
|
||||||
|
$(APP_BUNDLE)/Contents/MacOS/$(DAEMON_BIN); \
|
||||||
|
codesign --force --options runtime \
|
||||||
|
--sign "$(CODESIGN_IDENTITY)" \
|
||||||
|
--entitlements $$tmp \
|
||||||
|
$(APP_BUNDLE); \
|
||||||
|
rm -f $$tmp
|
||||||
|
@echo "Signed $(APP_BUNDLE) with identity: $(CODESIGN_IDENTITY)"
|
||||||
|
|
||||||
## icon: generate icon.icns from resources/icon.png (or resources/logo.svg)
|
## icon: generate icon.icns from resources/icon.png (or resources/logo.svg)
|
||||||
icon:
|
icon:
|
||||||
@if [ -f resources/logo.svg ] && [ ! -f resources/icon.png -o resources/logo.svg -nt resources/icon.png ]; then \
|
@if [ -f resources/logo.svg ] && [ ! -f resources/icon.png -o resources/logo.svg -nt resources/icon.png ]; then \
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ const ServiceName = paths.BundleID
|
|||||||
// ErrNotFound is returned when a secret is not present in the store.
|
// ErrNotFound is returned when a secret is not present in the store.
|
||||||
var ErrNotFound = fmt.Errorf("secret not found")
|
var ErrNotFound = fmt.Errorf("secret not found")
|
||||||
|
|
||||||
|
// ErrSecMissingEntitlement is returned when the biometric keychain path
|
||||||
|
// fails because the app lacks the required code-signing entitlements
|
||||||
|
// (errSecMissingEntitlement, OSStatus -34018). Callers should fall back
|
||||||
|
// to the non-biometric keychain path when this error is encountered.
|
||||||
|
var ErrSecMissingEntitlement = fmt.Errorf("missing keychain entitlement")
|
||||||
|
|
||||||
// ErrUserCanceled is returned when the user cancels a biometric
|
// ErrUserCanceled is returned when the user cancels a biometric
|
||||||
// authentication prompt (e.g. Touch ID) or the system cannot complete
|
// authentication prompt (e.g. Touch ID) or the system cannot complete
|
||||||
// authentication.
|
// authentication.
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
package keychain
|
package keychain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/keybase/go-keychain"
|
"github.com/keybase/go-keychain"
|
||||||
)
|
)
|
||||||
@@ -26,6 +28,36 @@ func SetTouchIDPrompt(prompt string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// biometricDisabled tracks whether the biometric keychain path has been
|
||||||
|
// disabled at runtime. This happens when a biometric operation fails with
|
||||||
|
// errSecMissingEntitlement (-34018), indicating the app lacks the required
|
||||||
|
// code-signing entitlements. Once disabled, all subsequent operations use
|
||||||
|
// the non-biometric file-based keychain path.
|
||||||
|
var (
|
||||||
|
biometricDisabled bool
|
||||||
|
biometricDisabledMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// shouldUseBiometric reports whether the biometric keychain path should be
|
||||||
|
// used. It returns false if biometric storage has been disabled at runtime
|
||||||
|
// (e.g. due to missing entitlements on an ad-hoc signed build).
|
||||||
|
func shouldUseBiometric() bool {
|
||||||
|
if !biometricAvailable() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
biometricDisabledMu.Lock()
|
||||||
|
defer biometricDisabledMu.Unlock()
|
||||||
|
return !biometricDisabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// disableBiometric disables the biometric keychain path for all subsequent
|
||||||
|
// operations, forcing a fallback to the non-biometric file-based keychain.
|
||||||
|
func disableBiometric() {
|
||||||
|
biometricDisabledMu.Lock()
|
||||||
|
defer biometricDisabledMu.Unlock()
|
||||||
|
biometricDisabled = true
|
||||||
|
}
|
||||||
|
|
||||||
// DarwinStore implements Store using the macOS Keychain.
|
// DarwinStore implements Store using the macOS Keychain.
|
||||||
type DarwinStore struct{}
|
type DarwinStore struct{}
|
||||||
|
|
||||||
@@ -37,18 +69,15 @@ func New() Store {
|
|||||||
return DarwinStore{}
|
return DarwinStore{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (DarwinStore) setItem(account, secret string) error {
|
// setItemPlain stores a secret in the file-based keychain (no biometric
|
||||||
// Use biometric-protected storage when Touch ID is available.
|
// protection) using the keybase/go-keychain library.
|
||||||
if biometricAvailable() {
|
func setItemPlain(account, secret string) error {
|
||||||
return storeBiometricItemGo(ServiceName, account, secret)
|
|
||||||
}
|
|
||||||
item := keychain.NewItem()
|
item := keychain.NewItem()
|
||||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
item.SetService(ServiceName)
|
item.SetService(ServiceName)
|
||||||
item.SetAccount(account)
|
item.SetAccount(account)
|
||||||
item.SetData([]byte(secret))
|
item.SetData([]byte(secret))
|
||||||
item.SetAccessible(keychain.AccessibleAfterFirstUnlock)
|
item.SetAccessible(keychain.AccessibleAfterFirstUnlock)
|
||||||
// Delete any existing item first (Add fails on duplicates).
|
|
||||||
_ = keychain.DeleteItem(item)
|
_ = keychain.DeleteItem(item)
|
||||||
if err := keychain.AddItem(item); err != nil {
|
if err := keychain.AddItem(item); err != nil {
|
||||||
return fmt.Errorf("keychain add %s: %w", account, err)
|
return fmt.Errorf("keychain add %s: %w", account, err)
|
||||||
@@ -56,12 +85,8 @@ func (DarwinStore) setItem(account, secret string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (DarwinStore) getItem(account string) (string, error) {
|
// getItemPlain retrieves a secret from the file-based keychain.
|
||||||
// When Touch ID is available, use the direct CGo path which can
|
func getItemPlain(account string) (string, error) {
|
||||||
// show a biometric prompt for protected items.
|
|
||||||
if biometricAvailable() {
|
|
||||||
return getBiometricItemGo(ServiceName, account, touchIDPrompt)
|
|
||||||
}
|
|
||||||
item := keychain.NewItem()
|
item := keychain.NewItem()
|
||||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
item.SetService(ServiceName)
|
item.SetService(ServiceName)
|
||||||
@@ -78,12 +103,8 @@ func (DarwinStore) getItem(account string) (string, error) {
|
|||||||
return string(results[0].Data), nil
|
return string(results[0].Data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (DarwinStore) deleteItem(account string) error {
|
// deleteItemPlain deletes a secret from the file-based keychain.
|
||||||
// Use the direct CGo delete which works for both biometric and
|
func deleteItemPlain(account string) error {
|
||||||
// non-biometric items (and doesn't trigger a Touch ID prompt).
|
|
||||||
if biometricAvailable() {
|
|
||||||
return deleteBiometricItemGo(ServiceName, account)
|
|
||||||
}
|
|
||||||
item := keychain.NewItem()
|
item := keychain.NewItem()
|
||||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
item.SetService(ServiceName)
|
item.SetService(ServiceName)
|
||||||
@@ -91,6 +112,57 @@ func (DarwinStore) deleteItem(account string) error {
|
|||||||
return keychain.DeleteItem(item)
|
return keychain.DeleteItem(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (DarwinStore) setItem(account, secret string) error {
|
||||||
|
if shouldUseBiometric() {
|
||||||
|
err := storeBiometricItemGo(ServiceName, account, secret)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrSecMissingEntitlement) {
|
||||||
|
disableBiometric()
|
||||||
|
// Fall through to non-biometric path.
|
||||||
|
} else {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return setItemPlain(account, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DarwinStore) getItem(account string) (string, error) {
|
||||||
|
if shouldUseBiometric() {
|
||||||
|
secret, err := getBiometricItemGo(ServiceName, account, touchIDPrompt)
|
||||||
|
if err == nil {
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrSecMissingEntitlement) {
|
||||||
|
disableBiometric()
|
||||||
|
// Fall through to non-biometric path.
|
||||||
|
} else if errors.Is(err, ErrNotFound) {
|
||||||
|
// Item not in the Data Protection Keychain; it may have
|
||||||
|
// been stored via the non-biometric path. Fall through.
|
||||||
|
} else {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getItemPlain(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DarwinStore) deleteItem(account string) error {
|
||||||
|
if shouldUseBiometric() {
|
||||||
|
err := deleteBiometricItemGo(ServiceName, account)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrSecMissingEntitlement) {
|
||||||
|
disableBiometric()
|
||||||
|
// Fall through to non-biometric path.
|
||||||
|
} else {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deleteItemPlain(account)
|
||||||
|
}
|
||||||
|
|
||||||
func (s DarwinStore) SetPassword(profileName, password string) error {
|
func (s DarwinStore) SetPassword(profileName, password string) error {
|
||||||
return s.setItem(passwordAccountPrefix+profileName, password)
|
return s.setItem(passwordAccountPrefix+profileName, password)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,6 +215,11 @@ import (
|
|||||||
// when the user cancels a Touch ID / password prompt (-128).
|
// when the user cancels a Touch ID / password prompt (-128).
|
||||||
const errSecUserCanceled = -128
|
const errSecUserCanceled = -128
|
||||||
|
|
||||||
|
// errSecMissingEntitlementCode is errSecMissingEntitlement (-34018),
|
||||||
|
// returned by the Data Protection Keychain when the app is not properly
|
||||||
|
// code-signed with keychain-access-groups entitlement.
|
||||||
|
const errSecMissingEntitlementCode = -34018
|
||||||
|
|
||||||
var (
|
var (
|
||||||
biometricCacheOnce sync.Once
|
biometricCacheOnce sync.Once
|
||||||
biometricCacheVal bool
|
biometricCacheVal bool
|
||||||
@@ -293,6 +298,9 @@ func storeBiometricItemGo(service, account, secret string) error {
|
|||||||
|
|
||||||
status := C.storeBiometricItem(svcRef, accRef, dataRef)
|
status := C.storeBiometricItem(svcRef, accRef, dataRef)
|
||||||
if status != 0 {
|
if status != 0 {
|
||||||
|
if status == errSecMissingEntitlementCode {
|
||||||
|
return fmt.Errorf("keychain biometric store %s: %w", account, ErrSecMissingEntitlement)
|
||||||
|
}
|
||||||
return fmt.Errorf("keychain biometric store %s: OSStatus %d", account, status)
|
return fmt.Errorf("keychain biometric store %s: OSStatus %d", account, status)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -329,6 +337,9 @@ func getBiometricItemGo(service, account, prompt string) (string, error) {
|
|||||||
return "", ErrUserCanceled
|
return "", ErrUserCanceled
|
||||||
}
|
}
|
||||||
if status != 0 {
|
if status != 0 {
|
||||||
|
if status == errSecMissingEntitlementCode {
|
||||||
|
return "", fmt.Errorf("keychain biometric get %s: %w", account, ErrSecMissingEntitlement)
|
||||||
|
}
|
||||||
return "", fmt.Errorf("keychain biometric get %s: OSStatus %d", account, status)
|
return "", fmt.Errorf("keychain biometric get %s: OSStatus %d", account, status)
|
||||||
}
|
}
|
||||||
defer cfRelease(C.CFTypeRef(dataOut))
|
defer cfRelease(C.CFTypeRef(dataOut))
|
||||||
@@ -354,6 +365,9 @@ func deleteBiometricItemGo(service, account string) error {
|
|||||||
|
|
||||||
status := C.deleteBiometricItem(svcRef, accRef)
|
status := C.deleteBiometricItem(svcRef, accRef)
|
||||||
if status != 0 {
|
if status != 0 {
|
||||||
|
if status == errSecMissingEntitlementCode {
|
||||||
|
return fmt.Errorf("keychain biometric delete %s: %w", account, ErrSecMissingEntitlement)
|
||||||
|
}
|
||||||
return fmt.Errorf("keychain biometric delete %s: OSStatus %d", account, status)
|
return fmt.Errorf("keychain biometric delete %s: OSStatus %d", account, status)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -99,13 +99,13 @@ func raceDial(ctx context.Context, network string, ips []string, port string) (n
|
|||||||
go func(target string) {
|
go func(target string) {
|
||||||
d := &net.Dialer{}
|
d := &net.Dialer{}
|
||||||
c, err := d.DialContext(raceCtx, network, net.JoinHostPort(target, port))
|
c, err := d.DialContext(raceCtx, network, net.JoinHostPort(target, port))
|
||||||
select {
|
// Always send the result: resultCh is buffered (len(ips)),
|
||||||
case resultCh <- result{conn: c, err: err}:
|
// so this never blocks. Do NOT use a select with
|
||||||
case <-raceCtx.Done():
|
// <-raceCtx.Done() here - when the context is cancelled
|
||||||
if c != nil {
|
// both cases would be ready and Go's random select pick
|
||||||
c.Close()
|
// could skip the send, causing the drain loop below to
|
||||||
}
|
// block forever.
|
||||||
}
|
resultCh <- result{conn: c, err: err}
|
||||||
}(ip)
|
}(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +117,8 @@ func raceDial(ctx context.Context, network string, ips []string, port string) (n
|
|||||||
// any late successful connections.
|
// any late successful connections.
|
||||||
cancel()
|
cancel()
|
||||||
for j := i + 1; j < len(ips); j++ {
|
for j := i + 1; j < len(ips); j++ {
|
||||||
if late := <-resultCh; late.conn != nil {
|
late := <-resultCh
|
||||||
|
if late.conn != nil {
|
||||||
late.conn.Close()
|
late.conn.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRaceDialNoDeadlock is a regression test for a select race
|
||||||
|
// condition that caused raceDial to deadlock ~50% of the time when
|
||||||
|
// multiple IPs were raced and the first succeeded. The bug was a
|
||||||
|
// select with two simultaneously-ready cases (send result vs
|
||||||
|
// <-raceCtx.Done()); Go's random selection could skip the send,
|
||||||
|
// leaving the drain loop blocked forever.
|
||||||
|
//
|
||||||
|
// We run many iterations because the bug was probabilistic.
|
||||||
|
func TestRaceDialNoDeadlock(t *testing.T) {
|
||||||
|
const iterations = 200
|
||||||
|
|
||||||
|
for n := 0; n < iterations; n++ {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept both connections in background (both IPs dial the
|
||||||
|
// same listener since raceDial uses a single port).
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
port := portOf(ln.Addr())
|
||||||
|
ips := []string{"127.0.0.1", "127.0.0.1"}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
conn, err := raceDial(ctx, "tcp", ips, port)
|
||||||
|
cancel()
|
||||||
|
if err == nil && conn != nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
ln.Close()
|
||||||
|
|
||||||
|
// If we get here without the 5s timeout, the iteration passed.
|
||||||
|
// A deadlock would trigger the test-wide 60s timeout.
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here, no iteration deadlocked.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRaceDialAllFail verifies that when all dials fail, raceDial
|
||||||
|
// returns an error instead of blocking.
|
||||||
|
func TestRaceDialAllFail(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Port 1: connection refused on most systems.
|
||||||
|
ips := []string{"127.0.0.1", "127.0.0.1"}
|
||||||
|
_, err := raceDial(ctx, "tcp", ips, "1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when all dials fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRaceDialSingleIP verifies the single-IP path still works.
|
||||||
|
func TestRaceDialSingleIP(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
c, _ := ln.Accept()
|
||||||
|
if c != nil {
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
conn, err := raceDial(ctx, "tcp", []string{"127.0.0.1"}, portOf(ln.Addr()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("raceDial single IP: %v", err)
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRaceDialContextCancelled ensures raceDial returns promptly when
|
||||||
|
// the parent context is cancelled while dials are in flight.
|
||||||
|
func TestRaceDialContextCancelled(t *testing.T) {
|
||||||
|
// Dial a non-routable address so the dial hangs until context
|
||||||
|
// cancellation.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go func() {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := raceDial(ctx, "tcp", []string{"10.255.255.1", "10.255.255.2"}, "80")
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on cancelled context")
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("raceDial did not return after context cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// portOf extracts the port from a net.Addr.
|
||||||
|
func portOf(addr net.Addr) string {
|
||||||
|
_, port, _ := net.SplitHostPort(addr.String())
|
||||||
|
return port
|
||||||
|
}
|
||||||
@@ -130,7 +130,6 @@ func Run() {
|
|||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
a.windowHidden = false
|
a.windowHidden = false
|
||||||
activateApp()
|
activateApp()
|
||||||
showDockIcon()
|
|
||||||
a.window.Show()
|
a.window.Show()
|
||||||
a.window.RequestFocus()
|
a.window.RequestFocus()
|
||||||
})
|
})
|
||||||
@@ -149,7 +148,6 @@ func Run() {
|
|||||||
if a.windowHidden {
|
if a.windowHidden {
|
||||||
a.windowHidden = false
|
a.windowHidden = false
|
||||||
activateApp()
|
activateApp()
|
||||||
showDockIcon()
|
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
if a.windowHidden {
|
if a.windowHidden {
|
||||||
return
|
return
|
||||||
@@ -173,7 +171,6 @@ func Run() {
|
|||||||
a.window.SetCloseIntercept(func() {
|
a.window.SetCloseIntercept(func() {
|
||||||
if cfg.CloseToTray {
|
if cfg.CloseToTray {
|
||||||
a.windowHidden = true
|
a.windowHidden = true
|
||||||
hideDockIcon()
|
|
||||||
a.window.Hide()
|
a.window.Hide()
|
||||||
} else {
|
} else {
|
||||||
a.quit()
|
a.quit()
|
||||||
|
|||||||
@@ -25,12 +25,6 @@ static BOOL appShouldHandleReopen(id self, SEL cmd, id sender, BOOL flag) {
|
|||||||
return YES;
|
return YES;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void setDockIconVisible(int visible) {
|
|
||||||
Class cls = objc_getClass("NSApplication");
|
|
||||||
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
|
|
||||||
((void (*)(id, SEL, long))objc_msgSend)(app, sel_getUid("setActivationPolicy:"), visible ? 0 : 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void cmActivateApp(void) {
|
static void cmActivateApp(void) {
|
||||||
Class cls = objc_getClass("NSApplication");
|
Class cls = objc_getClass("NSApplication");
|
||||||
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
|
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
|
||||||
@@ -53,9 +47,6 @@ static void cmRegisterReopenHandler(void) {
|
|||||||
*/
|
*/
|
||||||
import "C"
|
import "C"
|
||||||
|
|
||||||
func showDockIcon() { C.setDockIconVisible(1) }
|
|
||||||
func hideDockIcon() { C.setDockIconVisible(0) }
|
|
||||||
|
|
||||||
func activateApp() { C.cmActivateApp() }
|
func activateApp() { C.cmActivateApp() }
|
||||||
|
|
||||||
func registerReopenHandler() { C.cmRegisterReopenHandler() }
|
func registerReopenHandler() { C.cmRegisterReopenHandler() }
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ package ui
|
|||||||
|
|
||||||
var onAppActive func()
|
var onAppActive func()
|
||||||
|
|
||||||
func showDockIcon() {}
|
|
||||||
func hideDockIcon() {}
|
|
||||||
|
|
||||||
func activateApp() {}
|
func activateApp() {}
|
||||||
|
|
||||||
func registerReopenHandler() {}
|
func registerReopenHandler() {}
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ func (a *App) setupTray() {
|
|||||||
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
|
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
|
||||||
a.windowHidden = false
|
a.windowHidden = false
|
||||||
activateApp()
|
activateApp()
|
||||||
showDockIcon()
|
|
||||||
a.window.Show()
|
a.window.Show()
|
||||||
a.window.RequestFocus()
|
a.window.RequestFocus()
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
<false/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)com.lmvpn.client</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
Reference in New Issue
Block a user