diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 1be470f..def0da2 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -4,6 +4,7 @@ on: push: tags: - 'v*' + - 'Beta_v*' workflow_dispatch: # Reduce redundancy and improve caching @@ -21,12 +22,9 @@ jobs: - os: windows arch: amd64 runs-on: windows-latest - - os: darwin + - os: macos arch: amd64 runs-on: macos-latest - - os: darwin - arch: arm64 - runs-on: macos-latest runs-on: ${{ matrix.runs-on }} @@ -68,7 +66,7 @@ jobs: - name: Build GUI application shell: bash env: - GOOS: ${{ matrix.os }} + GOOS: ${{ matrix.os == 'macos' && 'darwin' || matrix.os }} GOARCH: ${{ matrix.arch }} run: | # Ensure Fyne CLI is in PATH @@ -93,13 +91,13 @@ jobs: # Find and rename the exe (handle different possible names) if [ -f "ScoreScrape Bridge.exe" ]; then - mv "ScoreScrape Bridge.exe" "ScoreScrape-Bridge-windows-${{ matrix.arch }}.exe" + mv "ScoreScrape Bridge.exe" "ScoreScrape-Bridge-${{ matrix.os }}-${{ matrix.arch }}.exe" elif [ -f "bridge.exe" ]; then - mv "bridge.exe" "ScoreScrape-Bridge-windows-${{ matrix.arch }}.exe" + mv "bridge.exe" "ScoreScrape-Bridge-${{ matrix.os }}-${{ matrix.arch }}.exe" elif [ -f "cmd/bridge/ScoreScrape Bridge.exe" ]; then - mv "cmd/bridge/ScoreScrape Bridge.exe" "ScoreScrape-Bridge-windows-${{ matrix.arch }}.exe" + mv "cmd/bridge/ScoreScrape Bridge.exe" "ScoreScrape-Bridge-${{ matrix.os }}-${{ matrix.arch }}.exe" elif [ -f "cmd/bridge/bridge.exe" ]; then - mv "cmd/bridge/bridge.exe" "ScoreScrape-Bridge-windows-${{ matrix.arch }}.exe" + mv "cmd/bridge/bridge.exe" "ScoreScrape-Bridge-${{ matrix.os }}-${{ matrix.arch }}.exe" else echo "ERROR: Could not find built exe file" find . -name "*.exe" -type f @@ -121,12 +119,11 @@ jobs: echo "=== Files in cmd/bridge ===" ls -la cmd/bridge || true - ARCH_SUFFIX=${{ matrix.arch }} # Find and copy the app bundle if [ -d "ScoreScrape Bridge.app" ]; then - ditto "ScoreScrape Bridge.app" "ScoreScrape-Bridge-macOS-${ARCH_SUFFIX}.app" + ditto "ScoreScrape Bridge.app" "ScoreScrape-Bridge-${{ matrix.os }}-${{ matrix.arch }}.app" elif [ -d "cmd/bridge/ScoreScrape Bridge.app" ]; then - ditto "cmd/bridge/ScoreScrape Bridge.app" "ScoreScrape-Bridge-macOS-${ARCH_SUFFIX}.app" + ditto "cmd/bridge/ScoreScrape Bridge.app" "ScoreScrape-Bridge-${{ matrix.os }}-${{ matrix.arch }}.app" else echo "ERROR: Could not find built app bundle" find . -name "*.app" -type d @@ -140,8 +137,8 @@ jobs: with: name: gui-${{ matrix.os }}-${{ matrix.arch }} path: | - ScoreScrape-Bridge-windows-${{ matrix.arch }}.exe - ScoreScrape-Bridge-macOS-${{ matrix.arch }}.app + ScoreScrape-Bridge-${{ matrix.os }}-${{ matrix.arch }}.exe + ScoreScrape-Bridge-${{ matrix.os }}-${{ matrix.arch }}.app if-no-files-found: ignore retention-days: 30 @@ -165,12 +162,33 @@ jobs: path: artifacts merge-multiple: true + - name: Debug artifacts + run: | + echo "=== All downloaded content ===" + find artifacts -type f -ls + find artifacts -type d -name "*.app" -ls + - name: Prepare release assets run: | mkdir -p release-assets - find artifacts -type f -name "ScoreScrape-Bridge-*" -exec cp {} release-assets/ \; - # Also copy .app directories - find artifacts -type d -name "*.app" -exec cp -r {} release-assets/ \; + + # Debug: show what we downloaded + echo "=== Downloaded artifacts ===" + find artifacts -type f -name "ScoreScrape-Bridge-*" -o -name "*.exe" -o -name "*.app" + find artifacts -type d -name "*.app" + + # Copy executable files + find artifacts -type f -name "ScoreScrape-Bridge-*.exe" -exec cp {} release-assets/ \; + + # Copy .app directories and create zip archives for easier download + find artifacts -type d -name "ScoreScrape-Bridge-*.app" | while read app_path; do + app_name=$(basename "$app_path") + cp -r "$app_path" release-assets/ + # Also create a zip for easier download + (cd release-assets && zip -r "${app_name}.zip" "$app_name") + done + + echo "=== Final release assets ===" ls -lh release-assets/ - name: Generate release notes @@ -191,7 +209,6 @@ jobs: ### Downloads - Windows (64-bit) - .exe - macOS Intel (64-bit) - .app - - macOS Apple Silicon (ARM64) - .app ### Changes $COMMITS" @@ -203,6 +220,6 @@ jobs: uses: softprops/action-gh-release@v1 with: files: release-assets/* - body_file: release-notes.md + body_path: release-notes.md draft: false prerelease: false \ No newline at end of file diff --git a/VERSION b/VERSION index d5cc44d..b09a54c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.2 \ No newline at end of file +0.7.3 \ No newline at end of file diff --git a/cmd/bridge/run_cli.go b/cmd/bridge/run_cli.go index 369915a..bd0a0cf 100644 --- a/cmd/bridge/run_cli.go +++ b/cmd/bridge/run_cli.go @@ -37,8 +37,26 @@ Ex: /dev/:/dev/ttyUSB0 b := bridge.New(bridgeID) + // Track rapid disconnections to detect session takeover + var lastDisconnectTime time.Time + var rapidDisconnectCount int + b.SetConnectionLostHandler(func(err error) { - log.Printf("Connection lost: %v", err) + now := time.Now() + + // Detect rapid disconnections (likely session takeover) + if !lastDisconnectTime.IsZero() && now.Sub(lastDisconnectTime) < 5*time.Second { + rapidDisconnectCount++ + } else { + rapidDisconnectCount = 0 + } + lastDisconnectTime = now + + if rapidDisconnectCount >= 2 { + log.Printf("Connection lost: %v (rapid disconnections detected - possible session takeover, will auto-recover)", err) + } else { + log.Printf("Connection lost: %v (MQTT will auto-reconnect)", err) + } }) // Enhanced reconnection logic with circuit breaker @@ -100,7 +118,7 @@ Ex: /dev/:/dev/ttyUSB0 lastSuccessTime = time.Now() errChan := make(chan error, 1) - healthCheckTicker := time.NewTicker(30 * time.Second) + healthCheckTicker := time.NewTicker(60 * time.Second) // Increased from 30s to give MQTT more time defer healthCheckTicker.Stop() go func() { @@ -112,7 +130,7 @@ Ex: /dev/:/dev/ttyUSB0 // Health monitoring loop healthCheckFailures := 0 - maxHealthCheckFailures := 5 + maxHealthCheckFailures := 10 // Increased from 5 to be more tolerant for { select { @@ -159,18 +177,38 @@ Ex: /dev/:/dev/ttyUSB0 goto reconnectLoop } case <-healthCheckTicker.C: - // Periodic health check + // Periodic health check with smarter MQTT reconnection awareness if !b.IsHealthy() { healthCheckFailures++ - log.Printf("Health check failed (%d/%d)", healthCheckFailures, maxHealthCheckFailures) - if healthCheckFailures >= maxHealthCheckFailures { - log.Printf("Device appears to be in unrecoverable state after %d failed health checks", healthCheckFailures) - b.Disconnect() + // Check if MQTT is actively attempting to auto-reconnect + isMQTTReconnecting := b.IsMQTTReconnecting() + disconnectedDuration, reconnectAttempts := b.GetMQTTReconnectionInfo() + + // Be more tolerant if MQTT is actively reconnecting + effectiveMaxFailures := maxHealthCheckFailures + if isMQTTReconnecting { + // Give MQTT more time to recover during session takeover scenarios + effectiveMaxFailures = maxHealthCheckFailures * 2 + log.Printf("Health check failed (%d/%d) - MQTT reconnecting (disconnected for %v, attempt %d)", + healthCheckFailures, effectiveMaxFailures, disconnectedDuration, reconnectAttempts) + } else { + log.Printf("Health check failed (%d/%d)", healthCheckFailures, effectiveMaxFailures) + } - // Force a longer delay and reset to try recovery - consecutiveFailures = maxConsecutiveFailures - 1 // Trigger near-circuit-breaker behavior - goto reconnectLoop + if healthCheckFailures >= effectiveMaxFailures { + if isMQTTReconnecting && disconnectedDuration < 10*time.Minute { + // MQTT is still trying and hasn't been disconnected too long + // Log but don't force reconnection yet + log.Printf("MQTT actively reconnecting for %v - allowing more time before forcing reset", disconnectedDuration) + } else { + log.Printf("Device appears to be in unrecoverable state after %d failed health checks", healthCheckFailures) + b.Disconnect() + + // Force a longer delay and reset to try recovery + consecutiveFailures = maxConsecutiveFailures - 1 // Trigger near-circuit-breaker behavior + goto reconnectLoop + } } } else { // Reset health check failures on successful check diff --git a/pkg/bridge/bridge.go b/pkg/bridge/bridge.go index 9750c27..13f5fcb 100644 --- a/pkg/bridge/bridge.go +++ b/pkg/bridge/bridge.go @@ -6,6 +6,8 @@ import ( "fmt" "io/ioutil" "os" + "path/filepath" + "runtime" "strings" "sync" "time" @@ -90,16 +92,29 @@ func isUnusablePort(name string) bool { // getAppVersion reads the version from the VERSION file func getAppVersion() string { + // Get the executable path to help locate VERSION file + execPath, _ := os.Executable() + execDir := filepath.Dir(execPath) + // Try multiple possible locations for the VERSION file possiblePaths := []string{ - "VERSION", // Current directory - "../VERSION", // Parent directory (if running from cmd/bridge) - "../../VERSION", // Two levels up (if running from nested build dir) + "VERSION", // Current working directory + filepath.Join(execDir, "VERSION"), // Same directory as executable + filepath.Join(execDir, "..", "VERSION"), // Parent of executable directory + filepath.Join(execDir, "..", "..", "VERSION"), // Two levels up from executable + filepath.Join(execDir, "..", "..", "..", "VERSION"), // Three levels up + "../VERSION", // Relative parent directory + "../../VERSION", // Two levels up relative + "../../../VERSION", // Three levels up relative + "./VERSION", // Explicit current directory } for _, path := range possiblePaths { if data, err := ioutil.ReadFile(path); err == nil { - return strings.TrimSpace(string(data)) + version := strings.TrimSpace(string(data)) + if version != "" { + return version + } } } @@ -107,20 +122,35 @@ func getAppVersion() string { return "unknown" } -// getAppType determines if this is a Docker or Windows deployment +// getAppType determines if this is a Docker, Windows, or macOS deployment func getAppType() string { // Check if running in Docker environment (CLI only, no GUI) if os.Getenv("BRIDGE_GUI") == "false" { return "docker" } - // Default to windows for GUI mode + + // Detect macOS by checking for .app bundle structure or darwin OS + if strings.Contains(os.Args[0], ".app/Contents/MacOS/") { + return "macos" + } + + // Check runtime OS as fallback + if runtime.GOOS == "darwin" { + return "macos" + } + + // Default to windows for GUI mode on other platforms return "windows" } // MQTTClient handles broker communication type MQTTClient struct { - client mqtt.Client - onConnectionLost func(error) + client mqtt.Client + onConnectionLost func(error) + mu sync.RWMutex + lastDisconnectTime time.Time + reconnectionAttempts int + isReconnecting bool } func NewMQTTClient(broker, clientID, lwtTopic string, lwtPayload []byte) *MQTTClient { @@ -142,15 +172,31 @@ func NewMQTTClient(broker, clientID, lwtTopic string, lwtPayload []byte) *MQTTCl m := &MQTTClient{} opts.SetConnectionLostHandler(func(c mqtt.Client, err error) { + m.mu.Lock() + m.lastDisconnectTime = time.Now() + m.isReconnecting = true + m.reconnectionAttempts = 0 + m.mu.Unlock() + if m.onConnectionLost != nil { m.onConnectionLost(err) } }) - // Add reconnect handler to log reconnection attempts + // Track reconnection attempts opts.SetReconnectingHandler(func(c mqtt.Client, options *mqtt.ClientOptions) { - // This helps us track when MQTT is trying to reconnect - // Could add logging here if needed + m.mu.Lock() + m.reconnectionAttempts++ + m.isReconnecting = true + m.mu.Unlock() + }) + + // Track successful reconnection + opts.SetOnConnectHandler(func(c mqtt.Client) { + m.mu.Lock() + m.isReconnecting = false + m.reconnectionAttempts = 0 + m.mu.Unlock() }) m.client = mqtt.NewClient(opts) @@ -191,6 +237,20 @@ func (m *MQTTClient) PublishRetained(topic string, data []byte) error { func (m *MQTTClient) IsConnected() bool { return m.client != nil && m.client.IsConnected() } func (m *MQTTClient) Disconnect() { m.client.Disconnect(250) } +// IsReconnecting returns true if MQTT is actively attempting to reconnect +func (m *MQTTClient) IsReconnecting() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.isReconnecting +} + +// GetReconnectionInfo returns disconnection time and attempt count +func (m *MQTTClient) GetReconnectionInfo() (time.Time, int) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.lastDisconnectTime, m.reconnectionAttempts +} + // Bridge is the main coordinator type Bridge struct { mu sync.RWMutex @@ -258,6 +318,37 @@ func (b *Bridge) IsHealthy() bool { return true } +// IsMQTTReconnecting returns true if MQTT is actively attempting to auto-reconnect +// This helps distinguish between "temporarily disconnected but recovering" vs "broken" +func (b *Bridge) IsMQTTReconnecting() bool { + b.mu.RLock() + mqttClient := b.mqttClient + b.mu.RUnlock() + + if mqttClient == nil { + return false + } + + return mqttClient.IsReconnecting() +} + +// GetMQTTReconnectionInfo returns how long MQTT has been disconnected and attempt count +func (b *Bridge) GetMQTTReconnectionInfo() (disconnectedDuration time.Duration, attempts int) { + b.mu.RLock() + mqttClient := b.mqttClient + b.mu.RUnlock() + + if mqttClient == nil { + return 0, 0 + } + + disconnectTime, attempts := mqttClient.GetReconnectionInfo() + if !disconnectTime.IsZero() { + disconnectedDuration = time.Since(disconnectTime) + } + return disconnectedDuration, attempts +} + // markHealthy updates the last healthy timestamp func (b *Bridge) markHealthy() { b.mu.Lock()