diff --git a/.github/scripts/linux_config_url_smoke.sh b/.github/scripts/linux_config_url_smoke.sh old mode 100644 new mode 100755 index f29e20e55b..f76877eafb --- a/.github/scripts/linux_config_url_smoke.sh +++ b/.github/scripts/linux_config_url_smoke.sh @@ -2,23 +2,25 @@ set -euo pipefail if [[ -z "${JOIN_SERVER_CONFIG_URLS:-}" ]]; then - echo "Skipping Linux config URL smoke test (JOIN_SERVER_CONFIG_URLS is not set)." - exit 0 + echo "Skipping Linux config URL smoke test (JOIN_SERVER_CONFIG_URLS is not set)." + exit 0 fi config_urls_file="$(mktemp)" cleanup() { - rm -f "$config_urls_file" + rm -f "$config_urls_file" } trap cleanup EXIT set +x -printf '%s' "$JOIN_SERVER_CONFIG_URLS" > "$config_urls_file" +printf '%s' "$JOIN_SERVER_CONFIG_URLS" >"$config_urls_file" chmod 600 "$config_urls_file" set -x config_server_base="${JOIN_SERVER_CONFIG_SERVER_NAME:-ci-config-url-smoke-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}}" config_skip_cert="${JOIN_SERVER_CONFIG_SKIP_CERT_VERIFICATION:-true}" -sg lantern -c "env PATH=$PATH HOME=$HOME JOIN_SERVER_CONFIG_URLS_FILE=\"$config_urls_file\" JOIN_SERVER_CONFIG_SERVER_NAME=\"${config_server_base}-api\" JOIN_SERVER_CONFIG_SKIP_CERT_VERIFICATION=\"$config_skip_cert\" xvfb-run -a flutter test integration_test/vpn/linux_config_url_api_smoke_test.dart -d linux --dart-define=DISABLE_SYSTEM_TRAY=true" -sg lantern -c "env PATH=$PATH HOME=$HOME JOIN_SERVER_CONFIG_URLS_FILE=\"$config_urls_file\" JOIN_SERVER_CONFIG_SERVER_NAME=\"${config_server_base}-ui\" JOIN_SERVER_CONFIG_SKIP_CERT_VERIFICATION=\"$config_skip_cert\" xvfb-run -a flutter test integration_test/vpn/linux_config_url_smoke_test.dart -d linux --dart-define=DISABLE_SYSTEM_TRAY=true" +JOIN_SERVER_CONFIG_URLS_FILE="$config_urls_file" JOIN_SERVER_CONFIG_SERVER_NAME="${config_server_base}-api" JOIN_SERVER_CONFIG_SKIP_CERT_VERIFICATION="$config_skip_cert" \ + xvfb-run -a flutter test integration_test/vpn/linux_config_url_api_smoke_test.dart -d linux --dart-define=DISABLE_SYSTEM_TRAY=true +JOIN_SERVER_CONFIG_URLS_FILE="$config_urls_file" JOIN_SERVER_CONFIG_SERVER_NAME="${config_server_base}-ui" JOIN_SERVER_CONFIG_SKIP_CERT_VERIFICATION="$config_skip_cert" \ + xvfb-run -a flutter test integration_test/vpn/linux_config_url_smoke_test.dart -d linux --dart-define=DISABLE_SYSTEM_TRAY=true diff --git a/.github/scripts/windows_connect_smoke.ps1 b/.github/scripts/windows_connect_smoke.ps1 index 9c36c52a2d..aedc66359d 100644 --- a/.github/scripts/windows_connect_smoke.ps1 +++ b/.github/scripts/windows_connect_smoke.ps1 @@ -1,10 +1,8 @@ param( [string]$ServiceName = "LanternSvc", - [string]$ServiceExe = "build/windows/x64/runner/Release/lanternsvc.exe", + [string]$ServiceExe = "build/windows/x64/runner/Release/lanternd.exe", [string]$InstallerPath = "", - [string]$TokenPath = "C:\ProgramData\Lantern\ipc-token", [string]$TestPath = "integration_test/vpn/windows_connect_smoke_test.dart", - [string]$SplitTunnelWebsiteTestPath = "integration_test/vpn/split_tunneling_website_smoke_test.dart", [string]$ConfigUrlApiTestPath = "integration_test/vpn/windows_config_url_api_smoke_test.dart", [string]$ConfigUrlUiTestPath = "integration_test/vpn/windows_config_url_smoke_test.dart", [string]$DefaultConfigServerName = "ci-config-url-smoke", @@ -13,13 +11,23 @@ param( [int]$UninstallTimeoutSeconds = 180, [int]$HeartbeatSeconds = 15, [switch]$EnableIpCheck, - [switch]$ForceFullTunnel, - [switch]$RunSplitTunnelWebsiteSmoke, - [switch]$RunConfigUrlSmoke, [switch]$UseInstaller ) -$ErrorActionPreference = "Stop" - -& "$PSScriptRoot/windows_smoke_suite.ps1" @PSBoundParameters @args -exit $LASTEXITCODE +& (Join-Path $PSScriptRoot "windows_smoke_suite.ps1") ` + -ServiceName $ServiceName ` + -ServiceExe $ServiceExe ` + -InstallerPath $InstallerPath ` + -TestPath $TestPath ` + -ConfigUrlApiTestPath $ConfigUrlApiTestPath ` + -ConfigUrlUiTestPath $ConfigUrlUiTestPath ` + -DefaultConfigServerName $DefaultConfigServerName ` + -WaitSeconds $WaitSeconds ` + -InstallerTimeoutSeconds $InstallerTimeoutSeconds ` + -UninstallTimeoutSeconds $UninstallTimeoutSeconds ` + -HeartbeatSeconds $HeartbeatSeconds ` + -RunConnectSmoke:$true ` + -RunSplitTunnelWebsiteSmoke:$false ` + -RunConfigUrlSmoke:$true ` + -EnableIpCheck:$EnableIpCheck ` + -UseInstaller:$UseInstaller diff --git a/.github/scripts/windows_smoke_suite.ps1 b/.github/scripts/windows_smoke_suite.ps1 index 7bbd7753c8..d16e0508bd 100644 --- a/.github/scripts/windows_smoke_suite.ps1 +++ b/.github/scripts/windows_smoke_suite.ps1 @@ -1,8 +1,7 @@ param( [string]$ServiceName = "LanternSvc", - [string]$ServiceExe = "build/windows/x64/runner/Release/lanternsvc.exe", + [string]$ServiceExe = "build/windows/x64/runner/Release/lanternd.exe", [string]$InstallerPath = "", - [string]$TokenPath = "C:\ProgramData\Lantern\ipc-token", [string]$TestPath = "integration_test/vpn/windows_connect_smoke_test.dart", [string]$SplitTunnelWebsiteTestPath = "integration_test/vpn/split_tunneling_website_smoke_test.dart", [string]$ConfigUrlApiTestPath = "integration_test/vpn/windows_config_url_api_smoke_test.dart", @@ -99,6 +98,32 @@ function Invoke-ProcessWithTimeout { } } +function Invoke-LanterndCommand { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList, + [string]$Description + ) + + $desc = if ([string]::IsNullOrWhiteSpace($Description)) { + "$FilePath $($ArgumentList -join ' ')" + } else { + $Description + } + + Write-Step $desc + $output = & $FilePath @ArgumentList 2>&1 + $exitCode = $LASTEXITCODE + if ($output) { + $output | ForEach-Object { Write-Host $_ } + } + if ($exitCode -ne 0) { + throw "$desc failed with exit code $exitCode" + } +} + function Invoke-FlutterSmokeTest { param( [Parameter(Mandatory = $true)] @@ -172,6 +197,20 @@ function Remove-ServiceIfPresent { } } +function Show-LanterndInstallDiagnostics { + $lanterndPath = "C:\Program Files\Lantern\lanternd.exe" + if (Test-Path $lanterndPath) { + Write-Step "lanternd.exe exists at $lanterndPath" + & $lanterndPath version 2>&1 | ForEach-Object { Write-Step " version: $_" } + return + } + + Write-Step "lanternd.exe NOT found at $lanterndPath" + Write-Step "Contents of C:\Program Files\Lantern\:" + Get-ChildItem "C:\Program Files\Lantern\" -ErrorAction SilentlyContinue | + ForEach-Object { Write-Step " $($_.Name)" } +} + function Wait-ServiceRunning { param( [string]$Name, @@ -191,35 +230,10 @@ function Wait-ServiceRunning { } sc.exe query $Name + Show-LanterndInstallDiagnostics throw "Windows service did not reach Running state" } -function Wait-TokenFile { - param( - [string]$Path, - [int]$TimeoutSeconds - ) - - for ($i = 0; $i -lt $TimeoutSeconds; $i++) { - if (Test-Path $Path) { - try { - $token = (Get-Content -Path $Path -Raw -ErrorAction Stop).Trim() - } catch { - $token = "" - } - if (-not [string]::IsNullOrWhiteSpace($token)) { - Write-Step "IPC token detected at $Path with content" - return - } - } - if ($i -gt 0 -and ($i % 5) -eq 0) { - Write-Step "Waiting for non-empty IPC token at $Path ($i/$TimeoutSeconds s)" - } - Start-Sleep -Seconds 1 - } - throw "IPC token file missing or empty at $Path" -} - function Install-FromInstaller { param( [string]$Path, @@ -238,6 +252,15 @@ function Install-FromInstaller { -PulseSeconds $HeartbeatSeconds ` -Description "Running installer" + $lanterndPath = "C:\Program Files\Lantern\lanternd.exe" + $svc = Get-Service -Name $Name -ErrorAction SilentlyContinue + if (-not $svc -and (Test-Path $lanterndPath)) { + Invoke-LanterndCommand ` + -FilePath $lanterndPath ` + -ArgumentList @("install") ` + -Description "Service not found after installer; running lanternd install manually for diagnostics" + } + Write-Step "Waiting for Windows service after installer" Wait-ServiceRunning -Name $Name -TimeoutSeconds $TimeoutSeconds } @@ -282,24 +305,13 @@ try { Write-Step "Smoke setup mode: direct service binary" $resolvedServiceExe = (Resolve-Path $ServiceExe).Path Remove-ServiceIfPresent -Name $ServiceName - Invoke-ScCommand ` - -ArgumentList @( - "create", - $ServiceName, - "binPath= `"$resolvedServiceExe`"", - "start= demand", - "DisplayName= Lantern Service (CI)" - ) ` - -Description "Creating Windows service from $resolvedServiceExe" - Invoke-ScCommand ` - -ArgumentList @("start", $ServiceName) ` - -AllowedExitCodes @(0, 1056) ` - -Description "Starting Windows service $ServiceName" + Invoke-LanterndCommand ` + -FilePath $resolvedServiceExe ` + -ArgumentList @("install") ` + -Description "Installing lanternd service from $resolvedServiceExe" Wait-ServiceRunning -Name $ServiceName -TimeoutSeconds $WaitSeconds } - Wait-TokenFile -Path $TokenPath -TimeoutSeconds $WaitSeconds - if ($RunConnectSmoke) { Invoke-FlutterSmokeTest ` -Path $TestPath ` @@ -340,7 +352,6 @@ try { $env:JOIN_SERVER_CONFIG_SKIP_CERT_VERIFICATION = "true" } - # Run API and UI smoke tests with unique names to avoid collisions. $env:JOIN_SERVER_CONFIG_SERVER_NAME = "$configServerBaseName-api" Invoke-FlutterSmokeTest ` -Path $ConfigUrlApiTestPath ` @@ -361,7 +372,15 @@ finally { if ($UseInstaller) { Uninstall-FromInstalledService -Name $ServiceName } else { - Remove-ServiceIfPresent -Name $ServiceName + $resolvedServiceExe = (Resolve-Path $ServiceExe -ErrorAction SilentlyContinue).Path + if ($resolvedServiceExe) { + Invoke-LanterndCommand ` + -FilePath $resolvedServiceExe ` + -ArgumentList @("uninstall") ` + -Description "Uninstalling lanternd service from $resolvedServiceExe" + } else { + Remove-ServiceIfPresent -Name $ServiceName + } } Write-Step "Cleanup finished" } catch { diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index d50a0e334c..b63e176f78 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -206,8 +206,6 @@ jobs: exit 1 fi - sudo usermod -aG lantern "$USER" || true - systemctl is-active --quiet lanternd.service test -S /run/lantern/lanternd.sock sudo stat -c "%a %U %G %n" /run/lantern/lanternd.sock @@ -217,7 +215,7 @@ jobs: run: | set -euxo pipefail code=0 - sg lantern -c "env HOME=$HOME PATH=$PATH xvfb-run -a timeout 15s /usr/bin/lantern >/tmp/lantern-installed-smoke.log 2>&1" || code=$? + env HOME=$HOME PATH=$PATH xvfb-run -a timeout 15s /usr/bin/lantern >/tmp/lantern-installed-smoke.log 2>&1 || code=$? if [[ "$code" -ne 124 ]]; then cat /tmp/lantern-installed-smoke.log || true echo "Installed /usr/bin/lantern did not stay up under xvfb" @@ -244,7 +242,7 @@ jobs: FLUTTER_DART_DEFINES="${FLUTTER_DART_DEFINES} --dart-define=SMOKE_FORCE_FULL_TUNNEL=true" fi set +e - sg lantern -c "env PATH=$PATH HOME=$HOME timeout --signal=TERM --kill-after=30s 8m xvfb-run -a flutter test integration_test/vpn/linux_connect_smoke_test.dart -d linux --reporter=expanded ${FLUTTER_DART_DEFINES}" + env PATH=$PATH HOME=$HOME timeout --signal=TERM --kill-after=30s 8m xvfb-run -a flutter test integration_test/vpn/linux_connect_smoke_test.dart -d linux --reporter=expanded ${FLUTTER_DART_DEFINES} SMOKE_EXIT=$? set -e @@ -259,14 +257,14 @@ jobs: exit "$SMOKE_EXIT" fi - if ! grep -Eq 'IPC request.*path=/service/start' /tmp/lanternd-journal-ui-smoke.log; then - echo "Missing /service/start IPC request in lanternd journal" + if ! grep -Eq 'IPC request.*path=/vpn/connect' /tmp/lanternd-journal-ui-smoke.log; then + echo "Missing /vpn/connect IPC request in lanternd journal" tail -n 200 /tmp/lanternd-journal-ui-smoke.log || true exit 1 fi - if ! grep -Eq 'IPC request.*path=/service/stop' /tmp/lanternd-journal-ui-smoke.log; then - echo "Missing /service/stop IPC request in lanternd journal" + if ! grep -Eq 'IPC request.*path=/vpn/disconnect' /tmp/lanternd-journal-ui-smoke.log; then + echo "Missing /vpn/disconnect IPC request in lanternd journal" tail -n 200 /tmp/lanternd-journal-ui-smoke.log || true exit 1 fi @@ -282,7 +280,7 @@ jobs: run: | set -euxo pipefail set +e - sg lantern -c "env PATH=$PATH HOME=$HOME timeout --signal=TERM --kill-after=30s 10m xvfb-run -a flutter test integration_test/auth/auth_smoke_test.dart -d linux --reporter=expanded --dart-define=DISABLE_SYSTEM_TRAY=true" + env PATH=$PATH HOME=$HOME timeout --signal=TERM --kill-after=30s 10m xvfb-run -a flutter test integration_test/auth/auth_smoke_test.dart -d linux --reporter=expanded --dart-define=DISABLE_SYSTEM_TRAY=true AUTH_SMOKE_EXIT=$? set -e if [[ "$AUTH_SMOKE_EXIT" -eq 124 ]]; then diff --git a/.github/workflows/build-windows-service.yml b/.github/workflows/build-windows-service.yml index 193f3f17af..045f949852 100644 --- a/.github/workflows/build-windows-service.yml +++ b/.github/workflows/build-windows-service.yml @@ -28,12 +28,12 @@ jobs: - name: Build Windows service shell: pwsh run: | - make windows-service-build - Move-Item "bin\windows-amd64\lanternsvc.exe" "lanternsvc.exe" + make lanternd-windows-amd64 + Move-Item "bin\windows-amd64\lanternd.exe" "lanternd.exe" - name: Upload Windows service executable uses: actions/upload-artifact@v4 with: name: lantern-service-exe - path: "lanternsvc.exe" + path: "lanternd.exe" retention-days: 2 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index a9ef25b468..24da27bdce 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -159,7 +159,6 @@ jobs: # Third-party binaries that are already signed by their vendors $thirdParty = @( - 'wintun.dll', # WinTun 'flutter_windows.dll', # Google/Flutter 'WebView2Loader.dll', # Microsoft 'WinSparkle.dll' # WinSparkle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3a843e0e8..14a8754e3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,6 +41,11 @@ on: required: false type: boolean default: true + windows_connect_smoke: + description: "Run Windows connect/disconnect smoke test on manual dispatch" + required: false + type: boolean + default: false permissions: contents: write @@ -292,7 +297,7 @@ jobs: build_type: ${{ needs.set-metadata.outputs.build_type }} installer_base_name: ${{ needs.set-metadata.outputs.installer_base_name }} enable_ip_check: ${{ needs.set-metadata.outputs.smoke_enable_ip_check == 'true' }} - run_connect_smoke: false + run_connect_smoke: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.windows_connect_smoke == 'true' }} run_split_tunnel_website_smoke: false run_config_url_smoke: false force_full_tunnel_smoke: ${{ needs.set-metadata.outputs.build_type == 'nightly' }} diff --git a/Makefile b/Makefile index 3c38ec62cf..e512d814e8 100644 --- a/Makefile +++ b/Makefile @@ -66,20 +66,15 @@ LINUX_PACKAGE_ARCH_SUFFIX := $(if $(filter amd64,$(LINUX_TARGET_ARCH)),,-$(LINUX LINUX_INSTALLER_DEB := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE))$(LINUX_PACKAGE_ARCH_SUFFIX).deb LINUX_INSTALLER_RPM := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE))$(LINUX_PACKAGE_ARCH_SUFFIX).rpm LINUX_INSTALLER_ARCH := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE))$(LINUX_PACKAGE_ARCH_SUFFIX).pkg.tar.zst -LINUX_SERVICE_NAME := lanternd -LINUX_SERVICE_SRC := $(RADIANCE_REPO)/cmd/lanternd -LINUX_SERVICE_BUILD_AMD64 := $(BIN_DIR)/linux-amd64/$(LINUX_SERVICE_NAME) -LINUX_SERVICE_BUILD_ARM64 := $(BIN_DIR)/linux-arm64/$(LINUX_SERVICE_NAME) -LINUX_SERVICE_BUILD_TARGET := $(BIN_DIR)/linux-$(LINUX_TARGET_ARCH)/$(LINUX_SERVICE_NAME) +LANTERND := lanternd +LANTERND_SRC := $(RADIANCE_REPO)/cmd/lanternd +LANTERND_LINUX_AMD64 := $(BIN_DIR)/linux-amd64/$(LANTERND) +LANTERND_LINUX_ARM64 := $(BIN_DIR)/linux-arm64/$(LANTERND) LINUX_BUNDLE_DIR_X64 := build/linux/x64/release/bundle LINUX_BUNDLE_DIR_ARM64 := build/linux/arm64/release/bundle LINUX_CC_AMD64 ?= x86_64-linux-gnu-gcc LINUX_CC_ARM64 ?= aarch64-linux-gnu-gcc LINUX_PKG_ROOT := linux/packaging -LINUX_SERVICE_DST := $(LINUX_PKG_ROOT)/usr/sbin -LINUX_PKG_SYSTEMD_DIR := $(LINUX_PKG_ROOT)/usr/lib/systemd/system -LINUX_SYSTEMD_UNIT_SRC := $(shell go list -m -f '{{.Dir}}' $(RADIANCE_REPO))/cmd/lanternd/lanternd.service -LINUX_SYSTEMD_UNIT_DST := $(LINUX_PKG_SYSTEMD_DIR)/lanternd.service ifeq ($(OS),Windows_NT) PS := powershell -NoProfile -ExecutionPolicy Bypass -Command @@ -92,12 +87,8 @@ else RM_RF = rm -rf -- '$(1)' endif -WINDOWS_SERVICE_NAME := lanternsvc.exe -WINDOWS_SERVICE_SRC := ./$(LANTERN_CORE)/cmd/lanternsvc -WINDOWS_SERVICE_BUILD_AMD64 := $(BIN_DIR)/windows-amd64/$(WINDOWS_SERVICE_NAME) -WINDOWS_SERVICE_BUILD_ARM64 := $(BIN_DIR)/windows-arm64/$(WINDOWS_SERVICE_NAME) -WINDOWS_SERVICE_BUILD := $(WINDOWS_SERVICE_BUILD_AMD64) -WINDOWS_SERVICE_CGO_ENABLED ?= 0 +LANTERND_WINDOWS_AMD64 := $(BIN_DIR)/windows-amd64/$(LANTERND).exe +LANTERND_WINDOWS_ARM64 := $(BIN_DIR)/windows-arm64/$(LANTERND).exe WINDOWS_LIB := $(LANTERN_LIB_NAME).dll WINDOWS_LIB_AMD64 := $(BIN_DIR)/windows-amd64/$(WINDOWS_LIB) @@ -105,18 +96,8 @@ WINDOWS_LIB_ARM64 := $(BIN_DIR)/windows-arm64/$(WINDOWS_LIB) WINDOWS_LIB_BUILD := $(BIN_DIR)/windows/$(WINDOWS_LIB) WINDOWS_DEBUG_DIR := $(BUILD_DIR)/windows/x64/runner/Debug WINDOWS_RELEASE_DIR := $(BUILD_DIR)/windows/x64/runner/Release -WINDOWS_SERVICE_BUILD_ARM64_RELEASE := $(WINDOWS_RELEASE_DIR)/arm64/$(WINDOWS_SERVICE_NAME) -WINTUN_VERSION ?= 0.14.1 -WINTUN_BASE_URL := https://wwW.wintun.net -WINTUN_BUILDS_URL := $(WINTUN_BASE_URL)/builds -WINTUN_OUT_DIR_AMD64 := windows/third_party/wintun/bin/amd64 -WINTUN_OUT_DIR_ARM64 := windows/third_party/wintun/bin/arm64 -WINTUN_DLL_AMD64 := $(WINTUN_OUT_DIR_AMD64)/wintun.dll -WINTUN_DLL_ARM64 := $(WINTUN_OUT_DIR_ARM64)/wintun.dll -WINTUN_DLL := $(WINTUN_DLL_AMD64) -WINTUN_DLL_RELEASE := $(WINDOWS_RELEASE_DIR)/wintun.dll -WINTUN_DLL_RELEASE_ARM64 := $(WINDOWS_RELEASE_DIR)/arm64/wintun.dll -WINTUN_DLL_DEBUG := $(WINDOWS_DEBUG_DIR)/wintun.dll +LANTERND_WINDOWS_RELEASE := $(WINDOWS_RELEASE_DIR)/$(LANTERND).exe +LANTERND_WINDOWS_RELEASE_ARM64 := $(WINDOWS_RELEASE_DIR)/arm64/$(LANTERND).exe ANDROID_LIB := $(LANTERN_LIB_NAME).aar @@ -331,32 +312,23 @@ linux: linux-$(LINUX_TARGET_ARCH) mkdir -p $(BIN_DIR)/linux cp $(BIN_DIR)/linux-$(LINUX_TARGET_ARCH)/$(LINUX_LIB) $(LINUX_LIB_BUILD) -.PHONY: linux-service-amd64 linux-service-arm64 linux-service stage-linux-service +.PHONY: lanternd-linux-amd64 lanternd-linux-arm64 -linux-service-amd64: $(GO_SOURCES) - $(call MKDIR_P,$(dir $(LINUX_SERVICE_BUILD_AMD64))) +lanternd-linux-amd64: $(GO_SOURCES) + $(call MKDIR_P,$(dir $(LANTERND_LINUX_AMD64))) GOOS=linux GOARCH=amd64 CGO_ENABLED=1 \ - go build -v -trimpath -tags "$(TAGS)" \ - -ldflags "-w -s $(EXTRA_LDFLAGS)" \ - -o $(LINUX_SERVICE_BUILD_AMD64) $(LINUX_SERVICE_SRC) - @echo "Built Linux service: $(LINUX_SERVICE_BUILD_AMD64)" + go build -mod=mod -v -trimpath -tags "$(TAGS)" \ + -ldflags "-w -s $(EXTRA_LDFLAGS)" \ + -o $(LANTERND_LINUX_AMD64) $(LANTERND_SRC) + @echo "Built lanternd (linux-amd64): $(LANTERND_LINUX_AMD64)" -linux-service-arm64: $(GO_SOURCES) - $(call MKDIR_P,$(dir $(LINUX_SERVICE_BUILD_ARM64))) +lanternd-linux-arm64: $(GO_SOURCES) + $(call MKDIR_P,$(dir $(LANTERND_LINUX_ARM64))) GOOS=linux GOARCH=arm64 CGO_ENABLED=1 \ - go build -v -trimpath -tags "$(TAGS)" \ - -ldflags "-w -s $(EXTRA_LDFLAGS)" \ - -o $(LINUX_SERVICE_BUILD_ARM64) $(LINUX_SERVICE_SRC) - @echo "Built Linux service: $(LINUX_SERVICE_BUILD_ARM64)" - -linux-service: linux-service-$(LINUX_TARGET_ARCH) - -stage-linux-service: linux-service - @echo "Staging systemd unit + service binary $(LINUX_PKG_ROOT)..." - $(call MKDIR_P,$(LINUX_SERVICE_DST)) - $(call COPY_FILE,$(LINUX_SERVICE_BUILD_TARGET),$(LINUX_SERVICE_DST)/$(LINUX_SERVICE_NAME)) - $(call MKDIR_P,$(LINUX_PKG_SYSTEMD_DIR)) - $(call COPY_FILE,$(LINUX_SYSTEMD_UNIT_SRC),$(LINUX_SYSTEMD_UNIT_DST)) + go build -mod=mod -v -trimpath -tags "$(TAGS)" \ + -ldflags "-w -s $(EXTRA_LDFLAGS)" \ + -o $(LANTERND_LINUX_ARM64) $(LANTERND_SRC) + @echo "Built lanternd (linux-arm64): $(LANTERND_LINUX_ARM64)" .PHONY: linux-debug linux-debug: @@ -369,7 +341,7 @@ linux-release: clean linux-release-ci linux-release-ci: linux pubget gen @echo "Building Flutter app (release) for Linux..." flutter build linux --release $(DART_DEFINES) - $(MAKE) stage-linux-service + $(MAKE) lanternd-linux-$(LINUX_TARGET_ARCH) @if [ "$(LINUX_TARGET_ARCH)" = "arm64" ]; then \ BUNDLE_DIR="$(LINUX_BUNDLE_DIR_ARM64)"; \ @@ -382,15 +354,13 @@ linux-release-ci: linux pubget gen fi; \ echo "Using Linux bundle dir: $$BUNDLE_DIR"; \ cp "$(LINUX_LIB_BUILD)" "$$BUNDLE_DIR"; \ + cp "$(BIN_DIR)/linux-$(LINUX_TARGET_ARCH)/$(LANTERND)" "$$BUNDLE_DIR"; \ patchelf --set-rpath '$$ORIGIN/lib' "$$BUNDLE_DIR/lantern" || true; \ - VERSION=$(APP_VERSION) GOARCH=$(LINUX_TARGET_ARCH) LINUX_BUNDLE_SRC="$$BUNDLE_DIR/" SYSTEMD_UNIT_SRC=$(LINUX_SYSTEMD_UNIT_DST) \ - LANTERND_SRC=$(LINUX_SERVICE_DST)/$(LINUX_SERVICE_NAME) LANTERND_DST=/usr/sbin/$(LINUX_SERVICE_NAME) \ + VERSION=$(APP_VERSION) GOARCH=$(LINUX_TARGET_ARCH) LINUX_BUNDLE_SRC="$$BUNDLE_DIR/" \ nfpm package -f $(LINUX_PKG_ROOT)/nfpm.yaml -p deb -t $(LINUX_INSTALLER_DEB); \ - VERSION=$(APP_VERSION) GOARCH=$(LINUX_TARGET_ARCH) LINUX_BUNDLE_SRC="$$BUNDLE_DIR/" SYSTEMD_UNIT_SRC=$(LINUX_SYSTEMD_UNIT_DST) \ - LANTERND_SRC=$(LINUX_SERVICE_DST)/$(LINUX_SERVICE_NAME) LANTERND_DST=/usr/sbin/$(LINUX_SERVICE_NAME) \ + VERSION=$(APP_VERSION) GOARCH=$(LINUX_TARGET_ARCH) LINUX_BUNDLE_SRC="$$BUNDLE_DIR/" \ nfpm package -f $(LINUX_PKG_ROOT)/nfpm.yaml -p rpm -t $(LINUX_INSTALLER_RPM); \ - VERSION=$(APP_VERSION) GOARCH=$(LINUX_TARGET_ARCH) LINUX_BUNDLE_SRC="$$BUNDLE_DIR/" SYSTEMD_UNIT_SRC=$(LINUX_SYSTEMD_UNIT_DST) \ - LANTERND_SRC=$(LINUX_SERVICE_DST)/$(LINUX_SERVICE_NAME) LANTERND_DST=/usr/bin/$(LINUX_SERVICE_NAME) \ + VERSION=$(APP_VERSION) GOARCH=$(LINUX_TARGET_ARCH) LINUX_BUNDLE_SRC="$$BUNDLE_DIR/" \ nfpm package -f $(LINUX_PKG_ROOT)/nfpm.yaml -p archlinux -t $(LINUX_INSTALLER_ARCH) .PHONY: verify-linux-package @@ -398,11 +368,8 @@ verify-linux-package: ./scripts/ci/verify_linux_package.sh $(LINUX_INSTALLER_DEB) # Windows Build -.PHONY: build-lanternsvc-windows build-lanternsvc-windows-arm64 \ - windows-service-build windows-service-build-amd64 windows-service-build-arm64 \ - copy-lanternsvc-release copy-lanternsvc-release-arm64 copy-lanternsvc-debug \ - wintun wintun-amd64 wintun-arm64 clean-wintun \ - copy-wintun-release copy-wintun-release-arm64 copy-wintun-debug +.PHONY: lanternd-windows-amd64 lanternd-windows-arm64 \ + copy-lanternd-release copy-lanternd-release-arm64 copy-lanternd-debug .PHONY: install-windows-deps install-windows-deps: @@ -423,101 +390,42 @@ windows-arm64: $(call MKDIR_P,$(dir $(WINDOWS_LIB_ARM64))) $(MAKE) desktop-lib GOOS=$(WINDOWS_GOOS) GOARCH=$(WINDOWS_GOARCH) LIB_NAME=$(WINDOWS_LIB_ARM64) CGO_LDFLAGS="$(WINDOWS_CGO_LDFLAGS)" -.PHONY: build-lanternsvc-windows -build-lanternsvc-windows: $(WINDOWS_SERVICE_BUILD_AMD64) - -.PHONY: build-lanternsvc-windows-arm64 -build-lanternsvc-windows-arm64: $(WINDOWS_SERVICE_BUILD_ARM64) - -windows-service-build: windows-service-build-amd64 - -windows-service-build-amd64: $(WINDOWS_SERVICE_BUILD_AMD64) +lanternd-windows-amd64: $(LANTERND_WINDOWS_AMD64) -windows-service-build-arm64: $(WINDOWS_SERVICE_BUILD_ARM64) +lanternd-windows-arm64: $(LANTERND_WINDOWS_ARM64) -$(WINDOWS_SERVICE_BUILD_AMD64): - $(call MKDIR_P,$(dir $(WINDOWS_SERVICE_BUILD_AMD64))) - GOOS=windows GOARCH=amd64 CGO_ENABLED=$(WINDOWS_SERVICE_CGO_ENABLED) go build -v -trimpath -tags "$(TAGS)" \ +$(LANTERND_WINDOWS_AMD64): + $(call MKDIR_P,$(dir $(LANTERND_WINDOWS_AMD64))) + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \ + go build -mod=mod -v -trimpath -tags "$(TAGS)" \ -ldflags "$(EXTRA_LDFLAGS)" \ - -o $(WINDOWS_SERVICE_BUILD_AMD64) $(WINDOWS_SERVICE_SRC) - @echo "Built Windows service (amd64): $(WINDOWS_SERVICE_BUILD_AMD64)" + -o $(LANTERND_WINDOWS_AMD64) $(LANTERND_SRC) + @echo "Built lanternd (windows-amd64): $(LANTERND_WINDOWS_AMD64)" -$(WINDOWS_SERVICE_BUILD_ARM64): - $(call MKDIR_P,$(dir $(WINDOWS_SERVICE_BUILD_ARM64))) - GOOS=windows GOARCH=arm64 CGO_ENABLED=$(WINDOWS_SERVICE_CGO_ENABLED) go build -v -trimpath -tags "$(TAGS)" \ +$(LANTERND_WINDOWS_ARM64): + $(call MKDIR_P,$(dir $(LANTERND_WINDOWS_ARM64))) + GOOS=windows GOARCH=arm64 CGO_ENABLED=0 \ + go build -mod=mod -v -trimpath -tags "$(TAGS)" \ -ldflags "$(EXTRA_LDFLAGS)" \ - -o $(WINDOWS_SERVICE_BUILD_ARM64) $(WINDOWS_SERVICE_SRC) - @echo "Built Windows service (arm64): $(WINDOWS_SERVICE_BUILD_ARM64)" + -o $(LANTERND_WINDOWS_ARM64) $(LANTERND_SRC) + @echo "Built lanternd (windows-arm64): $(LANTERND_WINDOWS_ARM64)" -copy-lanternsvc-release: $(WINDOWS_SERVICE_BUILD_AMD64) - $(call MKDIR_P,$(WINDOWS_RELEASE_DIR)) - $(call COPY_FILE,$(WINDOWS_SERVICE_BUILD_AMD64),$(WINDOWS_RELEASE_DIR)/$(WINDOWS_SERVICE_NAME)) - -copy-lanternsvc-release-arm64: $(WINDOWS_SERVICE_BUILD_ARM64) - $(call MKDIR_P,$(dir $(WINDOWS_SERVICE_BUILD_ARM64_RELEASE))) - $(call COPY_FILE,$(WINDOWS_SERVICE_BUILD_ARM64),$(WINDOWS_SERVICE_BUILD_ARM64_RELEASE)) - -copy-lanternsvc-debug: $(WINDOWS_SERVICE_BUILD_AMD64) - $(call MKDIR_P,$(WINDOWS_DEBUG_DIR)) - $(call COPY_FILE,$(WINDOWS_SERVICE_BUILD_AMD64),$(WINDOWS_DEBUG_DIR)/$(WINDOWS_SERVICE_NAME)) - -wintun: wintun-amd64 - -wintun-amd64: $(WINTUN_DLL_AMD64) - -wintun-arm64: $(WINTUN_DLL_ARM64) - -clean-wintun: - @$(call RM_RF,$(WINTUN_OUT_DIR_AMD64)) - @$(call RM_RF,$(WINTUN_OUT_DIR_ARM64)) - -$(WINTUN_DLL_AMD64): - $(call MKDIR_P,$(WINTUN_OUT_DIR_AMD64)) - @ver='$(WINTUN_VERSION)'; \ - zip='$(WINTUN_OUT_DIR_AMD64)/wintun-'$$ver'.zip'; \ - url='$(WINTUN_BUILDS_URL)/wintun-'$$ver'.zip'; \ - echo "Using Wintun $$ver"; \ - curl -fsSL -o "$$zip" "$$url"; \ - $(call MKDIR_P,$(WINTUN_OUT_DIR_AMD64)/_unz); \ - tar -xf "$$zip" -C "$(WINTUN_OUT_DIR_AMD64)/_unz" "wintun/bin/amd64/wintun.dll" \ - || powershell -NoProfile -Command "Expand-Archive -Force -LiteralPath '$$zip' -DestinationPath '$(WINTUN_OUT_DIR_AMD64)/_unz'"; - $(call COPY_FILE,$(WINTUN_OUT_DIR_AMD64)/_unz/wintun/bin/amd64/wintun.dll,$(WINTUN_DLL_AMD64)) - $(call RM_RF,$(WINTUN_OUT_DIR_AMD64)/_unz) - @echo "Installed: $(WINTUN_DLL_AMD64)"; - -$(WINTUN_DLL_ARM64): - $(call MKDIR_P,$(WINTUN_OUT_DIR_ARM64)) - @ver='$(WINTUN_VERSION)'; \ - zip='$(WINTUN_OUT_DIR_ARM64)/wintun-'$$ver'.zip'; \ - url='$(WINTUN_BUILDS_URL)/wintun-'$$ver'.zip'; \ - echo "Using Wintun $$ver"; \ - curl -fsSL -o "$$zip" "$$url"; \ - $(call MKDIR_P,$(WINTUN_OUT_DIR_ARM64)/_unz); \ - tar -xf "$$zip" -C "$(WINTUN_OUT_DIR_ARM64)/_unz" "wintun/bin/arm64/wintun.dll" \ - || powershell -NoProfile -Command "Expand-Archive -Force -LiteralPath '$$zip' -DestinationPath '$(WINTUN_OUT_DIR_ARM64)/_unz'"; - $(call COPY_FILE,$(WINTUN_OUT_DIR_ARM64)/_unz/wintun/bin/arm64/wintun.dll,$(WINTUN_DLL_ARM64)) - $(call RM_RF,$(WINTUN_OUT_DIR_ARM64)/_unz) - @echo "Installed: $(WINTUN_DLL_ARM64)"; - -.PHONY: copy-wintun-release copy-wintun-debug -copy-wintun-release: $(WINTUN_DLL_AMD64) +copy-lanternd-release: $(LANTERND_WINDOWS_AMD64) $(call MKDIR_P,$(WINDOWS_RELEASE_DIR)) - $(call COPY_FILE,$(WINTUN_DLL_AMD64),$(WINTUN_DLL_RELEASE)) + $(call COPY_FILE,$(LANTERND_WINDOWS_AMD64),$(LANTERND_WINDOWS_RELEASE)) -copy-wintun-release-arm64: $(WINTUN_DLL_ARM64) - $(call MKDIR_P,$(dir $(WINTUN_DLL_RELEASE_ARM64))) - $(call COPY_FILE,$(WINTUN_DLL_ARM64),$(WINTUN_DLL_RELEASE_ARM64)) +copy-lanternd-release-arm64: $(LANTERND_WINDOWS_ARM64) + $(call MKDIR_P,$(dir $(LANTERND_WINDOWS_RELEASE_ARM64))) + $(call COPY_FILE,$(LANTERND_WINDOWS_ARM64),$(LANTERND_WINDOWS_RELEASE_ARM64)) -copy-wintun-debug: $(WINTUN_DLL_AMD64) +copy-lanternd-debug: $(LANTERND_WINDOWS_AMD64) $(call MKDIR_P,$(WINDOWS_DEBUG_DIR)) - $(call COPY_FILE,$(WINTUN_DLL_AMD64),$(WINTUN_DLL_DEBUG)) + $(call COPY_FILE,$(LANTERND_WINDOWS_AMD64),$(WINDOWS_DEBUG_DIR)/$(LANTERND).exe) .PHONY: prepare-windows-release -prepare-windows-release: build-lanternsvc-windows build-lanternsvc-windows-arm64 wintun-amd64 wintun-arm64 - $(MAKE) copy-lanternsvc-release - $(MAKE) copy-lanternsvc-release-arm64 - $(MAKE) copy-wintun-release - $(MAKE) copy-wintun-release-arm64 +prepare-windows-release: lanternd-windows-amd64 lanternd-windows-arm64 + $(MAKE) copy-lanternd-release + $(MAKE) copy-lanternd-release-arm64 .PHONY: windows-debug windows-debug: windows diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/LanternApp.kt b/android/app/src/main/kotlin/org/getlantern/lantern/LanternApp.kt index 795a1a6834..44f5244ab8 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/LanternApp.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/LanternApp.kt @@ -6,7 +6,9 @@ import android.content.Context import android.net.ConnectivityManager import android.net.wifi.WifiManager import android.os.PowerManager +import android.util.Log import androidx.core.content.getSystemService +import lantern.io.mobile.Mobile class LanternApp : Application() { @@ -26,5 +28,49 @@ class LanternApp : Application() { } + override fun onCreate() { + super.onCreate() + applyQAEnvOverrides() + } + + /** + * Reads QA-only Android system properties and pushes them into the + * radiance process environment via a gomobile-exposed setter. Must run + * before any Mobile.setupRadiance / Mobile.startIPCServer call so the + * Go side picks them up at init time. + * + * Set with adb: e.g. + * adb shell setprop debug.lantern.outbound_socks 10.0.2.2:1080 + * adb shell setprop debug.lantern.tz Europe/Moscow + * + * No-op when neither property is set, so production builds aren't + * affected unless someone deliberately sets the props on the device. + */ + private fun applyQAEnvOverrides() { + val outboundSocks = systemProp("debug.lantern.outbound_socks") + val tz = systemProp("debug.lantern.tz") + if (outboundSocks.isEmpty() && tz.isEmpty()) return + try { + Mobile.setQAEnvOverrides(outboundSocks, tz) + Log.i(TAG, "QA env overrides applied: outbound_socks=$outboundSocks tz=$tz") + } catch (e: Throwable) { + Log.e(TAG, "Failed to apply QA env overrides", e) + } + } + + /** + * Reads an Android system property via reflection on android.os.SystemProperties. + * Returns "" if the property is unset or the call fails (e.g. policy denies it). + */ + private fun systemProp(key: String): String { + return try { + val cls = Class.forName("android.os.SystemProperties") + val m = cls.getMethod("get", String::class.java, String::class.java) + (m.invoke(null, key, "") as? String) ?: "" + } catch (e: Throwable) { + "" + } + } +} -} \ No newline at end of file +private const val TAG = "LanternApp" diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt b/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt index 541f16d9c7..34dd63ee7c 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt @@ -142,6 +142,17 @@ class MainActivity : FlutterFragmentActivity() { return } + // Check if VPN is already connected + // if so then user already have vpn on now wish to switch auto server + // Do not need to create service again just switch server + if (Mobile.isVPNConnected()) { + AppLogger.d(TAG, "VPN is already connected, switching auto server") + CoroutineScope(Dispatchers.Main).launch { + LanternVpnService.instance.connectToServer("auto") + } + return + } + try { val vpnIntent = Intent(this, LanternVpnService::class.java).apply { action = LanternVpnService.ACTION_START_VPN @@ -155,7 +166,7 @@ class MainActivity : FlutterFragmentActivity() { } } - fun connectToServer(location: String, tag: String) { + fun connectToServer(tag: String) { if (!isVPNServiceReady()) { AppLogger.d(TAG, "VPN service not ready") return @@ -166,7 +177,7 @@ class MainActivity : FlutterFragmentActivity() { if (Mobile.isVPNConnected()) { AppLogger.d(TAG, "VPN is already connected, switching server") CoroutineScope(Dispatchers.Main).launch { - LanternVpnService.instance.connectToServer(location, tag) + LanternVpnService.instance.connectToServer(tag) } return } @@ -175,7 +186,6 @@ class MainActivity : FlutterFragmentActivity() { val vpnIntent = Intent(this, LanternVpnService::class.java).apply { action = LanternVpnService.ACTION_CONNECT_TO_SERVER putExtra("tag", tag) - putExtra("location", location) } ContextCompat.startForegroundService(this, vpnIntent) AppLogger.d(TAG, "VPN service started") diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/handler/EventHandler.kt b/android/app/src/main/kotlin/org/getlantern/lantern/handler/EventHandler.kt index 8319659ecc..78924b2c81 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/handler/EventHandler.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/handler/EventHandler.kt @@ -8,21 +8,18 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import lantern.io.mobile.LogSubscription +import lantern.io.mobile.Mobile import lantern.io.utils.FlutterEvent +import lantern.io.utils.LogListener import org.getlantern.lantern.apps.AppDataHandler import org.getlantern.lantern.constant.VPNStatus import org.getlantern.lantern.utils.AppLogger import org.getlantern.lantern.utils.Event import org.getlantern.lantern.utils.FlutterEventStream -import org.getlantern.lantern.utils.LogTailer import org.getlantern.lantern.utils.PrivateServerEventStream import org.getlantern.lantern.utils.VpnStatusManager -import org.getlantern.lantern.utils.logDir -import java.io.File class EventHandler : FlutterPlugin { @@ -46,9 +43,8 @@ class EventHandler : FlutterPlugin { private var statusObserver: Observer>? = null private var flutterEventObserver: Observer>? = null var job: Job? = null - private var logsJob: Job? = null - var logFile: File = File(logDir(), "lantern.log") - private var logsTailer: LogTailer = LogTailer() + private var logsSubscription: LogSubscription? = null + private var logsListener: LogListener? = null private val eventScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) @@ -106,7 +102,9 @@ class EventHandler : FlutterPlugin { flutterEventObserver = null } logsChannel?.setStreamHandler(null) - logsJob?.cancel() + logsSubscription?.cancel() + logsSubscription = null + logsListener = null appDataChannel?.setStreamHandler(null) appDataHandler?.dispose() appDataHandler = null @@ -207,64 +205,29 @@ class EventHandler : FlutterPlugin { private fun logsChannelListeners() { logsChannel?.setStreamHandler(object : EventChannel.StreamHandler { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - logsJob = eventScope.launch { - // Send initial batch of last 200 lines, matching iOS/macOS behaviour - val initial = logsTailer.tail(logFile, 200) - if (initial.isNotEmpty()) { - withContext(Dispatchers.Main) { events?.success(initial) } - } - - // Track offset so we only send NEW lines on each poll (delta, not snapshot) - var fileOffset = logFile.length() - - while (isActive) { - delay(1000) - val currentSize = logFile.length() - if (currentSize < fileOffset) { - // File was rotated or truncated — reset - fileOffset = 0 - } - if (currentSize > fileOffset) { - val newLines = readLinesSinceOffset(logFile, fileOffset) - fileOffset = currentSize - if (newLines.isNotEmpty()) { - withContext(Dispatchers.Main) { events?.success(newLines) } - } - } + logsSubscription?.cancel() + val sink = events + val listener = object : LogListener { + override fun onLogEntry(entry: String) { + val trimmed = entry.trimEnd('\r', '\n') + if (trimmed.isEmpty()) return + eventScope.launch { sink?.success(listOf(trimmed)) } } } + logsListener = listener + try { + logsSubscription = Mobile.tailLogs(listener) + } catch (e: Exception) { + AppLogger.e(TAG, "Error starting log tail: ${e.message}") + logsListener = null + } } override fun onCancel(arguments: Any?) { - logsJob?.cancel() + logsSubscription?.cancel() + logsSubscription = null + logsListener = null } }) } - - private fun readLinesSinceOffset(file: File, offset: Long): List { - if (!file.exists() || offset < 0 || file.length() <= offset) return emptyList() - return try { - java.io.RandomAccessFile(file, "r").use { raf -> - raf.seek(offset) - val lines = mutableListOf() - java.io.BufferedReader( - java.io.InputStreamReader( - java.nio.channels.Channels.newInputStream(raf.channel), - Charsets.UTF_8, - ) - ).use { reader -> - var line = reader.readLine() - while (line != null) { - val trimmed = line.trimEnd('\r') - if (trimmed.isNotEmpty()) lines.add(trimmed) - line = reader.readLine() - } - } - lines - } - } catch (e: Exception) { - AppLogger.e(TAG, "Error reading new log lines: ${e.message}") - emptyList() - } - } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt b/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt index 8f8a724b5d..e8067ea101 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt @@ -99,6 +99,7 @@ enum class Methods(val method: String) { //custom/lantern servers GetLanternAvailableServers("getLanternAvailableServers"), GetAutoServerLocation("getAutoServerLocation"), + GetSelectedServerJSON("getSelectedServerJSON"), //Split Tunnel methods SetSplitTunnelingEnabled("setSplitTunnelingEnabled"), @@ -120,6 +121,14 @@ enum class Methods(val method: String) { // Smart routing SetRoutingMode("setRoutingMode"), + IsSmartRoutingEnabled("isSmartRoutingEnabled"), + + // Telemetry + IsTelemetryEnabled("isTelemetryEnabled"), + + // OAuth + IsOAuthLogin("isOAuthLogin"), + GetOAuthProvider("getOAuthProvider"), // VPN conflict detection CheckVpnConflict("checkVpnConflict"), @@ -184,12 +193,8 @@ class MethodHandler : FlutterPlugin, scope.launch { result.runCatching { val map = call.arguments as Map<*, *> - val location = map["location"] as String? ?: error("Missing location") val tag = map["serverName"] as String? ?: error("Missing serverName") - MainActivity.instance.connectToServer( - location, - tag, - ) + MainActivity.instance.connectToServer(tag) success("ok") }.onFailure { e -> result.error( @@ -438,7 +443,7 @@ class MethodHandler : FlutterPlugin, map["planId"] as String ) withContext(Dispatchers.Main) { - success(subscriptionData) + success(subscriptionData.toByteArray(Charsets.UTF_8)) } }.onFailure { e -> result.error( @@ -503,9 +508,9 @@ class MethodHandler : FlutterPlugin, scope.launch { result.runCatching { val token = call.arguments() - val bytes = Mobile.oAuthLoginCallback(token) + val json = Mobile.oAuthLoginCallback(token) withContext(Dispatchers.Main) { - success(bytes) + success(json.toByteArray(Charsets.UTF_8)) } }.onFailure { e -> result.error( @@ -520,9 +525,9 @@ class MethodHandler : FlutterPlugin, Methods.GetUserData.method -> { scope.launch { result.runCatching { - val bytes = Mobile.userData() + val json = Mobile.userData() withContext(Dispatchers.Main) { - success(bytes) + success(json.toByteArray(Charsets.UTF_8)) } }.onFailure { e -> result.error( @@ -537,9 +542,9 @@ class MethodHandler : FlutterPlugin, Methods.FetchUserData.method -> { scope.launch { result.runCatching { - val bytes = Mobile.fetchUserData() + val json = Mobile.fetchUserData() withContext(Dispatchers.Main) { - success(bytes) + success(json.toByteArray(Charsets.UTF_8)) } }.onFailure { e -> @@ -638,9 +643,9 @@ class MethodHandler : FlutterPlugin, val map = call.arguments as Map<*, *> val email = map["email"] as String? ?: error("Missing email") val password = map["password"] as String? ?: error("Missing password") - val bytes = Mobile.login(email, password) + val json = Mobile.login(email, password) withContext(Dispatchers.Main) { - success(bytes) + success(json.toByteArray(Charsets.UTF_8)) } }.onFailure { e -> result.error( @@ -677,9 +682,9 @@ class MethodHandler : FlutterPlugin, result.runCatching { val email = call.arguments(); AppLogger.d(TAG, "Logout email: $email") - val bytes = Mobile.logout(email) + val json = Mobile.logout(email) withContext(Dispatchers.Main) { - success(bytes) + success(json.toByteArray(Charsets.UTF_8)) } }.onFailure { e -> result.error( @@ -697,10 +702,9 @@ class MethodHandler : FlutterPlugin, val map = call.arguments as Map<*, *> val email = map["email"] as String? ?: error("Missing email") val password = map["password"] as String? ?: error("Missing password") - val isSSO = map["isSSO"] as Boolean? ?: error("Missing isSSO") - val bytes = Mobile.deleteAccount(email, password,isSSO) + val json = Mobile.deleteAccount(email, password) withContext(Dispatchers.Main) { - success(bytes) + success(json.toByteArray(Charsets.UTF_8)) } }.onFailure { e -> result.error( @@ -940,15 +944,13 @@ class MethodHandler : FlutterPlugin, val urls = map["urls"] as String? ?: error("Missing urls") val skipValidation = map["skipValidation"] as Boolean? ?: error("Missing skipValidation") - val serverName = map["serverName"] as String? ?: error("Missing serverName") - Mobile.addServerBasedOnURLs( + val tags = Mobile.addServerBasedOnURLs( urls, skipValidation, - serverName, ) withContext(Dispatchers.Main) { - success("ok") + success(tags) } }.onFailure { e -> result.error( @@ -1023,9 +1025,9 @@ class MethodHandler : FlutterPlugin, Methods.FeatureFlag.method -> { scope.launch { result.runCatching { - val map = Mobile.availableFeatures() + val flags = Mobile.availableFeatures() withContext(Dispatchers.Main) { - success(String(map)) + success(if (flags.isEmpty()) "{}" else flags) } }.onFailure { e -> result.error( @@ -1083,7 +1085,7 @@ class MethodHandler : FlutterPlugin, result.runCatching { val data = Mobile.getAvailableServers() withContext(Dispatchers.Main) { - success(String(data)) + success(if (data.isEmpty()) "[]" else data) } }.onFailure { e -> result.error( @@ -1112,6 +1114,13 @@ class MethodHandler : FlutterPlugin, } } + Methods.GetSelectedServerJSON.method -> { + scope.handleValue(result, "get_selected_server_json") { + val data = Mobile.getSelectedServerJSON() + if (data.isNullOrEmpty()) "{}" else data + } + } + Methods.UpdateTelemetryEvents.method -> { scope.handleResult(result, "UpdateTelemetryEvents") { val consent = call.arguments as Boolean @@ -1126,6 +1135,30 @@ class MethodHandler : FlutterPlugin, } } + Methods.IsSmartRoutingEnabled.method -> { + scope.handleValue(result, "is_smart_routing_enabled") { + Mobile.isSmartRoutingEnabled() + } + } + + Methods.IsTelemetryEnabled.method -> { + scope.handleValue(result, "is_telemetry_enabled") { + Mobile.isTelemetryEnabled() + } + } + + Methods.IsOAuthLogin.method -> { + scope.handleValue(result, "is_oauth_login") { + Mobile.isOAuthLogin() + } + } + + Methods.GetOAuthProvider.method -> { + scope.handleValue(result, "get_oauth_provider") { + Mobile.getOAuthProvider() + } + } + Methods.CheckVpnConflict.method -> { scope.launch { runCatching { diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternVpnService.kt b/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternVpnService.kt index 66898c6986..f2630686b9 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternVpnService.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternVpnService.kt @@ -4,13 +4,13 @@ import android.content.Intent import android.net.VpnService import android.os.Build import android.os.ParcelFileDescriptor +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.cancel -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -60,6 +60,15 @@ class LanternVpnService : // a phone reboot). Without this timeout the coroutine could wait forever. private const val VPN_START_TIMEOUT_MS = 60_000L + // Single-flight gate: when we time out the connect() call, we detach + // the coroutine rather than waiting for the JNI call to honor + // cancellation (it doesn't). The orphan keeps a Dispatchers.IO thread + // pinned until Go eventually returns. To prevent multiple rapid + // retries from accumulating orphans and pressuring the IO pool, reject + // new connect attempts while a previous one is still in flight. The + // flag clears when the orphan's coroutine actually completes. + private val connectInFlight = AtomicBoolean(false) + lateinit var instance: LanternVpnService } @@ -120,10 +129,7 @@ class LanternVpnService : ACTION_CONNECT_TO_SERVER -> { serviceScope.launch { - connectToServer( - intent.getStringExtra("location") ?: "", - intent.getStringExtra("tag") ?: "", - ) + connectToServer(intent.getStringExtra("tag") ?: "") } AppLogger.d(TAG, "Connecting to server") START_STICKY @@ -159,13 +165,17 @@ class LanternVpnService : closeTunInterface() // Clean up synchronously — cannot use serviceScope here because // it is cancelled in the finally block below. + // + // Call Mobile.stopVPN() as long as radiance is up. The previous + // isVPNConnected() guard (status == Connected) was wrong — if the + // tunnel is in any non-Connected state (Restarting, Connecting, + // Disconnecting, Error), c.tunnel is still non-nil on the Go side + // and needs to be closed so the next process lifetime starts clean. + // Mobile.stopVPN() itself is a no-op when c.tunnel is nil. val radianceConnected = Mobile.isRadianceConnected() - val vpnConnected = Mobile.isVPNConnected() - AppLogger.d(TAG, "onDestroy — radianceConnected=$radianceConnected vpnConnected=$vpnConnected") + AppLogger.d(TAG, "onDestroy — radianceConnected=$radianceConnected") if (!radianceConnected) { AppLogger.d(TAG, "Skipping stopVPN — Radiance IPC not running") - } else if (!vpnConnected) { - AppLogger.d(TAG, "Skipping stopVPN — VPN tunnel was never started") } else { runCatching { Mobile.stopVPN() } .onSuccess { AppLogger.d(TAG, "stopVPN completed during destroy") } @@ -214,10 +224,39 @@ class LanternVpnService : override fun restartService() { AppLogger.i(TAG, "restartService called") - serviceScope.launch { + // Radiance's Restart() sets the tunnel status to Restarting, then + // calls us synchronously and treats a successful return as "restart + // complete." If we fire-and-forget via serviceScope.launch and return, + // radiance thinks it succeeded but the tunnel is still in Restarting — + // and if the Android service is torn down (onDestroy, process + // pressure) before the launched coroutine completes, the tunnel + // wedges in Restarting forever. Every subsequent Connect fails with + // "tunnel is currently Restarting" (getlantern/engineering#3297 + // issues 1-3, Freshdesk #173681). + // + // Block until stopVPNTunnel + startVPN finish so the return actually + // reflects the state radiance observes. c.mu is released on the Go + // side before RestartService is invoked, so synchronous callbacks + // into Mobile.* from this thread don't deadlock. + runBlocking(Dispatchers.IO) { stopVPNTunnel() startVPN() + // launchVPN (wrapping startVPN) catches failures via + // runCatching { ... }.onFailure { ... } and returns normally, + // so a nil return from startVPN doesn't mean the restart + // succeeded. Verify the postcondition on the Go side and + // throw if it's not met — the exception propagates through + // runBlocking → restartService → radiance's Restart() as a + // non-nil error, which is what tells the caller the restart + // actually failed and the tunnel needs healing rather than + // wedging forever in Restarting. + if (!Mobile.isVPNConnected()) { + val msg = "restartService failed: VPN not connected after stopVPNTunnel + startVPN" + AppLogger.e(TAG, msg) + throw IllegalStateException(msg) + } } + AppLogger.i(TAG, "restartService completed") } override fun sendNotification(notification: Notification?) { @@ -244,6 +283,7 @@ class LanternVpnService : private suspend fun startRadiance() { try { withContext(Dispatchers.IO) { + Mobile.startIPCServer(this@LanternVpnService, opts()) Mobile.setupRadiance(opts(), flutterEventListener) } AppLogger.d(TAG, "Radiance setup completed") @@ -252,22 +292,21 @@ class LanternVpnService : } } - private suspend fun startVPN() = launchVPN( + suspend fun startVPN() = launchVPN( errorCode = "start_vpn", cleanUpOnFailure = true, ) { - Mobile.startVPN(this@LanternVpnService, opts()) + Mobile.startVPN() AppLogger.d(TAG, "VPN service started") } suspend fun connectToServer( - location: String, tag: String, ) = launchVPN( errorCode = "connect_to_server", cleanUpOnFailure = false, ) { - Mobile.connectToServer(location, tag, this@LanternVpnService, opts()) + Mobile.connectToServer( tag) AppLogger.d(TAG, "Connected to server") } @@ -294,6 +333,7 @@ class LanternVpnService : // to finish before we attempt to start the VPN tunnel. if (!Mobile.isRadianceConnected()) { AppLogger.d(TAG, "Radiance not ready, setting up before VPN start") + Mobile.startIPCServer(this@LanternVpnService, opts()) Mobile.setupRadiance(opts(), flutterEventListener) } DefaultNetworkMonitor.setNetworkChangeCallback { updateUnderlyingNetworks() } @@ -306,21 +346,41 @@ class LanternVpnService : // Bound the Mobile.startVPN / connectToServer call with a wall-clock // timeout. These are blocking JNI calls with no suspension points, // so withTimeout around a direct invocation wouldn't fire — we run - // the call in a child coroutine on Dispatchers.IO and await it, - // which gives withTimeout a real cancellation point. On timeout we - // abandon the awaited Deferred (the underlying JNI call keeps - // running in the background; we accept that leak — once Go - // eventually completes or the process exits, it will settle) so - // the UI gets unstuck with a clear error instead of a frozen - // button that only a phone reboot can clear (Freshdesk #173507). - coroutineScope { - val deferred = async(Dispatchers.IO) { connect() } - try { - withTimeout(VPN_START_TIMEOUT_MS) { deferred.await() } - } catch (e: TimeoutCancellationException) { - deferred.cancel() - throw e - } + // the call in async() and await it so withTimeout has a real + // cancellation point. + // + // Run it in a DETACHED CoroutineScope (not a structured + // coroutineScope { } / the enclosing withContext), because on + // timeout structured concurrency would cancel the deferred and + // then wait for it to complete — and since the JNI call doesn't + // honor cooperative cancellation, that wait is exactly the hang + // we're trying to prevent. A detached SupervisorJob scope lets + // us stop awaiting without joining; the orphan coroutine keeps + // running until Go returns (or the process exits), but the + // caller is unblocked and the UI surfaces a clear error instead + // of a frozen button only a phone reboot can clear + // (Freshdesk #173507). + // + // Reject concurrent attempts with connectInFlight so repeated + // retries while a previous call is stuck in JNI don't accumulate + // orphan coroutines on Dispatchers.IO. Clear the flag from the + // Deferred completion path so early cancellation before the async + // body starts can't wedge future attempts. + if (!connectInFlight.compareAndSet(false, true)) { + throw IllegalStateException("previous VPN connect attempt still in flight") + } + val connectScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val deferred = connectScope.async { connect() } + deferred.invokeOnCompletion { + connectInFlight.set(false) + } + try { + withTimeout(VPN_START_TIMEOUT_MS) { deferred.await() } + } catch (e: TimeoutCancellationException) { + deferred.cancel() + throw e + } finally { + connectScope.cancel() } VpnStatusManager.postVPNStatus(VPNStatus.Connected) notificationHelper.showVPNConnectedNotification(this@LanternVpnService) @@ -363,9 +423,16 @@ class LanternVpnService : private suspend fun stopVPNTunnel() { try { closeTunInterface() + // Unconditionally call Mobile.stopVPN() as long as radiance is up. + // The old isVPNConnected() guard looked at status == Connected, + // which is wrong during a restart: radiance sets the tunnel to + // Restarting before calling back into the platform, so the check + // would skip stopVPN and leave the tunnel wedged in Restarting + // (getlantern/engineering#3297). Mobile.stopVPN() itself is a + // no-op when c.tunnel is nil, so the guard is unnecessary. runCatching { - if (!Mobile.isVPNConnected()) { - AppLogger.d(TAG, "VPN is not connected, skipping stopVPN") + if (!Mobile.isRadianceConnected()) { + AppLogger.d(TAG, "Radiance IPC not running, skipping stopVPN") return@runCatching } Mobile.stopVPN() @@ -510,6 +577,7 @@ class LanternVpnService : locale = DeviceUtil.getLanguageCode(this@LanternVpnService) telemetryConsent = isTelemetryEnabled() env = getRadianceEnv() + platform = this@LanternVpnService } return opts } diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/utils/LogTailer.kt b/android/app/src/main/kotlin/org/getlantern/lantern/utils/LogTailer.kt deleted file mode 100644 index 891c1a6da0..0000000000 --- a/android/app/src/main/kotlin/org/getlantern/lantern/utils/LogTailer.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.getlantern.lantern.utils - -import java.io.File -import java.io.RandomAccessFile -import java.nio.charset.Charset -import java.util.ArrayDeque -import kotlin.text.Charsets -import kotlin.text.String -import kotlin.text.dropLast -import kotlin.text.endsWith -import kotlin.text.isNotEmpty -import kotlin.text.substring -import kotlin.text.take -import kotlin.text.toLong -import kotlin.text.trimEnd - -/**LogTailer reads the last 80 lines from a log file efficiently - * This does not load the entire file into memory, making it suitable for large log files. - * */ -class LogTailer(private val bufferSize: Int = 8192) { - fun tail(file: File, maxLines: Int = 80, charset: Charset = Charsets.UTF_8): List { - if (!file.exists() || file.length() == 0L) return emptyList() - val lines = ArrayDeque(maxLines) - try { - RandomAccessFile(file, "r").use { raf -> - var filePointer = raf.length() - var carry = "" - while (filePointer > 0 && lines.size < maxLines) { - try { - val bytesToRead = minOf(bufferSize.toLong(), filePointer).toInt() - filePointer -= bytesToRead - raf.seek(filePointer) - - val buffer = ByteArray(bytesToRead) - raf.readFully(buffer) - val chunk = String(buffer, charset) - val combined = chunk + carry - - var end = combined.length - for (i in combined.length - 1 downTo 0) { - if (combined[i] == '\n') { - if (lines.size == maxLines) break - val raw = combined.substring(i + 1, end) - val line = if (raw.endsWith('\r')) raw.dropLast(1) else raw - lines.addFirst(line) - end = i - } - } - carry = combined.take(end) - - } catch (e: Exception) { - // If anything fails inside the loop, stop reading gracefully - AppLogger.e("LogTailer", "Error reading log file chunk: ${e.message}") - break - } - } - - if (carry.isNotEmpty() && lines.size < maxLines) { - lines.addFirst(carry.trimEnd('\r')) - - } - } - } catch (e: Exception) { - AppLogger.e("LogTailer", "Error reading log file: ${e.message}") - } - - return lines.toList() - } -} diff --git a/go.mod b/go.mod index 5f63eed74b..ba4016cf86 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/getlantern/lantern -go 1.25.4 +go 1.26.2 // replace github.com/getlantern/radiance => ../radiance @@ -20,12 +20,12 @@ replace github.com/tetratelabs/wazero => github.com/getlantern/wazero v1.11.0-wa replace github.com/refraction-networking/water => github.com/getlantern/water v0.7.1-alpha.0.20260309190745-bd547c14b4aa +replace github.com/quic-go/qpack => github.com/quic-go/qpack v0.5.1 + require ( - github.com/Microsoft/go-winio v0.6.2 github.com/alecthomas/assert/v2 v2.3.0 - github.com/getlantern/common v1.2.1-0.20260326210434-cb69537aaf46 github.com/getlantern/lantern-server-provisioner v0.0.0-20251031121934-8ea031fccfa9 - github.com/getlantern/radiance v0.0.0-20260417101633-2d396075314e + github.com/getlantern/radiance v0.0.0-20260428130648-b884143b972c github.com/sagernet/sing-box v1.12.22 golang.org/x/mobile v0.0.0-20250711185624-d5bb5ecc55c0 golang.org/x/sys v0.41.0 @@ -39,7 +39,7 @@ require ( github.com/caddyserver/certmagic v0.23.0 // indirect github.com/caddyserver/zerossl v0.1.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/fsnotify/fsnotify v1.9.0 + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 // indirect github.com/go-chi/chi/v5 v5.2.2 // indirect @@ -66,7 +66,7 @@ require ( github.com/mholt/acmez/v3 v3.1.2 // indirect github.com/miekg/dns v1.1.67 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect - github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect github.com/sagernet/cors v1.2.1 // indirect github.com/sagernet/fswatch v0.1.1 // indirect @@ -101,7 +101,7 @@ require ( golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.41.0 // indirect - golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/grpc v1.79.2 // indirect lukechampine.com/blake3 v1.4.1 // indirect @@ -113,6 +113,7 @@ require ( github.com/1Password/srp v0.2.0 // indirect github.com/Jigsaw-Code/outline-sdk v0.0.19 // indirect github.com/Jigsaw-Code/outline-sdk/x v0.0.2 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/RoaringBitmap/roaring v1.2.3 // indirect github.com/STARRY-S/zip v0.2.3 // indirect github.com/Xuanwo/go-locale v1.1.3 // indirect @@ -160,6 +161,7 @@ require ( github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/edsrzf/mmap-go v1.1.0 // indirect + github.com/enobufs/go-nats v0.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/flynn/noise v1.0.1-0.20220214164934-d803f5c4b0f4 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect @@ -167,12 +169,13 @@ require ( github.com/gaukas/wazerofs v0.1.0 // indirect github.com/getlantern/algeneva v0.0.0-20250307163401-1824e7b54f52 // indirect github.com/getlantern/amp v0.0.0-20260305201851-782bc8045e58 // indirect - github.com/getlantern/appdir v0.0.0-20250324200952-507a0625eb01 // indirect + github.com/getlantern/broflake v0.0.0-20260421172440-caea0799b63a // indirect + github.com/getlantern/common v1.2.1-0.20260326210434-cb69537aaf46 // indirect github.com/getlantern/dnstt v0.0.0-20260112160750-05100563bd0d // indirect github.com/getlantern/fronted v0.0.0-20260325003030-cb5041ba1538 // indirect - github.com/getlantern/keepcurrent v0.0.0-20260304213122-017d542145ae // indirect + github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 // indirect github.com/getlantern/kindling v0.0.0-20260329144042-b1825b9cb1bb // indirect - github.com/getlantern/lantern-box v0.0.67 // indirect + github.com/getlantern/lantern-box v0.0.74 // indirect github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90 // indirect github.com/getlantern/osversion v0.0.0-20240418205916-2e84a4a4e175 // indirect github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 // indirect @@ -180,10 +183,10 @@ require ( github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974 // indirect github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb // indirect github.com/getlantern/timezone v0.0.0-20210901200113-3f9de9d360c9 // indirect + github.com/getsentry/sentry-go v0.31.1 // indirect github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 // indirect github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 // indirect github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421 // indirect - github.com/go-resty/resty/v2 v2.16.5 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobwas/ws v1.4.0 // indirect github.com/goccy/go-yaml v1.19.0 // indirect @@ -228,25 +231,31 @@ require ( github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-varint v0.0.6 // indirect github.com/nwaples/rardecode/v2 v2.2.0 // indirect - github.com/pion/datachannel v1.5.10 // indirect - github.com/pion/dtls/v3 v3.0.4 // indirect - github.com/pion/ice/v4 v4.0.7 // indirect - github.com/pion/interceptor v0.1.40 // indirect - github.com/pion/logging v0.2.3 // indirect - github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/datachannel v1.6.0 // indirect + github.com/pion/dtls/v2 v2.2.12 // indirect + github.com/pion/dtls/v3 v3.1.2 // indirect + github.com/pion/ice/v4 v4.2.2 // indirect + github.com/pion/interceptor v0.1.44 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/rtcp v1.2.15 // indirect - github.com/pion/rtp v1.8.18 // indirect - github.com/pion/sctp v1.8.37 // indirect - github.com/pion/sdp/v3 v3.0.11 // indirect - github.com/pion/srtp/v3 v3.0.4 // indirect - github.com/pion/stun/v3 v3.0.0 // indirect - github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pion/turn/v4 v4.0.0 // indirect - github.com/pion/webrtc/v4 v4.0.13 // indirect + github.com/pion/rtcp v1.2.16 // indirect + github.com/pion/rtp v1.10.1 // indirect + github.com/pion/sctp v1.9.4 // indirect + github.com/pion/sdp/v3 v3.0.18 // indirect + github.com/pion/srtp/v3 v3.0.10 // indirect + github.com/pion/stun v0.6.1 // indirect + github.com/pion/stun/v3 v3.1.1 // indirect + github.com/pion/transport v0.14.1 // indirect + github.com/pion/transport/v2 v2.2.10 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pion/turn v1.3.7 // indirect + github.com/pion/turn/v4 v4.1.4 // indirect + github.com/pion/webrtc/v4 v4.2.11 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect github.com/protolambda/ctxlock v0.1.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect github.com/refraction-networking/utls v1.8.2 // indirect github.com/refraction-networking/water v0.7.1-alpha // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect @@ -271,6 +280,7 @@ require ( github.com/templexxx/xorsimd v0.4.3 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect github.com/tevino/abool/v2 v2.1.0 // indirect + github.com/theodorsm/covert-dtls v1.5.0 // indirect github.com/tidwall/btree v1.8.1 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect github.com/tkuchiki/go-timezone v0.2.0 // indirect @@ -289,7 +299,7 @@ require ( go.opentelemetry.io/otel/sdk v1.41.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect - go.uber.org/mock v0.5.0 // indirect + go.uber.org/mock v0.5.2 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect golang.getoutline.org/sdk v0.0.21 // indirect diff --git a/go.sum b/go.sum index be179fb787..392fcdfbd1 100644 --- a/go.sum +++ b/go.sum @@ -198,6 +198,8 @@ github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1 github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ= github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q= +github.com/enobufs/go-nats v0.0.1 h1:uzC0mxan4hyGzUFG7cShFmk6c+XYgfoT8yTBgF5CJYw= +github.com/enobufs/go-nats v0.0.1/go.mod h1:ZF0vpSk02ALIMFsHkIO4MHXUN1v3nLZssTaG+fgX/io= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -225,8 +227,8 @@ github.com/getlantern/algeneva v0.0.0-20250307163401-1824e7b54f52 h1:w2/RqYPw7Pb github.com/getlantern/algeneva v0.0.0-20250307163401-1824e7b54f52/go.mod h1:PrNR8tMXO26YNs8K9653XCUH7u2Kv4OdfFC3Ke1GsX0= github.com/getlantern/amp v0.0.0-20260305201851-782bc8045e58 h1:3wxMKw90adxiEzsJmAmMHqBJQr/P/9Goqy/U2a1l/sg= github.com/getlantern/amp v0.0.0-20260305201851-782bc8045e58/go.mod h1:p6WdG48YAz5SCUpiMSGLy616A6YghKToc63y3NP7avI= -github.com/getlantern/appdir v0.0.0-20250324200952-507a0625eb01 h1:Mmeh4/DA1OKN9tVWRAvTL5efFx4c7v9/55hoK17NclA= -github.com/getlantern/appdir v0.0.0-20250324200952-507a0625eb01/go.mod h1:3vR6+jQdWfWojZ77w+htCqEF5MO/Y2twJOpAvFuM9po= +github.com/getlantern/broflake v0.0.0-20260421172440-caea0799b63a h1:WQ11Ms5jGvBaH6v/u1QBvmnnzRY0ckMiifCnDM/x6TI= +github.com/getlantern/broflake v0.0.0-20260421172440-caea0799b63a/go.mod h1:bZGGfTwne9NIsy3Kc1avcXNWn/yA8ghUwlXdS2z+AlA= github.com/getlantern/common v1.2.1-0.20260326210434-cb69537aaf46 h1:Ab2esudqgFz2K1WYQKtX+58kaiVMX0UohjW2XmdEgf4= github.com/getlantern/common v1.2.1-0.20260326210434-cb69537aaf46/go.mod h1:eSSuV4bMPgQJnczBw+KWWqWNo1itzmVxC++qUBPRTt0= github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA= @@ -243,12 +245,12 @@ github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc h1:sue+aeVx7JF5v36H github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc/go.mod h1:D9RWpXy/EFPYxiKUURo2TB8UBosbqkiLhttRrZYtvqM= github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 h1:cSrD9ryDfTV2yaur9Qk3rHYD414j3Q1rl7+L0AylxrE= github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770/go.mod h1:GOQsoDnEHl6ZmNIL+5uVo+JWRFWozMEp18Izcb++H+A= -github.com/getlantern/keepcurrent v0.0.0-20260304213122-017d542145ae h1:NMq3K7h3N/usgEtUMQs8WBzvhKKOfBvHo+18pXgtpds= -github.com/getlantern/keepcurrent v0.0.0-20260304213122-017d542145ae/go.mod h1:ag5g9aWUw2FJcX5RVRpJ9EBQBy5yJuy2WXDouIn/m4w= +github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 h1:iLWm6S/47Hfk7FjW6yaD+1h6kO7C/iauV0DkVia/bXU= +github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694/go.mod h1:ag5g9aWUw2FJcX5RVRpJ9EBQBy5yJuy2WXDouIn/m4w= github.com/getlantern/kindling v0.0.0-20260329144042-b1825b9cb1bb h1:A92dC/E/HvkEb1r4tAwCFNlcMsGdqKe5GMmxeUFid9M= github.com/getlantern/kindling v0.0.0-20260329144042-b1825b9cb1bb/go.mod h1:c5cFjpNrqX8wQ0PUE2blHrO7knAlRCVx3j1/G6zaVlY= -github.com/getlantern/lantern-box v0.0.67 h1:0uDILTY2fVzy47IoEecsMoeplqdxFU/KE/izaZXwM/Q= -github.com/getlantern/lantern-box v0.0.67/go.mod h1:n5NzI/rqr1USYIQPnEy3oZBYNPDyi8EODXNg8jPsQqY= +github.com/getlantern/lantern-box v0.0.74 h1:3LgqcjHX/lLJO4BCEg21vzFaDwiAcUyhdn5o6M6VAaQ= +github.com/getlantern/lantern-box v0.0.74/go.mod h1:lRpNV/lDbsQ2NfA747Oa3mdZXzc0rDsgtlN0lDHh9pM= github.com/getlantern/lantern-server-provisioner v0.0.0-20251031121934-8ea031fccfa9 h1:6seyD2f9tz2am0YQd/Qn+q7LFiiQgnmxgwWFnVceGZw= github.com/getlantern/lantern-server-provisioner v0.0.0-20251031121934-8ea031fccfa9/go.mod h1:s0VKrlJf/z+M0U8IKHFL2hfuflocRw3SINmMacrTlMA= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90 h1:P9JX1yAu2uq3b5YiT0sLtHkTrkZuttV8gPZh81nUuag= @@ -261,8 +263,8 @@ github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 h1:rtDmW8YL github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535/go.mod h1:WKJEdjMOD4IuTRYwjQHjT4bmqDl5J82RShMLxPAvi0Q= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b h1:gMYJzEhLrmIqQ+JnjiYNm+UyUDalK3WUmVyecFwmV5g= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b/go.mod h1:NpfXdK4ldEKkjQ4P1R+DBF4ua5VFOlxmgHROTnYrApg= -github.com/getlantern/radiance v0.0.0-20260417101633-2d396075314e h1:zxW4xfPSeHtfByibiF1Us2EfGOlpzMIpIPanQ6zD4i0= -github.com/getlantern/radiance v0.0.0-20260417101633-2d396075314e/go.mod h1:pQoVlqah4QNAb5tPVu+vZ1LA1nB1JclXPjMWwFv+Ec0= +github.com/getlantern/radiance v0.0.0-20260428130648-b884143b972c h1:2vEjJ+yj6BwWJpqmva9PDhD3f62IM5G4RPgJffqzY74= +github.com/getlantern/radiance v0.0.0-20260428130648-b884143b972c/go.mod h1:VbFkioh4iA56VZY2095NDAnBRXkQ0Fgn5/tMTPGFUjc= github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974 h1:k+/qNo5YNO+8M8LVUp6G5Evm1OQdEs3Z4ye8top4AhI= github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0= github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb h1:c5YM7b3a4r2J8Eh89KkI6M/iTFe6Bi+b8AJlfkKdFq4= @@ -281,6 +283,8 @@ github.com/getlantern/wazero v1.11.0-water.1 h1:mzUlaOoQKMDd16yL3mBIFrzg2nEK+gw7 github.com/getlantern/wazero v1.11.0-water.1/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/getlantern/wireguard-go v0.0.1-beta.7.0.20251208214020-d78e69f1eff4 h1:j/A6xSUbz78xQfFXyDbnWkg96D+UprbLgKlGjXbodxA= github.com/getlantern/wireguard-go v0.0.1-beta.7.0.20251208214020-d78e69f1eff4/go.mod h1:akc2Wh+rX9bFFNnHJGsQ8VIV3eJI1LXJYgx2Y+8lcW8= +github.com/getsentry/sentry-go v0.31.1 h1:ELVc0h7gwyhnXHDouXkhqTFSO5oslsRDk0++eyE0KJ4= +github.com/getsentry/sentry-go v0.31.1/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= @@ -293,6 +297,8 @@ github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 h1:KbX3Z3CgiYlbaavUq3Cj9/MjpO+88S7/AGXzynVDv84= @@ -314,8 +320,6 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= -github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= -github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= @@ -550,38 +554,62 @@ github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= -github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= -github.com/pion/dtls/v3 v3.0.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U= -github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg= -github.com/pion/ice/v4 v4.0.7 h1:mnwuT3n3RE/9va41/9QJqN5+Bhc0H/x/ZyiVlWMw35M= -github.com/pion/ice/v4 v4.0.7/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= -github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= -github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= -github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= -github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= -github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= -github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= +github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= +github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= +github.com/pion/ice/v4 v4.2.2 h1:dQJzzcgTFHDYyV3BoCfjPeX+JEtr58BWPi4PGyo6Vjg= +github.com/pion/ice/v4 v4.2.2/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c= +github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I= +github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY= +github.com/pion/logging v0.2.1/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= +github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= -github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= -github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU= -github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= -github.com/pion/sctp v1.8.37 h1:ZDmGPtRPX9mKCiVXtMbTWybFw3z/hVKAZgU81wcOrqs= -github.com/pion/sctp v1.8.37/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= -github.com/pion/sdp/v3 v3.0.11 h1:VhgVSopdsBKwhCFoyyPmT1fKMeV9nLMrEKxNOdy3IVI= -github.com/pion/sdp/v3 v3.0.11/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= -github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= -github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= -github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= -github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= -github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= -github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= -github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= -github.com/pion/webrtc/v4 v4.0.13 h1:XuUaWTjRufsiGJRC+G71OgiSMe7tl7mQ0kkd4bAqIaQ= -github.com/pion/webrtc/v4 v4.0.13/go.mod h1:Fadzxm0CbY99YdCEfxrgiVr0L4jN1l8bf8DBkPPpJbs= +github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= +github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= +github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA= +github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/sctp v1.9.4 h1:cMxEu0F5tbP4qH07bKf1Zjf4rUih9LIo0qQt424e258= +github.com/pion/sctp v1.9.4/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw= +github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= +github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= +github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ= +github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M= +github.com/pion/stun v0.3.1/go.mod h1:xrCld6XM+6GWDZdvjPlLMsTU21rNxnO6UO8XsAvHr/M= +github.com/pion/stun v0.3.2/go.mod h1:xrCld6XM+6GWDZdvjPlLMsTU21rNxnO6UO8XsAvHr/M= +github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= +github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8= +github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= +github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= +github.com/pion/transport v0.8.6/go.mod h1:nAmRRnn+ArVtsoNuwktvAD+jrjSD7pA+H3iRmZwdUno= +github.com/pion/transport v0.8.8/go.mod h1:lpeSM6KJFejVtZf8k0fgeN7zE73APQpTF83WvA1FVP8= +github.com/pion/transport v0.14.1 h1:XSM6olwW+o8J4SCmOBb/BpwZypkHeyM0PGFCxNQBr40= +github.com/pion/transport v0.14.1/go.mod h1:4tGmbk00NeYA3rUa9+n+dzCCoKkcy3YlYb99Jn2fNnI= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= +github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= +github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= +github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= +github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pion/turn v1.3.5/go.mod h1:zGPB7YYB/HTE9MWn0Sbznz8NtyfeVeanZ834cG/MXu0= +github.com/pion/turn v1.3.7 h1:/nyM2XrlZILD7KKfnh0oYEBTRG5JlbH21ibjluRoCeo= +github.com/pion/turn v1.3.7/go.mod h1:js0LBFqMcKAlaWAXoYqNjefGI7kfJCrkCBfHGuTToXE= +github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= +github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= +github.com/pion/webrtc/v4 v4.2.11 h1:QUX1QZKlNIn4O7U5JxLPGP0sV5RTncZkzu9SPR3jVNU= +github.com/pion/webrtc/v4 v4.2.11/go.mod h1:s/rAiyy77GyRFrZMx+Ls6aua26dIBPudH8/ZHYbIRWY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -613,6 +641,8 @@ github.com/protolambda/ctxlock v0.1.0 h1:rCUY3+vRdcdZXqT07iXgyr744J2DU2LCBIXowYA github.com/protolambda/ctxlock v0.1.0/go.mod h1:vefhX6rIZH8rsg5ZpOJfEDYQOppZi19SfPiGOFrNnwM= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= @@ -698,6 +728,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= @@ -724,6 +756,8 @@ github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbH github.com/templexxx/xorsimd v0.4.3/go.mod h1:oZQcD6RFDisW2Am58dSAGwwL6rHjbzrlu25VDqfWkQg= github.com/tevino/abool/v2 v2.1.0 h1:7w+Vf9f/5gmKT4m4qkayb33/92M+Um45F2BkHOR+L/c= github.com/tevino/abool/v2 v2.1.0/go.mod h1:+Lmlqk6bHDWHqN1cbxqhwEAwMPXgc8I1SDEamtseuXY= +github.com/theodorsm/covert-dtls v1.5.0 h1:kGUnCuGB65kLrga0e1mYv8t2RA4vfRMN0iYlakY0z/c= +github.com/theodorsm/covert-dtls v1.5.0/go.mod h1:MTb9IO4aqSxrcrh569UGO4PlC1Yel37M440z+gcm13E= github.com/things-go/go-socks5 v0.0.5 h1:qvKaGcBkfDrUL33SchHN93srAmYGzb4CxSM2DPYufe8= github.com/things-go/go-socks5 v0.0.5/go.mod h1:mtzInf8v5xmsBpHZVbIw2YQYhc4K0jRwzfsH64Uh0IQ= github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA= @@ -745,6 +779,7 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= +github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -799,8 +834,8 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= @@ -828,7 +863,10 @@ golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -891,10 +929,14 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -948,18 +990,26 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -970,9 +1020,12 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/integration_test/auth/auth_smoke_harness.dart b/integration_test/auth/auth_smoke_harness.dart index 52bb44e6e0..a32fbb02e3 100644 --- a/integration_test/auth/auth_smoke_harness.dart +++ b/integration_test/auth/auth_smoke_harness.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fpdart/fpdart.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/app_eum.dart'; +import 'package:lantern/core/models/user.dart'; import 'package:lantern/core/models/app_setting.dart'; import 'package:lantern/core/router/router.dart'; import 'package:lantern/core/router/router.gr.dart'; @@ -14,7 +15,6 @@ import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/lantern/lantern_service.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; -import 'package:lantern/lantern/protos/protos/auth.pb.dart'; import '../utils/widget_wait_utils.dart'; @@ -60,20 +60,20 @@ class _InMemoryAppSettingNotifier extends AppSettingNotifier { class _InMemoryHomeNotifier extends HomeNotifier { _InMemoryHomeNotifier(this._initialUser); - final UserResponse _initialUser; + final UserResponseModel _initialUser; @override - Future build() async => _initialUser; + Future build() async => _initialUser; @override - void updateUserData(UserResponse userData) { + void updateUserData(UserResponseModel userData) { state = AsyncValue.data(userData); } @override void clearLogoutData() { - ref.read(appSettingProvider.notifier).clearAuthSessionData(); - state = AsyncValue.data(UserResponse()); + ref.read(appSettingProvider.notifier).setUserLoggedIn(false); + state = AsyncValue.data(_emptyUser()); } } @@ -81,7 +81,7 @@ class _AuthSmokeFakeLanternService implements LanternService { _AuthSmokeFakeLanternService({required this.loginUsers}); final Map loginUsers; - final Map _users = {}; + final Map _users = {}; final Map _recoveryCodes = {}; bool forceLoginFailure = false; int deleteAccountCalls = 0; @@ -89,14 +89,14 @@ class _AuthSmokeFakeLanternService implements LanternService { void seedUser({ required String email, required String password, - required UserResponse user, + required UserResponseModel user, }) { loginUsers[email] = password; _users[email] = user; } @override - Future> login({ + Future> login({ required String email, required String password, }) async { @@ -181,12 +181,12 @@ class _AuthSmokeFakeLanternService implements LanternService { } @override - Future> logout(String email) async { - return right(UserResponse()..success = true); + Future> logout(String email) async { + return right(_emptyUser(success: true)); } @override - Future> deleteAccount({ + Future> deleteAccount({ required String email, required String password, bool isSSO = false, @@ -203,28 +203,35 @@ class _AuthSmokeFakeLanternService implements LanternService { loginUsers.remove(email); _users.remove(email); _recoveryCodes.remove(email); - return right(UserResponse()..success = true); + return right(_emptyUser(success: true)); } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -UserResponse _buildFreeUser({required String email}) { - final userData = UserResponse_UserData() - ..email = email - ..userLevel = 'free'; - - return UserResponse() - ..success = true - ..id = email - ..legacyUserData = userData; +UserResponseModel _emptyUser({bool success = false}) => UserResponseModel( + legacyID: 0, + legacyToken: '', + emailConfirmed: false, + success: success, +); + +UserResponseModel _buildFreeUser({required String email}) { + return UserResponseModel( + id: email, + legacyID: 0, + legacyToken: '', + emailConfirmed: false, + success: true, + legacyUserData: UserDataModel(email: email, userLevel: 'free'), + ); } Future<_AuthSmokeScenarioContext> _startScenario( WidgetTester tester, { required AppSetting initialAppSetting, - required UserResponse initialUser, + required UserResponseModel initialUser, required _AuthSmokeFakeLanternService fakeService, }) async { await sl.reset(); @@ -309,7 +316,7 @@ Future _runSignInSuccessScenario(WidgetTester tester) async { final context = await _startScenario( tester, initialAppSetting: const AppSetting(), - initialUser: UserResponse(), + initialUser: _emptyUser(), fakeService: fakeService, ); try { @@ -337,10 +344,9 @@ Future _runSignInSuccessScenario(WidgetTester tester) async { ); final settings = context.container.read(appSettingProvider); + final user = context.container.read(homeProvider).value; expect(settings.userLoggedIn, isTrue); - expect(settings.email, _existingUserEmail); - expect(settings.oAuthLoginProvider, SignUpMethodType.email.name); - expect(settings.oAuthToken, isEmpty); + expect(user?.legacyUserData.email, _existingUserEmail); } finally { context.dispose(); } @@ -358,7 +364,7 @@ Future _runSignInFailureScenario(WidgetTester tester) async { final context = await _startScenario( tester, initialAppSetting: const AppSetting(), - initialUser: UserResponse(), + initialUser: _emptyUser(), fakeService: fakeService, ); try { @@ -386,10 +392,9 @@ Future _runSignInFailureScenario(WidgetTester tester) async { ); final settings = context.container.read(appSettingProvider); + final user = context.container.read(homeProvider).value; expect(settings.userLoggedIn, isFalse); - expect(settings.email, isEmpty); - expect(settings.oAuthToken, isEmpty); - expect(settings.oAuthLoginProvider, isEmpty); + expect(user?.legacyUserData.email ?? '', isEmpty); } finally { context.dispose(); } @@ -401,7 +406,7 @@ Future _runSignUpSuccessScenario(WidgetTester tester) async { final context = await _startScenario( tester, initialAppSetting: const AppSetting(), - initialUser: UserResponse(), + initialUser: _emptyUser(), fakeService: fakeService, ); try { @@ -456,10 +461,9 @@ Future _runSignUpSuccessScenario(WidgetTester tester) async { ); final settings = context.container.read(appSettingProvider); + final user = context.container.read(homeProvider).value; expect(settings.userLoggedIn, isTrue); - expect(settings.email, _newUserEmail); - expect(settings.oAuthLoginProvider, SignUpMethodType.email.name); - expect(settings.oAuthToken, isEmpty); + expect(user?.legacyUserData.email, _newUserEmail); } finally { context.dispose(); } @@ -475,12 +479,7 @@ Future _runLogoutClearsSessionScenario(WidgetTester tester) async { final context = await _startScenario( tester, - initialAppSetting: const AppSetting( - userLoggedIn: true, - email: _existingUserEmail, - oAuthToken: 'session-token', - oAuthLoginProvider: 'email', - ), + initialAppSetting: const AppSetting(userLoggedIn: true), initialUser: _buildFreeUser(email: _existingUserEmail), fakeService: fakeService, ); @@ -498,10 +497,9 @@ Future _runLogoutClearsSessionScenario(WidgetTester tester) async { ); final settings = context.container.read(appSettingProvider); + final user = context.container.read(homeProvider).value; expect(settings.userLoggedIn, isFalse); - expect(settings.email, isEmpty); - expect(settings.oAuthToken, isEmpty); - expect(settings.oAuthLoginProvider, isEmpty); + expect(user?.legacyUserData.email ?? '', isEmpty); } finally { context.dispose(); } @@ -517,12 +515,7 @@ Future _runDeleteAccountClearsSessionScenario(WidgetTester tester) async { final context = await _startScenario( tester, - initialAppSetting: const AppSetting( - userLoggedIn: true, - email: _deleteUserEmail, - oAuthToken: '', - oAuthLoginProvider: 'email', - ), + initialAppSetting: const AppSetting(userLoggedIn: true), initialUser: _buildFreeUser(email: _deleteUserEmail), fakeService: fakeService, ); @@ -546,10 +539,9 @@ Future _runDeleteAccountClearsSessionScenario(WidgetTester tester) async { ); final settings = context.container.read(appSettingProvider); + final user = context.container.read(homeProvider).value; expect(settings.userLoggedIn, isFalse); - expect(settings.email, isEmpty); - expect(settings.oAuthToken, isEmpty); - expect(settings.oAuthLoginProvider, isEmpty); + expect(user?.legacyUserData.email ?? '', isEmpty); expect(context.fakeService.deleteAccountCalls, 1); } finally { diff --git a/integration_test/vpn/config_url_api_smoke_harness.dart b/integration_test/vpn/config_url_api_smoke_harness.dart index 23d6bad80d..55d7f8ee96 100644 --- a/integration_test/vpn/config_url_api_smoke_harness.dart +++ b/integration_test/vpn/config_url_api_smoke_harness.dart @@ -51,12 +51,10 @@ Future runConfigUrlApiConnectSmokeHarness( '${urls.length}.', ); } - final url = urls.single; - if (configServerName.trim().isEmpty) { - fail( - 'JOIN_SERVER_CONFIG_SERVER_NAME must not be empty for config URL API smoke test', - ); - } + var url = urls.single; + final hashIndex = url.indexOf('#'); + url = hashIndex >= 0 ? url.substring(0, hashIndex) : url; + url = '$url#$configServerName'; final finders = VpnSmokeFinders(); final vpnStateFinders = VpnStateFinders(); @@ -84,8 +82,8 @@ Future runConfigUrlApiConnectSmokeHarness( final addServerResult = await lantern.addServerBasedOnURLs( urls: url, skipCertVerification: skipCertVerification, - serverName: configServerName, ); + late final List addedTags; addServerResult.fold( (failure) => _failWithFailure( 'Failed to add server from config URL(s)', @@ -93,16 +91,21 @@ Future runConfigUrlApiConnectSmokeHarness( tester, vpnStateFinders, ), - (_) {}, + (tags) => addedTags = tags, ); + if (addedTags.isEmpty) { + fail('addServerBasedOnURLs succeeded but returned no server tags'); + } + final serverTag = addedTags.first; + final connectResult = await lantern.connectToServer( ServerLocationType.privateServer.name, - configServerName, + serverTag, ); connectResult.fold( (failure) => _failWithFailure( - 'Failed to connect to config URL server "$configServerName"', + 'Failed to connect to config URL server "$serverTag"', failure, tester, vpnStateFinders, diff --git a/integration_test/vpn/config_url_connect_smoke_harness.dart b/integration_test/vpn/config_url_connect_smoke_harness.dart index 1be64a766b..9e756bd563 100644 --- a/integration_test/vpn/config_url_connect_smoke_harness.dart +++ b/integration_test/vpn/config_url_connect_smoke_harness.dart @@ -230,7 +230,10 @@ Future runConfigUrlConnectSmokeHarness( 'Config URL UI smoke requires exactly one URL, but got ${urls.length}.', ); } - final url = urls.single; + var url = urls.single; + final hashIndex = url.indexOf('#'); + url = hashIndex >= 0 ? url.substring(0, hashIndex) : url; + url = '$url#$configServerName'; if (configServerName.trim().isEmpty) { fail( @@ -357,6 +360,7 @@ Future runConfigUrlConnectSmokeHarness( ); } + // Selecting the server should trigger a connect and navigate home. await WidgetWaitUtils.waitForFinder( tester, finders.homeScreen, diff --git a/integration_test/vpn/connect_smoke_harness.dart b/integration_test/vpn/connect_smoke_harness.dart index a79c85cc50..222db06e70 100644 --- a/integration_test/vpn/connect_smoke_harness.dart +++ b/integration_test/vpn/connect_smoke_harness.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/app_eum.dart'; -import 'package:lantern/features/home/provider/app_setting_notifier.dart'; +import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; import 'vpn_smoke_helpers.dart'; @@ -40,15 +40,9 @@ Future _setRoutingModeToFullTunnelForSmoke( homeElements.first, listen: false, ); - final currentMode = container.read(appSettingProvider).routingMode; - if (currentMode == RoutingMode.full) { - debugPrint('SMOKE_FORCE_FULL_TUNNEL enabled; routing mode already full'); - return; - } - debugPrint('SMOKE_FORCE_FULL_TUNNEL enabled; switching to full tunnel mode'); final result = await container - .read(appSettingProvider.notifier) + .read(radianceSettingsProvider.notifier) .setRoutingMode(RoutingMode.full); result.fold( @@ -61,7 +55,7 @@ Future _setRoutingModeToFullTunnelForSmoke( final deadline = DateTime.now().add(const Duration(seconds: 20)); while (DateTime.now().isBefore(deadline)) { await tester.pump(const Duration(milliseconds: 200)); - final updatedMode = container.read(appSettingProvider).routingMode; + final updatedMode = container.read(radianceSettingsProvider).routingMode; if (updatedMode == RoutingMode.full) { return; } diff --git a/ios/Runner/Handlers/LogsEventHandler.swift b/ios/Runner/Handlers/LogsEventHandler.swift index 6f28e4dcca..3d3fcbf074 100644 --- a/ios/Runner/Handlers/LogsEventHandler.swift +++ b/ios/Runner/Handlers/LogsEventHandler.swift @@ -1,15 +1,17 @@ import Flutter import Foundation +import Liblantern class LogsEventHandler: NSObject, FlutterPlugin, FlutterStreamHandler { static let name = "org.getlantern.lantern/logs" private var channel: FlutterEventChannel? private var eventSink: FlutterEventSink? - private var tailer: LogTailer? + private var subscription: MobileLogSubscription? + private var listener: LogEntryListener? deinit { - tailer?.stop() + subscription?.cancel() } public static func register(with registrar: FlutterPluginRegistrar) { @@ -23,132 +25,48 @@ class LogsEventHandler: NSObject, FlutterPlugin, FlutterStreamHandler { public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + subscription?.cancel() + subscription = nil eventSink = events - try? FileManager.default.createDirectory( - at: FilePath.logsDirectory, withIntermediateDirectories: true) - - let logFile = FilePath.logsDirectory.appendingPathComponent("lantern.log") - if let last = try? LogTailer.readLastLines(path: logFile.path, maxLines: 200), !last.isEmpty { - events(last) + let listener = LogEntryListener { [weak self] entry in + let trimmed = entry.trimmingCharacters(in: .newlines) + guard !trimmed.isEmpty else { return } + DispatchQueue.main.async { + self?.eventSink?([trimmed]) + } } - - tailer = LogTailer(path: logFile.path) { [weak self] newLines in - self?.eventSink?(newLines) + self.listener = listener + + var error: NSError? + subscription = MobileTailLogs(listener, &error) + if let error = error { + self.listener = nil + return FlutterError( + code: "tail_logs_failed", + message: error.localizedDescription, + details: nil) } return nil } public func onCancel(withArguments arguments: Any?) -> FlutterError? { - tailer?.stop() - tailer = nil + subscription?.cancel() + subscription = nil + listener = nil eventSink = nil return nil } } -final class LogTailer { - private let path: String - private var fd: Int32 = -1 - private var source: DispatchSourceFileSystemObject? - private var handle: FileHandle? - private var offset: UInt64 = 0 - private let onLines: ([String]) -> Void - - init?(path: String, onLines: @escaping ([String]) -> Void) { - self.path = path - self.onLines = onLines - - if !FileManager.default.fileExists(atPath: path) { - FileManager.default.createFile(atPath: path, contents: nil) - } - handle = FileHandle(forReadingAtPath: path) - - fd = open(path, O_EVTONLY) - guard fd >= 0 else { return nil } - - if let size = (try? FileManager.default.attributesOfItem(atPath: path)[.size]) as? UInt64 { - offset = size - try? handle?.seek(toOffset: offset) - } - - let queue = DispatchQueue.global(qos: .utility) - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fd, eventMask: [.write, .extend, .rename, .delete], queue: queue) - source.setEventHandler { [weak self] in self?.handleEvent() } - source.setCancelHandler { [weak self] in - if let fd = self?.fd, fd >= 0 { - close(fd) - } - } - source.resume() - self.source = source - } - - func stop() { - source?.cancel() - source = nil - try? handle?.close() - handle = nil - } - - private func reopenHandleIfNeeded(resetOffset: Bool) { - if !FileManager.default.fileExists(atPath: path) { - FileManager.default.createFile(atPath: path, contents: nil) - } - if handle == nil { - handle = FileHandle(forReadingAtPath: path) - } - if resetOffset { - offset = 0 - } - try? handle?.seek(toOffset: offset) - } - - private func handleEvent() { - guard let source = source else { return } - let event = source.data +private class LogEntryListener: NSObject, UtilsLogListenerProtocol { + private let onEntry: (String) -> Void - if event.contains(.rename) || event.contains(.delete) { - source.suspend() - try? handle?.close() - handle = nil - reopenHandleIfNeeded(resetOffset: true) - source.resume() - return - } - - do { - if handle == nil { - reopenHandleIfNeeded(resetOffset: false) - } - guard let handle else { return } - - try handle.seek(toOffset: offset) - let data = try handle.readToEnd() ?? Data() - guard !data.isEmpty else { return } - offset += UInt64(data.count) - - let lines = String(decoding: data, as: UTF8.self) - .split(whereSeparator: \.isNewline) - .map(String.init) - if !lines.isEmpty { - onLines(lines) - } - } catch { - } + init(onEntry: @escaping (String) -> Void) { + self.onEntry = onEntry } - static func readLastLines(path: String, maxLines: Int) throws -> [String] { - let handle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path)) - defer { try? handle.close() } - let fileSize = try handle.seekToEnd() - let readSize = min(fileSize, 64 * 1024) - try handle.seek(toOffset: fileSize - readSize) - let data = try handle.readToEnd() ?? Data() - let lines = String(decoding: data, as: UTF8.self) - .split(whereSeparator: \.isNewline) - .map(String.init) - return Array(lines.suffix(maxLines)) + func onLogEntry(_ entry: String?) { + onEntry(entry ?? "") } } diff --git a/ios/Runner/Handlers/MethodHandler.swift b/ios/Runner/Handlers/MethodHandler.swift index 933299fc57..5082fb18df 100644 --- a/ios/Runner/Handlers/MethodHandler.swift +++ b/ios/Runner/Handlers/MethodHandler.swift @@ -204,6 +204,9 @@ class MethodHandler { case "getAutoServerLocation": self.getAutoServerLocation(result: result) + case "getSelectedServerJSON": + self.getSelectedServerJSON(result: result) + // Utils case "featureFlag": self.featureFlags(result: result) @@ -219,6 +222,11 @@ class MethodHandler { case "diagnosticLogFiles": self.diagnosticLogFiles(result: result) + case "isBlockAdsEnabled": + Task { + await MainActor.run { result(MobileIsBlockAdsEnabled()) } + } + case "setBlockAdsEnabled": let data = call.arguments as? [String: Any] let enabled = data?["enabled"] as? Bool ?? false @@ -234,6 +242,26 @@ class MethodHandler { } self.setSmartRouteMode(mode: mode, result: result) + case "isSmartRoutingEnabled": + Task { + await MainActor.run { result(MobileIsSmartRoutingEnabled()) } + } + + case "isTelemetryEnabled": + Task { + await MainActor.run { result(MobileIsTelemetryEnabled()) } + } + + case "isOAuthLogin": + Task { + await MainActor.run { result(MobileIsOAuthLogin()) } + } + + case "getOAuthProvider": + Task { + await MainActor.run { result(MobileGetOAuthProvider()) } + } + default: result(FlutterMethodNotImplemented) } @@ -283,13 +311,7 @@ class MethodHandler { private func startVPN(result: @escaping FlutterResult) { Task { do { - // start auto location listener try await vpnManager.startTunnel() - var error: NSError? - MobileStartAutoLocationListener(&error) - if let error { - appLogger.error("Error getting auto location: \(error.localizedDescription)") - } await MainActor.run { result("VPN started successfully.") } @@ -315,17 +337,10 @@ class MethodHandler { private func connectToServer(result: @escaping FlutterResult, data: [String: Any]) { Task { do { - // Stop auto location listener before connecting to a specific server - var error: NSError? - MobileStopAutoLocationListener(&error) - if let error { - appLogger.error("Error stopping auto location listener: \(error.localizedDescription)") - } - let location = data["location"] as? String ?? "" let serverName = data["serverName"] as? String ?? "" - try await self.vpnManager.connectToServer(location: location, serverName: serverName) + try await self.vpnManager.connectToServer(serverName: serverName) await MainActor.run { - result("VPN connected successfully to \(serverName) at \(location).") + result("VPN connected successfully to \(serverName).") } } catch { await MainActor.run { @@ -344,13 +359,6 @@ class MethodHandler { private func stopVPN(result: @escaping FlutterResult) { Task { do { - // Stop auto location listener before connecting to a specific server - - var error: NSError? - MobileStopAutoLocationListener(&error) - if let error { - appLogger.error("Error stopping auto location listener: \(error.localizedDescription)") - } try await vpnManager.stopTunnel() await MainActor.run { result("VPN stopped successfully.") @@ -410,13 +418,15 @@ class MethodHandler { private func oauthLoginCallback(result: @escaping FlutterResult, token: String) { Task { var error: NSError? - let data = try MobileOAuthLoginCallback(token, &error) + let json = try MobileOAuthLoginCallback(token, &error) if let error { await self.handleFlutterError(error, result: result, code: "OAUTH_LOGIN_CALLBACK") return } await MainActor.run { - result(data) + // Dart side expects bytes to utf8.decode — convert the gomobile-returned + // string back to Data to preserve the Flutter contract. + result(json.data(using: .utf8)) } } } @@ -424,13 +434,13 @@ class MethodHandler { private func getUserData(result: @escaping FlutterResult) { Task { var error: NSError? - let data = try MobileUserData(&error) + let json = try MobileUserData(&error) if let error { await self.handleFlutterError(error, result: result, code: "USER_DATA_ERROR") return } await MainActor.run { - result(data) + result(json.data(using: .utf8)) } } } @@ -452,13 +462,13 @@ class MethodHandler { private func fetchUserData(result: @escaping FlutterResult) { Task { var error: NSError? - let bytes = MobileFetchUserData(&error) + let json = MobileFetchUserData(&error) if let error { await self.handleFlutterError(error, result: result, code: "FETCH_USER_DATA_ERROR") return } await MainActor.run { - result(bytes) + result(json.data(using: .utf8)) } } } @@ -508,13 +518,13 @@ class MethodHandler { func acknowledgeInAppPurchase(token: String, planId: String, result: @escaping FlutterResult) { Task { var error: NSError? - let data = MobileAcknowledgeApplePurchase(token, planId, &error) + let json = MobileAcknowledgeApplePurchase(token, planId, &error) if let error { await self.handleFlutterError(error, result: result, code: "ACKNOWLEDGE_FAILED") return } await MainActor.run { - result(data) + result(json.data(using: .utf8)) } } } @@ -579,7 +589,7 @@ class MethodHandler { return } await MainActor.run { - result(payload) + result(payload.data(using: .utf8)) } } } @@ -607,7 +617,7 @@ class MethodHandler { return } await MainActor.run { - result(payload) + result(payload.data(using: .utf8)) } } } @@ -616,15 +626,14 @@ class MethodHandler { Task { let email = data["email"] as? String ?? "" let password = data["password"] as? String ?? "" - let isSSO = data["isSSO"] as? Bool ?? false var error: NSError? - let payload = MobileDeleteAccount(email, password, isSSO, &error) + let payload = MobileDeleteAccount(email, password, &error) if let error { await self.handleFlutterError(error, result: result, code: "DELETE_ACCOUNT_FAILED") return } await MainActor.run { - result(payload) + result(payload.data(using: .utf8)) } } } @@ -859,15 +868,14 @@ class MethodHandler { Task { let urls = data["urls"] as? String ?? "" let skipVerification = data["skipValidation"] as? Bool ?? false - let serverName = data["serverName"] as? String ?? "" var error: NSError? - MobileAddServerBasedOnURLs(urls, skipVerification, serverName, &error) + let tags = MobileAddServerBasedOnURLs(urls, skipVerification, &error) if let error { await self.handleFlutterError(error, result: result, code: "ADD_SERVER_BASED_ON_URLS_ERROR") return } - await self.replyOK(result) + await MainActor.run { result(tags) } } } @@ -902,15 +910,9 @@ class MethodHandler { func featureFlags(result: @escaping FlutterResult) { Task { - let flags = MobileAvailableFeatures() - guard let flags else { - await MainActor.run { - result("{}") - } - return - } + let flags = MobileAvailableFeatures() ?? "" await MainActor.run { - result(String(data: flags, encoding: .utf8)) + result(flags.isEmpty ? "{}" : flags) } } } @@ -935,12 +937,9 @@ class MethodHandler { await self.handleFlutterError(error, result: result, code: "GET_LANTERN_SERVERS_ERROR") return } - guard let servers else { - await MainActor.run { result("[]") } - return - } await MainActor.run { - result(String(data: servers, encoding: .utf8)) + let s = servers ?? "" + result(s.isEmpty ? "[]" : s) } } } @@ -959,6 +958,22 @@ class MethodHandler { } } + func getSelectedServerJSON(result: @escaping FlutterResult) { + Task { + var error: NSError? + let data = MobileGetSelectedServerJSON(&error) + if let error { + await self.handleFlutterError(error, result: result, code: "GET_SELECTED_SERVER_ERROR") + return + } + let s = data ?? "" + let json = s.isEmpty ? "{}" : s + await MainActor.run { + result(json) + } + } + } + func reportIssue(result: @escaping FlutterResult, data: [String: Any]) { Task { let email = data["email"] as? String ?? "" diff --git a/ios/Runner/Handlers/PrivateServerEventHandler.swift b/ios/Runner/Handlers/PrivateServerEventHandler.swift index 1f05cf8ba8..55c58db324 100644 --- a/ios/Runner/Handlers/PrivateServerEventHandler.swift +++ b/ios/Runner/Handlers/PrivateServerEventHandler.swift @@ -25,12 +25,12 @@ class PrivateServerEventHandler: NSObject, FlutterPlugin, FlutterStreamHandler { appLogger.info("PrivateServerEvent onListen called") cancellable = PrivateServerListener.shared.$eventSink .compactMap { $0 } + .receive(on: DispatchQueue.main) .sink { event in appLogger.info("PrivateServerEvent received event: \(event)") if !event.isEmpty { events(event) } - } return nil } diff --git a/ios/Runner/VPN/VPNManager.swift b/ios/Runner/VPN/VPNManager.swift index d2bdc4f3db..b1594041e4 100644 --- a/ios/Runner/VPN/VPNManager.swift +++ b/ios/Runner/VPN/VPNManager.swift @@ -91,7 +91,7 @@ class VPNManager: VPNBase { ] if manager.connection.status == .connected || manager.connection.status == .connecting { - appLogger.info("VPN is already connected, sending command to extension") + appLogger.info("VPN is already connected, sending lantern/auto command to extension") do { let result = try await triggerExtensionMethod(methodName: "Lantern") return @@ -109,7 +109,6 @@ class VPNManager: VPNBase { } func connectToServer( - location: String, serverName: String, ) async throws { guard let manager = await Profile.shared.getManager() else { @@ -121,19 +120,13 @@ class VPNManager: VPNBase { userInfo: [NSLocalizedDescriptionKey: msg] ) } - let options: [String: NSObject] = [ - "netEx.Type": "PrivateServer" as NSString, - "netEx.StartReason": "Private server Initiated" as NSString, - "netEx.ServerName": serverName as NSString, - "netEx.Location": location as NSString, - ] if manager.connection.status == .connected || manager.connection.status == .connecting { - appLogger.info("VPN is already connected, sending command to extension") + appLogger.info("VPN is already connected, sending privateServer command to extension") do { let result = try await triggerExtensionMethod( methodName: "PrivateServer", - params: ["server": serverName, "location": location] + params: ["server": serverName] ) return } catch { @@ -141,7 +134,11 @@ class VPNManager: VPNBase { throw error } } - + let options: [String: NSObject] = [ + "netEx.Type": "PrivateServer" as NSString, + "netEx.StartReason": "Private server Initiated" as NSString, + "netEx.ServerName": serverName as NSString, + ] try manager.connection.startVPNTunnel(options: options) // self.manager.isOnDemandEnabled = true // try await self.saveThenLoadProvider() diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 9ca6ccb48d..1a78ea6ca3 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -3,32 +3,4 @@ import Foundation import XCTest final class RunnerTests: XCTestCase { - - func testReadLastLinesReturnsTail() throws { - let tempFile = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - defer { - try? FileManager.default.removeItem(at: tempFile) - } - - let content = (1...8).map { "line-\($0)" }.joined(separator: "\n") - try content.write(to: tempFile, atomically: true, encoding: .utf8) - - let lines = try LogTailer.readLastLines(path: tempFile.path, maxLines: 3) - XCTAssertEqual(lines, ["line-6", "line-7", "line-8"]) - } - - func testReadLastLinesReturnsAllWhenFileHasFewerLinesThanLimit() throws { - let tempFile = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - defer { - try? FileManager.default.removeItem(at: tempFile) - } - - let content = ["a", "b", "c"].joined(separator: "\n") - try content.write(to: tempFile, atomically: true, encoding: .utf8) - - let lines = try LogTailer.readLastLines(path: tempFile.path, maxLines: 10) - XCTAssertEqual(lines, ["a", "b", "c"]) - } } diff --git a/ios/Tunnel/PacketTunnelProvider.swift b/ios/Tunnel/PacketTunnelProvider.swift index 868be3b9af..777d417e0a 100644 --- a/ios/Tunnel/PacketTunnelProvider.swift +++ b/ios/Tunnel/PacketTunnelProvider.swift @@ -39,29 +39,27 @@ class PacketTunnelProvider: ExtensionProvider { switch method { case "PrivateServer": appLogger.info("Received connectServer command with params: \(params)") - guard let server = params["server"] as? String, - let location = params["location"] as? String - else { + guard let server = params["server"] as? String else { return respond(["error": "Missing parameters"]) } - appLogger.info("Connecting to server \(server) at location \(location)") - connectToServer(location: location, serverName: server) { success, errorMessage in + appLogger.info("VPN already active received connectServer command with params: \(params)") + connectToServer(serverName: server) { success, errorMessage in if success { - respond(["result": "Connected to \(server) at \(location)"]) + respond(["result": "Connected to \(server)"]) } else { respond(["error": errorMessage ?? "Unknown error"]) } } break case "Lantern": - appLogger.info("Received Lantern command") - startVPN(completion: { success, errorMessage in + appLogger.info("VPN already active connecting to Lantern/auto") + connectToServer(serverName: "auto") { success, errorMessage in if success { - respond(["result": "Lantern VPN started"]) + respond(["result": "Connected to auto tag"]) } else { respond(["error": errorMessage ?? "Unknown error"]) } - }) + } break default: respond(["error": "Unknown method"]) diff --git a/ios/Tunnel/SingBox/ExtensionProvider.swift b/ios/Tunnel/SingBox/ExtensionProvider.swift index 078adfb38d..b3fc2501d4 100644 --- a/ios/Tunnel/SingBox/ExtensionProvider.swift +++ b/ios/Tunnel/SingBox/ExtensionProvider.swift @@ -29,14 +29,22 @@ class ExtensionProvider: NEPacketTunnelProvider { if platformInterface == nil { platformInterface = ExtensionPlatformInterface(self) } + + // Start the IPC server before any VPN operations + var ipcError: NSError? + MobileStartIPCServer(platformInterface, opts(), &ipcError) + if let ipcError { + appLogger.error("error starting IPC server: \(ipcError.localizedDescription)") + throw ipcError + } + let tunnelType = options?["netEx.Type"] as? String switch tunnelType { case "Lantern": startVPN() case "PrivateServer": let serverName = options?["netEx.ServerName"] as? String - let location = options?["netEx.Location"] as? String - connectToServer(location: location!, serverName: serverName!) + connectToServer(serverName: serverName!) default: // Fallback or unknown type startVPN() @@ -54,7 +62,7 @@ class ExtensionProvider: NEPacketTunnelProvider { appLogger.log("(lantern-tunnel) quick connect") var error: NSError? - MobileStartVPN(platformInterface, opts(), &error) + MobileStartVPN(&error) if error != nil { appLogger.log("error while starting tunnel \(error?.localizedDescription ?? "")") // Inform system and close tunnel @@ -68,11 +76,11 @@ class ExtensionProvider: NEPacketTunnelProvider { } func connectToServer( - location: String, serverName: String, completion: ((Bool, String?) -> Void)? = nil + serverName: String, completion: ((Bool, String?) -> Void)? = nil ) { appLogger.log("(lantern-tunnel) connecting to server") var error: NSError? - MobileConnectToServer(location, serverName, platformInterface, opts(), &error) + MobileConnectToServer(serverName, &error) if error != nil { appLogger.log("error while connecting to server \(error?.localizedDescription ?? "")") completion?(false, error?.localizedDescription) @@ -89,9 +97,9 @@ class ExtensionProvider: NEPacketTunnelProvider { appLogger.log("(lantern-tunnel) stopping, reason: \(reason)") stopService() var error: NSError? - MobileCloseIPC(&error) + MobileCloseIPCServer(&error) if error != nil { - appLogger.log("error closing IPC \(error?.localizedDescription ?? "")") + appLogger.log("error closing IPC server \(error?.localizedDescription ?? "")") } let elapsed = Date().timeIntervalSince(startTime) appLogger.log("(lantern-tunnel) stopTunnel completed in \(elapsed) seconds") @@ -124,7 +132,15 @@ class ExtensionProvider: NEPacketTunnelProvider { reasserting = false } stopService() - startVPN() + + // Don't cancelTunnelWithError on failure; this extension hosts the IPC server. + var error: NSError? + MobileStartVPN(&error) + if let error { + appLogger.log("(lantern-tunnel) restart failed: \(error.localizedDescription)") + return + } + appLogger.log("(lantern-tunnel) tunnel restarted successfully") } func postServiceClose() { diff --git a/lantern-core/apps/apps_test.go b/lantern-core/apps/apps_test.go index 1ad8a8f3ce..da76febaec 100644 --- a/lantern-core/apps/apps_test.go +++ b/lantern-core/apps/apps_test.go @@ -75,6 +75,9 @@ func TestScanAppDirs_FindsAppsAndIconWindows(t *testing.T) { } func TestScanAppDirs_FindsAppsAndIcon(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("test requires macOS .app bundle scanning") + } tmp := t.TempDir() root := filepath.Join(tmp, "Applications") if err := os.MkdirAll(root, 0o755); err != nil { @@ -111,6 +114,9 @@ func TestScanAppDirs_FindsAppsAndIcon(t *testing.T) { } func TestScanAppDirs_DedupByBundleID(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("test requires macOS .app bundle scanning") + } tmp := t.TempDir() root := filepath.Join(tmp, "Applications") if err := os.MkdirAll(root, 0o755); err != nil { @@ -150,6 +156,9 @@ func TestCacheLoadSaveRoundTrip(t *testing.T) { } func TestLoadInstalledAppsWithDirs_EmitsCachedThenNew(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("test requires macOS .app bundle scanning") + } tmp := t.TempDir() cached := &AppData{Name: "Cached", BundleID: "com.cached", AppPath: "/Cached.app"} diff --git a/lantern-core/cmd/lanternsvc/main.go b/lantern-core/cmd/lanternsvc/main.go deleted file mode 100644 index d893b11ec7..0000000000 --- a/lantern-core/cmd/lanternsvc/main.go +++ /dev/null @@ -1,192 +0,0 @@ -//go:build windows - -package main - -import ( - "context" - "errors" - "flag" - "fmt" - "log/slog" - "os" - "os/signal" - "runtime" - "runtime/debug" - "strings" - "syscall" - "unsafe" - - "golang.org/x/sys/windows" - "golang.org/x/sys/windows/svc" - - "github.com/getlantern/lantern/lantern-core/common" - "github.com/getlantern/lantern/lantern-core/wintunmgr" - - rcommon "github.com/getlantern/radiance/common" - "github.com/getlantern/radiance/common/settings" -) - -const ( - adapterName = "Lantern" - poolName = "Lantern" - servicePipeName = `\\.\pipe\LanternService` -) - -func guard(where string) { - if r := recover(); r != nil { - buf := make([]byte, 1<<20) - n := runtime.Stack(buf, true) - slog.Error("PANIC in "+where, "error", r, "stack", string(buf[:n])) - } -} - -func init() { - debug.SetTraceback("all") - debug.SetPanicOnFault(true) -} - -func main() { - // Initialize radiance to ensure our directories and logging are set up. - rcommon.InitReadOnly("", "", "trace") - consoleMode := flag.Bool("console", false, "Run in console mode instead of Windows service") - flag.Parse() - - isService, err := isWindowsService() - if err != nil { - slog.Error("Failed to determine if running as Windows service", "error", err) - return - } - - if *consoleMode || !isService { - slog.Info("Starting Windows service executable", "service", common.WindowsServiceName, "version", rcommon.Version, "mode", "console") - runConsole() - return - } - - slog.Info("Starting Windows service executable", "service", common.WindowsServiceName, "version", rcommon.Version, "mode", "service") - - // let SCM specify the service name - if err := svc.Run(common.WindowsServiceName, &lanternHandler{}); err != nil { - slog.Error("Service failed", "error", err) - } -} - -func newWindowsService() (*wintunmgr.Manager, *wintunmgr.Service, error) { - wt := wintunmgr.New(adapterName, poolName, nil) - s := wintunmgr.NewService(wintunmgr.ServiceOptions{ - PipeName: servicePipeName, - LogDir: settings.GetString(settings.LogPathKey), - }, wt) - return wt, s, nil -} - -func runConsole() { - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer cancel() - - slog.Debug("Starting " + common.WindowsServiceName + " in console mode (pid=" + fmt.Sprint(os.Getpid()) + ")") - - defer guard("runConsole") - - _, s, err := newWindowsService() - if err != nil { - slog.Error("Failed to create new Windows service", "error", err) - return - } - - if err := s.Start(ctx); err != nil { - slog.Error("Failed to start Windows service", "error", err) - return - } -} - -type lanternHandler struct{} - -func (h *lanternHandler) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (svcSpecificEC bool, exitCode uint32) { - const accepts = svc.AcceptStop | svc.AcceptShutdown - - defer guard("lanternHandler.Execute") - - changes <- svc.Status{State: svc.StartPending, WaitHint: 30 * 1000} - - // Report Running to SCM - changes <- svc.Status{State: svc.Running, Accepts: accepts} - - ctx, cancel := context.WithCancel(context.Background()) - - started := make(chan error, 1) - go func() { - defer guard("service worker") - _, s, err := newWindowsService() - if err == nil { - err = s.Start(ctx) - } - started <- err - }() - - for { - select { - case c := <-r: - switch c.Cmd { - case svc.Interrogate: - changes <- c.CurrentStatus - case svc.Stop, svc.Shutdown: - changes <- svc.Status{State: svc.StopPending} - cancel() - if err := <-started; err != nil { - slog.Error("service worker exited with error on stop", "error", err) - changes <- svc.Status{State: svc.Stopped} - return false, 1 - } - changes <- svc.Status{State: svc.Stopped} - return false, 0 - } - case err := <-started: - if err != nil { - slog.Error("service worker exited unexpectedly", "error", err) - changes <- svc.Status{State: svc.Stopped} - cancel() - return false, 1 - } - } - } -} - -// Copyright 2023-present Datadog, Inc. -// Licensed under the Apache License, Version 2.0 (the "License"); -// https://github.com/DataDog/datadog-agent/blob/46740e82ef40a04c4be545ed8c16a4b0d1f046cf/pkg/util/winutil/servicemain/servicemain.go#L128 -func isWindowsService() (bool, error) { - var currentProcess windows.PROCESS_BASIC_INFORMATION - - infoSize := uint32(unsafe.Sizeof(currentProcess)) - - err := windows.NtQueryInformationProcess(windows.CurrentProcess(), windows.ProcessBasicInformation, unsafe.Pointer(¤tProcess), infoSize, &infoSize) - if err != nil { - return false, err - } - - var parentProcess *windows.SYSTEM_PROCESS_INFORMATION - - for infoSize = uint32((unsafe.Sizeof(*parentProcess) + unsafe.Sizeof(uintptr(0))) * 1024); ; { - parentProcess = (*windows.SYSTEM_PROCESS_INFORMATION)(unsafe.Pointer(&make([]byte, infoSize)[0])) - - err = windows.NtQuerySystemInformation(windows.SystemProcessInformation, unsafe.Pointer(parentProcess), infoSize, &infoSize) - if err == nil { - break - } else if !errors.Is(err, windows.STATUS_INFO_LENGTH_MISMATCH) { - return false, err - } - } - - for ; ; parentProcess = (*windows.SYSTEM_PROCESS_INFORMATION)(unsafe.Pointer(uintptr(unsafe.Pointer(parentProcess)) + uintptr(parentProcess.NextEntryOffset))) { - if parentProcess.UniqueProcessID == currentProcess.InheritedFromUniqueProcessId { - return strings.EqualFold("services.exe", parentProcess.ImageName.String()), nil - } - - if parentProcess.NextEntryOffset == 0 { - break - } - } - - return false, nil -} diff --git a/lantern-core/common/win_command.go b/lantern-core/common/win_command.go deleted file mode 100644 index 896985fe55..0000000000 --- a/lantern-core/common/win_command.go +++ /dev/null @@ -1,20 +0,0 @@ -package common - -type Command string - -// only VPN related commands for Windows service -// Other commands are handled in the ffi layer directly -const ( - CmdSetupAdapter Command = "SetupAdapter" - CmdStartTunnel Command = "StartTunnel" - CmdStopTunnel Command = "StopTunnel" - CmdIsVPNRunning Command = "IsVPNRunning" - CmdConnectToServer Command = "ConnectToServer" - CmdStopService Command = "Stop" - CmdWatchStatus Command = "WatchStatus" - CmdWatchLogs Command = "WatchLogs" -) - -const ( - WindowsServiceName = "LanternSvc" -) diff --git a/lantern-core/core.go b/lantern-core/core.go index 4dd0f59a3e..6451772f47 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -7,18 +7,17 @@ import ( "log/slog" "os" "path/filepath" - "strconv" + "runtime" "strings" "sync" "sync/atomic" + "time" - "github.com/getlantern/radiance" - "github.com/getlantern/radiance/api" + "github.com/getlantern/radiance/account" "github.com/getlantern/radiance/common" "github.com/getlantern/radiance/common/env" "github.com/getlantern/radiance/common/settings" - "github.com/getlantern/radiance/config" - "github.com/getlantern/radiance/events" + "github.com/getlantern/radiance/ipc" "github.com/getlantern/radiance/issue" "github.com/getlantern/radiance/servers" "github.com/getlantern/radiance/vpn" @@ -26,26 +25,24 @@ import ( "github.com/getlantern/lantern/lantern-core/apps" privateserver "github.com/getlantern/lantern/lantern-core/private-server" "github.com/getlantern/lantern/lantern-core/utils" + "github.com/getlantern/lantern/lantern-core/vpn_tunnel" ) type EventType = string const ( - EventTypeConfig EventType = "config" EventTypeServerLocation EventType = "server-location" + EventTypeConfig EventType = "config" DefaultLogLevel = "trace" - - plansCacheFile = "plans-cache.json" ) -// LanternCore is the main structure accessing the Lantern backend. +// LanternCore wraps an IPC client and provides the interface expected by the FFI and mobile layers. type LanternCore struct { - rad *radiance.Radiance - splitTunnel *vpn.SplitTunnel - serverManager *servers.Manager - apiClient *api.APIClient - initOnce sync.Once - eventEmitter utils.FlutterEventEmitter + client *ipc.Client + ctx context.Context + cancel context.CancelFunc + initOnce sync.Once + eventEmitter utils.FlutterEventEmitter } var ( @@ -61,19 +58,28 @@ type App interface { GetAvailableServers() []byte MyDeviceId() string GetServerByTagJSON(tag string) ([]byte, bool, error) + GetSelectedServerJSON() ([]byte, error) + GetSelectedServerTag() (string, error) + GetAutoLocationJSON() ([]byte, error) + CheckDaemonReachable() error + PatchSettings(settings.Settings) error + GetSettingsJSON() ([]byte, error) + PatchEnvVars(map[string]string) (map[string]string, error) + GetEnvVars() map[string]string + RunOfflineURLTests() error + UpdateConfig() error ReferralAttachment(referralCode string) (bool, error) UpdateLocale(locale string) error - StartBackgroundListeners() - StopBackgroundListeners() UpdateTelemetryConsent(consent bool) error - GetAppDataDir() string + IsTelemetryEnabled() bool + IsOAuthLogin() bool + GetOAuthProvider() string GetEnabledApps() (string, error) } type User interface { UserData() ([]byte, error) DataCapInfo() (string, error) - DataCapStream(ctx context.Context) error FetchUserData() ([]byte, error) OAuthLoginUrl(provider string) (string, error) OAuthLoginCallback(oAuthToken string) ([]byte, error) @@ -84,9 +90,8 @@ type User interface { StartRecoveryByEmail(email string) error ValidateChangeEmailCode(email, code string) error CompleteRecoveryByEmail(email, password, code string) error - DeleteAccount(email, password string, isOAuthUser bool) ([]byte, error) - RemoveDevice(deviceId string) (*api.LinkResponse, error) - //Change email + DeleteAccount(email, password string) ([]byte, error) + RemoveDevice(deviceId string) (*account.LinkResponse, error) StartChangeEmail(newEmail, password string) error CompleteChangeEmail(email, password, code string) error } @@ -102,7 +107,7 @@ type PrivateServer interface { InviteToServerManagerInstance(ip string, port string, accessToken string, inviteName string) (string, error) RevokeServerManagerInvite(ip string, port string, accessToken string, inviteName string) error StartDeployment(location, serverName string) error - AddServerBasedOnURLs(urls string, skipCertVerification bool, serverName string) error + AddServersByURL(urls string, skipCertVerification bool) ([]byte, error) DeleteServer(tag string) error UpdatePrivateServerName(oldTag, newTag string) error } @@ -115,20 +120,20 @@ type Payment interface { AcknowledgeApplePurchase(receipt, planII string) (string, error) PaymentRedirect(provider, planID, email string) (string, error) ActivationCode(email, resellerCode string) error - SubscriptionPaymentRedirectURL(redirectBody api.PaymentRedirectData) (string, error) + SubscriptionPaymentRedirectURL(redirectBody account.PaymentRedirectData) (string, error) StripeSubscriptionPaymentRedirect(subscriptionType, planID, email string) (string, error) } type SplitTunnel interface { LoadInstalledApps(dataDir string) (string, error) IsSplitTunnelingEnabled() bool - SetSplitTunnelingEnabled(bool) + SetSplitTunnelingEnabled(bool) error AddSplitTunnelItem(filterType, item string) error AddSplitTunnelItems(items string) error RemoveSplitTunnelItem(filterType, item string) error RemoveSplitTunnelItems(items string) error - GetSplitTunnelStateJSON() (string, error) - GetSplitTunnelItems(filterType string) (string, error) + GetSplitTunnelItems() (string, error) + GetSplitTunnelItemsFor(filterType string) (string, error) } type Ads interface { @@ -141,6 +146,14 @@ type SmartRouting interface { IsSmartRoutingEnabled() bool } +type VPN interface { + ConnectVPN(tag string) error + SelectServer(tag string) error + DisconnectVPN() error + VPNStatus() (vpn.VPNStatus, error) + VPNStatusEvents(ctx context.Context, callback func(evt vpn.StatusUpdateEvent)) error +} + type Core interface { App User @@ -149,9 +162,10 @@ type Core interface { SplitTunnel Ads SmartRouting + VPN + Client() *ipc.Client } -// Make sure LanternCore implements the Core interface var _ Core = (*LanternCore)(nil) func New(opts *utils.Opts, eventEmitter utils.FlutterEventEmitter) (Core, error) { @@ -159,9 +173,6 @@ func New(opts *utils.Opts, eventEmitter utils.FlutterEventEmitter) (Core, error) return nil, fmt.Errorf("opts and eventEmitter cannot be nil") } - // This isn't ideal, but currently on Android and maybe other platforms - // there are multiple places that try to initialize the backend, so we - // need to ensure it's only done once. core.initOnce.Do(func() { if opts.LogLevel == "" { opts.LogLevel = DefaultLogLevel @@ -179,216 +190,251 @@ func New(opts *utils.Opts, eventEmitter utils.FlutterEventEmitter) (Core, error) } func (lc *LanternCore) initialize(opts *utils.Opts, eventEmitter utils.FlutterEventEmitter) error { + // Wire up slog for the host process according to how the backend is + // hosted on each platform: + // + // - windows/linux: the UI is a separate process talking to a daemon + // over IPC, so it needs its own full common.Init. + // - darwin/ios: the UI shares its logDir with the tunnel extension, + // which is the process that called common.Init. Re-running it here + // would collide; instead we set up app-process-only logging into a + // distinct file so the two lumberjacks don't race on rotation. + // - android: the backend is embedded in the same process as the UI + // (see init_mobile.go), and Mobile.SetupRadiance has already called + // common.Init by the time we reach here. Fall through with no + // additional setup. + switch runtime.GOOS { + case "windows", "linux": + if err := common.Init(opts.DataDir, opts.LogDir, opts.LogLevel); err != nil { + return fmt.Errorf("common.Init: %w", err) + } + case "darwin", "ios": + setupAppLogging(opts.LogDir, opts.LogLevel) + } slog.Debug("Starting LanternCore initialization") - // Set the environment before initializing Radiance so that common.Stage()/Prod()/Dev() - // pick up the correct value during initialization. + if opts.Env == "stage" || opts.Env == "staging" { slog.Debug("Setting staging environment") env.SetStagingEnv() } - var radErr error - if lc.rad, radErr = radiance.NewRadiance(radiance.Options{ - LogDir: opts.LogDir, - DataDir: opts.DataDir, - DeviceID: opts.Deviceid, - LogLevel: opts.LogLevel, - Locale: opts.Locale, - TelemetryConsent: opts.TelemetryConsent, - }); radErr != nil { - return fmt.Errorf("failed to create Radiance: %w", radErr) - } - slog.Debug("Paths:", "logs", settings.GetString(settings.LogPathKey), "data", settings.GetString(settings.DataPathKey)) - fixStaleSettingsFilePath(opts.DataDir) - - var sthErr error - if lc.splitTunnel, sthErr = vpn.NewSplitTunnelHandler(); sthErr != nil { - return fmt.Errorf("unable to create split tunnel handler: %v", sthErr) + ctx, cancel := context.WithCancel(context.Background()) + client, err := createClient(ctx, opts) + if err != nil { + cancel() + return fmt.Errorf("failed to create IPC client: %w", err) } - lc.serverManager = lc.rad.ServerManager() - lc.apiClient = lc.rad.APIHandler() + lc.client = client + lc.ctx = ctx + lc.cancel = cancel lc.eventEmitter = eventEmitter - // Listen for config updates and notify Flutter - events.Subscribe(func(evt config.NewConfigEvent) { - core.notifyFlutter(EventTypeConfig, "Config is fetched/updated") - }) + go lc.listenAutoSelectedEvents() + go lc.listenConfigEvents() + go lc.listenDataCapEvents() - lc.listeningServerLocationChanges() - lc.listeningDataCapChanges() slog.Debug("LanternCore initialized successfully") + return nil +} - // If we have a legacy user ID, fetch user data - if settings.GetInt64(settings.UserIDKey) != 0 { - userData, _ := core.FetchUserData() - slog.Debug("Fetched user data", "data", string(userData)) - } +func (lc *LanternCore) Client() *ipc.Client { + return lc.client +} - return nil +// notifyFlutter sends an event to the Flutter frontend via the event emitter. +func (lc *LanternCore) notifyFlutter(event EventType, message string) { + slog.Debug("Notifying Flutter") + lc.eventEmitter.SendEvent(&utils.FlutterEvent{ + Type: string(event), + Message: message, + }) } -// Listen for server location changes and notify Flutter -func (lc *LanternCore) listeningServerLocationChanges() { - events.Subscribe(func(evt vpn.AutoSelectionsEvent) { - tag := evt.Selections.Lantern - servers, ok := lc.serverManager.GetServerByTag(tag) - if !ok { - slog.Error("no server found with tag", "tag", tag) +// listenAutoSelectedEvents listens for auto-selected server changes from the IPC client and forwards +// them to Flutter. Blocks until lc.ctx is cancelled. +func (lc *LanternCore) listenAutoSelectedEvents() { + err := lc.client.AutoSelectedEvents(lc.ctx, func(evt vpn.AutoSelectedEvent) { + server, found, err := lc.client.GetServerByTag(lc.ctx, evt.Selected) + if err != nil || !found { + slog.Error("no server found with tag", "tag", evt.Selected, "error", err) return } - jsonBytes, err := json.Marshal(servers) + jsonBytes, err := json.Marshal(server) if err != nil { slog.Error("Error marshalling server location", "error", err) return } - stringBody := string(jsonBytes) - slog.Debug("Auto location server:", "server", stringBody) - lc.notifyFlutter(EventTypeServerLocation, stringBody) + slog.Debug("Auto location server:", "server", string(jsonBytes)) + lc.notifyFlutter(EventTypeServerLocation, string(jsonBytes)) }) + if err != nil && lc.ctx.Err() == nil { + slog.Error("auto-selected event stream exited unexpectedly", "error", err) + } } -func (lc *LanternCore) listeningDataCapChanges() { - events.Subscribe(func(evt api.DataCapChangeEvent) { - dataCapResponse := evt.DataCapUsageResponse - jsonBytes, err := json.Marshal(dataCapResponse) + +// listenConfigEvents listens for config updates from the IPC client and notifies Flutter when they +// occur. Blocks until lc.ctx is cancelled. +func (lc *LanternCore) listenConfigEvents() { + err := lc.client.ConfigEvents(lc.ctx, func() { + slog.Debug("Config updated, notifying Flutter") + lc.notifyFlutter(EventTypeConfig, "") + }) + if err != nil && lc.ctx.Err() == nil { + slog.Error("config event stream exited unexpectedly", "error", err) + } +} + +// listenDataCapEvents listens for DataCapInfo updates from the IPC client and forwards them to Flutter. +// Blocks until lc.ctx is cancelled. +func (lc *LanternCore) listenDataCapEvents() { + err := lc.client.DataCapStream(lc.ctx, func(info account.DataCapInfo) { + jsonBytes, err := json.Marshal(info) if err != nil { slog.Error("Error marshalling DataCap event", "error", err) return } - stringBody := string(jsonBytes) - slog.Debug("DataCap event:", "event", stringBody) - lc.notifyFlutter("data-cap-event", stringBody) + lc.notifyFlutter("data-cap-event", string(jsonBytes)) }) + if err != nil && lc.ctx.Err() == nil { + slog.Error("datacap event stream exited unexpectedly", "error", err) + } } -func (lc *LanternCore) UpdateTelemetryConsent(consent bool) error { - slog.Debug("Updating telemetry consent", "consent", consent) - if consent { - slog.Info("User has opted in to telemetry") - lc.rad.EnableTelemetry() - } else { - slog.Info("User has opted out of telemetry") - lc.rad.DisableTelemetry() - } - return nil +///////////////// +// VPN // +///////////////// + +// Per-call IPC timeouts. These bound the worst case if lanternd is hung +// (pipe open, no replies). They're long enough to never fire during normal +// operation — the connect path involves real DNS / TLS / sing-box bring-up +// that can take many seconds, while a /vpn/status query should be near- +// instant — but tight enough that a stuck daemon surfaces as a UI error +// instead of an indefinite spinner. The dialer already has a 10 s connect +// timeout (radiance/ipc/conn_windows.go), so these only matter once the +// pipe is established. +const ( + ipcConnectTimeout = 60 * time.Second + ipcStateChangeTimeout = 30 * time.Second + ipcStatusTimeout = 10 * time.Second +) + +// ConnectVPN routes a connect request through vpn_tunnel.ConnectToServer, +// which picks between /vpn/connect (fresh tunnel) and /server/selected +// (live-tunnel outbound swap) based on VPNStatus. This is load-bearing for +// the Smart-from-connected flow: Jigar's onSmartLocation rewrite +// (server_selection.dart) routes "switch back to auto" through +// startVPN(force: true) → ffi.go:startVPN → c.ConnectVPN(""). Without the +// dispatch the call 500s with ErrTunnelAlreadyConnected from +// radiance/vpn/vpn.go:130 and the user sees a snackbar. +// +// Fixes getlantern/engineering#3291 issue 3. +func (lc *LanternCore) ConnectVPN(tag string) error { + ctx, cancel := context.WithTimeout(lc.ctx, ipcConnectTimeout) + defer cancel() + return vpn_tunnel.ConnectToServer(ctx, lc.client, tag) } -func (lc *LanternCore) SetSmartRoutingMode(mode bool) error { - slog.Debug("Setting Smart Routing Mode to:", "mode", mode) - if err := vpn.SetSmartRouting(mode); err != nil { - return fmt.Errorf("failed to set Smart Routing Mode: %w", err) - } - return nil +func (lc *LanternCore) SelectServer(tag string) error { + ctx, cancel := context.WithTimeout(lc.ctx, ipcStateChangeTimeout) + defer cancel() + return lc.client.SelectServer(ctx, tag) } -func (lc *LanternCore) GetSmartRoutingMode() bool { - return vpn.SmartRoutingEnabled() +func (lc *LanternCore) DisconnectVPN() error { + ctx, cancel := context.WithTimeout(lc.ctx, ipcStateChangeTimeout) + defer cancel() + return lc.client.DisconnectVPN(ctx) } -// Internal methods -// notifyFlutter sends an event to the Flutter frontend via the event emitter. -// On mobile we use EventChannel; on desktop this goes over the FFI event port -func (lc *LanternCore) notifyFlutter(event EventType, message string) { - slog.Debug("Notifying Flutter") - lc.eventEmitter.SendEvent(&utils.FlutterEvent{ - Type: string(event), - Message: message, - }) +func (lc *LanternCore) VPNStatus() (vpn.VPNStatus, error) { + ctx, cancel := context.WithTimeout(lc.ctx, ipcStatusTimeout) + defer cancel() + return lc.client.VPNStatus(ctx) } -type backgroundListenerManager struct { - cancel context.CancelFunc - isRunning bool - mu sync.Mutex +func (lc *LanternCore) IsVPNRunning() (bool, error) { + status, err := lc.VPNStatus() + if err != nil { + return false, err + } + return status == vpn.Connected, nil } -var listenerManager = &backgroundListenerManager{ - // avoid nil cancel - cancel: func() {}, +func (lc *LanternCore) VPNStatusEvents(ctx context.Context, callback func(evt vpn.StatusUpdateEvent)) error { + return lc.client.VPNStatusEvents(ctx, callback) } -func (lc *LanternCore) StartBackgroundListeners() { - slog.Info("Starting background listeners...") - listenerManager.mu.Lock() - defer listenerManager.mu.Unlock() +///////////////// +// Settings // +///////////////// - if listenerManager.isRunning { - slog.Info("Background listeners already running") - return +// settings returns the current settings from radiance. +func (lc *LanternCore) settings() settings.Settings { + s, err := lc.client.Settings(lc.ctx) + if err != nil { + return settings.Settings{} } + return s +} - ctx, cancel := context.WithCancel(context.Background()) - listenerManager.cancel = cancel - listenerManager.isRunning = true - - // Auto location listener - go vpn.AutoSelectionsChangeListener(ctx) +func (lc *LanternCore) UpdateTelemetryConsent(consent bool) error { + return lc.client.EnableTelemetry(lc.ctx, consent) +} - // DataCap SSE stream - go func() { - if err := lc.apiClient.DataCapStream(ctx); err != nil { - slog.Error("datacap stopped", "error", err) - } - }() +func (lc *LanternCore) SetBlockAdsEnabled(enabled bool) error { + return lc.client.EnableAdBlocking(lc.ctx, enabled) +} - slog.Info("Background listeners started") +func (lc *LanternCore) IsBlockAdsEnabled() bool { + b, _ := lc.settings()[settings.AdBlockKey].(bool) + return b } -// stopAutoLocationListener stops the location listener +func (lc *LanternCore) SetSmartRoutingEnabled(enabled bool) error { + return lc.client.EnableSmartRouting(lc.ctx, enabled) +} -func (lc *LanternCore) StopBackgroundListeners() { - slog.Info("Stopping background listeners...") - listenerManager.mu.Lock() - defer listenerManager.mu.Unlock() - if !listenerManager.isRunning { - slog.Info("Background listeners not running") - return - } - listenerManager.cancel() - listenerManager.isRunning = false - slog.Info("Background listeners stopped") +func (lc *LanternCore) IsSmartRoutingEnabled() bool { + b, _ := lc.settings()[settings.SmartRoutingKey].(bool) + return b } -// GetServerByTagJSON returns the server for a given tag as pre-marshalled JSON. -// This is safe to call from CGo callback stacks because the pointer-rich Server -// types are marshalled here rather than being returned to the caller. -func (lc *LanternCore) GetServerByTagJSON(tag string) ([]byte, bool, error) { - return lc.serverManager.GetServerByTagJSON(tag) +func (lc *LanternCore) IsTelemetryEnabled() bool { + b, _ := lc.settings()[settings.TelemetryKey].(bool) + return b } -func (lc *LanternCore) VPNStatus() (vpn.Status, error) { - return vpn.GetStatus() +func (lc *LanternCore) IsOAuthLogin() bool { + b, _ := lc.settings()[settings.OAuthLoginKey].(bool) + return b } -func (lc *LanternCore) IsVPNRunning() (bool, error) { - st, err := vpn.GetStatus() - if err != nil { - return false, err - } - return st.TunnelOpen, nil +func (lc *LanternCore) GetOAuthProvider() string { + v, _ := lc.settings()[settings.OAuthProviderKey].(string) + return v } func (lc *LanternCore) IsRadianceConnected() bool { - return lc.rad != nil + return lc.client != nil } func (lc *LanternCore) MyDeviceId() string { - return settings.GetString(settings.DeviceIDKey) + v, _ := lc.settings()[settings.DeviceIDKey].(string) + return v } func (lc *LanternCore) UpdateLocale(locale string) error { - slog.Debug("Updating locale", "locale", locale) - settings.Set(settings.LocaleKey, locale) - return nil -} - -func (lc *LanternCore) ReferralAttachment(referralCode string) (bool, error) { - return lc.apiClient.ReferralAttach(context.Background(), referralCode) + _, err := lc.client.PatchSettings(lc.ctx, settings.Settings{settings.LocaleKey: locale}) + return err } func (lc *LanternCore) AvailableFeatures() []byte { - features := lc.rad.Features() - slog.Debug("Available features", "features", features) + features, err := lc.client.Features(lc.ctx) + if err != nil { + slog.Error("Error getting features", "error", err) + return nil + } jsonBytes, err := json.Marshal(features) if err != nil { slog.Error("Error marshalling features", "error", err) @@ -398,25 +444,96 @@ func (lc *LanternCore) AvailableFeatures() []byte { } func (lc *LanternCore) GetAvailableServers() []byte { - // Use ServersJSON which marshals under the lock, avoiding GC write barrier - // panics when pointer-rich sing-box types are copied on a CGo callback stack. - jsonBytes, err := lc.rad.ServerManager().ServersJSON() + data, err := lc.client.ServersJSON(lc.ctx) if err != nil { - slog.Error("Error marshalling servers", "error", err) + slog.Error("Error getting servers", "error", err) return nil } - return jsonBytes + return data +} + +func (lc *LanternCore) GetServerByTagJSON(tag string) ([]byte, bool, error) { + return lc.client.GetServerByTagJSON(lc.ctx, tag) +} + +func (lc *LanternCore) GetSelectedServerJSON() ([]byte, error) { + return lc.client.SelectedServerJSON(lc.ctx) } -// LoadInstalledApps fetches the app list or rescans if needed using common macOS locations -// currently only works on/enabled for macOS +func (lc *LanternCore) GetSelectedServerTag() (string, error) { + server, exists, err := lc.client.SelectedServer(lc.ctx) + if err != nil { + return "", err + } + if !exists { + return "", nil + } + return server.Tag, nil +} + +func (lc *LanternCore) GetAutoLocationJSON() ([]byte, error) { + server, err := lc.client.AutoSelected(lc.ctx) + if err != nil { + return nil, fmt.Errorf("failed to get auto location: %w", err) + } + return json.Marshal(server) +} + +func (lc *LanternCore) CheckDaemonReachable() error { + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + _, err := lc.client.VPNStatus(ctx) + return err +} + +func (lc *LanternCore) PatchSettings(s settings.Settings) error { + _, err := lc.client.PatchSettings(lc.ctx, s) + return err +} + +func (lc *LanternCore) GetSettingsJSON() ([]byte, error) { + s, err := lc.client.Settings(lc.ctx) + if err != nil { + return nil, err + } + return json.Marshal(s) +} + +func (lc *LanternCore) PatchEnvVars(updates map[string]string) (map[string]string, error) { + return lc.client.PatchEnvVars(lc.ctx, updates) +} + +// GetEnvVars returns the daemon's in-memory env vars. Uses an empty PATCH +// because ipc.Client exposes no dedicated GET; the daemon returns the full +// env map on both GET and PATCH. +func (lc *LanternCore) GetEnvVars() map[string]string { + vars, err := lc.client.PatchEnvVars(lc.ctx, map[string]string{}) + if err != nil { + slog.Error("Error fetching env vars", "error", err) + return nil + } + return vars +} + +func (lc *LanternCore) RunOfflineURLTests() error { + return lc.client.RunOfflineURLTests(lc.ctx) +} + +func (lc *LanternCore) UpdateConfig() error { + return lc.client.UpdateConfig(lc.ctx) +} + +///////////////// +// Split Tunnel // +///////////////// + +// TODO: ??? not sure what to do about this one. it can't access dataDir func (lc *LanternCore) LoadInstalledApps(dataDir string) (string, error) { appsList := []*apps.AppData{} apps.LoadInstalledApps(dataDir, func(a ...*apps.AppData) error { appsList = append(appsList, a...) return nil }) - b, err := json.Marshal(appsList) if err != nil { return "", err @@ -424,348 +541,349 @@ func (lc *LanternCore) LoadInstalledApps(dataDir string) (string, error) { return string(b), nil } -// SetSplitTunnelingEnabled turns split tunneling on or off for this device -func (lc *LanternCore) SetSplitTunnelingEnabled(enabled bool) { - if enabled { - lc.splitTunnel.Enable() - } else { - lc.splitTunnel.Disable() - } +func (lc *LanternCore) SetSplitTunnelingEnabled(enabled bool) error { + return lc.client.EnableSplitTunneling(lc.ctx, enabled) } -// IsSplitTunnelingEnabled returns whether split tunneling is currently enabled func (lc *LanternCore) IsSplitTunnelingEnabled() bool { - return lc.splitTunnel.IsEnabled() + b, _ := lc.settings()[settings.SplitTunnelKey].(bool) + return b } -// AddSplitTunnelItem adds a single split tunnel rule func (lc *LanternCore) AddSplitTunnelItem(filterType, item string) error { - return lc.splitTunnel.AddItem(filterType, item) + filter := filterFromTypeAndItems(filterType, []string{item}) + return lc.client.AddSplitTunnelItems(lc.ctx, filter) } -// AddSplitTunnelItems adds multiple split tunnel rules from a comma-separated string func (lc *LanternCore) AddSplitTunnelItems(items string) error { split := splitCSVClean(items) + filter := platformFilter(split) + return lc.client.AddSplitTunnelItems(lc.ctx, filter) +} - var vpnFilter vpn.Filter - if common.IsMacOS() { - vpnFilter = vpn.Filter{ - ProcessPathRegex: split, - } - } else if common.IsWindows() { - vpnFilter = vpn.Filter{ - ProcessPath: split, - } - } else { - vpnFilter = vpn.Filter{ - PackageName: split, - } - } - - return lc.splitTunnel.AddItems(vpnFilter) +func (lc *LanternCore) RemoveSplitTunnelItem(filterType, item string) error { + filter := filterFromTypeAndItems(filterType, []string{item}) + return lc.client.RemoveSplitTunnelItems(lc.ctx, filter) } func (lc *LanternCore) RemoveSplitTunnelItems(items string) error { split := splitCSVClean(items) + filter := platformFilter(split) + return lc.client.RemoveSplitTunnelItems(lc.ctx, filter) +} - var vpnFilter vpn.Filter - if common.IsMacOS() { - vpnFilter = vpn.Filter{ - ProcessPathRegex: split, - } - } else if common.IsWindows() { - vpnFilter = vpn.Filter{ - ProcessPath: split, - } - } else { - vpnFilter = vpn.Filter{ - PackageName: split, - } +func (lc *LanternCore) GetSplitTunnelItems() (string, error) { + filter, err := lc.client.SplitTunnelFilters(lc.ctx) + if err != nil { + return "{}", nil + } + b, err := json.Marshal(filter) + if err != nil { + return "{}", nil } - return lc.splitTunnel.RemoveItems(vpnFilter) + return string(b), nil } -// RemoveSplitTunnelItem removes a single split tunnel rule -func (lc *LanternCore) RemoveSplitTunnelItem(filterType, item string) error { - return lc.splitTunnel.RemoveItem(filterType, item) +func (lc *LanternCore) GetSplitTunnelItemsFor(filterType string) (string, error) { + filter, err := lc.client.SplitTunnelFilters(lc.ctx) + if err != nil { + return "", err + } + items := itemsForType(filter, filterType) + b, err := json.Marshal(items) + if err != nil { + return "", err + } + return string(b), nil } -// resolveLogDir returns a directory that contains the logs -func resolveLogDir(logFilePath string) string { - p := strings.TrimSpace(logFilePath) - if p == "" { - return settings.GetString(settings.LogPathKey) +func (lc *LanternCore) GetEnabledApps() (string, error) { + filter, err := lc.client.SplitTunnelFilters(lc.ctx) + if err != nil { + return "", err } - if st, err := os.Stat(p); err == nil && st.IsDir() { - return p + // Initialize as empty slice so json.Marshal emits "[]" rather than + // "null" when no items are enabled — Dart's jsonDecode("null") returns + // null and the receiver does `as List`, which throws. Was the actual + // cause of "Failed to fetch installed apps" empty list in + // Freshdesk #173774 / #173778 / #173826. + enabledApps := []string{} + enabledApps = append(enabledApps, filter.ProcessPath...) + enabledApps = append(enabledApps, filter.ProcessPathRegex...) + enabledApps = append(enabledApps, filter.PackageName...) + b, err := json.Marshal(enabledApps) + if err != nil { + return "", err } - return filepath.Dir(p) + return string(b), nil } -// ReportIssue is used to send an issue report via Radiance. -// We include a few helpful config files plus the main Lantern + Flutter logs when available -func (lc *LanternCore) ReportIssue( - email, issueType, description, device, model, logFilePath string, -) error { - report := radiance.IssueReport{ - Type: issueType, - Description: description, - Device: device, - Model: model, +///////////////// +// Issue Report // +///////////////// + +func (lc *LanternCore) ReportIssue(email, issueType, description, device, model, logFilePath string) error { + it := parseIssueType(issueType) + var attachments []string + // Windows + Linux have separate UI and daemon logDirs, so the daemon's + // own archive glob misses UI-process logs — pass them through as paths. + // Mobile + macOS already share the directory; no pass-through needed. + // Relies on the UI logDir being readable by the daemon (SYSTEM on + // Windows); %PUBLIC%\Lantern\logs is chosen for that. + if runtime.GOOS == "windows" || runtime.GOOS == "linux" { + attachments = collectLocalLogs(settings.GetString(settings.LogPathKey)) } - - // Attach config files from the Lantern data directory - dataDir := settings.GetString(settings.DataPathKey) - configFiles := []string{ - "config.json", - "servers.json", - "split-tunnel.json", + if logFilePath != "" { + attachments = append(attachments, logFilePath) } - - for _, name := range configFiles { - path := filepath.Join(dataDir, name) - b, err := os.ReadFile(path) - if err != nil { - if !os.IsNotExist(err) { - slog.Error("Failed to read file for issue report", - "file", name, - "path", path, - "error", err, - ) - } - continue - } - if len(b) == 0 { + return lc.client.ReportIssue(lc.ctx, it, description, email, attachments) +} + +// collectLocalLogs returns every *.log directly under dir, with paths shaped +// however filepath.Glob returns them (relative if dir is relative; the +// daemon-side ReportIssue path on windows/linux passes the absolute +// settings.LogPathKey so this is absolute in practice). +// +// Files we can't os.Stat from the UI process are dropped. That's a +// best-effort screen, not a guarantee — the daemon runs as SYSTEM on +// Windows and may be able to read files this process can't, and vice +// versa. The drop avoids attaching obviously-broken paths to issue +// reports; the daemon's own readability check is authoritative. +func collectLocalLogs(dir string) []string { + if dir == "" { + return nil + } + matches, err := filepath.Glob(filepath.Join(dir, "*.log")) + if err != nil { + slog.Warn("ReportIssue: unable to glob local logs", "dir", dir, "err", err) + return nil + } + out := matches[:0] + for _, p := range matches { + if _, err := os.Stat(p); err != nil { + slog.Warn("ReportIssue: skipping log (unreadable from this process)", "path", p, "err", err) continue } - - report.Attachments = append(report.Attachments, &issue.Attachment{ - Name: name, - Data: b, - }) - } - - // On IOS flutter.log file should be attached separately - // since flutter.log is in a different location due to tunnel running in a different process - // On other platforms flutter.log is already included in the main Lantern log file - if logFilePath != "" { - report.Attachments = append( - report.Attachments, - utils.CreateLogAttachment(logFilePath)..., - ) + out = append(out, p) } + return out +} - // Send issue report via Radiance - if err := lc.rad.ReportIssue(email, report); err != nil { - return fmt.Errorf("error reporting issue: %w", err) +func parseIssueType(s string) issue.IssueType { + switch strings.ToLower(s) { + case "cannot_complete_purchase": + return issue.CannotCompletePurchase + case "cannot_sign_in": + return issue.CannotSignIn + case "spinner_loads_endlessly": + return issue.SpinnerLoadsEndlessly + case "cannot_access_blocked_sites": + return issue.CannotAccessBlockedSites + case "slow": + return issue.Slow + case "cannot_link_device": + return issue.CannotLinkDevice + case "application_crashes": + return issue.ApplicationCrashes + case "update_fails": + return issue.UpdateFails + default: + return issue.Other } - - slog.Debug("Reported issue", "type", issueType, "device", device, "model", model) - return nil } -// DataCapInfo returns information about this user's data cap. Only valid for free accounts -func (lc *LanternCore) DataCapInfo() (string, error) { - return lc.apiClient.DataCapInfo(context.Background()) -} +///////////////// +// Account // +///////////////// -// DataCapStream starts a stream to receive data cap updates -func (lc *LanternCore) DataCapStream(ctx context.Context) error { - return lc.apiClient.DataCapStream(ctx) +func (lc *LanternCore) DataCapInfo() (string, error) { + info, err := lc.client.DataCapInfo(lc.ctx) + if err != nil { + return "", err + } + jsonBytes, err := json.Marshal(info) + if err != nil { + return "", fmt.Errorf("error marshalling DataCapInfo: %w", err) + } + return string(jsonBytes), nil } -// User Methods -// UserData returns user data that has already been fetched. -// If user data has not been fetched yet (e.g., for a first-time user), this method will return an error. -// This is expected behavior and not necessarily a problem. func (lc *LanternCore) UserData() ([]byte, error) { - return lc.apiClient.UserData() + userData, err := lc.client.UserData(lc.ctx) + if err != nil { + return nil, err + } + return json.Marshal(userData) } -// FetchUserData will get the user data from the server func (lc *LanternCore) FetchUserData() ([]byte, error) { - return lc.apiClient.FetchUserData(context.Background()) + userData, err := lc.client.FetchUserData(lc.ctx) + if err != nil { + return nil, err + } + return json.Marshal(userData) } -// OAuth Methods func (lc *LanternCore) OAuthLoginUrl(provider string) (string, error) { - return lc.apiClient.OAuthLoginUrl(context.Background(), provider) + return lc.client.OAuthLoginURL(lc.ctx, provider) } func (lc *LanternCore) OAuthLoginCallback(oAuthToken string) ([]byte, error) { - return lc.apiClient.OAuthLoginCallback(context.Background(), oAuthToken) -} - -func (lc *LanternCore) StripeSubscriptionPaymentRedirect(subscriptionType, planID, email string) (string, error) { - redirectBody := api.PaymentRedirectData{ - Provider: "stripe", - Plan: planID, - DeviceName: settings.GetString(settings.DeviceIDKey), - Email: email, - BillingType: api.SubscriptionType(subscriptionType), + userData, err := lc.client.OAuthLoginCallback(lc.ctx, oAuthToken) + if err != nil { + return nil, err } - return lc.SubscriptionPaymentRedirectURL(redirectBody) + return json.Marshal(userData) } -func (lc *LanternCore) StripeSubscription(email, planID string) (string, error) { - slog.Debug("Creating stripe subscription") - return lc.apiClient.NewStripeSubscription(context.Background(), email, planID) +func (lc *LanternCore) Login(email, password string) ([]byte, error) { + userData, err := lc.client.Login(lc.ctx, email, password) + if err != nil { + return nil, err + } + return json.Marshal(userData) } -func (lc *LanternCore) Plans(channel string) (string, error) { - slog.Debug("Getting plans") - return lc.apiClient.SubscriptionPlans(context.Background(), channel) -} -func (lc *LanternCore) StripeBillingPortalUrl() (string, error) { - slog.Debug("Getting stripe billing portal") - return lc.apiClient.StripeBillingPortalUrl(context.Background()) +func (lc *LanternCore) SignUp(email, password string) error { + _, _, err := lc.client.SignUp(lc.ctx, email, password) + return err } -func (lc *LanternCore) AcknowledgeGooglePurchase(purchaseToken, planId string) (string, error) { - slog.Debug("Purchase token: ", "token", purchaseToken, "planId", planId) - params := map[string]string{ - "purchaseToken": purchaseToken, - "planId": planId, - } - status, err := lc.apiClient.VerifySubscription(context.Background(), api.GoogleService, params) +func (lc *LanternCore) Logout(email string) ([]byte, error) { + userData, err := lc.client.Logout(lc.ctx, email) if err != nil { - return "", fmt.Errorf("error acknowledging google purchase: %w", err) + return nil, err } - slog.Debug("acknowledge google purchase:", "status", status) - return status, nil + return json.Marshal(userData) } -func (lc *LanternCore) AcknowledgeApplePurchase(receipt, planII string) (string, error) { - params := map[string]string{ - "receipt": receipt, - "planId": planII, - } - data, err := lc.apiClient.VerifySubscription(context.Background(), api.AppleService, params) - if err != nil { - return "", fmt.Errorf("error acknowledging apple purchase: %w", err) - } - slog.Debug("acknowledge apple purchase: ", "data", data) - return data, nil +func (lc *LanternCore) StartRecoveryByEmail(email string) error { + return lc.client.StartRecoveryByEmail(lc.ctx, email) } -func (lc *LanternCore) SubscriptionPaymentRedirectURL(redirectBody api.PaymentRedirectData) (string, error) { - slog.Debug("Getting payment redirect URL") - return lc.apiClient.SubscriptionPaymentRedirectURL(context.Background(), redirectBody) +func (lc *LanternCore) ValidateChangeEmailCode(email, code string) error { + return lc.client.ValidateEmailRecoveryCode(lc.ctx, email, code) } -func (lc *LanternCore) PaymentRedirect(provider, planId, email string) (string, error) { - slog.Debug("Payment redirect") - deviceName := settings.GetString(settings.DeviceIDKey) - body := api.PaymentRedirectData{ - Provider: provider, - Plan: planId, - DeviceName: deviceName, - Email: email, - } - paymentRedirect, err := lc.apiClient.PaymentRedirect(context.Background(), body) +func (lc *LanternCore) CompleteRecoveryByEmail(email, password, code string) error { + return lc.client.CompleteRecoveryByEmail(lc.ctx, email, password, code) +} + +func (lc *LanternCore) DeleteAccount(email, password string) ([]byte, error) { + userData, err := lc.client.DeleteAccount(lc.ctx, email, password) if err != nil { - return "", fmt.Errorf("error getting payment redirect: %w", err) + return nil, err } - slog.Debug("Payment redirect response: ", "response", paymentRedirect) - return paymentRedirect, nil + return json.Marshal(userData) } -/// User management apis +func (lc *LanternCore) RemoveDevice(deviceID string) (*account.LinkResponse, error) { + return lc.client.RemoveDevice(lc.ctx, deviceID) +} -func (lc *LanternCore) Login(email, password string) ([]byte, error) { - slog.Debug("Logging in user") - return lc.apiClient.Login(context.Background(), email, password) +func (lc *LanternCore) StartChangeEmail(newEmail, password string) error { + return lc.client.StartChangeEmail(lc.ctx, newEmail, password) } -func (lc *LanternCore) SignUp(email, password string) error { - slog.Debug("Signing up user") - salt, body, err := lc.apiClient.SignUp(context.Background(), email, password) - if err != nil { - return fmt.Errorf("error signing up: %w", err) - } - slog.Debug("SignUp response: ", "salt", salt, "body", body) - return nil +func (lc *LanternCore) CompleteChangeEmail(email, password, code string) error { + return lc.client.CompleteChangeEmail(lc.ctx, email, password, code) } -func (lc *LanternCore) Logout(email string) ([]byte, error) { - slog.Debug("Logging out") - return lc.apiClient.Logout(context.Background(), email) +func (lc *LanternCore) ReferralAttachment(referralCode string) (bool, error) { + return lc.client.ReferralAttach(lc.ctx, referralCode) } -// Email Recovery Methods -// This will start the email recovery process by sending a recovery code to the user's email -func (lc *LanternCore) StartRecoveryByEmail(email string) error { - slog.Debug("Starting change email") - return lc.apiClient.StartRecoveryByEmail(context.Background(), email) +///////////////// +// Payments // +///////////////// + +func (lc *LanternCore) StripeSubscription(email, planID string) (string, error) { + return lc.client.NewStripeSubscription(lc.ctx, email, planID) } -// This will validate the recovery code sent to the user's email -func (lc *LanternCore) ValidateChangeEmailCode(email, code string) error { - slog.Debug("Validating change email code") - return lc.apiClient.ValidateEmailRecoveryCode(context.Background(), email, code) +func (lc *LanternCore) Plans(channel string) (string, error) { + return lc.client.SubscriptionPlans(lc.ctx, channel) } -// This will complete the email recovery by setting the new password -func (lc *LanternCore) CompleteRecoveryByEmail(email, password, code string) error { - slog.Debug("Completing email recovery") - return lc.apiClient.CompleteRecoveryByEmail(context.Background(), email, password, code) +func (lc *LanternCore) StripeBillingPortalUrl() (string, error) { + return lc.client.StripeBillingPortalURL(lc.ctx) +} + +func (lc *LanternCore) AcknowledgeGooglePurchase(purchaseToken, planId string) (string, error) { + params := map[string]string{ + "purchaseToken": purchaseToken, + "planId": planId, + } + return lc.client.VerifySubscription(lc.ctx, account.GoogleService, params) } -func (lc *LanternCore) DeleteAccount(email, password string, isOAuthUser bool) ([]byte, error) { - slog.Debug("Deleting account") - return lc.apiClient.DeleteAccount(context.Background(), email, password, isOAuthUser) +func (lc *LanternCore) AcknowledgeApplePurchase(receipt, planII string) (string, error) { + params := map[string]string{ + "receipt": receipt, + "planId": planII, + } + return lc.client.VerifySubscription(lc.ctx, account.AppleService, params) } -func (lc *LanternCore) RemoveDevice(deviceID string) (*api.LinkResponse, error) { - slog.Debug("Removing device: ", "deviceID", deviceID) - return lc.apiClient.RemoveDevice(context.Background(), deviceID) +func (lc *LanternCore) SubscriptionPaymentRedirectURL(redirectBody account.PaymentRedirectData) (string, error) { + return lc.client.SubscriptionPaymentRedirectURL(lc.ctx, redirectBody) } -// Change email -func (lc *LanternCore) StartChangeEmail(newEmail, password string) error { - slog.Debug("Starting change email") - return lc.apiClient.StartChangeEmail(context.Background(), newEmail, password) +func (lc *LanternCore) StripeSubscriptionPaymentRedirect(subscriptionType, planID, email string) (string, error) { + deviceID := lc.MyDeviceId() + redirectBody := account.PaymentRedirectData{ + Provider: "stripe", + Plan: planID, + DeviceName: deviceID, + Email: email, + BillingType: account.SubscriptionType(subscriptionType), + } + return lc.SubscriptionPaymentRedirectURL(redirectBody) } -func (lc *LanternCore) CompleteChangeEmail(email, password, code string) error { - slog.Debug("Completing change email") - return lc.apiClient.CompleteChangeEmail(context.Background(), email, password, code) +func (lc *LanternCore) PaymentRedirect(provider, planId, email string) (string, error) { + deviceName := lc.MyDeviceId() + body := account.PaymentRedirectData{ + Provider: provider, + Plan: planId, + DeviceName: deviceName, + Email: email, + } + return lc.client.PaymentRedirect(lc.ctx, body) } func (lc *LanternCore) ActivationCode(email, resellerCode string) error { - slog.Debug("Getting activation code") - purchase, err := lc.apiClient.ActivationCode(context.Background(), email, resellerCode) + purchase, err := lc.client.ActivationCode(lc.ctx, email, resellerCode) if err != nil { return fmt.Errorf("error getting activation code: %w", err) } - slog.Debug("ActivationCode response: ", "response", purchase) if purchase.Status != "ok" { return fmt.Errorf("activation code failed: %s", purchase.Status) } return nil } +///////////////////// +// Private Servers // +///////////////////// + func (lc *LanternCore) DigitalOceanPrivateServer(events utils.PrivateServerEventListener) error { - slog.Debug("Starting DigitalOcean private server flow") - return privateserver.StartDigitalOceanPrivateServerFlow(events, lc.serverManager) + return privateserver.StartDigitalOceanPrivateServerFlow(events, lc.client) } func (lc *LanternCore) GoogleCloudPrivateServer(events utils.PrivateServerEventListener) error { - return privateserver.StartGoogleCloudPrivateServerFlow(events, lc.serverManager) + return privateserver.StartGoogleCloudPrivateServerFlow(events, lc.client) } func (lc *LanternCore) ValidateSession() error { - slog.Debug("Validating private server session") return privateserver.ValidateSession(context.Background()) } func (lc *LanternCore) SelectAccount(account string) error { - slog.Debug("Selecting account: ", "account", account) return privateserver.SelectAccount(account) } func (lc *LanternCore) SelectProject(project string) error { - slog.Debug("Selecting project: ", "project", project) return privateserver.SelectProject(project) } @@ -778,19 +896,15 @@ func (lc *LanternCore) CancelDeployment() error { } func (lc *LanternCore) AddServerManagerInstance(ip, port, accessToken, tag string, events utils.PrivateServerEventListener) error { - return privateserver.AddServerManually(ip, port, accessToken, tag, lc.serverManager, events) + return privateserver.AddServerManually(ip, port, accessToken, tag, lc.client, events) } + func (lc *LanternCore) InviteToServerManagerInstance(ip, port, accessToken, inviteName string) (string, error) { portInt, err := parsePort(port) if err != nil { return "", err } - accessToken, err = lc.serverManager.InviteToPrivateServer(ip, portInt, accessToken, inviteName) - if err != nil { - return "", fmt.Errorf("error inviting to server manager instance: %w", err) - } - slog.Debug("Invite to server manager instance:", "ip", ip, "port", portInt, "name", inviteName) - return accessToken, nil + return lc.client.InviteToPrivateServer(lc.ctx, ip, portInt, accessToken, inviteName) } func (lc *LanternCore) RevokeServerManagerInvite(ip, port, accessToken, inviteName string) error { @@ -798,13 +912,11 @@ func (lc *LanternCore) RevokeServerManagerInvite(ip, port, accessToken, inviteNa if err != nil { return err } - slog.Debug("Revoking invite:", "name", inviteName, "ip", ip, "port", port) - return lc.serverManager.RevokePrivateServerInvite(ip, portInt, accessToken, inviteName) + return lc.client.RevokePrivateServerInvite(lc.ctx, ip, portInt, accessToken, inviteName) } func (lc *LanternCore) DeleteServer(tag string) error { - slog.Debug("Deleting server with tag: ", "tag", tag) - return lc.serverManager.RemoveServer(tag) + return lc.client.RemoveServers(lc.ctx, []string{tag}) } func (lc *LanternCore) UpdatePrivateServerName(oldTag, newTag string) error { @@ -815,54 +927,54 @@ func (lc *LanternCore) UpdatePrivateServerName(oldTag, newTag string) error { return nil } - // Ensure the source exists in user servers. - userServers := lc.serverManager.Servers()[servers.SGUser] - sourceExists := false - for _, ep := range userServers.Endpoints { - if ep.Tag == oldTag { - sourceExists = true - break - } - } - if !sourceExists { - for _, out := range userServers.Outbounds { - if out.Tag == oldTag { - sourceExists = true - break - } - } + // Find source server + source, exists, err := lc.client.GetServerByTag(lc.ctx, oldTag) + if err != nil { + return fmt.Errorf("failed to get server %q: %w", oldTag, err) } - if !sourceExists { + if !exists { return fmt.Errorf("server with tag %q not found", oldTag) } - // Prevent collisions against any existing server tag. - if _, exists := lc.serverManager.GetServerByTag(newTag); exists { + // Check new tag doesn't collide + _, collision, _ := lc.client.GetServerByTag(lc.ctx, newTag) + if collision { return fmt.Errorf("server with tag %q already exists", newTag) } - for i, ep := range userServers.Endpoints { - if ep.Tag == oldTag { - userServers.Endpoints[i].Tag = newTag - } + // Remove old, add renamed copy + if err := lc.client.RemoveServers(lc.ctx, []string{oldTag}); err != nil { + return fmt.Errorf("failed to remove old server %q: %w", oldTag, err) } - for i, out := range userServers.Outbounds { - if out.Tag == oldTag { - userServers.Outbounds[i].Tag = newTag - } + source.Tag = newTag + list := servers.ServerList{Servers: []*servers.Server{source}} + if err := lc.client.AddServers(lc.ctx, list); err != nil { + return fmt.Errorf("failed to add renamed server %q: %w", newTag, err) } - if loc, ok := userServers.Locations[oldTag]; ok { - delete(userServers.Locations, oldTag) - userServers.Locations[newTag] = loc + return nil +} + +func (lc *LanternCore) AddServersByURL(urls string, skipCertVerification bool) ([]byte, error) { + urlList := strings.Split(urls, ",") + for i, u := range urlList { + urlList[i] = strings.TrimSpace(u) } - if err := lc.serverManager.SetServers(servers.SGUser, userServers); err != nil { - return fmt.Errorf("failed to rename private server %q to %q: %w", oldTag, newTag, err) + + tags, err := lc.client.AddServersByURL(lc.ctx, urlList, skipCertVerification) + if err != nil { + return nil, err } - return nil + + return json.Marshal(tags) } +///////////////// +// Helpers // +///////////////// + func parsePort(port string) (int, error) { - portInt, err := strconv.Atoi(port) + portInt := 0 + _, err := fmt.Sscanf(port, "%d", &portInt) if err != nil { return 0, fmt.Errorf("invalid port %q: %w", port, err) } @@ -872,29 +984,6 @@ func parsePort(port string) (int, error) { return portInt, nil } -func (lc *LanternCore) SetBlockAdsEnabled(enabled bool) error { - return vpn.SetAdBlock(enabled) -} - -func (lc *LanternCore) IsBlockAdsEnabled() bool { - return vpn.AdBlockEnabled() -} - -func (lc *LanternCore) SetSmartRoutingEnabled(enabled bool) error { - return vpn.SetSmartRouting(enabled) -} - -func (lc *LanternCore) IsSmartRoutingEnabled() bool { - return vpn.SmartRoutingEnabled() -} - -func (lc *LanternCore) AddServerBasedOnURLs(urls string, skipCertVerification bool, serverName string) error { - slog.Debug("Adding server based on URLs", "urls", urls, "skipCertVerification", skipCertVerification) - return lc.serverManager.AddServerBasedOnURLs(context.Background(), urls, skipCertVerification, serverName) -} - -// splitCSVClean splits a comma-separated string into a stable list -// It trims whitespace and surrounding quotes and removes duplicates func splitCSVClean(s string) []string { raw := strings.Split(s, ",") out := make([]string, 0, len(raw)) @@ -917,103 +1006,57 @@ func splitCSVClean(s string) []string { return out } -func (lc *LanternCore) GetSplitTunnelStateJSON() (string, error) { - path := filepath.Join(settings.GetString(settings.DataPathKey), "split-tunnel.json") - b, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return `{}`, nil - } - return "", err - } - if len(b) == 0 { - return `{}`, nil - } - return string(b), nil -} - -func (lc *LanternCore) splitTunnelHandler() (*vpn.SplitTunnel, error) { - if lc.splitTunnel != nil { - return lc.splitTunnel, nil - } - st, err := vpn.NewSplitTunnelHandler() - if err != nil { - return nil, err - } - lc.splitTunnel = st - return st, nil -} - -func (lc *LanternCore) GetSplitTunnelItems(filterType string) (string, error) { - st, err := lc.splitTunnelHandler() - if err != nil { - return "", err - } - return st.ItemsJSON(filterType) -} - -func jsonNumberToIntString(f float64) string { - // ports are integral; safe enough here - return string([]byte((func() string { - n := int(f) - return itoa(n) - })())) -} - -// tiny local itoa to avoid importing strconv in this file (optional) -func itoa(n int) string { - if n == 0 { - return "0" - } - neg := n < 0 - if neg { - n = -n - } - buf := make([]byte, 0, 12) - for n > 0 { - d := n % 10 - buf = append(buf, byte('0'+d)) - n /= 10 - } - if neg { - buf = append(buf, '-') +func platformFilter(items []string) vpn.SplitTunnelFilter { + if common.IsMacOS() { + return vpn.SplitTunnelFilter{ProcessPathRegex: items} + } else if common.IsWindows() { + return vpn.SplitTunnelFilter{ProcessPath: items} } - // reverse - for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 { - buf[i], buf[j] = buf[j], buf[i] + return vpn.SplitTunnelFilter{PackageName: items} +} + +func filterFromTypeAndItems(filterType string, items []string) vpn.SplitTunnelFilter { + switch filterType { + case vpn.TypeDomain: + return vpn.SplitTunnelFilter{Domain: items} + case vpn.TypeDomainSuffix: + return vpn.SplitTunnelFilter{DomainSuffix: items} + case vpn.TypeDomainKeyword: + return vpn.SplitTunnelFilter{DomainKeyword: items} + case vpn.TypeDomainRegex: + return vpn.SplitTunnelFilter{DomainRegex: items} + case vpn.TypeProcessName: + return vpn.SplitTunnelFilter{ProcessName: items} + case vpn.TypeProcessPath: + return vpn.SplitTunnelFilter{ProcessPath: items} + case vpn.TypeProcessPathRegex: + return vpn.SplitTunnelFilter{ProcessPathRegex: items} + case vpn.TypePackageName: + return vpn.SplitTunnelFilter{PackageName: items} + default: + return vpn.SplitTunnelFilter{} } - return string(buf) -} - -func (lc *LanternCore) GetAppDataDir() string { - return settings.GetString(settings.DataPathKey) } -func (lc *LanternCore) GetEnabledApps() (string, error) { - st, err := lc.splitTunnelHandler() - if err != nil { - return "", err - } - return st.EnabledAppsJSON() -} - -// fixStaleSettingsFilePath corrects a stale "file_path" entry in local.json that can -// occur after the iOS migration from the App Group root to the data/ subdirectory. -// The radiance settings watcher reads "file_path" from local.json to decide which -// directory to watch via fsnotify. If it still points to the App Group root, the -// Network Extension sandbox blocks lstat on -// .com.apple.mobile_container_manager.metadata.plist, causing the tunnel to fail. -// The main app always runs before the tunnel (user must tap Connect), so this fix -// is guaranteed to persist to disk before the tunnel process starts. -func fixStaleSettingsFilePath(dataDir string) { - // "file_path" and "local.json" mirror unexported constants in the radiance settings package. - expected := filepath.Join(dataDir, "local.json") - current := settings.GetString("file_path") - if current == "" || current == expected { - return - } - slog.Info("Fixing stale file_path in settings", "from", current, "to", expected) - if err := settings.Set("file_path", expected); err != nil { - slog.Warn("Failed to fix file_path in settings", "error", err) +func itemsForType(filter vpn.SplitTunnelFilter, filterType string) []string { + switch filterType { + case vpn.TypeDomain: + return filter.Domain + case vpn.TypeDomainSuffix: + return filter.DomainSuffix + case vpn.TypeDomainKeyword: + return filter.DomainKeyword + case vpn.TypeDomainRegex: + return filter.DomainRegex + case vpn.TypeProcessName: + return filter.ProcessName + case vpn.TypeProcessPath: + return filter.ProcessPath + case vpn.TypeProcessPathRegex: + return filter.ProcessPathRegex + case vpn.TypePackageName: + return filter.PackageName + default: + return nil } } diff --git a/lantern-core/ffi/ffi.go b/lantern-core/ffi/ffi.go index 1eec4e13f2..5d3ab945a2 100644 --- a/lantern-core/ffi/ffi.go +++ b/lantern-core/ffi/ffi.go @@ -10,28 +10,32 @@ package main import "C" import ( + "context" "encoding/base64" "encoding/json" "fmt" "log/slog" + "sync" "sync/atomic" + "time" "unsafe" - "github.com/getlantern/radiance/common" - lanterncore "github.com/getlantern/lantern/lantern-core" "github.com/getlantern/lantern/lantern-core/apps" "github.com/getlantern/lantern/lantern-core/dart_api_dl" + "github.com/getlantern/lantern/lantern-core/logs" "github.com/getlantern/lantern/lantern-core/utils" - "github.com/getlantern/lantern/lantern-core/vpn_tunnel" + + "github.com/getlantern/radiance/common/settings" + "github.com/getlantern/radiance/vpn" ) -// runOnGoStack wraps common.RunOffCgoStack for FFI functions that return *C.char. +// runOnGoStack wraps utils.RunOffCgoStack for FFI functions that return *C.char. // CGo-exported functions run on a callback stack whose memory isn't tracked -// by the GC heap bitmap. Allocating Go pointers (like C.CString or base64 -// encoding) on that stack triggers bulkBarrierPreWrite panics. +// by the GC heap bitmap. Allocating Go pointers (like C.CString) on that stack +// triggers bulkBarrierPreWrite panics. func runOnGoStack(fn func() *C.char) *C.char { - result, _ := common.RunOffCgoStack(func() (*C.char, error) { + result, _ := utils.RunOffCgoStack(func() (*C.char, error) { return fn(), nil }) return result @@ -51,11 +55,12 @@ const ( var ( lanternCore atomic.Pointer[lanterncore.Core] - appsPort int64 - logsPort int64 - statusPort int64 - privateserverPort int64 - appEventPort int64 + appDataDir string + appsPort atomic.Int64 + logsPort atomic.Int64 + statusPort atomic.Int64 + privateserverPort atomic.Int64 + appEventPort atomic.Int64 ) func requireCore() (lanterncore.Core, *C.char) { @@ -66,6 +71,13 @@ func requireCore() (lanterncore.Core, *C.char) { return *c, nil } +//export getAppDataDir +func getAppDataDir() *C.char { + return runOnGoStack(func() *C.char { + return C.CString(appDataDir) + }) +} + func sendApps(port int64) func(apps ...*apps.AppData) error { return func(apps ...*apps.AppData) error { data, err := json.Marshal(apps) @@ -83,7 +95,8 @@ type ffiFlutterEventEmitter struct{} func (e *ffiFlutterEventEmitter) SendEvent(event *utils.FlutterEvent) { slog.Debug("Sending event to Flutter:", "event", event) - if appEventPort == 0 { + port := appEventPort.Load() + if port == 0 { slog.Error("Apps port is not set, cannot send event") return } @@ -93,17 +106,22 @@ func (e *ffiFlutterEventEmitter) SendEvent(event *utils.FlutterEvent) { return } slog.Debug("Marshalled event data:", "data", string(eventData)) - go dart_api_dl.SendToPort(appEventPort, string(eventData)) + go dart_api_dl.SendToPort(port, string(eventData)) } //export setup func setup(_logDir, _dataDir, _locale, _env *C.char, logP, appsP, statusP, privateServerP, appEventP C.int64_t, consent C.int, api unsafe.Pointer) *C.char { + logDir := C.GoString(_logDir) + dataDir := C.GoString(_dataDir) + appDataDir = dataDir + locale := C.GoString(_locale) + env := C.GoString(_env) return runOnGoStack(func() *C.char { core, err := lanterncore.New(&utils.Opts{ - LogDir: C.GoString(_logDir), - DataDir: C.GoString(_dataDir), - Locale: C.GoString(_locale), - Env: C.GoString(_env), + LogDir: logDir, + DataDir: dataDir, + Locale: locale, + Env: env, Deviceid: "", LogLevel: lanterncore.DefaultLogLevel, TelemetryConsent: consent == 1, @@ -114,11 +132,17 @@ func setup(_logDir, _dataDir, _locale, _env *C.char, logP, appsP, statusP, priva } dart_api_dl.Init(api) lanternCore.Store(&core) - logsPort = int64(logP) - appsPort = int64(appsP) - statusPort = int64(statusP) - privateserverPort = int64(privateServerP) - appEventPort = int64(appEventP) + logsPort.Store(int64(logP)) + appsPort.Store(int64(appsP)) + statusPort.Store(int64(statusP)) + privateserverPort.Store(int64(privateServerP)) + appEventPort.Store(int64(appEventP)) + + // Start the VPN status listener immediately so the UI reflects the + // current VPN state even if the VPN was already connected (e.g. macOS + // system extension started before the Flutter app). + startStatusListener(core) + startLogsListener(core) slog.Debug("Radiance setup successfully") return C.CString("ok") @@ -134,14 +158,42 @@ func updateTelemetryConsent(consent C.int) *C.char { if errStr != nil { return errStr } - err := c.UpdateTelemetryConsent(consent != 0) - if err != nil { + if err := c.UpdateTelemetryConsent(consent != 0); err != nil { return SendError(err) } return C.CString("ok") }) } +//export isTelemetryEnabled +func isTelemetryEnabled() C.int { + c, _ := requireCore() + if c != nil && c.IsTelemetryEnabled() { + return 1 + } + return 0 +} + +//export isOAuthLogin +func isOAuthLogin() C.int { + c, _ := requireCore() + if c != nil && c.IsOAuthLogin() { + return 1 + } + return 0 +} + +//export getOAuthProvider +func getOAuthProvider() *C.char { + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr + } + return C.CString(c.GetOAuthProvider()) + }) +} + // availableFeatures returns a list of available features in JSON format. // //export availableFeatures @@ -157,50 +209,46 @@ func availableFeatures() *C.char { //export updateLocale func updateLocale(_locale *C.char) *C.char { + locale := C.GoString(_locale) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - c.UpdateLocale(C.GoString(_locale)) + c.UpdateLocale(locale) return C.CString("ok") }) } //export addSplitTunnelItem func addSplitTunnelItem(filterTypeC, itemC *C.char) *C.char { + filterType := C.GoString(filterTypeC) + item := C.GoString(itemC) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - - filterType := C.GoString(filterTypeC) - item := C.GoString(itemC) - if err := c.AddSplitTunnelItem(filterType, item); err != nil { - return C.CString(fmt.Sprintf("error adding item: %v", err)) + return SendError(err) } - slog.Debug("added split tunneling item", "filterType", filterType, "item", item) - return nil + return C.CString("ok") }) } //export removeSplitTunnelItem func removeSplitTunnelItem(filterTypeC, itemC *C.char) *C.char { + filterType := C.GoString(filterTypeC) + item := C.GoString(itemC) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - filterType := C.GoString(filterTypeC) - item := C.GoString(itemC) - if err := c.RemoveSplitTunnelItem(filterType, item); err != nil { - return C.CString(fmt.Sprintf("error removing item: %v", err)) + return SendError(err) } - slog.Debug("removed split tunneling item", "filterType", filterType, "item", item) - return nil + return C.CString("ok") }) } @@ -211,10 +259,8 @@ func setSplitTunnelingEnabled(enabled C.int) *C.char { if errStr != nil { return errStr } - if enabled != 0 { - c.SetSplitTunnelingEnabled(true) - } else { - c.SetSplitTunnelingEnabled(false) + if err := c.SetSplitTunnelingEnabled(enabled != 0); err != nil { + return SendError(err) } return C.CString("ok") }) @@ -231,12 +277,13 @@ func isSplitTunnelingEnabled() C.int { //export loadInstalledApps func loadInstalledApps(dataDir *C.char) *C.char { + dir := C.GoString(dataDir) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - appsJson, err := c.LoadInstalledApps(C.GoString(dataDir)) + appsJson, err := c.LoadInstalledApps(dir) if err != nil { return C.CString(fmt.Sprintf("error loading installed apps: %v", err)) } @@ -278,54 +325,55 @@ func getDataCapInfo() *C.char { //export reportIssue func reportIssue(emailC, typeC, descC, deviceC, modelC, logPathC *C.char) *C.char { + email := C.GoString(emailC) + issueType := C.GoString(typeC) + desc := C.GoString(descC) + device := C.GoString(deviceC) + model := C.GoString(modelC) + logPath := C.GoString(logPathC) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - email := C.GoString(emailC) - issueType := C.GoString(typeC) - desc := C.GoString(descC) - device := C.GoString(deviceC) - model := C.GoString(modelC) - logPath := C.GoString(logPathC) - if err := c.ReportIssue(email, issueType, desc, device, model, logPath); err != nil { return C.CString(fmt.Sprintf("error reporting issue: %v", err)) } - - slog.Debug( - "Reported issue: %s – %s on %s/%s", - email, issueType, device, model, - ) return C.CString("ok") }) } -// getAutoLocation returns the auto location in JSON format. +// getSelectedServerJSON returns the selected server response as raw JSON. // -//export getAutoLocation -func getAutoLocation() *C.char { +//export getSelectedServerJSON +func getSelectedServerJSON() *C.char { return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - location, err := vpn_tunnel.GetAutoLocation() + data, err := c.GetSelectedServerJSON() if err != nil { return SendError(err) } + return C.CString(string(data)) + }) +} - // Use GetServerByTagJSON which marshals internally, avoiding GC write - // barrier panics when pointer-rich Server types are copied on the CGo stack. - jsonBytes, ok, err := c.GetServerByTagJSON(location.Lantern) - if err != nil { - return SendError(fmt.Errorf("error marshalling server: %v", err)) +// getAutoLocation returns the auto location in JSON format. +// +//export getAutoLocation +func getAutoLocation() *C.char { + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr } - if !ok { - return SendError(fmt.Errorf("error finding server with tag: %s", location.Lantern)) + data, err := c.GetAutoLocationJSON() + if err != nil { + return SendError(err) } - return C.CString(string(jsonBytes)) + return C.CString(string(data)) }) } @@ -335,8 +383,8 @@ func getAutoLocation() *C.char { // //export isTagAvailable func isTagAvailable(_tag *C.char) *C.char { + tag := C.GoString(_tag) return runOnGoStack(func() *C.char { - tag := C.GoString(_tag) c, errStr := requireCore() if errStr != nil { slog.Warn("Unable to check tag availability (core not ready), assuming available", "tag", tag) @@ -355,60 +403,194 @@ func isTagAvailable(_tag *C.char) *C.char { }) } -// startAutoLocationListener starts the auto location listener. +// GetAvailableServers returns the available servers in JSON format. // -//export startAutoLocationListener -func startAutoLocationListener() *C.char { +//export getAvailableServers +func getAvailableServers() *C.char { + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr + } + return C.CString(string(c.GetAvailableServers())) + }) +} + +func sendStatusToPort(status VPNStatus, errMsg string) { + slog.Debug("sendStatusToPort called", "status", status) + port := statusPort.Load() + if port == 0 { + slog.Error("Status port is not set, cannot send status") + return + } + msg := map[string]any{"status": status} + if errMsg != "" { + msg["error"] = errMsg + } + slog.Debug("Sending status to port", "port", port) + data, _ := json.Marshal(msg) + slog.Debug("Marshalled status data", "data", string(data)) + dart_api_dl.SendToPort(port, string(data)) + slog.Debug("Status sent to port successfully", "status", status) +} + +var ( + statusListenerOnce sync.Once + statusListenerLastMu sync.Mutex + statusListenerLast string +) + +// startStatusListener subscribes to radiance's VPN status SSE stream and +// forwards status changes to Flutter via the Dart status port. +func startStatusListener(c lanterncore.Core) { + statusListenerOnce.Do(func() { + go func() { + for { + if statusPort.Load() == 0 { + time.Sleep(100 * time.Millisecond) + continue + } + c.VPNStatusEvents(context.Background(), func(evt vpn.StatusUpdateEvent) { + ui, errMsg := mapStatusEvent(evt) + + statusListenerLastMu.Lock() + changed := ui != statusListenerLast + if changed { + statusListenerLast = ui + } + statusListenerLastMu.Unlock() + + if changed { + sendStatusToPort(VPNStatus(ui), errMsg) + } + }) + // SSE stream disconnected — retry after a short delay. + time.Sleep(500 * time.Millisecond) + } + }() + }) +} + +var logsListenerOnce sync.Once + +// startLogsListener subscribes to radiance's log SSE stream and forwards each +// entry to Flutter via the Dart logs port. +func startLogsListener(c lanterncore.Core) { + logsListenerOnce.Do(func() { + go func() { + for { + port := logsPort.Load() + if port == 0 { + time.Sleep(100 * time.Millisecond) + continue + } + err := logs.Subscribe(context.Background(), c.Client(), func(entry string) { + dart_api_dl.SendToPort(logsPort.Load(), entry) + }) + if err != nil { + slog.Debug("log stream disconnected", "error", err) + } + time.Sleep(500 * time.Millisecond) + } + }() + }) +} + +func mapStatusEvent(evt vpn.StatusUpdateEvent) (string, string) { + if evt.Error != "" { + return string(Error), evt.Error + } + switch evt.Status { + case vpn.Connected: + return string(Connected), "" + case vpn.Connecting, vpn.Restarting: + return string(Connecting), "" + case vpn.Disconnecting: + return string(Disconnecting), "" + case vpn.Disconnected: + return string(Disconnected), "" + case vpn.ErrorStatus: + return string(Error), "" + default: + return string(Disconnected), "" + } +} + +//export startVPN +func startVPN() *C.char { return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - c.StartBackgroundListeners() + startStatusListener(c) + + if err := checkDaemonReachable(c); err != nil { + return C.CString(err.Error()) + } + + if err := c.ConnectVPN(""); err != nil { + return C.CString(fmt.Sprintf("start service failed: %v", err)) + } + return C.CString("ok") }) } -// stopAutoLocationListener stops the auto location listener. -// -//export stopAutoLocationListener -func stopAutoLocationListener() *C.char { +//export stopVPN +func stopVPN() *C.char { return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - c.StopBackgroundListeners() + + if err := c.DisconnectVPN(); err != nil { + return C.CString(fmt.Sprintf("stop service failed: %v", err)) + } + return C.CString("ok") }) } -// GetAvailableServers returns the available servers in JSON format. -// -//export getAvailableServers -func getAvailableServers() *C.char { +//export connectToServer +func connectToServer(_tag *C.char) *C.char { + tag := C.GoString(_tag) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - return C.CString(string(c.GetAvailableServers())) + startStatusListener(c) + + if err := checkDaemonReachable(c); err != nil { + return SendError(err) + } + + // LanternCore.ConnectVPN picks between /vpn/connect and /server/selected + // based on VPNStatus — no dispatch needed here. + if err := c.ConnectVPN(tag); err != nil { + return SendError(fmt.Errorf("start service failed: %w", err)) + } + return C.CString("ok") }) } -func sendStatusToPort(status VPNStatus) { - slog.Debug("sendStatusToPort called", "status", status) - if statusPort == 0 { - slog.Error("Status port is not set, cannot send status") - return +//export isVPNConnected +func isVPNConnected() C.int { + c, errStr := requireCore() + if errStr != nil { + return 0 } - msg := map[string]any{"status": status} - slog.Debug("Sending status to port", "port", statusPort) - data, _ := json.Marshal(msg) - slog.Debug("Marshalled status data", "data", string(data)) - dart_api_dl.SendToPort(statusPort, string(data)) - slog.Debug("Status sent to port successfully", "status", status) + running, err := c.IsVPNRunning() + if err != nil { + return 0 + } + if running { + return 1 + } + return 0 } // APIS @@ -426,8 +608,7 @@ func getUserData() *C.char { if err != nil { return SendError(err) } - encoded := base64.StdEncoding.EncodeToString(bytes) - return C.CString(encoded) + return C.CString(string(bytes)) }) } @@ -445,8 +626,7 @@ func fetchUserData() *C.char { if err != nil { return SendError(fmt.Errorf("error fetching user data: %v", err)) } - encoded := base64.StdEncoding.EncodeToString(bytes) - return C.CString(encoded) + return C.CString(string(bytes)) }) } @@ -454,21 +634,18 @@ func fetchUserData() *C.char { // //export stripeSubscriptionPaymentRedirect func stripeSubscriptionPaymentRedirect(subType, _planId, _email *C.char) *C.char { + subscriptionType := C.GoString(subType) + planID := C.GoString(_planId) + email := C.GoString(_email) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - slog.Debug("stripeSubscriptionPaymentRedirect called") - subscriptionType := C.GoString(subType) - planID := C.GoString(_planId) - email := C.GoString(_email) - slog.Debug("subscription type:", "subscriptionType", subscriptionType) redirect, err := c.StripeSubscriptionPaymentRedirect(subscriptionType, planID, email) if err != nil { return SendError(err) } - slog.Debug("stripeSubscriptionPaymentRedirect response:", "redirect", redirect) return C.CString(redirect) }) } @@ -477,20 +654,18 @@ func stripeSubscriptionPaymentRedirect(subType, _planId, _email *C.char) *C.char // //export paymentRedirect func paymentRedirect(_plan, _provider, _email *C.char) *C.char { + plan := C.GoString(_plan) + provider := C.GoString(_provider) + email := C.GoString(_email) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - plan := C.GoString(_plan) - provider := C.GoString(_provider) - email := C.GoString(_email) - redirect, err := c.PaymentRedirect(provider, plan, email) if err != nil { return SendError(err) } - slog.Debug("PaymentRedirect response:", "redirect", redirect) return C.CString(redirect) }) } @@ -508,7 +683,6 @@ func stripeBillingPortalUrl() *C.char { if err != nil { return SendError(err) } - slog.Debug("StripeBillingPortalUrl response", "url", url) return C.CString(url) }) } @@ -522,7 +696,6 @@ func plans() *C.char { if errStr != nil { return errStr } - slog.Debug("Getting plans") jsonData, err := c.Plans("non-store") if err != nil { return SendError(err) @@ -535,12 +708,13 @@ func plans() *C.char { // //export oauthLoginUrl func oauthLoginUrl(_provider *C.char) *C.char { + provider := C.GoString(_provider) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - url, err := c.OAuthLoginUrl(C.GoString(_provider)) + url, err := c.OAuthLoginUrl(provider) if err != nil { return SendError(err) } @@ -560,7 +734,7 @@ func oAuthLoginCallback(_oAuthToken *C.char) *C.char { if err != nil { return SendError(err) } - return C.CString(base64.StdEncoding.EncodeToString(bytes)) + return C.CString(string(bytes)) }) } @@ -580,7 +754,7 @@ func login(_email, _password *C.char) *C.char { if err != nil { return SendError(err) } - return C.CString(base64.StdEncoding.EncodeToString(bytes)) + return C.CString(string(bytes)) }) } @@ -611,7 +785,7 @@ func logout(_email *C.char) *C.char { if err != nil { return SendError(err) } - return C.CString(base64.StdEncoding.EncodeToString(bytes)) + return C.CString(string(bytes)) }) } @@ -619,12 +793,13 @@ func logout(_email *C.char) *C.char { // //export startRecoveryByEmail func startRecoveryByEmail(_email *C.char) *C.char { + email := C.GoString(_email) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - if err := c.StartRecoveryByEmail(C.GoString(_email)); err != nil { + if err := c.StartRecoveryByEmail(email); err != nil { return SendError(err) } return C.CString("ok") @@ -635,12 +810,13 @@ func startRecoveryByEmail(_email *C.char) *C.char { // //export validateEmailRecoveryCode func validateEmailRecoveryCode(_email, _code *C.char) *C.char { + email, code := C.GoString(_email), C.GoString(_code) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - if err := c.ValidateChangeEmailCode(C.GoString(_email), C.GoString(_code)); err != nil { + if err := c.ValidateChangeEmailCode(email, code); err != nil { return SendError(fmt.Errorf("invalid_code: %v", err)) } return C.CString("ok") @@ -651,12 +827,13 @@ func validateEmailRecoveryCode(_email, _code *C.char) *C.char { // //export completeRecoveryByEmail func completeRecoveryByEmail(_email, _newPassword, _code *C.char) *C.char { + email, newPassword, code := C.GoString(_email), C.GoString(_newPassword), C.GoString(_code) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - if err := c.CompleteRecoveryByEmail(C.GoString(_email), C.GoString(_newPassword), C.GoString(_code)); err != nil { + if err := c.CompleteRecoveryByEmail(email, newPassword, code); err != nil { return SendError(err) } return C.CString("ok") @@ -667,12 +844,13 @@ func completeRecoveryByEmail(_email, _newPassword, _code *C.char) *C.char { // //export removeDevice func removeDevice(deviceId *C.char) *C.char { + id := C.GoString(deviceId) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - if _, err := c.RemoveDevice(C.GoString(deviceId)); err != nil { + if _, err := c.RemoveDevice(id); err != nil { return SendError(err) } return C.CString("ok") @@ -683,12 +861,13 @@ func removeDevice(deviceId *C.char) *C.char { // //export referralAttachment func referralAttachment(_referralCode *C.char) *C.char { + referralCode := C.GoString(_referralCode) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - ok, err := c.ReferralAttachment(C.GoString(_referralCode)) + ok, err := c.ReferralAttachment(referralCode) if err != nil { return SendError(err) } @@ -703,12 +882,13 @@ func referralAttachment(_referralCode *C.char) *C.char { // //export startChangeEmail func startChangeEmail(_newEmail, _password *C.char) *C.char { + newEmail, password := C.GoString(_newEmail), C.GoString(_password) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - if err := c.StartChangeEmail(C.GoString(_newEmail), C.GoString(_password)); err != nil { + if err := c.StartChangeEmail(newEmail, password); err != nil { return SendError(err) } return C.CString("ok") @@ -719,12 +899,13 @@ func startChangeEmail(_newEmail, _password *C.char) *C.char { // //export completeChangeEmail func completeChangeEmail(_newEmail, _password, _code *C.char) *C.char { + newEmail, password, code := C.GoString(_newEmail), C.GoString(_password), C.GoString(_code) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - if err := c.CompleteChangeEmail(C.GoString(_newEmail), C.GoString(_password), C.GoString(_code)); err != nil { + if err := c.CompleteChangeEmail(newEmail, password, code); err != nil { return SendError(err) } return C.CString("ok") @@ -734,18 +915,18 @@ func completeChangeEmail(_newEmail, _password, _code *C.char) *C.char { // Delete account permanently // //export deleteAccount -func deleteAccount(_email, _password *C.char, _isSSO C.int) *C.char { - email, password, isSSO := C.GoString(_email), C.GoString(_password), _isSSO != 0 +func deleteAccount(_email, _password *C.char) *C.char { + email, password := C.GoString(_email), C.GoString(_password) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - bytes, err := c.DeleteAccount(email, password, isSSO) + bytes, err := c.DeleteAccount(email, password) if err != nil { return SendError(err) } - return C.CString(base64.StdEncoding.EncodeToString(bytes)) + return C.CString(string(bytes)) }) } @@ -753,12 +934,13 @@ func deleteAccount(_email, _password *C.char, _isSSO C.int) *C.char { // //export activationCode func activationCode(_email, _resellerCode *C.char) *C.char { + email, resellerCode := C.GoString(_email), C.GoString(_resellerCode) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - if err := c.ActivationCode(C.GoString(_email), C.GoString(_resellerCode)); err != nil { + if err := c.ActivationCode(email, resellerCode); err != nil { return SendError(err) } return C.CString("ok") @@ -770,6 +952,116 @@ func freeCString(cstr *C.char) { C.free(unsafe.Pointer(cstr)) } +// patchSettings applies a JSON-encoded settings.Settings patch on the daemon. +// +//export patchSettings +func patchSettings(patchJSON *C.char) *C.char { + raw := C.GoString(patchJSON) + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr + } + var updates settings.Settings + if err := json.Unmarshal([]byte(raw), &updates); err != nil { + return SendError(fmt.Errorf("invalid settings JSON: %w", err)) + } + if err := c.PatchSettings(updates); err != nil { + return SendError(err) + } + return C.CString("ok") + }) +} + +// getSettings returns the daemon's current settings as JSON. +// +//export getSettings +func getSettings() *C.char { + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr + } + data, err := c.GetSettingsJSON() + if err != nil { + return SendError(err) + } + return C.CString(string(data)) + }) +} + +// patchEnvVars applies a JSON-encoded map[string]string patch on the daemon's +// in-memory env vars. Returns the resulting env map as JSON. +// +//export patchEnvVars +func patchEnvVars(patchJSON *C.char) *C.char { + raw := C.GoString(patchJSON) + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr + } + var updates map[string]string + if err := json.Unmarshal([]byte(raw), &updates); err != nil { + return SendError(fmt.Errorf("invalid env JSON: %w", err)) + } + result, err := c.PatchEnvVars(updates) + if err != nil { + return SendError(err) + } + data, err := json.Marshal(result) + if err != nil { + return SendError(err) + } + return C.CString(string(data)) + }) +} + +// getEnvVars returns the daemon's in-memory env vars as JSON. +// +//export getEnvVars +func getEnvVars() *C.char { + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr + } + data, err := json.Marshal(c.GetEnvVars()) + if err != nil { + return SendError(err) + } + return C.CString(string(data)) + }) +} + +//export runURLTests +func runURLTests() *C.char { + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr + } + if err := c.RunOfflineURLTests(); err != nil { + return SendError(err) + } + return C.CString("ok") + }) +} + +//export updateConfig +func updateConfig() *C.char { + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr + } + if err := c.UpdateConfig(); err != nil { + return SendError(err) + } + return C.CString("ok") + }) +} + func main() { } @@ -787,6 +1079,14 @@ func (l *ffiPrivateServerEventListener) OnPrivateServerEvent(event string) { func (l *ffiPrivateServerEventListener) OnError(err string) { slog.Debug("Private server error:", "err", err) + // err may already be JSON (from convertErrorToJSON) or a raw string. + // Ensure we always send valid JSON so the Dart jsonDecode doesn't crash. + if !json.Valid([]byte(err)) { + wrapped := map[string]string{"status": "error", "error": err} + data, _ := json.Marshal(wrapped) + sendPrivateServerEvent(string(data)) + return + } sendPrivateServerEvent(err) } @@ -802,13 +1102,14 @@ func (l *ffiPrivateServerEventListener) OpenBrowser(url string) error { } func sendPrivateServerEvent(event string) { - if privateserverPort == 0 { + port := privateserverPort.Load() + if port == 0 { slog.Error("Private server port is not set, cannot send event") return } go func() { - dart_api_dl.SendToPort(privateserverPort, event) + dart_api_dl.SendToPort(port, event) }() } @@ -821,19 +1122,13 @@ func digitalOceanPrivateServer() *C.char { if errStr != nil { return errStr } - ffiEventListener := &ffiPrivateServerEventListener{} - err := c.DigitalOceanPrivateServer(ffiEventListener) - if err != nil { - slog.Error("Error starting DigitalOcean private server flow:", "err", err) + if err := c.DigitalOceanPrivateServer(&ffiPrivateServerEventListener{}); err != nil { return SendError(err) } - slog.Debug("DigitalOcean private server flow started successfully") return C.CString("ok") }) } -// googleCloudPrivateServer starts the Google Cloud private server flow. -// //export googleCloudPrivateServer func googleCloudPrivateServer() *C.char { return runOnGoStack(func() *C.char { @@ -841,56 +1136,43 @@ func googleCloudPrivateServer() *C.char { if errStr != nil { return errStr } - ffiEventListener := &ffiPrivateServerEventListener{} - err := c.GoogleCloudPrivateServer(ffiEventListener) - if err != nil { - return SendError(fmt.Errorf("Error starting Google Cloud private server flow: %v", err)) + if err := c.GoogleCloudPrivateServer(&ffiPrivateServerEventListener{}); err != nil { + return SendError(err) } - slog.Debug("Google Cloud private server flow started successfully") return C.CString("ok") }) } -// selectAccount selects the account for the private server. -// //export selectAccount func selectAccount(_account *C.char) *C.char { + account := C.GoString(_account) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - account := C.GoString(_account) - slog.Debug("Selecting account:", "account", account) if err := c.SelectAccount(account); err != nil { - return SendError(fmt.Errorf("Error selecting account: %v", err)) + return SendError(err) } - slog.Debug("Account selected successfully:", "account", account) return C.CString("ok") }) } -// selectedProject selects the project for the private server. -// //export selectProject func selectProject(_project *C.char) *C.char { + project := C.GoString(_project) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - project := C.GoString(_project) - err := c.SelectProject(project) - if err != nil { - return SendError(fmt.Errorf("Error getting selected project: %v", err)) + if err := c.SelectProject(project); err != nil { + return SendError(err) } - slog.Debug("Selected project:", "project", project) return C.CString("ok") }) } -// validateSession validates the session for the private server. -// //export validateSession func validateSession() *C.char { return runOnGoStack(func() *C.char { @@ -898,40 +1180,29 @@ func validateSession() *C.char { if errStr != nil { return errStr } - slog.Debug("Validating session") - err := c.ValidateSession() - if err != nil { - return SendError(fmt.Errorf("Error validating session: %v", err)) + if err := c.ValidateSession(); err != nil { + return SendError(err) } - slog.Debug("Session validated successfully") return C.CString("ok") }) } -// startDepolyment starts the deployment for the private server. -// //export startDepolyment func startDepolyment(_selectedLocation, _serverName *C.char) *C.char { + location := C.GoString(_selectedLocation) + serverName := C.GoString(_serverName) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - location := C.GoString(_selectedLocation) - serverName := C.GoString(_serverName) - - slog.Debug("Starting deployment with location: %s and plan: %s", location, serverName) - err := c.StartDeployment(location, serverName) - if err != nil { - return SendError(fmt.Errorf("Error starting deployment: %v", err)) + if err := c.StartDeployment(location, serverName); err != nil { + return SendError(err) } - slog.Debug("Deployment started successfully with location: %s and plan: %s", location, serverName) return C.CString("ok") }) } -// cancelDepolyment cancels the deployment for the private server. -// //export cancelDepolyment func cancelDepolyment() *C.char { return runOnGoStack(func() *C.char { @@ -939,81 +1210,64 @@ func cancelDepolyment() *C.char { if errStr != nil { return errStr } - slog.Debug("Cancelling deployment") if err := c.CancelDeployment(); err != nil { - return SendError(fmt.Errorf("Error cancelling deployment: %v", err)) + return SendError(err) } - slog.Debug("Deployment cancelled successfully") return C.CString("ok") }) } -// addServerManagerInstance adds a server manager instance manually. -// //export addServerManagerInstance func addServerManagerInstance(_ip, _port, _accessToken, _tag *C.char) *C.char { + ip := C.GoString(_ip) + port := C.GoString(_port) + accessToken := C.GoString(_accessToken) + tag := C.GoString(_tag) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - ffiEventListener := &ffiPrivateServerEventListener{} - ip := C.GoString(_ip) - port := C.GoString(_port) - accessToken := C.GoString(_accessToken) - tag := C.GoString(_tag) - - err := c.AddServerManagerInstance(ip, port, accessToken, tag, ffiEventListener) - if err != nil { - return SendError(fmt.Errorf("Error adding server manager instance: %v", err)) + if err := c.AddServerManagerInstance(ip, port, accessToken, tag, &ffiPrivateServerEventListener{}); err != nil { + return SendError(err) } - slog.Debug("Server manager instance added successfully with IP: %s, Port: %s, AccessToken: %s, Tag: %s", ip, port, accessToken, tag) return C.CString("ok") }) } -// inviteToServerManagerInstance invites to the server manager instance. -// //export inviteToServerManagerInstance func inviteToServerManagerInstance(_ip, _port, _accessToken, _inviteName *C.char) *C.char { + ip := C.GoString(_ip) + port := C.GoString(_port) + accessToken := C.GoString(_accessToken) + inviteName := C.GoString(_inviteName) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - ip := C.GoString(_ip) - port := C.GoString(_port) - accessToken := C.GoString(_accessToken) - inviteName := C.GoString(_inviteName) - slog.Debug("Inviting to server manager instance:", "ip", ip, "port", port, "inviteName", inviteName) invite, err := c.InviteToServerManagerInstance(ip, port, accessToken, inviteName) if err != nil { - return SendError(fmt.Errorf("Error inviting to server manager instance: %v", err)) + return SendError(err) } - slog.Debug("Invite created successfully:", "invite", invite) return C.CString(invite) }) } -// revokeServerManagerInvite revokes the server manager invite. -// //export revokeServerManagerInvite func revokeServerManagerInvite(_ip, _port, _accessToken, _inviteName *C.char) *C.char { + ip := C.GoString(_ip) + port := C.GoString(_port) + accessToken := C.GoString(_accessToken) + inviteName := C.GoString(_inviteName) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - ip := C.GoString(_ip) - port := C.GoString(_port) - accessToken := C.GoString(_accessToken) - inviteName := C.GoString(_inviteName) - slog.Debug("Revoking invite:", "inviteName", inviteName, "ip", ip, "port", port) - err := c.RevokeServerManagerInvite(ip, port, accessToken, inviteName) - if err != nil { - return SendError(fmt.Errorf("Error revoking server manager invite: %v", err)) + if err := c.RevokeServerManagerInvite(ip, port, accessToken, inviteName); err != nil { + return SendError(err) } - slog.Debug("Invite revoked successfully:", "inviteName", inviteName, "ip", ip, "port", port) return C.CString("ok") }) } @@ -1021,22 +1275,20 @@ func revokeServerManagerInvite(_ip, _port, _accessToken, _inviteName *C.char) *C // addServerBasedOnURLs adds a server based on the provided URLs. // //export addServerBasedOnURLs -func addServerBasedOnURLs(_urls *C.char, _skipCertVerification C.int, _serverName *C.char) *C.char { +func addServerBasedOnURLs(_urls *C.char, _skipCertVerification C.int) *C.char { + urls := C.GoString(_urls) + skipCertVerification := _skipCertVerification != 0 return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - urls := C.GoString(_urls) - skipCertVerification := _skipCertVerification != 0 - serverName := C.GoString(_serverName) slog.Debug("Adding server based on URLs:", "urls", urls, "skipCertVerification", skipCertVerification) - err := c.AddServerBasedOnURLs(urls, skipCertVerification, serverName) + bytes, err := c.AddServersByURL(urls, skipCertVerification) if err != nil { return SendError(fmt.Errorf("Error adding server based on URLs: %v", err)) } - slog.Debug("Server added successfully based on URLs:", "urls", urls) - return C.CString("ok") + return C.CString(string(bytes)) }) } @@ -1093,7 +1345,7 @@ func getSplitTunnelState() *C.char { if errStr != nil { return errStr } - s, err := c.GetSplitTunnelStateJSON() + s, err := c.GetSplitTunnelItems() if err != nil { return SendError(err) } @@ -1103,28 +1355,28 @@ func getSplitTunnelState() *C.char { //export getSplitTunnelItems func getSplitTunnelItems(filterTypeC *C.char) *C.char { + filterType := C.GoString(filterTypeC) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - filterType := C.GoString(filterTypeC) - s, err := c.GetSplitTunnelItems(filterType) + bytes, err := c.GetSplitTunnelItemsFor(filterType) if err != nil { return SendError(err) } - return C.CString(s) + return C.CString(bytes) }) } //export deletePrivateServerByName func deletePrivateServerByName(_name *C.char) *C.char { + name := C.GoString(_name) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - name := C.GoString(_name) if err := c.DeleteServer(name); err != nil { return SendError(err) } @@ -1134,13 +1386,13 @@ func deletePrivateServerByName(_name *C.char) *C.char { //export updatePrivateServerName func updatePrivateServerName(_oldName, _newName *C.char) *C.char { + oldName := C.GoString(_oldName) + newName := C.GoString(_newName) return runOnGoStack(func() *C.char { c, errStr := requireCore() if errStr != nil { return errStr } - oldName := C.GoString(_oldName) - newName := C.GoString(_newName) if err := c.UpdatePrivateServerName(oldName, newName); err != nil { return SendError(err) } @@ -1148,17 +1400,6 @@ func updatePrivateServerName(_oldName, _newName *C.char) *C.char { }) } -//export getAppDataDir -func getAppDataDir() *C.char { - return runOnGoStack(func() *C.char { - c, errStr := requireCore() - if errStr != nil { - return errStr - } - return C.CString(c.GetAppDataDir()) - }) -} - //export getEnabledApps func getEnabledApps() *C.char { return runOnGoStack(func() *C.char { diff --git a/lantern-core/ffi/ffi_linux.go b/lantern-core/ffi/ffi_linux.go index 9c3089270b..88472ee573 100644 --- a/lantern-core/ffi/ffi_linux.go +++ b/lantern-core/ffi/ffi_linux.go @@ -1,52 +1,31 @@ -//go:build linux && !android && !ios && !macos +//go:build linux && !android package main -/* -#include -#include "stdint.h" -*/ -import "C" - import ( "context" - "errors" "fmt" "os/exec" "path/filepath" "strings" - "sync" "time" - "github.com/getlantern/radiance/servers" - "github.com/getlantern/radiance/vpn" - "github.com/getlantern/radiance/vpn/ipc" -) - -const ( - linuxServiceName = "lanternd" - linuxSocketPath = "/var/run/lantern/lanternd.sock" + lanterncore "github.com/getlantern/lantern/lantern-core" ) -var ( - linuxStatusOnce sync.Once - linuxLastStatusMu sync.Mutex - linuxLastStatus string -) +const daemonServiceName = "lanternd" -func requireLanternServiceAvailable() error { - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) - defer cancel() - - st, err := ipc.GetStatus(ctx) - if err == nil && st != "" { +// checkDaemonReachable verifies that the radiance daemon is reachable via IPC. +// On Linux it provides additional diagnostics via systemd if the daemon is not responding. +func checkDaemonReachable(c lanterncore.Core) error { + if err := c.CheckDaemonReachable(); err == nil { return nil } - if diag := systemdDiag(linuxServiceName); diag != "" { - return fmt.Errorf("%s not reachable (%s): %s", linuxServiceName, linuxSocketPath, diag) + if diag := systemdDiag(daemonServiceName); diag != "" { + return fmt.Errorf("%s not reachable: %s", daemonServiceName, diag) } - return fmt.Errorf("%s not reachable (%s)", linuxServiceName, linuxSocketPath) + return fmt.Errorf("%s not reachable", daemonServiceName) } func systemdDiag(unit string) string { @@ -78,146 +57,3 @@ func systemdDiag(unit string) string { return strings.TrimSpace(string(out)) } } - -func startLinuxStatusPoller() { - linuxStatusOnce.Do(func() { - go func() { - t := time.NewTicker(500 * time.Millisecond) - defer t.Stop() - - for range t.C { - if statusPort == 0 { - continue - } - - ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) - st, err := ipc.GetStatus(ctx) - cancel() - - ui := mapIPCStateToUIStatus(st, err) - - linuxLastStatusMu.Lock() - changed := ui != linuxLastStatus - if changed { - linuxLastStatus = ui - } - linuxLastStatusMu.Unlock() - - if changed { - sendStatusToPort(VPNStatus(ui)) - } - } - }() - }) -} - -func mapIPCStateToUIStatus(state ipc.VPNStatus, err error) string { - if err != nil { - return string(Disconnected) - } - switch state { - case ipc.Connected: - return string(Connected) - case ipc.Connecting: - return string(Connecting) - case ipc.Disconnecting: - return string(Disconnecting) - case ipc.Disconnected: - return string(Disconnected) - default: - return string(Disconnected) - } -} - -func normalizeIPCGroup(locationType string) string { - switch locationType { - case "", "auto", "auto-all": - return "all" - case "privateServer": - return string(servers.SGUser) - case "lanternLocation": - return string(servers.SGLantern) - default: - return locationType - } -} - -//export startVPN -func startVPN(_logDir, _dataDir, _locale *C.char) *C.char { - startLinuxStatusPoller() - sendStatusToPort(Connecting) - - if err := requireLanternServiceAvailable(); err != nil { - sendStatusToPort(Error) - return C.CString(err.Error()) - } - - if err := vpn.AutoConnect(""); err != nil && !errors.Is(err, ipc.ErrServiceIsNotReady) { - sendStatusToPort(Error) - if errors.Is(err, ipc.ErrIPCNotRunning) { - if diagErr := requireLanternServiceAvailable(); diagErr != nil { - return C.CString(diagErr.Error()) - } - } - return C.CString(fmt.Sprintf("start service failed: %v", err)) - } - - sendStatusToPort(Connected) - return C.CString("ok") -} - -//export stopVPN -func stopVPN() *C.char { - sendStatusToPort(Disconnecting) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := ipc.StopService(ctx); err != nil { - sendStatusToPort(Disconnected) - return C.CString(fmt.Sprintf("stop service failed: %v", err)) - } - - sendStatusToPort(Disconnected) - return C.CString("ok") -} - -//export connectToServer -func connectToServer(_location, _tag, _logDir, _dataDir, _locale *C.char) *C.char { - locationType := C.GoString(_location) - tag := C.GoString(_tag) - group := normalizeIPCGroup(locationType) - - startLinuxStatusPoller() - - if err := requireLanternServiceAvailable(); err != nil { - return SendError(err) - } - - if err := vpn.Connect(group, tag); err != nil && !errors.Is(err, ipc.ErrServiceIsNotReady) { - if errors.Is(err, ipc.ErrIPCNotRunning) { - if diagErr := requireLanternServiceAvailable(); diagErr != nil { - return SendError(diagErr) - } - } - return SendError(fmt.Errorf("start service failed: %w", err)) - } - - return C.CString("ok") -} - -//export isVPNConnected -func isVPNConnected() C.int { - ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) - defer cancel() - - st, err := ipc.GetStatus(ctx) - ui := mapIPCStateToUIStatus(st, err) - - sendStatusToPort(VPNStatus(ui)) - - if ui == string(Connected) { - return 1 - } - return 0 -} diff --git a/lantern-core/ffi/ffi_nonlinux.go b/lantern-core/ffi/ffi_nonlinux.go index 22beb24c9b..db6767a6de 100644 --- a/lantern-core/ffi/ffi_nonlinux.go +++ b/lantern-core/ffi/ffi_nonlinux.go @@ -1,72 +1,31 @@ -//go:build !linux && !android && !ios && !macos +//go:build !linux package main -/* -#include -#include "stdint.h" -*/ -import "C" - import ( - "fmt" - "log/slog" - - "github.com/getlantern/lantern/lantern-core/utils" - "github.com/getlantern/lantern/lantern-core/vpn_tunnel" + lanterncore "github.com/getlantern/lantern/lantern-core" ) -//export startVPN -func startVPN(_logDir, _dataDir, _locale *C.char) *C.char { - slog.Debug("startVPN called (non-linux)") - sendStatusToPort(Connecting) - if err := vpn_tunnel.StartVPN(nil, &utils.Opts{ - DataDir: C.GoString(_dataDir), - Locale: C.GoString(_locale), - }); err != nil { - err = fmt.Errorf("unable to start vpn server: %v", err) - sendStatusToPort(Disconnected) - return C.CString(err.Error()) - } - sendStatusToPort(Connected) - return C.CString("ok") -} - -//export stopVPN -func stopVPN() *C.char { - slog.Debug("stopVPN called (non-linux)") - sendStatusToPort(Disconnecting) - if err := vpn_tunnel.StopVPN(); err != nil { - err = fmt.Errorf("unable to stop vpn server: %v", err) - sendStatusToPort(Connected) - return C.CString(err.Error()) - } - sendStatusToPort(Disconnected) - return C.CString("ok") -} - -//export connectToServer -func connectToServer(_location, _tag, _logDir, _dataDir, _locale *C.char) *C.char { - locationType := C.GoString(_location) - tag := C.GoString(_tag) - - if err := vpn_tunnel.ConnectToServer(locationType, tag, nil, &utils.Opts{ - DataDir: C.GoString(_dataDir), - Locale: C.GoString(_locale), - }); err != nil { - return SendError(fmt.Errorf("error setting private server: %v", err)) - } - slog.Debug("connectToServer OK (non-linux)", "tag", tag) - return C.CString("ok") -} - -//export isVPNConnected -func isVPNConnected() C.int { - connected := vpn_tunnel.IsVPNRunning() - if connected { - sendStatusToPort(Connected) - return 1 - } - sendStatusToPort(Disconnected) - return 0 +// checkDaemonReachable is a no-op on Windows / macOS / mobile. The +// fast-probe-then-diagnose pattern this preflight came from (PR #8494, +// `requireLanternServiceAvailable` in ffi_linux.go) only pays off on +// platforms with a service-management diagnostic to fall back to — +// `systemctl is-active` on Linux. On other platforms there is no +// equivalent fallback, so the 300 ms `CheckDaemonReachable` timeout +// in lantern-core/core.go just caps the cold-start IPC roundtrip +// below what it can reliably hit (named-pipe dial + impersonation + +// H2c preface + cold goroutine scheduling regularly run >300 ms on +// the first request after lanternd has been idle). +// +// `ConnectVPN` itself surfaces "lanternd not reachable" with the same +// precision when the daemon really is dead. The preflight on these +// platforms was strictly an artificial guillotine in front of that +// real call. See getlantern/engineering#3382 and Freshdesk #173696 / +// #173932 for the user-visible regression this skip resolves. +// +// If we add Windows (`sc query LanternSvc`) or macOS (`launchctl +// list`) diagnostics later, restore the preflight and call them from +// here. +func checkDaemonReachable(c lanterncore.Core) error { + return nil } diff --git a/lantern-core/ffi/util.go b/lantern-core/ffi/util.go index cb444a3846..ad5795d757 100644 --- a/lantern-core/ffi/util.go +++ b/lantern-core/ffi/util.go @@ -25,13 +25,6 @@ func SendError(err error) *C.char { }) } -func booltoCString(value bool) *C.char { - if value { - return C.CString(string("true")) - } - return C.CString(string("false")) -} - // create binary data from proto func CreateBinaryFile(name string, data protoreflect.ProtoMessage) error { b, err := proto.Marshal(data) diff --git a/lantern-core/init_desktop.go b/lantern-core/init_desktop.go new file mode 100644 index 0000000000..63bd4d5c79 --- /dev/null +++ b/lantern-core/init_desktop.go @@ -0,0 +1,15 @@ +//go:build !android && !ios && !darwin + +package lanterncore + +import ( + "context" + + "github.com/getlantern/radiance/ipc" + + "github.com/getlantern/lantern/lantern-core/utils" +) + +func createClient(_ context.Context, _ *utils.Opts) (*ipc.Client, error) { + return ipc.NewClient(), nil +} diff --git a/lantern-core/init_mobile.go b/lantern-core/init_mobile.go new file mode 100644 index 0000000000..8540fdb4b0 --- /dev/null +++ b/lantern-core/init_mobile.go @@ -0,0 +1,24 @@ +//go:build android || ios || darwin + +package lanterncore + +import ( + "context" + + "github.com/getlantern/radiance/backend" + "github.com/getlantern/radiance/ipc" + + "github.com/getlantern/lantern/lantern-core/utils" +) + +func createClient(ctx context.Context, opts *utils.Opts) (*ipc.Client, error) { + backendOpts := backend.Options{ + DataDir: opts.DataDir, + LogDir: opts.LogDir, + DeviceID: opts.Deviceid, + LogLevel: opts.LogLevel, + Locale: opts.Locale, + TelemetryConsent: opts.TelemetryConsent, + } + return ipc.NewClient(ctx, backendOpts) +} diff --git a/lantern-core/logging.go b/lantern-core/logging.go new file mode 100644 index 0000000000..ca73c0ff82 --- /dev/null +++ b/lantern-core/logging.go @@ -0,0 +1,40 @@ +package lanterncore + +import ( + "log/slog" + "os" + "path/filepath" + + rlog "github.com/getlantern/radiance/log" +) + +// AppLogFileName is the basename used for main-app-side slog output on +// platforms where the tunnel extension owns lantern.log. Distinct from the +// extension's lantern.log so two lumberjack writers aren't racing on +// rotation of the same file. +const AppLogFileName = "lantern-app.log" + +// setupAppLogging installs a file-based default slog handler writing to +// /lantern-app.log. Used on iOS and macOS where the main app shares +// its logDir with the tunnel extension (which runs its own common.Init). +// Best-effort — any failure leaves the default stderr handler in place. +func setupAppLogging(logDir, level string) { + if logDir == "" { + slog.Warn("setupAppLogging: empty logDir, sticking with default handler") + return + } + if err := os.MkdirAll(logDir, 0o755); err != nil { + slog.Warn("setupAppLogging: unable to create logDir", "logDir", logDir, "err", err) + return + } + if level == "" { + level = DefaultLogLevel + } + logger := rlog.NewLogger(rlog.Config{ + LogPath: filepath.Join(logDir, AppLogFileName), + Level: level, + Prod: true, + DisablePublisher: true, + }) + slog.SetDefault(logger) +} diff --git a/lantern-core/logging/logging.go b/lantern-core/logging/logging.go deleted file mode 100644 index 67b9f4a0c4..0000000000 --- a/lantern-core/logging/logging.go +++ /dev/null @@ -1,151 +0,0 @@ -package logging - -import ( - "bufio" - "context" - "errors" - "fmt" - "io" - "os" - "strings" - - "github.com/fsnotify/fsnotify" - "github.com/getlantern/lantern/lantern-core/dart_api_dl" -) - -// LogHandler is a function that handles new log messages. -type LogHandler func(string) - -// Configure is used to setup log handling. It returns an error on failure. -func Configure(ctx context.Context, logFile string, logPort int64) error { - if logPort == 0 { - return errors.New("missing log port") - } - // Check if the log file exists. - if _, err := os.Stat(logFile); err == nil { - // Read and send the last 30 lines of the log file. - lines, err := readLastLines(logFile, 30) - if err != nil { - return err - } - dart_api_dl.SendToPort(logPort, strings.Join(lines, "\n")) - } - - go watchLogFile(ctx, logFile, func(message string) { - dart_api_dl.SendToPort(logPort, message) - }) - - return nil - -} - -// watchLogFile watches the log file for changes and sends new lines to Dart. -func watchLogFile(ctx context.Context, filePath string, logHandler LogHandler) error { - file, err := os.Open(filePath) - if err != nil { - return fmt.Errorf("error opening log file: %w", err) - } - defer file.Close() - - // Move to the end of the file - offset, err := file.Seek(0, io.SeekEnd) - if err != nil { - return fmt.Errorf("error seeking log file: %w", err) - } - - watcher, err := fsnotify.NewWatcher() - if err != nil { - return fmt.Errorf("error creating file watcher: %w", err) - } - defer watcher.Close() - - // Add file to watcher - err = watcher.Add(filePath) - if err != nil { - return fmt.Errorf("error watching file: %w", err) - } - - reader := bufio.NewReader(file) - - // Listen for file changes. - for { - select { - // Handle context cancellation - case <-ctx.Done(): - return ctx.Err() - case event, ok := <-watcher.Events: - if !ok { - return nil - } - // If the file is modified, read new lines. - if event.Op&fsnotify.Write == fsnotify.Write { - offset = readNewLogLines(file, reader, offset, logHandler) - } - case err, ok := <-watcher.Errors: - if !ok { - return nil - } - fmt.Println("Error watching file:", err) - } - } -} - -// readNewLogLines reads new lines from the open file and calls logHandler on each line. -func readNewLogLines(file *os.File, reader *bufio.Reader, lastOffset int64, logHandler LogHandler) int64 { - // Get the current file size. - fileInfo, err := file.Stat() - if err != nil { - fmt.Println("Error getting file info:", err) - return lastOffset - } - - // If the file was truncated, reset the offset. - if fileInfo.Size() < lastOffset { - fmt.Println("Log file was truncated, resetting offset to 0") - file.Seek(0, os.SEEK_SET) - lastOffset = 0 - } - - // Move to the last known offset. - file.Seek(lastOffset, os.SEEK_SET) - - for { - line, err := reader.ReadString('\n') - if err != nil { - break - } - logHandler(line) - } - - // Update the offset for the next read. - newOffset, _ := file.Seek(0, os.SEEK_CUR) - return newOffset -} - -// readLastLines reads the last `n` lines of a file and sends them to the logHandler. -func readLastLines(filePath string, n int) ([]string, error) { - file, err := os.Open(filePath) - if err != nil { - return nil, fmt.Errorf("error opening log file: %w", err) - } - defer file.Close() - - lines := []string{} - scanner := bufio.NewScanner(file) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - - // Handle scanning errors. - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error reading log file: %w", err) - } - - // Determine how many lines to return. - start := 0 - if len(lines) > n { - start = len(lines) - n - } - - return lines[start:], nil -} diff --git a/lantern-core/logs/stream.go b/lantern-core/logs/stream.go new file mode 100644 index 0000000000..b8c0d925fe --- /dev/null +++ b/lantern-core/logs/stream.go @@ -0,0 +1,24 @@ +// Package logs provides a shared helper for streaming diagnostic log entries +// from radiance's ipc client. +package logs + +import ( + "context" + "log/slog" + + "github.com/getlantern/radiance/ipc" + rlog "github.com/getlantern/radiance/log" +) + +// Subscribe streams log entries from client, invoking cb for each entry as a +// string. It blocks until ctx is cancelled or the underlying stream returns. +func Subscribe(ctx context.Context, client *ipc.Client, cb func(string)) error { + defer func() { + if r := recover(); r != nil { + slog.Error("log stream panic", "panic", r) + } + }() + return client.TailLogs(ctx, func(entry rlog.LogEntry) { + cb(string(entry)) + }) +} diff --git a/lantern-core/mobile/ipc_extension_mobile.go b/lantern-core/mobile/ipc_extension_mobile.go new file mode 100644 index 0000000000..485cc00d7c --- /dev/null +++ b/lantern-core/mobile/ipc_extension_mobile.go @@ -0,0 +1,12 @@ +//go:build android || ios || darwin + +package mobile + +import ( + "github.com/getlantern/radiance/backend" + "github.com/getlantern/radiance/ipc" +) + +func newLoopbackClient(be *backend.LocalBackend) *ipc.Client { + return ipc.NewLoopbackClient(be) +} diff --git a/lantern-core/mobile/ipc_extension_other.go b/lantern-core/mobile/ipc_extension_other.go new file mode 100644 index 0000000000..b9ed7f9ad1 --- /dev/null +++ b/lantern-core/mobile/ipc_extension_other.go @@ -0,0 +1,12 @@ +//go:build !android && !ios && !darwin + +package mobile + +import ( + "github.com/getlantern/radiance/backend" + "github.com/getlantern/radiance/ipc" +) + +func newLoopbackClient(_ *backend.LocalBackend) *ipc.Client { + return nil +} diff --git a/lantern-core/mobile/mobile.go b/lantern-core/mobile/mobile.go index 3763c60f18..6c6dc387b7 100644 --- a/lantern-core/mobile/mobile.go +++ b/lantern-core/mobile/mobile.go @@ -1,19 +1,26 @@ package mobile import ( + "context" "encoding/json" "errors" "fmt" "log/slog" + "os" + "sync" "sync/atomic" + "time" _ "golang.org/x/mobile/bind" - "github.com/getlantern/radiance/api" + "github.com/getlantern/radiance/account" + "github.com/getlantern/radiance/backend" "github.com/getlantern/radiance/common" "github.com/getlantern/radiance/common/settings" + "github.com/getlantern/radiance/ipc" lanterncore "github.com/getlantern/lantern/lantern-core" + "github.com/getlantern/lantern/lantern-core/logs" "github.com/getlantern/lantern/lantern-core/utils" "github.com/getlantern/lantern/lantern-core/vpn_tunnel" ) @@ -21,6 +28,12 @@ import ( var ( lanternCore atomic.Value errLanternNotReady = errors.New("radiance not initialized") + + ipcServer *ipc.Server + ipcClient *ipc.Client // loopback client for extension process + ipcBackend *backend.LocalBackend + ipcMu sync.Mutex + ipcOnce sync.Once ) func getCore() (lanterncore.Core, error) { @@ -35,7 +48,7 @@ func getCore() (lanterncore.Core, error) { // It runs fn on a real Go goroutine via RunOffCgoStack to avoid GC write barrier // panics when gomobile-exported functions are called from CGo callback stacks. func withCore(fn func(c lanterncore.Core) error) error { - _, err := common.RunOffCgoStack(func() (struct{}, error) { + _, err := utils.RunOffCgoStack(func() (struct{}, error) { c, err := getCore() if err != nil { return struct{}{}, err @@ -49,7 +62,7 @@ func withCore(fn func(c lanterncore.Core) error) error { // It runs fn on a real Go goroutine via RunOffCgoStack to avoid GC write barrier // panics when gomobile-exported functions are called from CGo callback stacks. func withCoreR[T any](fn func(c lanterncore.Core) (T, error)) (T, error) { - return common.RunOffCgoStack(func() (T, error) { + return utils.RunOffCgoStack(func() (T, error) { c, err := getCore() if err != nil { var zero T @@ -59,17 +72,50 @@ func withCoreR[T any](fn func(c lanterncore.Core) (T, error)) (T, error) { }) } -// panicRecover is a helper function that recovers from panics and logs the error. -func panicRecover() { - defer func() { - if r := recover(); r != nil { - slog.Error("Recovered from panic:", "error", r) +// getClient returns an IPC client. It prefers the loopback client created by +// StartIPCServer (extension process), falling back to lanternCore's client +// (main app process). +func getClient() (*ipc.Client, error) { + ipcMu.Lock() + c := ipcClient + ipcMu.Unlock() + if c != nil { + return c, nil + } + core, err := getCore() + if err != nil { + return nil, err + } + return core.Client(), nil +} + +// SetQAEnvOverrides sets process environment variables that radiance reads +// at init time, before SetupRadiance / StartIPCServer / StartVPN is called. +// Used by QA / dev builds to point radiance at an upstream SOCKS5 (typically +// the local pinger bridge running on the host) and to spoof the timezone so +// the API treats the client as being in the corresponding country. +// +// Empty values are ignored — pass empty strings for any override you don't +// want to apply. Must be called before any other Mobile.* function that +// touches radiance to take effect. +func SetQAEnvOverrides(outboundSocks, tz string) error { + if outboundSocks != "" { + if err := os.Setenv("RADIANCE_OUTBOUND_SOCKS_ADDRESS", outboundSocks); err != nil { + return fmt.Errorf("setenv RADIANCE_OUTBOUND_SOCKS_ADDRESS: %w", err) } - }() + slog.Info("QA: set RADIANCE_OUTBOUND_SOCKS_ADDRESS", "value", outboundSocks) + } + if tz != "" { + if err := os.Setenv("TZ", tz); err != nil { + return fmt.Errorf("setenv TZ: %w", err) + } + slog.Info("QA: set TZ", "value", tz) + } + return nil } func SetupRadiance(opts *utils.Opts, eventEmitter utils.FlutterEventEmitter) error { - _, err := common.RunOffCgoStack(func() (struct{}, error) { + _, err := utils.RunOffCgoStack(func() (struct{}, error) { slog.Info("Setting up Radiance", "opts", opts) c, err := lanterncore.New(opts, eventEmitter) if err != nil { @@ -88,6 +134,36 @@ func UpdateTelemetryConsent(consent bool) error { }) } +func IsTelemetryEnabled() bool { + ok, err := withCoreR(func(c lanterncore.Core) (bool, error) { + return c.IsTelemetryEnabled(), nil + }) + if err != nil { + return false + } + return ok +} + +func IsOAuthLogin() bool { + ok, err := withCoreR(func(c lanterncore.Core) (bool, error) { + return c.IsOAuthLogin(), nil + }) + if err != nil { + return false + } + return ok +} + +func GetOAuthProvider() string { + provider, err := withCoreR(func(c lanterncore.Core) (string, error) { + return c.GetOAuthProvider(), nil + }) + if err != nil { + return "" + } + return provider +} + func SetBlockAdsEnabled(enabled bool) error { slog.Info("adblock: SetBlockAdsEnabled", "enabled", enabled) return withCore(func(c lanterncore.Core) error { @@ -122,12 +198,18 @@ func IsSmartRoutingEnabled() bool { return ok } -func AvailableFeatures() []byte { - b, err := withCoreR(func(c lanterncore.Core) ([]byte, error) { return c.AvailableFeatures(), nil }) +// AvailableFeatures returns feature-flag data as a JSON string. +// +// Returns string (not []byte) so the gomobile wrapper marshals the return value +// via C.malloc rather than leaving it as a Go slice header. This avoids a +// runtime.bulkBarrierPreWrite panic on the cgo callback goroutine during GC +// (see getlantern/engineering#3175). +func AvailableFeatures() string { + s, err := withCoreR(func(c lanterncore.Core) (string, error) { return string(c.AvailableFeatures()), nil }) if err != nil { - return []byte(`{}`) + return `{}` } - return b + return s } func MyDeviceId() (string, error) { @@ -150,21 +232,17 @@ func IsRadianceConnected() bool { return ok } -func StartVPN(platform utils.PlatformInterface, opts *utils.Opts) error { - _, err := common.RunOffCgoStack(func() (struct{}, error) { +func StartVPN() error { + _, err := utils.RunOffCgoStack(func() (struct{}, error) { slog.Info("Starting VPN") - if err := vpn_tunnel.StartVPN(platform, opts); err != nil { + client, err := getClient() + if err != nil { return struct{}{}, err } - // On non-iOS/macOS platforms, start the auto location listener - // For iOS/macOS, the listener is managed by Native code due to platform restrictions - if !common.IsMacOS() && !common.IsIOS() { - slog.Info("Starting auto location listener on non-iOS/macOS platform") - c, err := getCore() - if err != nil { - return struct{}{}, err - } - c.StartBackgroundListeners() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if err := vpn_tunnel.StartVPN(ctx, client); err != nil { + return struct{}{}, err } return struct{}{}, nil }) @@ -172,29 +250,65 @@ func StartVPN(platform utils.PlatformInterface, opts *utils.Opts) error { } func StopVPN() error { - _, err := common.RunOffCgoStack(func() (struct{}, error) { + _, err := utils.RunOffCgoStack(func() (struct{}, error) { slog.Info("Stopping VPN") - if err := vpn_tunnel.StopVPN(); err != nil { + client, err := getClient() + if err != nil { return struct{}{}, err } - // On non-iOS/macOS platforms, stop the auto location listener since radiance is still running - // For iOS/macOS, the listener is managed by Native code due to platform restrictions - if !common.IsMacOS() && !common.IsIOS() { - slog.Info("Stopping auto location listener on non-iOS/macOS platform") - c, err := getCore() - if err != nil { - return struct{}{}, err - } - c.StopBackgroundListeners() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := vpn_tunnel.StopVPN(ctx, client); err != nil { + return struct{}{}, err } return struct{}{}, nil }) return err } -func CloseIPC() error { - _, err := common.RunOffCgoStack(func() (struct{}, error) { - return struct{}{}, vpn_tunnel.CloseIPC() +func StartIPCServer(platform utils.PlatformInterface, opts *utils.Opts) error { + _, err := utils.RunOffCgoStack(func() (struct{}, error) { + ipcMu.Lock() + defer ipcMu.Unlock() + if ipcServer != nil { + return struct{}{}, nil + } + bopts := backend.Options{ + DataDir: opts.DataDir, + LogDir: opts.LogDir, + Locale: opts.Locale, + LogLevel: opts.LogLevel, + DeviceID: opts.Deviceid, + TelemetryConsent: opts.TelemetryConsent, + PlatformInterface: platform, + } + be, err := backend.NewLocalBackend(context.Background(), bopts) + if err != nil { + return struct{}{}, fmt.Errorf("error creating backend for IPC server: %v", err) + } + be.Start() + ipcBackend = be + ipcServer = ipc.NewServer(be, !common.IsMobile()) + ipcClient = newLoopbackClient(be) + return struct{}{}, ipcServer.Start() + }) + return err +} + +func CloseIPCServer() error { + _, err := utils.RunOffCgoStack(func() (struct{}, error) { + ipcMu.Lock() + defer ipcMu.Unlock() + if ipcBackend != nil { + ipcBackend.Close() + ipcBackend = nil + } + if ipcServer != nil { + ipcServer.Close() + ipcServer = nil + } + ipcClient = nil + return struct{}{}, nil }) return err } @@ -216,79 +330,66 @@ func IsTagAvailable(tag string) bool { // ConnectToServer connects to a server using the provided location type and tag. // It works with private servers and lantern location servers. -func ConnectToServer(locationType, tag string, platIfce utils.PlatformInterface, options *utils.Opts) error { - err := vpn_tunnel.ConnectToServer(locationType, tag, platIfce, options) - if err != nil { - return err - } - // On non-iOS/macOS platforms, start the auto location listener since radiance is still running - // For iOS/macOS, the listener is managed by Native code due to platform restrictions - if !common.IsMacOS() && !common.IsIOS() { - slog.Info("Stopping auto location listener on non-iOS/macOS platform") - return withCore(func(c lanterncore.Core) error { - c.StopBackgroundListeners() - return nil - }) - } - return nil -} - -// StartAutoLocationListener starts the auto location listener in the core. -// Should be called only on iOS and macOS -func StartAutoLocationListener() error { - return withCore(func(c lanterncore.Core) error { - c.StartBackgroundListeners() - return nil - }) -} - -// StopAutoLocationListener stops the auto location listener in the core. -// Should be called only on iOS and macOS -func StopAutoLocationListener() error { - return withCore(func(c lanterncore.Core) error { - c.StopBackgroundListeners() - return nil +func ConnectToServer(tag string) error { + _, err := utils.RunOffCgoStack(func() (struct{}, error) { + client, err := getClient() + if err != nil { + return struct{}{}, err + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if err := vpn_tunnel.ConnectToServer(ctx, client, tag); err != nil { + return struct{}{}, err + } + return struct{}{}, nil }) + return err } // GetAvailableServers returns the available servers in JSON format. -func GetAvailableServers() ([]byte, error) { - return withCoreR(func(c lanterncore.Core) ([]byte, error) { return c.GetAvailableServers(), nil }) +// +// Returns string (not []byte) — see AvailableFeatures for the rationale. +func GetAvailableServers() (string, error) { + return withCoreR(func(c lanterncore.Core) (string, error) { + return string(c.GetAvailableServers()), nil + }) } func IsVPNConnected() bool { - r, _ := common.RunOffCgoStack(func() (bool, error) { - return vpn_tunnel.IsVPNRunning(), nil + ok, err := withCoreR(func(c lanterncore.Core) (bool, error) { + return c.IsVPNRunning() }) - return r + if err != nil { + return false + } + return ok } func GetSelectedServer() string { - r, _ := common.RunOffCgoStack(func() (string, error) { - return vpn_tunnel.GetSelectedServer(), nil + s, err := withCoreR(func(c lanterncore.Core) (string, error) { + return c.GetSelectedServerTag() + }) + if err != nil { + return "" + } + return s +} + +func GetSelectedServerJSON() (string, error) { + return withCoreR(func(c lanterncore.Core) (string, error) { + b, err := c.GetSelectedServerJSON() + return string(b), err }) - return r } func GetAutoLocation() (string, error) { - return common.RunOffCgoStack(func() (string, error) { - location, err := vpn_tunnel.GetAutoLocation() - if err != nil { - return "", err - } - c, err := getCore() + return withCoreR(func(c lanterncore.Core) (string, error) { + data, err := c.GetAutoLocationJSON() if err != nil { return "", err } - jsonBytes, ok, err := c.GetServerByTagJSON(location.Lantern) - if err != nil { - return "", fmt.Errorf("error marshalling server: %v", err) - } - if !ok { - return "", fmt.Errorf("no server found with tag: %s", location.Lantern) - } - slog.Debug("Auto location server:", "server", string(jsonBytes)) - return string(jsonBytes), nil + slog.Debug("Auto location server:", "server", string(data)) + return string(data), nil }) } @@ -310,7 +411,7 @@ func RemoveSplitTunnelItems(items string) error { } func SetSplitTunnelingEnabled(enabled bool) error { - return withCore(func(c lanterncore.Core) error { c.SetSplitTunnelingEnabled(enabled); return nil }) + return withCore(func(c lanterncore.Core) error { return c.SetSplitTunnelingEnabled(enabled) }) } func IsSplitTunnelingEnabled() bool { @@ -335,15 +436,21 @@ func LoadInstalledApps(dataDir string) (string, error) { // User Methods // UserData returns pre-fetched user data. -func UserData() ([]byte, error) { +func UserData() (string, error) { slog.Debug("User data") - return withCoreR(func(c lanterncore.Core) ([]byte, error) { return c.UserData() }) + return withCoreR(func(c lanterncore.Core) (string, error) { + b, err := c.UserData() + return string(b), err + }) } // FetchUserData will get the user data from the server -func FetchUserData() ([]byte, error) { +func FetchUserData() (string, error) { slog.Debug("Fetching user data") - return withCoreR(func(c lanterncore.Core) ([]byte, error) { return c.FetchUserData() }) + return withCoreR(func(c lanterncore.Core) (string, error) { + b, err := c.FetchUserData() + return string(b), err + }) } // OAuth Methods @@ -351,8 +458,11 @@ func OAuthLoginUrl(provider string) (string, error) { return withCoreR(func(c lanterncore.Core) (string, error) { return c.OAuthLoginUrl(provider) }) } -func OAuthLoginCallback(oAuthToken string) ([]byte, error) { - return withCoreR(func(c lanterncore.Core) ([]byte, error) { return c.OAuthLoginCallback(oAuthToken) }) +func OAuthLoginCallback(oAuthToken string) (string, error) { + return withCoreR(func(c lanterncore.Core) (string, error) { + b, err := c.OAuthLoginCallback(oAuthToken) + return string(b), err + }) } func StripeSubscription(email, planID string) (string, error) { @@ -366,60 +476,66 @@ func StripeBillingPortalUrl() (string, error) { return withCoreR(func(c lanterncore.Core) (string, error) { return c.StripeBillingPortalUrl() }) } -func AcknowledgeGooglePurchase(purchaseToken, planId string) ([]byte, error) { - return withCoreR(func(c lanterncore.Core) ([]byte, error) { +func AcknowledgeGooglePurchase(purchaseToken, planId string) (string, error) { + return withCoreR(func(c lanterncore.Core) (string, error) { data, err := c.AcknowledgeGooglePurchase(purchaseToken, planId) if err != nil { - return nil, err + return "", err } - var resp api.VerifySubscriptionResponse + var resp account.VerifySubscriptionResponse if err := json.Unmarshal([]byte(data), &resp); err != nil { - return nil, fmt.Errorf("error unmarshalling acknowledge google purchase response: %v", err) + return "", fmt.Errorf("error unmarshalling acknowledge google purchase response: %v", err) } - if resp.ActualUserId != 0 && resp.ActualUserToken != "" { + if resp.ActualUserID != 0 && resp.ActualUserToken != "" { /// This means the purchase was made on a different account and we need to switch to that account - slog.Info("Purchase made on a different account, switching accounts", "actualUserId", resp.ActualUserId) - //reset all data - settings.Set(settings.UserIDKey, fmt.Sprintf("%d", resp.ActualUserId)) - settings.Set(settings.TokenKey, resp.ActualUserToken) + slog.Info("Purchase made on a different account, switching accounts", "actualUserId", resp.ActualUserID) + if err := c.PatchSettings(settings.Settings{ + settings.UserIDKey: fmt.Sprintf("%d", resp.ActualUserID), + settings.TokenKey: resp.ActualUserToken, + }); err != nil { + return "", fmt.Errorf("error updating settings after account switch: %v", err) + } userData, err := FetchUserData() if err != nil { - return nil, err + return "", err } return userData, nil } - /// Purchase was made on the same account, just return nil to indicate success - return nil, nil + /// Purchase was made on the same account, just return "" to indicate success + return "", nil }) } -func AcknowledgeApplePurchase(receipt, planII string) ([]byte, error) { - return withCoreR(func(c lanterncore.Core) ([]byte, error) { +func AcknowledgeApplePurchase(receipt, planII string) (string, error) { + return withCoreR(func(c lanterncore.Core) (string, error) { data, err := c.AcknowledgeApplePurchase(receipt, planII) if err != nil { - return nil, err + return "", err } - var resp api.VerifySubscriptionResponse + var resp account.VerifySubscriptionResponse if err := json.Unmarshal([]byte(data), &resp); err != nil { - return nil, fmt.Errorf("error unmarshalling acknowledge apple purchase response: %v", err) + return "", fmt.Errorf("error unmarshalling acknowledge apple purchase response: %v", err) } - if resp.ActualUserId != 0 && resp.ActualUserToken != "" { + if resp.ActualUserID != 0 && resp.ActualUserToken != "" { /// This means the purchase was made on a different account and we need to switch to that account - slog.Info("Purchase made on a different account, switching accounts", "actualUserId", resp.ActualUserId) - //reset all data - settings.Set(settings.UserIDKey, fmt.Sprintf("%d", resp.ActualUserId)) - settings.Set(settings.TokenKey, resp.ActualUserToken) + slog.Info("Purchase made on a different account, switching accounts", "actualUserId", resp.ActualUserID) + if err := c.PatchSettings(settings.Settings{ + settings.UserIDKey: fmt.Sprintf("%d", resp.ActualUserID), + settings.TokenKey: resp.ActualUserToken, + }); err != nil { + return "", fmt.Errorf("error updating settings after account switch: %v", err) + } userData, err := FetchUserData() if err != nil { - return nil, err + return "", err } - slog.Debug("fetched user data after account switch", "userdata", string(userData)) + slog.Debug("fetched user data after account switch", "userdata", userData) return userData, nil } - /// Purchase was made on the same account, just return nil to indicate success - return nil, nil + /// Purchase was made on the same account, just return "" to indicate success + return "", nil }) } @@ -440,8 +556,12 @@ func StripeSubscriptionPaymentRedirect(subType, planId, email string) (string, e /// User management apis -func Login(email, password string) ([]byte, error) { - return withCoreR(func(c lanterncore.Core) ([]byte, error) { return c.Login(email, password) }) +func Login(email, password string) (string, error) { + return withCoreR(func(c lanterncore.Core) (string, error) { + b, err := c.Login(email, password) + slog.Debug("Login response", "response", string(b), "error", err) + return string(b), err + }) } func StartChangeEmail(newEmail, password string) error { @@ -456,8 +576,11 @@ func SignUp(email, password string) error { return withCore(func(c lanterncore.Core) error { return c.SignUp(email, password) }) } -func Logout(email string) ([]byte, error) { - return withCoreR(func(c lanterncore.Core) ([]byte, error) { return c.Logout(email) }) +func Logout(email string) (string, error) { + return withCoreR(func(c lanterncore.Core) (string, error) { + b, err := c.Logout(email) + return string(b), err + }) } func GetDataCapInfo() (string, error) { @@ -500,8 +623,11 @@ func ReferralAttachment(referralCode string) error { }) } -func DeleteAccount(email, password string, isOAuthUser bool) ([]byte, error) { - return withCoreR(func(c lanterncore.Core) ([]byte, error) { return c.DeleteAccount(email, password, isOAuthUser) }) +func DeleteAccount(email, password string) (string, error) { + return withCoreR(func(c lanterncore.Core) (string, error) { + b, err := c.DeleteAccount(email, password) + return string(b), err + }) } func ActivationCode(email, resellerCode string) error { @@ -552,10 +678,18 @@ func RevokeServerManagerInvite(ip string, port string, accessToken string, invit return withCore(func(c lanterncore.Core) error { return c.RevokeServerManagerInvite(ip, port, accessToken, inviteName) }) } -func AddServerBasedOnURLs(urls string, skipCertVerification bool, serverName string) error { +func AddServerBasedOnURLs(urls string, skipCertVerification bool) (string, error) { slog.Debug("Adding server based on URLs", "urls", urls, "skipCertVerification", skipCertVerification) - return withCore(func(c lanterncore.Core) error { - return c.AddServerBasedOnURLs(urls, skipCertVerification, serverName) + return withCoreR(func(c lanterncore.Core) (string, error) { + tags, err := c.AddServersByURL(urls, skipCertVerification) + if err != nil { + return "", err + } + b, err := json.Marshal(tags) + if err != nil { + return "", fmt.Errorf("marshal tags: %w", err) + } + return string(b), nil }) } @@ -571,36 +705,45 @@ func UpdatePrivateServerName(oldTag, newTag string) error { func GetSplitTunnelItems(filterType string) (string, error) { return withCoreR(func(c lanterncore.Core) (string, error) { - return c.GetSplitTunnelItems(filterType) + return c.GetSplitTunnelItemsFor(filterType) }) } func GetSplitTunnelStateJSON() (string, error) { return withCoreR(func(c lanterncore.Core) (string, error) { - return c.GetSplitTunnelStateJSON() + return c.GetSplitTunnelItems() }) } -// Smart Routing Methods +// LogSubscription holds the cancellation handle for a TailLogs stream. Call +// Cancel to stop receiving log entries. +type LogSubscription struct { + cancel context.CancelFunc +} -// SetSmartRoutingMode sets the smart routing mode. -func SetSmartRoutingMode(mode bool) error { - slog.Debug("mobile: SetSmartRoutingEnabled called", "mode", mode) - return withCore(func(c lanterncore.Core) error { - return c.SetSmartRoutingEnabled(mode) - }) +func (s *LogSubscription) Cancel() { + if s == nil || s.cancel == nil { + return + } + s.cancel() + s.cancel = nil } -// GetSmartRoutingEnabled gets the current smart routing enabled state. -func GetSmartRoutingEnabled() bool { - slog.Debug("mobile: GetSmartRoutingEnabled called") - ok, err := withCoreR(func(c lanterncore.Core) (bool, error) { - return c.IsSmartRoutingEnabled(), nil - }) +// TailLogs streams log entries to the provided listener until the returned +// subscription is cancelled. +func TailLogs(listener utils.LogListener) (*LogSubscription, error) { + if listener == nil { + return nil, errors.New("log listener is required") + } + client, err := getClient() if err != nil { - slog.Error("mobile: GetSmartRoutingEnableds error", "error", err) - return false + return nil, err } - slog.Debug("mobile: GetSmartRoutingEnabled result", "mode", ok) - return ok + ctx, cancel := context.WithCancel(context.Background()) + go func() { + if err := logs.Subscribe(ctx, client, listener.OnLogEntry); err != nil && ctx.Err() == nil { + slog.Debug("log stream exited", "error", err) + } + }() + return &LogSubscription{cancel: cancel}, nil } diff --git a/lantern-core/private-server/server.go b/lantern-core/private-server/server.go index adf5775877..ebc41b15f4 100644 --- a/lantern-core/private-server/server.go +++ b/lantern-core/private-server/server.go @@ -14,8 +14,7 @@ import ( "sync" "time" - "github.com/getlantern/common" - "github.com/getlantern/radiance/servers" + "github.com/getlantern/radiance/ipc" pcommon "github.com/getlantern/lantern-server-provisioner/common" "github.com/getlantern/lantern-server-provisioner/digitalocean" @@ -39,7 +38,7 @@ type provisionSession struct { userProjectString string serverName string serverLocation string - manager *servers.Manager + client *ipc.Client } type provisionerResponse struct { @@ -75,7 +74,7 @@ func getSession() (*provisionSession, error) { // StartDigitalOceanPrivateServerFlow initializes the DigitalOcean provisioner and starts listening for events. // It takes a PrivateServerEventListener to handle events and browser opening. // It returns an error if the provisioner fails to start or if there are issues during the session. -func StartDigitalOceanPrivateServerFlow(events utils.PrivateServerEventListener, vpnClient *servers.Manager) error { +func StartDigitalOceanPrivateServerFlow(events utils.PrivateServerEventListener, vpnClient *ipc.Client) error { ctx := context.Background() provisioner := digitalocean.GetProvisioner(ctx, func(url string) error { return events.OpenBrowser(url) @@ -88,7 +87,7 @@ func StartDigitalOceanPrivateServerFlow(events utils.PrivateServerEventListener, ps := &provisionSession{ provisioner: provisioner, eventSink: events, - manager: vpnClient, + client: vpnClient, } storeSession(ps) go listenToServerEvents(*ps) @@ -96,7 +95,7 @@ func StartDigitalOceanPrivateServerFlow(events utils.PrivateServerEventListener, } // StartGoogleCloudPrivateServerFlow initializes the GCP provisioner and starts listening for events -func StartGoogleCloudPrivateServerFlow(events utils.PrivateServerEventListener, vpnClient *servers.Manager) error { +func StartGoogleCloudPrivateServerFlow(events utils.PrivateServerEventListener, vpnClient *ipc.Client) error { ctx := context.Background() provisioner := gcp.GetProvisioner(ctx, func(url string) error { return events.OpenBrowser(url) @@ -109,7 +108,7 @@ func StartGoogleCloudPrivateServerFlow(events utils.PrivateServerEventListener, ps := &provisionSession{ provisioner: provisioner, eventSink: events, - manager: vpnClient, + client: vpnClient, } storeSession(ps) go listenToServerEvents(*ps) @@ -159,7 +158,7 @@ func listenToServerEvents(ps provisionSession) { compartments := provisioner.Compartments() if len(compartments) == 0 { slog.Error("No valid projects found, please check your billing account and permissions") - events.OnError("No valid projects found, please check your billing account and permissions") + events.OnError(convertErrorToJSON("EventTypeNoProjects", errors.New("no valid projects found, please check your billing account and permissions"))) return } // if only one compartment, select it by default @@ -226,14 +225,20 @@ func listenToServerEvents(ps provisionSession) { // sgp1 - SG [SG] region, city, country := ParseLocation(provisioner.serverLocation) slog.Debug("Provisioner response", slog.Any("response", resp), slog.String("region", region), slog.String("country", country), slog.String("city", city)) - mangerErr := provisioner.manager.AddPrivateServer(resp.Tag, resp.ExternalIP, resp.Port, resp.AccessToken, &common.ServerLocation{CountryCode: country, City: city}, false) + ctx := context.Background() + mangerErr := provisioner.client.AddPrivateServer(ctx, resp.Tag, resp.ExternalIP, resp.Port, resp.AccessToken) if mangerErr != nil { slog.Error("Error adding server manager instance", slog.Any("error", mangerErr)) events.OnError(convertErrorToJSON("EventTypeProvisioningError", mangerErr)) return } slog.Debug("Server manager instance added successfully", slog.String("tag", resp.Tag)) - serverInfo, found := ps.manager.GetServerByTag(resp.Tag) + serverInfo, found, err := ps.client.GetServerByTag(ctx, resp.Tag) + if err != nil { + slog.Error("Error getting server by tag", slog.Any("error", err)) + events.OnError(convertErrorToJSON("EventTypeProvisioningError", err)) + return + } // add protocol info if found if found { resp.Protocol = serverInfo.Type @@ -338,7 +343,7 @@ func CancelDeployment() error { // AddServerManually adds a server manually to the VPN client. // It takes the server's IP, port, access token, and tag, along with the VPN client and event listener. -func AddServerManually(ip, port, accessToken, tag string, vpnClient *servers.Manager, events utils.PrivateServerEventListener) error { +func AddServerManually(ip, port, accessToken, tag string, vpnClient *ipc.Client, events utils.PrivateServerEventListener) error { slog.Debug("Adding server manually", slog.String("ip", ip), slog.String("port", port), slog.String("tag", tag)) portInt, err := strconv.Atoi(port) if err != nil { @@ -351,17 +356,14 @@ func AddServerManually(ip, port, accessToken, tag string, vpnClient *servers.Man Tag: tag, } provisionSession := &provisionSession{ - manager: vpnClient, + client: vpnClient, eventSink: events, } storeSession(provisionSession) location := getGeoInfo(ip) - _, city, country := ParseLocation(location) - err = provisionSession.manager.AddPrivateServer(resp.Tag, resp.ExternalIP, resp.Port, resp.AccessToken, &common.ServerLocation{ - Country: "", - City: city, - CountryCode: country, - }, true) + _, _, _ = ParseLocation(location) + ctx := context.Background() + err = provisionSession.client.AddPrivateServer(ctx, resp.Tag, resp.ExternalIP, resp.Port, resp.AccessToken) if err != nil { return err } diff --git a/lantern-core/utils/common.go b/lantern-core/utils/common.go index 934f374299..fc4cd3db45 100644 --- a/lantern-core/utils/common.go +++ b/lantern-core/utils/common.go @@ -1,10 +1,6 @@ package utils import ( - "log/slog" - "os" - - "github.com/getlantern/radiance/issue" "github.com/sagernet/sing-box/experimental/libbox" ) @@ -16,6 +12,7 @@ type Opts struct { Locale string Env string TelemetryConsent bool + Platform PlatformInterface } type PrivateServerEventListener interface { @@ -34,21 +31,9 @@ type FlutterEventEmitter interface { SendEvent(event *FlutterEvent) } -// CreateLogAttachment tries to read the log file at logFilePath and returns -// an []*issue.Attachment with the log (if found) -func CreateLogAttachment(logFilePath string) []*issue.Attachment { - if logFilePath == "" { - return nil - } - data, err := os.ReadFile(logFilePath) - if err != nil { - slog.Debug("could not read log file %q: %v", logFilePath, err) - return nil - } - return []*issue.Attachment{{ - Name: "flutter.log", - Data: data, - }} +// LogListener receives log entries streamed from the IPC client. +type LogListener interface { + OnLogEntry(entry string) } type PlatformInterface interface { diff --git a/lantern-core/utils/gostack.go b/lantern-core/utils/gostack.go new file mode 100644 index 0000000000..99d6b5b660 --- /dev/null +++ b/lantern-core/utils/gostack.go @@ -0,0 +1,38 @@ +package utils + +import ( + "fmt" + "log/slog" + "runtime/debug" +) + +// RunOffCgoStack executes fn on a new goroutine and returns its result. +// A new goroutine is spawned per call; there is no persistent worker. +// +// Gomobile-exported functions run on a CGo callback stack whose memory isn't +// covered by the GC heap bitmap. When the gomobile-generated wrapper copies Go +// pointer-containing return values to the C thread stack, bulkBarrierPreWrite +// can panic. Running the body on a real Go goroutine avoids this entirely. +// +// If fn panics, the panic is recovered and a zero value + error are returned +// instead of blocking the caller forever. +func RunOffCgoStack[T any](fn func() (T, error)) (T, error) { + type result struct { + val T + err error + } + ch := make(chan result, 1) + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("panic in RunOffCgoStack", "panic", r, "stack", string(debug.Stack())) + var zero T + ch <- result{val: zero, err: fmt.Errorf("panic: %v", r)} + } + }() + v, err := fn() + ch <- result{val: v, err: err} + }() + r := <-ch + return r.val, r.err +} diff --git a/lantern-core/vpn_tunnel/vpn_tunnel.go b/lantern-core/vpn_tunnel/vpn_tunnel.go index fa782e878b..af32d1f799 100644 --- a/lantern-core/vpn_tunnel/vpn_tunnel.go +++ b/lantern-core/vpn_tunnel/vpn_tunnel.go @@ -1,139 +1,57 @@ package vpn_tunnel import ( + "context" "fmt" - "runtime" - "sync/atomic" - "log/slog" - "github.com/getlantern/radiance/servers" + "github.com/getlantern/radiance/ipc" "github.com/getlantern/radiance/vpn" - "github.com/getlantern/radiance/vpn/ipc" - "github.com/getlantern/radiance/vpn/rvpn" - - "github.com/getlantern/lantern/lantern-core/utils" ) type InternalTag string const ( InternalTagAutoAll InternalTag = "auto-all" - InternalTagUser InternalTag = InternalTag(servers.SGUser) - InternalTagLantern InternalTag = InternalTag(servers.SGLantern) ) -var ipcServer atomic.Pointer[ipc.Server] - -// StartVPN will start the VPN tunnel using the provided platform interface. -// If the user previously selected a specific server (persisted by -// vpn.SelectServer), the new tunnel is pinned to that selection. Otherwise it -// falls back to AutoConnect which picks the best server. -// -// This is critical on Android: the OS VPN service lifecycle tears down and -// recreates libbox on every settings-driven restart, and the in-memory selector -// state of the previous libbox doesn't carry over. Reading the persisted -// selection here is what keeps the user pinned to their chosen server across -// routing-mode toggles, ad-block toggles, etc. -func StartVPN(platform rvpn.PlatformInterface, opts *utils.Opts) error { +// StartVPN is the gomobile entry point for Mobile.StartVPN (Android +// MainActivity / iOS VPNManager). It is also reached from Jigar's +// onSmartLocation rewrite in server_selection.dart via startVPN(force: true) +// → lantern.startVPN() → Mobile.StartVPN, which expects "switch back to +// auto" to work on a live tunnel. Delegate to ConnectToServer so the +// VPNStatus → /server/selected dispatch handles that case. +func StartVPN(ctx context.Context, client *ipc.Client) error { slog.Info("StartVPN called") - if err := initIPC(opts, platform); err != nil { - return fmt.Errorf("failed to initialize IPC server: %w", err) - } - if group, tag := vpn.LastSelectedServer(); group != "" && tag != "" { - slog.Info("Restoring persisted server selection", "group", group, "tag", tag) - if err := vpn.Connect(group, tag); err != nil { - slog.Warn("Failed to restore persisted server, falling back to AutoConnect", - "group", group, "tag", tag, "error", err) - vpn.ClearLastSelectedServer() - } else { - return nil - } - } else { - slog.Info("No persisted server selection found, falling back to AutoConnect") - } - if err := vpn.AutoConnect(""); err != nil { - return fmt.Errorf("failed to start VPN: %w", err) - } - return nil -} - -// StopVPN will stop the VPN tunnel. -func StopVPN() error { - return vpn.Disconnect() -} - -// ConnectToServer will connect to a specific VPN server identified by the group and tag. If tag is -// empty, it will connect to the best server available in that group. ConnectToServer will start the -// VPN tunnel if it's not already running. -func ConnectToServer(group, tag string, platIfce rvpn.PlatformInterface, opts *utils.Opts) error { - slog.Debug("ConnectToServer called", "group", group, "tag", tag) - if err := initIPC(opts, platIfce); err != nil { - return fmt.Errorf("failed to initialize IPC server: %w", err) - } - switch group { - case string(InternalTagAutoAll), "auto": - group = "all" - case "privateServer": - group = string(InternalTagUser) - case "lanternLocation": - group = string(InternalTagLantern) - } - slog.Debug("Connecting to VPN server", "group", group, "tag", tag) - if tag == "" { - return vpn.QuickConnect(group, platIfce) - } - slog.Debug("Connecting to specific VPN server", "group", group, "tag", tag) - return vpn.ConnectToServer(group, tag, platIfce) -} - -func IsVPNRunning() bool { - slog.Debug("Checking if VPN is running...") - status, err := vpn.GetStatus() - slog.Debug("VPN status:", "status", status, "Error:", err) - return status.TunnelOpen + return ConnectToServer(ctx, client, vpn.AutoSelectTag) } -func GetSelectedServer() string { - slog.Debug("Getting selected VPN server...") - status, err := vpn.GetStatus() - slog.Debug("VPN status:", "status", status, "Error:", err) - return status.SelectedServer -} - -func CloseIPC() error { - if runtime.GOOS == "linux" { - return nil - } - if svr := ipcServer.Swap(nil); svr != nil { - return svr.Close() - } - return nil +func StopVPN(ctx context.Context, client *ipc.Client) error { + return client.DisconnectVPN(ctx) } -func initIPC(opts *utils.Opts, platIfce rvpn.PlatformInterface) error { - if runtime.GOOS == "linux" { - return nil - } - if ipcServer.Load() != nil { - return nil - } - slog.Debug("Initializing IPC", "dataDir", opts.DataDir, "logDir", opts.LogDir, "logLevel", opts.LogLevel) - svr, err := vpn.InitIPC(opts.DataDir, opts.LogDir, opts.LogLevel, platIfce) +// ConnectToServer switches the live tunnel to a specific server or, when the +// caller passes an empty tag or vpn.AutoSelectTag, back to auto-select. +// Radiance normalizes the empty-tag case server-side (fac9089) for both +// ConnectVPN and SelectServer. +// +// The caller is responsible for putting a deadline on ctx — the connect +// path involves real network work (DNS, TLS, sing-box bring-up) and we +// don't want a hung lanternd to stall the UI forever. LanternCore.ConnectVPN +// uses 60 s. +func ConnectToServer(ctx context.Context, client *ipc.Client, tag string) error { + slog.Debug("Connecting to VPN server", "tag", tag) + + // Switch outbounds on the live tunnel when already connected; + // otherwise start the tunnel with the chosen tag. + status, err := client.VPNStatus(ctx) if err != nil { - return err + return fmt.Errorf("get VPN status failed: %w", err) } - ipcServer.Store(svr) - return nil -} - -// GetAutoLocation returns the current auto location as a JSON string. -func GetAutoLocation() (*vpn.AutoSelections, error) { - slog.Debug("Getting auto location...") - location, err := vpn.AutoServerSelections() - slog.Debug("Auto location:", "location", location, "Error:", err) - if err != nil { - return nil, fmt.Errorf("failed to get auto location: %w", err) + if status == vpn.Connected { + slog.Debug("VPN is already connected, switching server", "tag", tag) + return client.SelectServer(ctx, tag) } - return &location, nil + slog.Debug("VPN is not connected, starting VPN with selected server", "tag", tag) + return client.ConnectVPN(ctx, tag) } diff --git a/lantern-core/wintunmgr/ipc.go b/lantern-core/wintunmgr/ipc.go deleted file mode 100644 index 98674fa041..0000000000 --- a/lantern-core/wintunmgr/ipc.go +++ /dev/null @@ -1,30 +0,0 @@ -package wintunmgr - -import ( - "encoding/json" - - "github.com/getlantern/lantern/lantern-core/common" -) - -// IPC structs -type Request struct { - ID string `json:"id"` - Cmd common.Command `json:"cmd"` - Params json.RawMessage `json:"params,omitempty"` - Token string `json:"token,omitempty"` -} - -type Response struct { - ID string `json:"id"` - Result interface{} `json:"result,omitempty"` - Error *RPCError `json:"error,omitempty"` -} - -type RPCError struct { - Code string `json:"code"` - Message string `json:"message"` -} - -func rpcErr(id, code, msg string) *Response { - return &Response{ID: id, Error: &RPCError{Code: code, Message: msg}} -} diff --git a/lantern-core/wintunmgr/manager.go b/lantern-core/wintunmgr/manager.go deleted file mode 100644 index e739fd5ea2..0000000000 --- a/lantern-core/wintunmgr/manager.go +++ /dev/null @@ -1,109 +0,0 @@ -//go:build windows - -package wintunmgr - -import ( - "context" - "fmt" - "log/slog" - "time" - - "golang.org/x/sys/windows" - "golang.zx2c4.com/wintun" -) - -// Manager owns Wintun adapter lifecycle for a given name/pool -type Manager struct { - AdapterName string - PoolName string - // deterministic GUID for stable NLA entries - GUID *windows.GUID -} - -// New returns a new Manager with some defaults -func New(adapterName, poolName string, guid *windows.GUID) *Manager { - if adapterName == "" { - adapterName = "Lantern" - } - if poolName == "" { - poolName = adapterName - } - return &Manager{ - AdapterName: adapterName, - PoolName: poolName, - GUID: guid, - } -} - -// OpenOrCreateTunAdapter opens an existing adapter or creates it if missing -func (m *Manager) OpenOrCreateTunAdapter(ctx context.Context) (*wintun.Adapter, error) { - if v, _ := wintun.RunningVersion(); v != 0 { - slog.Debug("Wintun running version", "version", v) - } - - start := time.Now() - if ad, err := wintun.OpenAdapter(m.AdapterName); err == nil { - slog.Debug("wintun.OpenAdapter opened", "name", m.AdapterName, "elapsed_ms", sinceMs(start)) - return ad, nil - } else { - slog.Debug("wintun.OpenAdapter miss", "name", m.AdapterName, "err", err) - } - - select { - case <-ctx.Done(): - slog.Debug("OpenOrCreateTunAdapter canceled", "adapter", m.AdapterName, "err", ctx.Err()) - return nil, ctx.Err() - default: - } - - const maxRetries = 3 - var ad *wintun.Adapter - var err error - for i := 1; i <= maxRetries; i++ { - att := time.Now() - ad, err = wintun.CreateAdapter(m.AdapterName, m.PoolName, m.GUID) - if err == nil { - slog.Debug("CreateAdapter created", "name", m.AdapterName, "pool", m.PoolName, "guid", m.GUID, "attempt", i, "elapsed_ms", sinceMs(att), "total_ms", sinceMs(start)) - return ad, nil - } - slog.Error("wintun.CreateAdapter failed", "name", m.AdapterName, "pool", m.PoolName, "attempt", i, "err", err) - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(300 * time.Millisecond): - } - } - return nil, fmt.Errorf("create Wintun adapter %q after %d attempts: %w", m.AdapterName, maxRetries, err) -} - -// Open tries opening the adapter by name -func (m *Manager) Open() (*wintun.Adapter, error) { - start := time.Now() - ad, err := wintun.OpenAdapter(m.AdapterName) - if err != nil { - return nil, fmt.Errorf("open Wintun adapter %q: %w", m.AdapterName, err) - } - slog.Debug("wintun.OpenAdapter ok", "name", m.AdapterName, "elapsed_ms", sinceMs(start)) - - return ad, nil -} - -// Create forces creation (installs driver on first use) -func (m *Manager) Create() (*wintun.Adapter, error) { - start := time.Now() - ad, err := wintun.CreateAdapter(m.AdapterName, m.PoolName, m.GUID) - if err != nil { - return nil, fmt.Errorf("create Wintun adapter %q: %w", m.AdapterName, err) - } - slog.Debug("wintun.CreateAdapter ok", "name", m.AdapterName, "pool", m.PoolName, "elapsed_ms", sinceMs(start)) - - return ad, nil -} - -// UninstallDriver attempts to remove the Wintun driver -func (m *Manager) UninstallDriver() error { - if err := wintun.Uninstall(); err != nil { - return fmt.Errorf("wintun uninstall: %w", err) - } - return nil -} diff --git a/lantern-core/wintunmgr/observability.go b/lantern-core/wintunmgr/observability.go deleted file mode 100644 index 088077959a..0000000000 --- a/lantern-core/wintunmgr/observability.go +++ /dev/null @@ -1,40 +0,0 @@ -//go:build windows - -package wintunmgr - -import ( - "crypto/rand" - "encoding/hex" - "fmt" - "log/slog" - "runtime/debug" - "time" -) - -func shortGUID(s string) string { - if len(s) <= 8 { - return s - } - return s[:8] -} - -func sinceMs(t time.Time) int64 { return time.Since(t).Milliseconds() } - -func randID(prefix string, n int) string { - if n <= 0 { - n = 8 - } - b := make([]byte, n) - _, _ = rand.Read(b) - return prefix + hex.EncodeToString(b) -} - -func recoverErr(where string, perr *error) { - if r := recover(); r != nil { - slog.Error("panic", "where", where, "error", r, "stack", string(debug.Stack())) - - if perr != nil && *perr == nil { - *perr = fmt.Errorf("panic in %s: %v", where, r) - } - } -} diff --git a/lantern-core/wintunmgr/service_windows.go b/lantern-core/wintunmgr/service_windows.go deleted file mode 100644 index ecaa1de155..0000000000 --- a/lantern-core/wintunmgr/service_windows.go +++ /dev/null @@ -1,436 +0,0 @@ -//go:build windows - -package wintunmgr - -import ( - "context" - "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net" - "os" - "path/filepath" - "runtime/debug" - "strings" - "sync" - "time" - - "github.com/Microsoft/go-winio" - "github.com/getlantern/lantern/lantern-core/common" - "github.com/getlantern/lantern/lantern-core/utils" - "github.com/getlantern/lantern/lantern-core/vpn_tunnel" - "github.com/getlantern/radiance/events" - "github.com/getlantern/radiance/vpn/ipc" -) - -type ServiceOptions struct { - PipeName string - DataDir string - LogDir string - Locale string - TokenPath string -} - -// Service hosts the command server and manages LanternCore -// It proxies privileged commands and interacts with Radiance IPC when available -type Service struct { - opts ServiceOptions - wtmgr *Manager - cancel context.CancelFunc -} - -type statusEvent struct { - Event string `json:"event"` - State string `json:"state"` - Ts int64 `json:"ts"` - Error string `json:"error,omitempty"` -} - -type logsEvent struct { - Event string `json:"event"` - Lines []string `json:"lines"` - Ts int64 `json:"ts"` -} - -// concurrentEncoder lets multiple goroutines write JSON responses to the -// IPC stream sequentially without colliding and corrupting the data. -type concurrentEncoder struct { - mu sync.Mutex - enc *json.Encoder -} - -func (ce *concurrentEncoder) Encode(v any) error { - ce.mu.Lock() - defer ce.mu.Unlock() - return ce.enc.Encode(v) -} - -func NewService(opts ServiceOptions, wt *Manager) *Service { - return &Service{ - opts: opts, - wtmgr: wt, - } -} - -// token file is created at install time (we also generate if missing) -func (s *Service) getToken() (string, error) { - if _, err := os.Stat(s.opts.TokenPath); errors.Is(err, os.ErrNotExist) { - if err := os.MkdirAll(filepath.Dir(s.opts.TokenPath), 0o755); err != nil { - return "", err - } - buf := make([]byte, 32) - if _, err := rand.Read(buf); err != nil { - return "", err - } - token := base64.RawURLEncoding.EncodeToString(buf) - if err := os.WriteFile(s.opts.TokenPath, []byte(token), 0o600); err != nil { - return "", err - } - return token, nil - } - b, err := os.ReadFile(s.opts.TokenPath) - if err != nil { - return "", err - } - token := strings.TrimSpace(string(b)) - if token != "" { - return token, nil - } - - buf := make([]byte, 32) - if _, err := rand.Read(buf); err != nil { - return "", err - } - token = base64.RawURLEncoding.EncodeToString(buf) - if err := os.WriteFile(s.opts.TokenPath, []byte(token), 0o600); err != nil { - return "", err - } - slog.Warn("IPC token file was empty, regenerated token", "token_path", s.opts.TokenPath) - return token, nil -} - -func (s *Service) Start(ctx context.Context) error { - var err error - defer recoverErr("Service.Start", &err) - - if s.opts.PipeName == "" { - s.opts.PipeName = `\\.\pipe\LanternService` - } - if s.opts.TokenPath == "" { - progData := os.Getenv("ProgramData") - if progData == "" { - progData = `C:\ProgramData` - } - s.opts.TokenPath = filepath.Join(progData, "Lantern", "ipc-token") - } - - slog.Info("Starting Windows service", "pipe", s.opts.PipeName, "data_dir", - s.opts.DataDir, "log_dir", s.opts.LogDir, "token_path", s.opts.TokenPath) - - token, err := s.getToken() - if err != nil { - return fmt.Errorf("token: %w", err) - } - - ctx, s.cancel = context.WithCancel(ctx) - - cfg := &winio.PipeConfig{ - SecurityDescriptor: `D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;AU)`, - MessageMode: true, - InputBufferSize: 128 * 1024, - OutputBufferSize: 128 * 1024, - } - ln, err := winio.ListenPipe(s.opts.PipeName, cfg) - if err != nil { - return err - } - slog.Debug("Service listening on pipe", "pipe", s.opts.PipeName) - - go func() { - <-ctx.Done() - _ = ln.Close() - slog.Debug("Service listener closed pipe", "pipe", s.opts.PipeName) - }() - - for { - conn, err := ln.Accept() - if err != nil { - if ctx.Err() != nil { - return nil - } - continue - } - connID := randID("c_", 6) - slog.Debug("Service accept", "conn_id", connID) - go s.handleConn(ctx, conn, token, connID) - } -} - -func (s *Service) handleWatchStatus(ctx context.Context, enc *concurrentEncoder) { - sub := events.Subscribe(func(evt ipc.StatusUpdateEvent) { - slog.Debug("Sending status event", "state", evt.Status.String(), "error", evt.Error) - se := statusEvent{Event: "Status", State: evt.Status.String(), Ts: time.Now().Unix()} - if evt.Error != nil { - se.Error = evt.Error.Error() - } - _ = enc.Encode(se) - }) - - // Unsubscribe when context is done - go func() { - <-ctx.Done() - events.Unsubscribe(sub) - }() -} - -func (s *Service) handleWatchLogs(ctx context.Context, enc *concurrentEncoder, done chan struct{}) { - logFile := filepath.Join(s.opts.LogDir, "lantern.log") - _ = os.MkdirAll(s.opts.LogDir, 0o755) - if _, err := os.Stat(logFile); errors.Is(err, os.ErrNotExist) { - _ = os.WriteFile(logFile, nil, 0o644) - } - - // Start by sending the most recent chunk of the log - const maxTail = 200 - if last, err := readLastLines(logFile, maxTail); err == nil && len(last) > 0 { - _ = enc.Encode(logsEvent{Event: "Logs", Lines: last, Ts: time.Now().Unix()}) - } - - // Then keep watching the file to stream new lines as they’re written - go func() { - var f *os.File - var err error - var off int64 = 0 - - open := func(reset bool) error { - if f != nil { - _ = f.Close() - } - f, err = os.Open(logFile) - if err != nil { - return err - } - fi, _ := f.Stat() - if reset || fi == nil { - off = 0 - } else { - off = fi.Size() - } - _, _ = f.Seek(off, io.SeekStart) - return nil - } - - _ = open(false) - defer func() { - if f != nil { - _ = f.Close() - } - }() - - // Poll for changes - ticker := time.NewTicker(600 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-done: - return - case <-ticker.C: - fi, err := os.Stat(logFile) - if err != nil { - _ = open(true) - continue - } - if fi.Size() < off { - _ = open(true) - continue - } - if fi.Size() == off { - // Nothing new to read - continue - } - // Read only new bytes that were just appended - n := fi.Size() - off - buf := make([]byte, n) - _, err = io.ReadFull(f, buf) - if err != nil { - _ = open(false) - continue - } - off = fi.Size() - - chunk := string(buf) - raw := strings.Split(chunk, "\n") - var lines []string - for _, ln := range raw { - if ln == "" { - continue - } - lines = append(lines, ln) - } - // Send new lines to client - if len(lines) > 0 { - _ = enc.Encode(logsEvent{Event: "Logs", Lines: lines, Ts: time.Now().Unix()}) - } - } - } - }() -} - -func (s *Service) handleConn(ctx context.Context, c net.Conn, token, connID string) { - dec := json.NewDecoder(c) - enc := json.NewEncoder(c) - - done := make(chan struct{}) - defer func() { - close(done) - _ = c.Close() - slog.Debug("conn closed", "conn_id", connID) - }() - - for { - var req Request - if err := dec.Decode(&req); err != nil { - if !errors.Is(err, io.EOF) { - slog.Debug("decode error", "error", err) - } - return - } - reqID := req.ID - cmd := string(req.Cmd) - - if req.Token != token { - _ = enc.Encode(rpcErr(req.ID, "unauthorized", "bad token")) - continue - } - if req.Cmd == common.CmdWatchStatus { - s.handleWatchStatus(ctx, &concurrentEncoder{enc: enc}) - continue - } - if req.Cmd == common.CmdWatchLogs { - s.handleWatchLogs(ctx, &concurrentEncoder{enc: enc}, done) - continue - } - start := time.Now() - resp := s.dispatch(ctx, &req) - elapsed := sinceMs(start) - if resp.Error != nil { - slog.Error("cmd error", "conn_id", connID, "req_id", reqID, "cmd", cmd, "elapsed_ms", elapsed, - "code", resp.Error.Code, "msg", resp.Error.Message) - } else { - slog.Debug("cmd ok", "conn_id", connID, "req_id", reqID, "cmd", cmd, "elapsed_ms", elapsed) - } - _ = enc.Encode(resp) - } -} - -func (s *Service) setupAdapter(ctx context.Context) error { - if s.wtmgr == nil { - return nil - } - ad, err := s.wtmgr.OpenOrCreateTunAdapter(ctx) - if err != nil { - return err - } - return ad.Close() -} - -func (s *Service) dispatch(ctx context.Context, r *Request) *Response { - defer func() { - if rec := recover(); rec != nil { - slog.Error("panic in dispatch", "cmd", r.Cmd, "error", rec, "stack", string(debug.Stack())) - } - }() - - slog.Debug("Service dispatch", "cmd", r.Cmd) - switch r.Cmd { - - case common.CmdSetupAdapter: - if err := s.setupAdapter(ctx); err != nil { - return rpcErr(r.ID, "adapter_error", err.Error()) - } - return &Response{ID: r.ID, Result: map[string]any{"ok": true}} - - case common.CmdStartTunnel: - go func() { - events.Emit(ipc.StatusUpdateEvent{Status: ipc.Connecting}) - if err := vpn_tunnel.StartVPN(nil, &utils.Opts{LogLevel: "trace"}); err != nil { - slog.Error("Error starting service", "error", err) - events.Emit(ipc.StatusUpdateEvent{Status: ipc.ErrorStatus, Error: err}) - } else { - events.Emit(ipc.StatusUpdateEvent{Status: ipc.Connected}) - } - }() - return &Response{ID: r.ID, Result: map[string]any{"started": true}} - - case common.CmdStopTunnel: - go func() { - events.Emit(ipc.StatusUpdateEvent{Status: ipc.Disconnecting}) - if err := vpn_tunnel.StopVPN(); err != nil { - slog.Error("Error stopping service", "error", err) - events.Emit(ipc.StatusUpdateEvent{Status: ipc.ErrorStatus, Error: err}) - } else { - events.Emit(ipc.StatusUpdateEvent{Status: ipc.Disconnected}) - } - }() - return &Response{ID: r.ID, Result: map[string]any{"stopped": true}} - - case common.CmdIsVPNRunning: - running := vpn_tunnel.IsVPNRunning() - go func() { - if running { - events.Emit(ipc.StatusUpdateEvent{Status: ipc.Connected}) - } else { - events.Emit(ipc.StatusUpdateEvent{Status: ipc.Disconnected}) - } - }() - return &Response{ID: r.ID, Result: map[string]any{"running": running}} - - case common.CmdConnectToServer: - var p struct { - Location string `json:"location"` - Tag string `json:"tag"` - } - if err := json.Unmarshal(r.Params, &p); err != nil { - return rpcErr(r.ID, "bad_params", err.Error()) - } - group := strings.TrimSpace(p.Location) - go func(group, tag string) { - events.Emit(ipc.StatusUpdateEvent{Status: ipc.Connecting}) - if err := vpn_tunnel.ConnectToServer(group, p.Tag, nil, &utils.Opts{LogLevel: "trace"}); err != nil { - slog.Error("Error connecting to server", "error", err) - events.Emit(ipc.StatusUpdateEvent{Status: ipc.ErrorStatus, Error: err}) - } else { - events.Emit(ipc.StatusUpdateEvent{Status: ipc.Connected}) - } - }(group, p.Tag) - return &Response{ID: r.ID, Result: "ok"} - - default: - return rpcErr(r.ID, "unknown_cmd", string(r.Cmd)) - } -} - -func readLastLines(path string, max int) ([]string, error) { - b, err := os.ReadFile(path) - if err != nil { - return nil, err - } - if len(b) > 64*1024 { - b = b[len(b)-64*1024:] - } - lines := strings.Split(string(b), "\n") - if len(lines) > 0 && lines[len(lines)-1] == "" { - lines = lines[:len(lines)-1] - } - if len(lines) > max { - lines = lines[len(lines)-max:] - } - return lines, nil -} diff --git a/lib/core/common/app_build_info.dart b/lib/core/common/app_build_info.dart index 819c676c17..dd800e9920 100644 --- a/lib/core/common/app_build_info.dart +++ b/lib/core/common/app_build_info.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; class AppBuildInfo { @@ -15,6 +16,9 @@ class AppBuildInfo { 'DISABLE_SYSTEM_TRAY', defaultValue: false, ); + + /// Developer mode is exposed in debug and nightly builds only. + static bool get isDevModeEnabled => kDebugMode || buildType == 'nightly'; } ///Always use values from app build info this will ensure that the version and build number are same diff --git a/lib/core/common/common.dart b/lib/core/common/common.dart index cdbd8f2577..6eab9a5459 100644 --- a/lib/core/common/common.dart +++ b/lib/core/common/common.dart @@ -25,6 +25,7 @@ import '../services/local_storage_service.dart'; import '../utils/store_utils.dart'; export 'package:lantern/core/common/app_asset.dart'; +export 'package:lantern/core/common/app_build_info.dart'; export 'package:lantern/core/common/app_buttons.dart'; export 'package:lantern/core/common/app_colors.dart'; export 'package:lantern/core/common/app_dialog.dart'; diff --git a/lib/core/extensions/error.dart b/lib/core/extensions/error.dart index f2abe11dae..c3024a0f45 100644 --- a/lib/core/extensions/error.dart +++ b/lib/core/extensions/error.dart @@ -5,8 +5,12 @@ extension ErrorExetension on Object { String get localizedDescription { // Check if the error is a PlatformException if (this is PlatformException) { - // Extract the message from the PlatformException - String description = (this as PlatformException).message ?? ''; + // Extract the message from the PlatformException and strip the + // radiance IPC wrapper (e.g. "ipc: status 401: actual error") + // so only the upstream error is kept. + String description = _stripIpcPrefix( + (this as PlatformException).message ?? '', + ); if (description.contains("proxy_error")) { return "proxy_error".i18n; } @@ -34,13 +38,16 @@ extension ErrorExetension on Object { if (description.contains("wrong-reseller-code")) { return "wrong_seller_code".i18n; } - if (description - .contains("unexpected status 400 body missing verifier or salt ")) { + if (description.contains( + "unexpected status 400 body missing verifier or salt ", + ) || + description.contains('unexpected status 403 body')) { return "user_not_found".i18n; } if (description.contains("user already exists") || - description - .contains("user with this legacy user ID already exists")) { + description.contains( + "user with this legacy user ID already exists", + )) { return "signup_error_user_exists".i18n; } @@ -85,6 +92,17 @@ extension ErrorExetension on Object { } } +/// Strips the radiance IPC prefix from error messages. +/// e.g. "ipc: status 401: unexpected status 403 body forbidden" +/// → "unexpected status 403 body forbidden" +String _stripIpcPrefix(String message) { + final match = RegExp(r'^ipc:\s*status\s+\d+:\s*').firstMatch(message); + if (match != null) { + return message.substring(match.end); + } + return message; +} + extension PurchaseErrorExtension on String { String get localizedDescription { if (this == 'BillingResponse.itemAlreadyOwned') { diff --git a/lib/core/extensions/plan.dart b/lib/core/extensions/plan.dart index f23df2f1fe..05da9113fc 100644 --- a/lib/core/extensions/plan.dart +++ b/lib/core/extensions/plan.dart @@ -2,7 +2,7 @@ import 'package:intl/intl.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/plan_data.dart'; import 'package:lantern/core/utils/currency_utils.dart'; -import 'package:lantern/lantern/protos/protos/auth.pb.dart'; +import 'package:lantern/core/models/user.dart'; final _ddmmyyFormatter = DateFormat('dd/MM/yy'); final _mmddyyFormatter = DateFormat('MM/dd/yy'); @@ -31,11 +31,10 @@ extension PlanExtension on Plan { } } -extension IsoDateFormatter on UserResponse_UserData { +extension IsoDateFormatter on UserDataModel { String toDate() { try { if (userLevel == 'expired') { - final lastExpiredOn = this.lastExpiredOn.toInt(); if (lastExpiredOn <= 0) { return "N/A"; } @@ -48,19 +47,18 @@ extension IsoDateFormatter on UserResponse_UserData { } final autoRenew = subscriptionData.autoRenew; - final endAt = subscriptionData.endAt.toInt(); + final endAt = subscriptionData.endAt; // Validate expiration exists if (expiration <= 0) { return "N/A"; } if (autoRenew && endAt != 0) { // Active subscription case - final endAtTimestamp = subscriptionData.endAt.toInt(); - if (endAtTimestamp <= 0) { + if (endAt <= 0) { return "N/A"; } final dateTime = DateTime.fromMillisecondsSinceEpoch( - endAtTimestamp * 1000, + endAt * 1000, isUtc: true, ).toLocal(); @@ -68,7 +66,7 @@ extension IsoDateFormatter on UserResponse_UserData { } // Non-subscription plan case final expirationDate = DateTime.fromMillisecondsSinceEpoch( - expiration.toInt() * 1000, + expiration * 1000, isUtc: true, ).toLocal(); final formattedDate = _formatDate(expirationDate); diff --git a/lib/core/extensions/ref.dart b/lib/core/extensions/ref.dart index 185bbaae90..3825c7a22c 100644 --- a/lib/core/extensions/ref.dart +++ b/lib/core/extensions/ref.dart @@ -29,7 +29,7 @@ final userEmailProvider = Provider((ref) { final isPrivateServerFoundProvider = Provider((ref) { final privateServersAsync = ref.watch(availableServersProvider); return privateServersAsync.maybeWhen( - data: (servers) => servers.user.locations.values.isNotEmpty, + data: (servers) => servers.hasUserServers, orElse: () => false, ); }); diff --git a/lib/core/extensions/user_data.dart b/lib/core/extensions/user_data.dart index 2e76fca634..b5b92fea44 100644 --- a/lib/core/extensions/user_data.dart +++ b/lib/core/extensions/user_data.dart @@ -1,5 +1,5 @@ -import 'package:lantern/lantern/protos/protos/auth.pb.dart'; +import 'package:lantern/core/models/user.dart'; -extension UserDataProX on UserResponse_UserData { +extension UserDataProX on UserDataModel { bool get isPro => userLevel == 'pro'; } diff --git a/lib/core/models/app_setting.dart b/lib/core/models/app_setting.dart index 26bd859954..3f6eb7a6ee 100644 --- a/lib/core/models/app_setting.dart +++ b/lib/core/models/app_setting.dart @@ -1,135 +1,71 @@ -import 'package:lantern/core/common/common.dart'; - class AppSetting { - final bool isPro; - final bool isSplitTunnelingOn; final String locale; final String themeMode; final String environment; - final String oAuthToken; - final String oAuthLoginProvider; final bool userLoggedIn; - final bool blockAds; - final String email; final bool showSplashScreen; final bool telemetryDialogDismissed; - final bool telemetryConsent; final bool successfulConnection; - final String routingModeRaw; final String dataCapThreshold; final bool onboardingCompleted; const AppSetting({ - this.isPro = false, - this.isSplitTunnelingOn = false, this.themeMode = 'system', this.environment = 'prod', this.userLoggedIn = false, - this.oAuthToken = '', - this.oAuthLoginProvider = '', - this.blockAds = false, - this.email = '', this.locale = 'en_US', this.showSplashScreen = true, this.telemetryDialogDismissed = false, - this.telemetryConsent = false, this.successfulConnection = false, - this.routingModeRaw = 'full_tunnel', this.dataCapThreshold = '', this.onboardingCompleted = false, }); AppSetting copyWith({ - bool? newPro, - bool? newIsSpiltTunnelingOn, String? newLocale, String? themeMode, String? environment, bool? userLoggedIn, - bool? blockAds, - String? oAuthToken, - String? oAuthLoginProvider, - String? email, bool? showSplashScreen, bool? showTelemetryDialog, - bool? telemetryConsent, bool? successfulConnection, - String? routingModeRaw, String? dataCapThreshold, bool? onboardingCompleted, }) { return AppSetting( - isPro: newPro ?? isPro, - isSplitTunnelingOn: newIsSpiltTunnelingOn ?? isSplitTunnelingOn, locale: newLocale ?? locale, themeMode: themeMode ?? this.themeMode, environment: environment ?? this.environment, - blockAds: blockAds ?? this.blockAds, userLoggedIn: userLoggedIn ?? this.userLoggedIn, - oAuthToken: oAuthToken ?? this.oAuthToken, - oAuthLoginProvider: oAuthLoginProvider ?? this.oAuthLoginProvider, - email: email ?? this.email, showSplashScreen: showSplashScreen ?? this.showSplashScreen, telemetryDialogDismissed: showTelemetryDialog ?? telemetryDialogDismissed, - telemetryConsent: telemetryConsent ?? this.telemetryConsent, successfulConnection: successfulConnection ?? this.successfulConnection, - routingModeRaw: routingModeRaw ?? this.routingModeRaw, dataCapThreshold: dataCapThreshold ?? this.dataCapThreshold, onboardingCompleted: onboardingCompleted ?? this.onboardingCompleted, ); } - RoutingMode get routingMode => RoutingModeX.fromRaw(routingModeRaw); - Map toJson() => { - 'isPro': isPro, - 'isSplitTunnelingOn': isSplitTunnelingOn, - 'themeMode': themeMode, - 'environment': environment, - 'userLoggedIn': userLoggedIn, - 'oAuthToken': oAuthToken, - 'oAuthLoginProvider': oAuthLoginProvider, - 'blockAds': blockAds, - 'email': email, - 'locale': locale, - 'showSplashScreen': showSplashScreen, - 'telemetryDialogDismissed': telemetryDialogDismissed, - 'telemetryConsent': telemetryConsent, - 'successfulConnection': successfulConnection, - 'routingModeRaw': routingModeRaw, - 'dataCapThreshold': dataCapThreshold, - 'onboardingCompleted': onboardingCompleted, - }; + 'themeMode': themeMode, + 'environment': environment, + 'userLoggedIn': userLoggedIn, + 'locale': locale, + 'showSplashScreen': showSplashScreen, + 'telemetryDialogDismissed': telemetryDialogDismissed, + 'successfulConnection': successfulConnection, + 'dataCapThreshold': dataCapThreshold, + 'onboardingCompleted': onboardingCompleted, + }; factory AppSetting.fromJson(Map json) => AppSetting( - isPro: json['isPro'] == true, - isSplitTunnelingOn: json['isSplitTunnelingOn'] == true, - themeMode: (json['themeMode'] ?? 'system').toString(), - environment: (json['environment'] ?? 'prod').toString(), - userLoggedIn: json['userLoggedIn'] == true, - oAuthToken: (json['oAuthToken'] ?? '').toString(), - oAuthLoginProvider: (json['oAuthLoginProvider'] ?? '').toString(), - blockAds: json['blockAds'] == true, - email: (json['email'] ?? '').toString(), - locale: (json['locale'] ?? 'en_US').toString(), - showSplashScreen: json['showSplashScreen'] != false, - telemetryDialogDismissed: json['telemetryDialogDismissed'] == true, - telemetryConsent: json['telemetryConsent'] == true, - successfulConnection: json['successfulConnection'] == true, - routingModeRaw: (json['routingModeRaw'] ?? 'full_tunnel').toString(), - dataCapThreshold: (json['dataCapThreshold'] ?? '').toString(), - onboardingCompleted: json['onboardingCompleted'] == true, - ); - - bool get isSSOUser => oAuthToken.isNotEmpty && oAuthLoginProvider.isNotEmpty; - - AppSetting clearAuthSessionData({bool clearEmail = true}) { - return copyWith( - newPro: false, - userLoggedIn: false, - oAuthToken: '', - oAuthLoginProvider: '', - email: clearEmail ? '' : email, - ); - } + themeMode: (json['themeMode'] ?? 'system').toString(), + environment: (json['environment'] ?? 'prod').toString(), + userLoggedIn: json['userLoggedIn'] == true, + locale: (json['locale'] ?? 'en_US').toString(), + showSplashScreen: json['showSplashScreen'] != false, + telemetryDialogDismissed: json['telemetryDialogDismissed'] == true, + successfulConnection: json['successfulConnection'] == true, + dataCapThreshold: (json['dataCapThreshold'] ?? '').toString(), + onboardingCompleted: json['onboardingCompleted'] == true, + ); } diff --git a/lib/core/models/available_servers.dart b/lib/core/models/available_servers.dart index 9eed2c7ace..635b9bb1b8 100644 --- a/lib/core/models/available_servers.dart +++ b/lib/core/models/available_servers.dart @@ -1,218 +1,129 @@ class AvailableServers { - Lantern lantern; - Lantern user; + final List servers; - AvailableServers({ - required this.lantern, - required this.user, - }); - - factory AvailableServers.fromJson(Map json) => - AvailableServers( - lantern: Lantern.fromJson( - (json["lantern"] as Map?) ?? const {}), - user: Lantern.fromJson( - (json["user"] as Map?) ?? const {}), - ); - - Map toJson() => { - "lantern": lantern.toJson(), - "user": user.toJson(), - }; -} - -class Lantern { - List endpoints; - List outbounds; - Map locations; - Map credentials; - - Lantern({ - required this.endpoints, - required this.outbounds, - required this.locations, - required this.credentials, - }); + AvailableServers(this.servers); - factory Lantern.fromJson(Map json) => Lantern( - endpoints: json["endpoints"] == null - ? [] - : List.from( - (json["endpoints"] as List).map((x) => Endpoint.fromJson(x))), - outbounds: json["outbounds"] == null - ? [] - : List.from( - (json["outbounds"] as List).map((x) => Endpoint.fromJson(x))), - locations: json["locations"] == null - ? {} - : Map.from( - (json["locations"] as Map).map( - (k, v) => MapEntry( - k, - Location_.fromJson(v as Map)..tag = k, - ), - ), - ), - credentials: json["credentials"] == null - ? {} - : Map.from( - (json["credentials"] as Map).map( - (k, v) => MapEntry( - k, - ServerCredential.fromJson(v as Map), - ), - ), - ), - ); + factory AvailableServers.fromJson(List json) => AvailableServers( + json.map((e) => Server.fromJson(e as Map)).toList(), + ); - Map toJson() => { - "endpoints": List.from(endpoints.map((x) => x.toJson())), - "locations": locations.map((k, v) => MapEntry(k, v.toJson())), - "credentials": credentials.map((k, v) => MapEntry(k, v.toJson())), - }; -} + List get lanternServers => servers.where((s) => s.isLantern).toList(); -class ServerCredential { - String accessToken; - bool isJoined; - String port; + List get userServers => servers.where((s) => !s.isLantern).toList(); - ServerCredential({ - required this.accessToken, - required this.isJoined, - required this.port, - }); + bool get hasUserServers => servers.any((s) => !s.isLantern); - factory ServerCredential.fromJson(Map json) => - ServerCredential( - accessToken: json["access_token"] ?? '', - isJoined: json["isJoined"] ?? false, - port: json["port"]?.toString() ?? '', + /// Lantern server with the lowest URL-test delay. Null when no server has + /// a usable probe result — sing-box reports delay 0 for unreachable probes, + /// so those are excluded. + Server? get fastestLanternServer { + final ranked = lanternServers + .where((s) => s.urlTestResult != null && s.urlTestResult!.delay > 0) + .toList() + ..sort( + (a, b) => a.urlTestResult!.delay.compareTo(b.urlTestResult!.delay), ); - - Map toJson() => { - "access_token": accessToken, - "isJoined": isJoined, - "port": port, - }; + return ranked.isEmpty ? null : ranked.first; + } } -class Endpoint { - String type; - String tag; - String server; - String serverPort; +class Server { + final String tag; + final String type; + final bool isLantern; + final Map? outbound; + final Map? endpoint; + final GeoLocation location; + final ServerCredential? credentials; + final UrlTestResult? urlTestResult; - Endpoint({ - required this.type, + Server({ required this.tag, - required this.server, - required this.serverPort, + required this.type, + required this.isLantern, + this.outbound, + this.endpoint, + required this.location, + this.credentials, + this.urlTestResult, }); - factory Endpoint.fromJson(Map json) => Endpoint( - type: json["type"], - tag: json["tag"], - server: json["server"] ?? '', - serverPort: - json["server_port"] == null ? "" : json["server_port"].toString()); - - Map toJson() => { - "type": type, - "tag": tag, - "server": server, - "server_port": serverPort, - }; + factory Server.fromJson(Map json) => Server( + tag: json['tag'] ?? '', + type: json['type'] ?? '', + isLantern: json['isLantern'] ?? false, + outbound: json['outbound'] as Map?, + endpoint: json['endpoint'] as Map?, + location: GeoLocation.fromJson( + (json['location'] as Map?) ?? const {}, + ), + credentials: json['credentials'] != null + ? ServerCredential.fromJson(json['credentials'] as Map) + : null, + urlTestResult: json["urlTestResult"] == null + ? null + : UrlTestResult.fromJson(json["urlTestResult"]), + ); + + /// IP address extracted from outbound or endpoint options. + String get serverIP => + outbound?['server'] as String? ?? endpoint?['server'] as String? ?? ''; } -class Location_ { - String country; - String countryCode; - String city; - double latitude; - double longitude; - - // tag will be assigned later, not in the JSON - // it will map to the endpoint tag - String tag; - - // As have default value, we can derive protocol from tag - String protocol = ''; +class GeoLocation { + final String country; + final String countryCode; + final String city; + final double latitude; + final double longitude; - Location_({ + GeoLocation({ required this.country, required this.countryCode, required this.city, required this.latitude, required this.longitude, - required this.tag, }); - factory Location_.fromJson(Map json) => Location_( - country: json["country"] ?? '', - countryCode: json["country_code"] ?? '', - city: json["city"] ?? '', - latitude: json["latitude"]?.toDouble() ?? 0.0, - longitude: json["longitude"]?.toDouble() ?? 0.0, - tag: "", - ); - - Location_ copyWith({ - String? country, - String? countryCode, - String? city, - double? latitude, - double? longitude, - String? tag, - String? protocol, - }) { - return Location_( - country: country ?? this.country, - countryCode: countryCode ?? this.countryCode, - city: city ?? this.city, - latitude: latitude ?? this.latitude, - longitude: longitude ?? this.longitude, - tag: tag ?? this.tag, - )..protocol = protocol ?? this.protocol; - } - - Map toJson() => { - "country": country, - "city": city, - "latitude": latitude, - "longitude": longitude, - "country_code": countryCode, - }; + factory GeoLocation.fromJson(Map json) => GeoLocation( + country: json['country'] ?? '', + countryCode: json['country_code'] ?? '', + city: json['city'] ?? '', + latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0, + longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0, + ); } -class Server { - String group; - String tag; - String type; - Endpoint? options; - Location_? location; +class ServerCredential { + final String accessToken; + final bool isJoined; + final String port; - Server({ - required this.group, - required this.tag, - required this.type, - required this.options, - required this.location, + ServerCredential({ + required this.accessToken, + required this.isJoined, + required this.port, }); - factory Server.fromJson(Map json) => Server( - group: json["Group"], - tag: json["Tag"], - type: json["Type"], - options: Endpoint.fromJson(json["Options"]), - location: Location_.fromJson(json["Location"]), + factory ServerCredential.fromJson(Map json) => + ServerCredential( + accessToken: json['access_token'] ?? '', + isJoined: json['is_joined'] ?? false, + port: json['port']?.toString() ?? '', ); +} + +class UrlTestResult { + int delay; + DateTime time; + + UrlTestResult({required this.delay, required this.time}); + + factory UrlTestResult.fromJson(Map json) => + UrlTestResult(delay: json["delay"], time: DateTime.parse(json["time"])); Map toJson() => { - "Group": group, - "Tag": tag, - "Type": type, - "Options": options?.toJson(), - "Location": location?.toJson(), - }; + "delay": delay, + "time": time.toIso8601String(), + }; } diff --git a/lib/core/models/developer_daemon_state.dart b/lib/core/models/developer_daemon_state.dart new file mode 100644 index 0000000000..3741154201 --- /dev/null +++ b/lib/core/models/developer_daemon_state.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart'; + +/// Snapshot of the radiance-daemon settings and env vars surfaced on the +/// developer screen. `loading` is true while the initial fetch is in flight. +@immutable +class DeveloperDaemonState { + final String logLevel; + final bool configFetchEnabled; + final String country; + final String version; + final String featureOverrides; + final bool loading; + + const DeveloperDaemonState({ + this.logLevel = 'info', + this.configFetchEnabled = true, + this.country = '', + this.version = '', + this.featureOverrides = '', + this.loading = true, + }); + + DeveloperDaemonState copyWith({ + String? logLevel, + bool? configFetchEnabled, + String? country, + String? version, + String? featureOverrides, + bool? loading, + }) { + return DeveloperDaemonState( + logLevel: logLevel ?? this.logLevel, + configFetchEnabled: configFetchEnabled ?? this.configFetchEnabled, + country: country ?? this.country, + version: version ?? this.version, + featureOverrides: featureOverrides ?? this.featureOverrides, + loading: loading ?? this.loading, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DeveloperDaemonState && + logLevel == other.logLevel && + configFetchEnabled == other.configFetchEnabled && + country == other.country && + version == other.version && + featureOverrides == other.featureOverrides && + loading == other.loading; + + @override + int get hashCode => Object.hash( + logLevel, + configFetchEnabled, + country, + version, + featureOverrides, + loading, + ); +} diff --git a/lib/core/models/radiance_settings_state.dart b/lib/core/models/radiance_settings_state.dart new file mode 100644 index 0000000000..70323e0a13 --- /dev/null +++ b/lib/core/models/radiance_settings_state.dart @@ -0,0 +1,49 @@ +import 'package:flutter/foundation.dart'; +import 'package:lantern/core/common/app_eum.dart'; + +/// Immutable snapshot of radiance-backed VPN preferences. +/// +/// Fields default to safe "off"/full-tunnel values so callers can read them +/// synchronously at app start while the real values are being fetched from +/// the native layer in the background. +@immutable +class RadianceSettingsState { + final bool blockAds; + final RoutingMode routingMode; + final bool splitTunneling; + final bool telemetry; + + const RadianceSettingsState({ + this.blockAds = false, + this.routingMode = RoutingMode.full, + this.splitTunneling = false, + this.telemetry = false, + }); + + RadianceSettingsState copyWith({ + bool? blockAds, + RoutingMode? routingMode, + bool? splitTunneling, + bool? telemetry, + }) { + return RadianceSettingsState( + blockAds: blockAds ?? this.blockAds, + routingMode: routingMode ?? this.routingMode, + splitTunneling: splitTunneling ?? this.splitTunneling, + telemetry: telemetry ?? this.telemetry, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RadianceSettingsState && + blockAds == other.blockAds && + routingMode == other.routingMode && + splitTunneling == other.splitTunneling && + telemetry == other.telemetry; + + @override + int get hashCode => + Object.hash(blockAds, routingMode, splitTunneling, telemetry); +} diff --git a/lib/core/models/server_location.dart b/lib/core/models/server_location.dart index 7d574d9ed9..6a19e3a6df 100644 --- a/lib/core/models/server_location.dart +++ b/lib/core/models/server_location.dart @@ -38,19 +38,19 @@ class ServerLocation { return '$c - $t'; } - /// Replaces `lanternLocation(...)` instance method - factory ServerLocation.fromLanternLocation({ - required Location_ server, + /// Build from a radiance [Server] object. + factory ServerLocation.fromServer({ + required Server server, AutoLocation? autoLocation, }) { return ServerLocation( serverName: server.tag, serverType: ServerLocationType.lanternLocation.name, - country: server.country, - city: server.city, - countryCode: server.countryCode, - displayName: '${server.country} - ${server.city}', - protocol: server.protocol, + country: server.location.country, + city: server.location.city, + countryCode: server.location.countryCode, + displayName: '${server.location.country} - ${server.location.city}', + protocol: server.type, autoLocation: autoLocation, ); } diff --git a/lib/core/models/user.dart b/lib/core/models/user.dart index 768f3d01e0..c7e11270c7 100644 --- a/lib/core/models/user.dart +++ b/lib/core/models/user.dart @@ -1,31 +1,34 @@ class UserResponseModel { + final String id; final int legacyID; final String legacyToken; final bool emailConfirmed; final bool success; - final UserDataModel? legacyUserData; + final UserDataModel legacyUserData; final List devices; const UserResponseModel({ + this.id = '', required this.legacyID, required this.legacyToken, required this.emailConfirmed, required this.success, - this.legacyUserData, + this.legacyUserData = const UserDataModel(), this.devices = const [], }); factory UserResponseModel.fromJson(Map json) => UserResponseModel( + id: (json['id'] ?? '').toString(), legacyID: (json['legacyID'] as num?)?.toInt() ?? 0, legacyToken: (json['legacyToken'] ?? '').toString(), emailConfirmed: json['emailConfirmed'] == true, - success: json['success'] == true, + success: (json['success'] == true || json['Success'] == true), legacyUserData: json['legacyUserData'] is Map ? UserDataModel.fromJson( Map.from(json['legacyUserData'] as Map), ) - : null, + : const UserDataModel(), devices: ((json['devices'] as List?) ?? const []) .whereType() .map((m) => DeviceModel.fromJson(Map.from(m))) @@ -33,13 +36,14 @@ class UserResponseModel { ); Map toJson() => { - 'legacyID': legacyID, - 'legacyToken': legacyToken, - 'emailConfirmed': emailConfirmed, - 'success': success, - 'legacyUserData': legacyUserData?.toJson(), - 'devices': devices.map((d) => d.toJson()).toList(), - }; + 'id': id, + 'legacyID': legacyID, + 'legacyToken': legacyToken, + 'emailConfirmed': emailConfirmed, + 'success': success, + 'legacyUserData': legacyUserData.toJson(), + 'devices': devices.map((d) => d.toJson()).toList(), + }; } class DeviceModel { @@ -54,16 +58,16 @@ class DeviceModel { }); factory DeviceModel.fromJson(Map json) => DeviceModel( - deviceId: (json['deviceId'] ?? '').toString(), - name: (json['name'] ?? '').toString(), - created: (json['created'] as num?)?.toInt() ?? 0, - ); + deviceId: (json['id'] ?? '').toString(), + name: (json['name'] ?? '').toString(), + created: (json['created'] as num?)?.toInt() ?? 0, + ); Map toJson() => { - 'deviceId': deviceId, - 'name': name, - 'created': created, - }; + 'deviceId': deviceId, + 'name': name, + 'created': created, + }; } class UserDataModel { @@ -85,147 +89,146 @@ class UserDataModel { final String inviters; final String invitees; final List devices; - final String purchases; // consider List instead - final SubscriptionDataModel? subscriptionData; + final String purchases; + final SubscriptionDataModel subscriptionData; final String deviceID; final bool unpassRegistered; final int lastExpiredOn; const UserDataModel({ - required this.userId, - required this.code, - required this.token, - required this.referral, - required this.phone, - required this.email, - required this.userStatus, - required this.userLevel, - required this.locale, - required this.expiration, - required this.subscription, - required this.bonusDays, - required this.bonusMonths, - required this.yinbiEnabled, - required this.servers, - required this.inviters, - required this.invitees, - required this.purchases, - required this.deviceID, - required this.unpassRegistered, - required this.lastExpiredOn, + this.userId = 0, + this.code = '', + this.token = '', + this.referral = '', + this.phone = '', + this.email = '', + this.userStatus = '', + this.userLevel = '', + this.locale = '', + this.expiration = 0, + this.subscription = '', + this.bonusDays = '', + this.bonusMonths = '', + this.yinbiEnabled = false, + this.servers = '', + this.inviters = '', + this.invitees = '', + this.purchases = '', + this.deviceID = '', + this.unpassRegistered = false, + this.lastExpiredOn = 0, this.devices = const [], - this.subscriptionData, + this.subscriptionData = const SubscriptionDataModel(), }); factory UserDataModel.fromJson(Map json) => UserDataModel( - userId: (json['userId'] as num?)?.toInt() ?? 0, - code: (json['code'] ?? '').toString(), - token: (json['token'] ?? '').toString(), - referral: (json['referral'] ?? '').toString(), - phone: (json['phone'] ?? '').toString(), - email: (json['email'] ?? '').toString(), - userStatus: (json['userStatus'] ?? '').toString(), - userLevel: (json['userLevel'] ?? '').toString(), - locale: (json['locale'] ?? '').toString(), - expiration: (json['expiration'] as num?)?.toInt() ?? 0, - subscription: (json['subscription'] ?? '').toString(), - bonusDays: (json['bonusDays'] ?? '').toString(), - bonusMonths: (json['bonusMonths'] ?? '').toString(), - yinbiEnabled: json['yinbiEnabled'] == true, - servers: (json['servers'] ?? '').toString(), - inviters: (json['inviters'] ?? '').toString(), - invitees: (json['invitees'] ?? '').toString(), - purchases: (json['purchases'] ?? '').toString(), - deviceID: (json['deviceID'] ?? '').toString(), - unpassRegistered: json['unpassRegistered'] == true, - lastExpiredOn: (json['lastExpiredOn'] as num?)?.toInt() ?? 0, - devices: ((json['devices'] as List?) ?? const []) - .whereType() - .map((m) => DeviceModel.fromJson(Map.from(m))) - .toList(), - subscriptionData: json['subscriptionData'] is Map - ? SubscriptionDataModel.fromJson( - Map.from(json['subscriptionData'] as Map), - ) - : null, - ); + userId: (json['userId'] as num?)?.toInt() ?? 0, + code: (json['code'] ?? '').toString(), + token: (json['token'] ?? '').toString(), + referral: (json['referral'] ?? '').toString(), + phone: (json['phone'] ?? '').toString(), + email: (json['email'] ?? '').toString(), + userStatus: (json['userStatus'] ?? '').toString(), + userLevel: (json['userLevel'] ?? '').toString(), + locale: (json['locale'] ?? '').toString(), + expiration: (json['expiration'] as num?)?.toInt() ?? 0, + subscription: (json['subscription'] ?? '').toString(), + bonusDays: (json['bonusDays'] ?? '').toString(), + bonusMonths: (json['bonusMonths'] ?? '').toString(), + yinbiEnabled: json['yinbiEnabled'] == true, + servers: (json['servers'] ?? '').toString(), + inviters: (json['inviters'] ?? '').toString(), + invitees: (json['invitees'] ?? '').toString(), + purchases: (json['purchases'] ?? '').toString(), + deviceID: (json['deviceID'] ?? '').toString(), + unpassRegistered: json['unpassRegistered'] == true, + lastExpiredOn: (json['lastExpiredOn'] as num?)?.toInt() ?? 0, + devices: ((json['devices'] as List?) ?? const []) + .map((m) => DeviceModel.fromJson(Map.from(m))) + .toList(), + subscriptionData: json['subscriptionData'] is Map + ? SubscriptionDataModel.fromJson( + Map.from(json['subscriptionData'] as Map), + ) + : const SubscriptionDataModel(), + ); Map toJson() => { - 'userId': userId, - 'code': code, - 'token': token, - 'referral': referral, - 'phone': phone, - 'email': email, - 'userStatus': userStatus, - 'userLevel': userLevel, - 'locale': locale, - 'expiration': expiration, - 'subscription': subscription, - 'bonusDays': bonusDays, - 'bonusMonths': bonusMonths, - 'yinbiEnabled': yinbiEnabled, - 'servers': servers, - 'inviters': inviters, - 'invitees': invitees, - 'devices': devices.map((d) => d.toJson()).toList(), - 'purchases': purchases, - 'subscriptionData': subscriptionData?.toJson(), - 'deviceID': deviceID, - 'unpassRegistered': unpassRegistered, - 'lastExpiredOn': lastExpiredOn, - }; + 'userId': userId, + 'code': code, + 'token': token, + 'referral': referral, + 'phone': phone, + 'email': email, + 'userStatus': userStatus, + 'userLevel': userLevel, + 'locale': locale, + 'expiration': expiration, + 'subscription': subscription, + 'bonusDays': bonusDays, + 'bonusMonths': bonusMonths, + 'yinbiEnabled': yinbiEnabled, + 'servers': servers, + 'inviters': inviters, + 'invitees': invitees, + 'devices': devices.map((d) => d.toJson()).toList(), + 'purchases': purchases, + 'subscriptionData': subscriptionData.toJson(), + 'deviceID': deviceID, + 'unpassRegistered': unpassRegistered, + 'lastExpiredOn': lastExpiredOn, + }; } class SubscriptionDataModel { final String planID; final String stripeCustomerID; - final String startAt; - final String cancelledAt; + final int startAt; + final int cancelledAt; final bool autoRenew; final String subscriptionID; final String status; final String provider; - final String createdAt; - final String endAt; + final int createdAt; + final int endAt; const SubscriptionDataModel({ - required this.planID, - required this.stripeCustomerID, - required this.startAt, - required this.cancelledAt, - required this.autoRenew, - required this.subscriptionID, - required this.status, - required this.provider, - required this.createdAt, - required this.endAt, + this.planID = '', + this.stripeCustomerID = '', + this.startAt = 0, + this.cancelledAt = 0, + this.autoRenew = false, + this.subscriptionID = '', + this.status = '', + this.provider = '', + this.createdAt = 0, + this.endAt = 0, }); factory SubscriptionDataModel.fromJson(Map json) => SubscriptionDataModel( planID: (json['planID'] ?? '').toString(), stripeCustomerID: (json['stripeCustomerID'] ?? '').toString(), - startAt: (json['startAt'] ?? '').toString(), - cancelledAt: (json['cancelledAt'] ?? '').toString(), + startAt: (json['startAt'] as num?)?.toInt() ?? 0, + cancelledAt: (json['cancelledAt'] as num?)?.toInt() ?? 0, autoRenew: json['autoRenew'] == true, subscriptionID: (json['subscriptionID'] ?? '').toString(), status: (json['status'] ?? '').toString(), provider: (json['provider'] ?? '').toString(), - createdAt: (json['createdAt'] ?? '').toString(), - endAt: (json['endAt'] ?? '').toString(), + createdAt: (json['createdAt'] as num?)?.toInt() ?? 0, + endAt: (json['endAt'] as num?)?.toInt() ?? 0, ); Map toJson() => { - 'planID': planID, - 'stripeCustomerID': stripeCustomerID, - 'startAt': startAt, - 'cancelledAt': cancelledAt, - 'autoRenew': autoRenew, - 'subscriptionID': subscriptionID, - 'status': status, - 'provider': provider, - 'createdAt': createdAt, - 'endAt': endAt, - }; + 'planID': planID, + 'stripeCustomerID': stripeCustomerID, + 'startAt': startAt, + 'cancelledAt': cancelledAt, + 'autoRenew': autoRenew, + 'subscriptionID': subscriptionID, + 'status': status, + 'provider': provider, + 'createdAt': createdAt, + 'endAt': endAt, + }; } diff --git a/lib/core/router/router.gr.dart b/lib/core/router/router.gr.dart index 4d771b2975..4f9329ba4f 100644 --- a/lib/core/router/router.gr.dart +++ b/lib/core/router/router.gr.dart @@ -13,6 +13,7 @@ import 'package:auto_route/auto_route.dart' as _i44; import 'package:collection/collection.dart' as _i48; import 'package:flutter/material.dart' as _i45; import 'package:lantern/core/common/common.dart' as _i46; +import 'package:lantern/core/models/user.dart' as _i47; import 'package:lantern/core/widgets/app_webview.dart' as _i3; import 'package:lantern/features/account/account.dart' as _i1; import 'package:lantern/features/account/delete_account.dart' as _i9; @@ -68,7 +69,6 @@ import 'package:lantern/features/split_tunneling/website_split_tunneling.dart' as _i43; import 'package:lantern/features/support/support.dart' as _i41; import 'package:lantern/features/vpn/server_selection.dart' as _i34; -import 'package:lantern/lantern/protos/protos/auth.pb.dart' as _i47; /// generated route for /// [_i1.Account] @@ -483,7 +483,7 @@ class DeveloperMode extends _i44.PageRouteInfo { class DeviceLimitReached extends _i44.PageRouteInfo { DeviceLimitReached({ _i45.Key? key, - required List<_i47.UserResponse_Device> devices, + required List<_i47.DeviceModel> devices, List<_i44.PageRouteInfo>? children, }) : super( DeviceLimitReached.name, @@ -507,7 +507,7 @@ class DeviceLimitReachedArgs { final _i45.Key? key; - final List<_i47.UserResponse_Device> devices; + final List<_i47.DeviceModel> devices; @override String toString() { @@ -519,7 +519,7 @@ class DeviceLimitReachedArgs { if (identical(this, other)) return true; if (other is! DeviceLimitReachedArgs) return false; return key == other.key && - const _i48.ListEquality<_i47.UserResponse_Device>().equals( + const _i48.ListEquality<_i47.DeviceModel>().equals( devices, other.devices, ); @@ -527,8 +527,7 @@ class DeviceLimitReachedArgs { @override int get hashCode => - key.hashCode ^ - const _i48.ListEquality<_i47.UserResponse_Device>().hash(devices); + key.hashCode ^ const _i48.ListEquality<_i47.DeviceModel>().hash(devices); } /// generated route for diff --git a/lib/core/services/app_purchase.dart b/lib/core/services/app_purchase.dart index b3bfc1b5e8..0f01fd3727 100644 --- a/lib/core/services/app_purchase.dart +++ b/lib/core/services/app_purchase.dart @@ -327,9 +327,8 @@ class AppPurchase { } final userLevel = user.legacyUserData.userLevel.toLowerCase(); - final subscriptionStatus = user.legacyUserData.hasSubscriptionData() - ? user.legacyUserData.subscriptionData.status.toLowerCase() - : ''; + final subscriptionStatus = + user.legacyUserData.subscriptionData.status.toLowerCase(); return userLevel == 'pro' || subscriptionStatus == 'active'; } diff --git a/lib/core/services/injection_container.dart b/lib/core/services/injection_container.dart index b5a7241a09..972726ccfb 100644 --- a/lib/core/services/injection_container.dart +++ b/lib/core/services/injection_container.dart @@ -7,6 +7,7 @@ import 'package:lantern/core/updater/updater.dart'; import 'package:lantern/core/utils/deeplink_utils.dart'; import 'package:lantern/core/utils/platform_utils.dart' show PlatformUtils; import 'package:lantern/core/utils/store_utils.dart'; +import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/lantern/lantern_ffi_service.dart'; import 'package:lantern/lantern/lantern_platform_service.dart'; import 'package:lantern/lantern/lantern_service.dart'; @@ -31,6 +32,11 @@ Future injectServices() async { rethrow; } + // Detect when the data directory was deleted but SharedPreferences + // (e.g. NSUserDefaults on macOS) survived. Must run before runApp() + // so that AppSettingNotifier.build() reads the correct defaults. + await AppSettingNotifier.resetIfFreshInstall(storage); + sl.registerLazySingleton(() => Updater()); sl.registerLazySingleton(() => AppRouter()); sl.registerLazySingleton( diff --git a/lib/core/services/local_storage_service.dart b/lib/core/services/local_storage_service.dart index a77aeaf226..7e9ddcb102 100644 --- a/lib/core/services/local_storage_service.dart +++ b/lib/core/services/local_storage_service.dart @@ -17,9 +17,9 @@ class LocalStorageService { /// Keys for stored values static const _appSettingsKey = 'app_settings_json'; - static const _serverLocationKey = 'selected_server_location'; static const _plansKey = 'plans_json'; static const _developerModeKey = 'developer_mode_json'; + static const _serverLocationKey = 'server_location_json'; Future init() async { _prefs = await SharedPreferencesWithCache.create( @@ -46,39 +46,42 @@ class LocalStorageService { await setString(_appSettingsKey, jsonEncode(settings.toJson())); } - // ── ServerLocation ──────────────────────────────────────────────────────── + // ── PlansData ───────────────────────────────────────────────────────────── - ServerLocation? getServerLocation() { - final raw = getString(_serverLocationKey); + PlansData? getPlans() { + final raw = getString(_plansKey); if (raw == null || raw.isEmpty) return null; try { - return ServerLocation.fromJsonString(raw); + final decoded = jsonDecode(raw) as Map; + return PlansData.fromJson(decoded); } catch (e, st) { - appLogger.error('Failed to parse server location from prefs', e, st); + appLogger.error('Error reading cached plans from prefs', e, st); } return null; } - Future saveServerLocation(ServerLocation location) async { - await setString(_serverLocationKey, location.toJsonString()); + Future savePlans(PlansData plans) async { + await setString(_plansKey, jsonEncode(plans.toJson())); } - // ── PlansData ───────────────────────────────────────────────────────────── + // ── ServerLocation ──────────────────────────────────────────────────────── - PlansData? getPlans() { - final raw = getString(_plansKey); + ServerLocation? getServerLocation() { + final raw = getString(_serverLocationKey); if (raw == null || raw.isEmpty) return null; try { - final decoded = jsonDecode(raw) as Map; - return PlansData.fromJson(decoded); + final decoded = jsonDecode(raw); + if (decoded is Map) { + return ServerLocation.fromJson(Map.from(decoded)); + } } catch (e, st) { - appLogger.error('Error reading cached plans from prefs', e, st); + appLogger.error('Failed to parse stored server location', e, st); } return null; } - Future savePlans(PlansData plans) async { - await setString(_plansKey, jsonEncode(plans.toJson())); + Future saveServerLocation(ServerLocation location) async { + await setString(_serverLocationKey, jsonEncode(location.toJson())); } // ── DeveloperMode ───────────────────────────────────────────────────────── diff --git a/lib/core/services/logger_service.dart b/lib/core/services/logger_service.dart index f3290d4b8d..b7431c3f59 100644 --- a/lib/core/services/logger_service.dart +++ b/lib/core/services/logger_service.dart @@ -9,6 +9,12 @@ import 'package:loggy/loggy.dart'; final dbLogger = Loggy("DB-Logger"); final appLogger = Loggy("app-Logger"); +final StreamController _flutterLogController = + StreamController.broadcast(); + +/// Emits every log line written to flutter.log as it is written. +Stream get flutterLogLinesStream => _flutterLogController.stream; + /// Pick the right console printer per platform LoggyPrinter _defaultConsolePrinter() { if (PlatformUtils.isDesktop) { @@ -99,11 +105,15 @@ class FileLogPrinter extends LoggyPrinter { buffer.write("Stack: ${record.stackTrace}"); } + final line = buffer.toString(); try { - _controller.add('${buffer.toString()}\n'); + _controller.add('$line\n'); } catch (_) { // If add throws (controller closed between check and add), ignore silently. } + if (!_flutterLogController.isClosed) { + _flutterLogController.add(line); + } } /// Formats timestamp as: 2026-01-20 16:03:50.628 UTC diff --git a/lib/core/utils/storage_utils.dart b/lib/core/utils/storage_utils.dart index 3279168b2a..268d5a3762 100644 --- a/lib/core/utils/storage_utils.dart +++ b/lib/core/utils/storage_utils.dart @@ -21,7 +21,8 @@ class AppStorageUtils { final baseDir = await getApplicationSupportDirectory(); logDir = Directory("${baseDir.path}/logs"); } else if (Platform.isWindows) { - final baseDir = await getWindowsAppDataDirectory(); + final appDataPath = Platform.environment['PUBLIC'] ?? r'C:\Users\Public'; + final baseDir = Directory("$appDataPath/Lantern"); logDir = Directory("${baseDir.path}/logs"); } else { throw UnsupportedError("Unsupported platform for log directory"); @@ -45,14 +46,9 @@ class AppStorageUtils { } else if (Platform.isMacOS) { appDir = Directory('/Users/Shared/Lantern'); } else if (Platform.isWindows) { - Directory appDataDir = await getWindowsAppDataDirectory(); - - // On Windows, the Windows service starts without any knowledge of - // the app directory. It passes the empty string to the radiance - // common.Init function, which creates the app data directory as - // a subdirectory of the Lantern app data directory at - // C:\Users\Public\Lantern. So we need to follow the same logic here. - appDir = Directory("${appDataDir.path}/data"); + final appDataPath = Platform.environment['PUBLIC'] ?? r'C:\Users\Public'; + final baseDir = Directory("$appDataPath/Lantern"); + appDir = Directory("${baseDir.path}/data"); } else { // Note this is the application support directory *with* // the fully qualified name of our app. @@ -98,19 +94,4 @@ class AppStorageUtils { .map((f) => f.path) .toList(growable: false); } - - static Future getWindowsAppDataDirectory() async { - if (!Platform.isWindows) throw UnsupportedError("Not running on Windows"); - - // On Windows, we want to store app data in C:\Users\Public\Lantern to - // ensure that the Windows service can access it without needing to know - // the specific user profile. The Windows service will create a subdirectory - // called "data" within this directory to store its own data. - final appDataPath = Platform.environment['PUBLIC']; - final appDir = Directory("$appDataPath/Lantern"); - if (!appDir.existsSync()) { - await appDir.create(recursive: true); - } - return appDir; - } } diff --git a/lib/core/widgets/app_webview.dart b/lib/core/widgets/app_webview.dart index e6e56007e8..29abc0e111 100644 --- a/lib/core/widgets/app_webview.dart +++ b/lib/core/widgets/app_webview.dart @@ -117,10 +117,6 @@ class _InnerWebViewState extends ConsumerState<_InnerWebView> { // Handle load start final loading = ref.read(webViewLoadingProvider.notifier); loading.start(); - final handled = await _handleCompletionUrl( - webUri == null ? null : Uri.tryParse(webUri.toString()), - ); - if (handled) return; }, onLoadStop: (controller, webUri) async { // Handle load stop @@ -222,6 +218,12 @@ class _InnerWebViewState extends ConsumerState<_InnerWebView> { return NavigationActionPolicy.ALLOW; } + // Allow localhost requests to go through so the local server actually + // receives the callback (e.g. private server auth). + if (u.host == 'localhost' || u.host == '127.0.0.1') { + return NavigationActionPolicy.ALLOW; + } + final handled = await _handleCompletionUrl(u); if (handled) { return NavigationActionPolicy.CANCEL; diff --git a/lib/core/widgets/user_devices.dart b/lib/core/widgets/user_devices.dart index b66bba203a..db66667414 100644 --- a/lib/core/widgets/user_devices.dart +++ b/lib/core/widgets/user_devices.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/features/auth/provider/auth_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; -import 'package:lantern/lantern/protos/protos/auth.pb.dart'; +import 'package:lantern/core/models/user.dart'; import '../common/common.dart'; class UserDevices extends HookConsumerWidget { - // final List userDevices; + // final List userDevices; // final String myDeviceId; const UserDevices({ @@ -21,7 +21,7 @@ class UserDevices extends HookConsumerWidget { return const SizedBox(); } final userDevices = user.legacyUserData.devices.toList(); - final myDeviceId = user.legacyUserData.deviceID ?? ''; + final myDeviceId = user.legacyUserData.deviceID; return AppCard( padding: EdgeInsets.zero, @@ -39,7 +39,7 @@ class UserDevices extends HookConsumerWidget { ); } - Widget _buildRow(UserResponse_Device e, WidgetRef ref, BuildContext context, + Widget _buildRow(DeviceModel e, WidgetRef ref, BuildContext context, bool isMyDevice) { return AppTile( label: e.name, @@ -54,10 +54,10 @@ class UserDevices extends HookConsumerWidget { } Future _removeDevice( - UserResponse_Device device, WidgetRef ref, BuildContext context) async { + DeviceModel device, WidgetRef ref, BuildContext context) async { context.showLoadingDialog(); final result = - await ref.read(authProvider.notifier).deviceRemove(device.id); + await ref.read(authProvider.notifier).deviceRemove(device.deviceId); result.fold((failure) { context.showSnackBar(failure.localizedErrorMessage); diff --git a/lib/core/windows/pipe_client.dart b/lib/core/windows/pipe_client.dart deleted file mode 100644 index 20563dcabf..0000000000 --- a/lib/core/windows/pipe_client.dart +++ /dev/null @@ -1,589 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; -import 'dart:ffi'; -import 'dart:io'; -import 'dart:isolate'; -import 'dart:typed_data'; - -import 'package:ffi/ffi.dart'; -import 'package:lantern/core/common/common.dart'; -import 'package:win32/win32.dart'; - -class PipeClient { - PipeClient({ - this.pipeName = r'\\.\pipe\LanternService', - this.token, - this.tokenPath, - this.timeoutMs = 3000, - this.tokenWaitMs = 5000, - this.bufSize = 64 * 1024, - }); - - final String pipeName; - String? token; - final String? tokenPath; - final int timeoutMs; - final int tokenWaitMs; - final int bufSize; - final Random _jitter = Random(); - - int _retryFailureStreak = 0; - DateTime? _lastRetryFailureAt; - - int _hPipe = INVALID_HANDLE_VALUE; - - bool get isConnected => _hPipe != INVALID_HANDLE_VALUE; - - Future _getToken() async { - if (token != null && token!.isNotEmpty) return; - final programData = - Platform.environment['ProgramData'] ?? r'C:\ProgramData'; - final path = tokenPath ?? '$programData\\Lantern\\ipc-token'; - final deadline = DateTime.now().add(Duration(milliseconds: tokenWaitMs)); - PipeTokenErrorKind failureKind = PipeTokenErrorKind.missing; - String failureDetail = 'IPC token file not found at $path'; - while (true) { - try { - final currentToken = (await File(path).readAsString()).trim(); - if (currentToken.isEmpty) { - failureKind = PipeTokenErrorKind.empty; - failureDetail = 'IPC token file is empty at $path'; - } else { - token = currentToken; - return; - } - } on FileSystemException catch (e) { - final errorCode = e.osError?.errorCode; - if (errorCode == ERROR_FILE_NOT_FOUND || - errorCode == ERROR_PATH_NOT_FOUND) { - failureKind = PipeTokenErrorKind.missing; - } else { - failureKind = PipeTokenErrorKind.unreadable; - } - failureDetail = e.toString(); - } catch (e) { - failureKind = PipeTokenErrorKind.unreadable; - failureDetail = e.toString(); - } - if (DateTime.now().isAfter(deadline)) { - throw PipeTokenException( - path: path, - kind: failureKind, - waitedMs: tokenWaitMs, - detail: failureDetail, - ); - } - await Future.delayed(const Duration(milliseconds: 200)); - } - } - - Future connect() async { - await _getToken(); - if (isConnected) { - await close(); - } - final start = DateTime.now(); - final lpName = TEXT(pipeName); - try { - while (true) { - _hPipe = CreateFile( - lpName, - GENERIC_READ | GENERIC_WRITE, - 0, - nullptr, - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, - 0, - ); - if (_hPipe != INVALID_HANDLE_VALUE) return; - - final code = GetLastError(); - final retryableOpen = - code == ERROR_PIPE_BUSY || - code == ERROR_FILE_NOT_FOUND || - code == ERROR_PATH_NOT_FOUND || - code == 0; - if (retryableOpen) { - if (DateTime.now().difference(start).inMilliseconds >= timeoutMs) { - throw PipeTransportException( - operation: 'Open pipe', - code: code, - timedOut: true, - ); - } - await Future.delayed(const Duration(milliseconds: 100)); - continue; - } - throw PipeTransportException(operation: 'Open pipe', code: code); - } - } finally { - free(lpName); - } - } - - Future> call( - String cmd, [ - Map? params, - ]) async { - try { - await _connectPipeIfNeeded(); - final response = await _callConnected(cmd, params); - _resetRetryWindow(); - return response; - } catch (e) { - if (_isAuthOrTokenError(e)) { - appLogger.warning( - 'Pipe call failed with auth/token error; clearing cached token: $e', - ); - await _resetConnectionState(clearToken: true); - rethrow; - } - if (!_isRecoverablePipeTransportError(e)) { - rethrow; - } - final delay = _nextRetryDelay(); - appLogger.warning( - 'Pipe transport failure, reconnecting once in ' - '${delay.inMilliseconds}ms: $e', - ); - await Future.delayed(delay); - await _resetConnectionState(clearToken: false); - await _connectPipeIfNeeded(); - try { - final response = await _callConnected(cmd, params); - _resetRetryWindow(); - return response; - } catch (retryError) { - if (_isAuthOrTokenError(retryError)) { - appLogger.warning( - 'Reconnect retry hit auth/token error; clearing cached token: ' - '$retryError', - ); - await _resetConnectionState(clearToken: true); - } - rethrow; - } - } - } - - Future _connectPipeIfNeeded() async { - if (isConnected) { - return; - } - await connect(); - } - - Future _resetConnectionState({required bool clearToken}) async { - await close(); - if (clearToken) { - token = null; - } - } - - bool _isRecoverablePipeTransportError(Object e) { - if (e is PipeTransportException) { - const recoverable = { - ERROR_BROKEN_PIPE, - ERROR_PIPE_NOT_CONNECTED, - ERROR_NO_DATA, - ERROR_INVALID_HANDLE, - ERROR_PIPE_BUSY, - ERROR_FILE_NOT_FOUND, - ERROR_PATH_NOT_FOUND, - 0, - }; - return recoverable.contains(e.code); - } - return false; - } - - bool _isAuthOrTokenError(Object e) { - if (e is PipeTokenException) { - return true; - } - if (e is PipeRpcException) { - final code = e.code.toLowerCase(); - if (code == 'unauthorized' || code == 'invalid_token') { - return true; - } - final message = e.message.toLowerCase(); - return message.contains('token') || message.contains('unauthorized'); - } - return false; - } - - Duration _nextRetryDelay() { - final now = DateTime.now(); - if (_lastRetryFailureAt == null || - now.difference(_lastRetryFailureAt!) > const Duration(seconds: 5)) { - _retryFailureStreak = 0; - } - _lastRetryFailureAt = now; - _retryFailureStreak += 1; - - const baseMs = 120; - const maxBackoffMs = 2000; - var exponent = _retryFailureStreak - 1; - if (exponent < 0) { - exponent = 0; - } else if (exponent > 5) { - exponent = 5; - } - final exponentialMs = baseMs * (1 << exponent); - final cappedMs = min(exponentialMs, maxBackoffMs); - final jitterMs = 40 + _jitter.nextInt(161); - return Duration(milliseconds: cappedMs + jitterMs); - } - - void _resetRetryWindow() { - _retryFailureStreak = 0; - _lastRetryFailureAt = null; - } - - Future> _callConnected( - String cmd, - Map? params, - ) async { - await _getToken(); - final request = { - 'id': DateTime.now().microsecondsSinceEpoch.toString(), - 'cmd': cmd, - 'token': token, - }; - if (params != null) { - request['params'] = params; - } - final payload = '${jsonEncode(request)}\n'; - - final bytes = utf8.encode(payload); - final pBuf = calloc(bytes.length); - final pWritten = calloc(); - try { - pBuf.asTypedList(bytes.length).setAll(0, bytes); - final ok = WriteFile(_hPipe, pBuf, bytes.length, pWritten, nullptr); - if (ok == 0) { - throw PipeTransportException( - operation: 'WriteFile', - code: GetLastError(), - ); - } - } finally { - free(pWritten); - free(pBuf); - } - - return _readOneJsonLine(); - } - - Map _parse(Map resp) { - final err = resp['error']; - if (err != null) { - final e = err as Map; - throw PipeRpcException( - code: e['code']?.toString() ?? 'rpc_error', - message: e['message']?.toString() ?? 'unknown rpc error', - ); - } - final result = resp['result']; - return (result is Map) - ? result - : {'value': result}; - } - - Map _decode(String s) => - _parse(jsonDecode(s) as Map); - - Future> _readOneJsonLine() async { - final pBuf = calloc(bufSize); - final pRead = calloc(); - final bldr = BytesBuilder(); - try { - while (true) { - final ok = ReadFile(_hPipe, pBuf, bufSize, pRead, nullptr); - if (ok == 0) { - throw PipeTransportException( - operation: 'ReadFile', - code: GetLastError(), - ); - } - final n = pRead.value; - if (n == 0) continue; - final chunk = Uint8List.sublistView(pBuf.asTypedList(n)); - final nl = chunk.indexOf(0x0A); - if (nl >= 0) { - bldr.add(chunk.sublist(0, nl)); - break; - } - bldr.add(chunk); - } - return _decode(utf8.decode(bldr.takeBytes())); - } finally { - free(pBuf); - free(pRead); - } - } - - Future close() async { - if (_hPipe != INVALID_HANDLE_VALUE) { - CloseHandle(_hPipe); - _hPipe = INVALID_HANDLE_VALUE; - } - } - - Stream _watchRaw(String cmd) { - final controller = StreamController.broadcast(); - final events = ReceivePort(); - Isolate? iso; - SendPort? stopSend; - - controller.onListen = () async { - await _getToken(); - - iso = await Isolate.spawn<_WatchArgs>( - _watchIsolateMain, - _WatchArgs( - pipeName: pipeName, - token: token!, - bufSize: bufSize, - cmd: cmd, - events: events.sendPort, - ), - debugName: 'pipe-watch-$cmd', - ); - - events.listen((msg) { - if (msg is SendPort) { - stopSend = msg; - return; - } - if (msg == null) { - appLogger.info('Pipe watch $cmd ended - closing stream'); - controller.close(); - return; - } - if (msg is String) { - controller.add(msg); - return; - } - if (msg is Map) { - final err = msg['error']; - if (err is String) controller.addError(Exception(err)); - } - }); - }; - - controller.onCancel = () async { - appLogger.info('Pipe watch $cmd cancelled - closing stream'); - stopSend?.send(true); - iso?.kill(priority: Isolate.beforeNextEvent); - events.close(); - }; - - return controller.stream; - } - - Stream> watchStatus() { - return _watchRaw('WatchStatus').transform( - StreamTransformer.fromHandlers( - handleData: (line, sink) { - try { - sink.add(jsonDecode(line) as Map); - } catch (e, st) { - sink.addError(e, st); - } - }, - ), - ); - } - - Stream> watchLogs() { - return _watchRaw('WatchLogs').transform( - StreamTransformer.fromHandlers( - handleData: (line, sink) { - try { - final obj = jsonDecode(line); - if (obj is Map && obj['event'] == 'Logs') { - final lines = - (obj['lines'] as List?)?.cast() ?? const []; - if (lines.isNotEmpty) sink.add(lines); - } - } catch (e) { - appLogger.error('[PipeClient] failed to parse pipe line: $line', e); - } - }, - handleError: (e, st, sink) { - appLogger.error('[PipeClient] watchLogs stream error', e, st); - sink.addError(e, st); - }, - handleDone: (sink) { - sink.close(); - }, - ), - ); - } -} - -class PipeTransportException implements Exception { - const PipeTransportException({ - required this.operation, - required this.code, - this.timedOut = false, - }); - - final String operation; - final int code; - final bool timedOut; - - @override - String toString() { - final hex = '0x${code.toRadixString(16)}'; - if (timedOut) { - return '$operation timed out (last error: $code/$hex)'; - } - return '$operation failed: $code ($hex)'; - } -} - -class PipeRpcException implements Exception { - const PipeRpcException({required this.code, required this.message}); - - final String code; - final String message; - - @override - String toString() => '$code: $message'; -} - -enum PipeTokenErrorKind { missing, empty, unreadable } - -class PipeTokenException implements Exception { - const PipeTokenException({ - required this.path, - required this.kind, - required this.waitedMs, - required this.detail, - }); - - final String path; - final PipeTokenErrorKind kind; - final int waitedMs; - final String detail; - - @override - String toString() { - final kindText = switch (kind) { - PipeTokenErrorKind.missing => 'missing', - PipeTokenErrorKind.empty => 'empty', - PipeTokenErrorKind.unreadable => 'unreadable', - }; - return 'IPC token $kindText after ${waitedMs}ms at $path: $detail'; - } -} - -class _WatchArgs { - const _WatchArgs({ - required this.pipeName, - required this.token, - required this.bufSize, - required this.cmd, - required this.events, - }); - final String pipeName; - final String token; - final int bufSize; - final String cmd; - final SendPort events; -} - -void _watchIsolateMain(_WatchArgs args) async { - final stopPort = ReceivePort(); - args.events.send(stopPort.sendPort); - - int hPipe = INVALID_HANDLE_VALUE; - - String watchReq(String token, String cmd) => - '${jsonEncode({'id': DateTime.now().microsecondsSinceEpoch.toString(), 'cmd': cmd, 'token': token})}\n'; - - try { - final name = TEXT(args.pipeName); - try { - hPipe = CreateFile( - name, - GENERIC_READ | GENERIC_WRITE, - 0, - nullptr, - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, - 0, - ); - } finally { - free(name); - } - if (hPipe == INVALID_HANDLE_VALUE) { - args.events.send({'error': 'open pipe failed: ${GetLastError()}'}); - args.events.send(null); - return; - } - - final req = utf8.encode(watchReq(args.token, args.cmd)); - final p = calloc(req.length); - final w = calloc(); - try { - p.asTypedList(req.length).setAll(0, req); - final ok = WriteFile(hPipe, p, req.length, w, nullptr); - if (ok == 0) { - args.events.send({'error': 'WriteFile failed: ${GetLastError()}'}); - args.events.send(null); - return; - } - } finally { - free(w); - free(p); - } - - bool stopping = false; - final stopSub = stopPort.listen((_) { - stopping = true; - if (hPipe != INVALID_HANDLE_VALUE) { - CloseHandle(hPipe); - hPipe = INVALID_HANDLE_VALUE; - } - stopPort.close(); - }); - - final buf = calloc(args.bufSize); - final r = calloc(); - String carry = ''; - try { - while (!stopping) { - final ok = ReadFile(hPipe, buf, args.bufSize, r, nullptr); - if (ok == 0) break; - final n = r.value; - if (n == 0) continue; - - final s = utf8.decode(Uint8List.sublistView(buf.asTypedList(n))); - final combined = carry + s; - final parts = combined.split('\n'); - for (var i = 0; i < parts.length - 1; i++) { - final line = parts[i]; - if (line.isEmpty) continue; - // send raw JSON line back - args.events.send(line); - } - carry = parts.isNotEmpty ? parts.last : ''; - } - } finally { - stopSub.cancel(); - free(buf); - free(r); - } - } catch (e) { - args.events.send({'error': e.toString()}); - } finally { - if (hPipe != INVALID_HANDLE_VALUE) { - CloseHandle(hPipe); - } - args.events.send(null); - } -} diff --git a/lib/core/windows/pipe_commands.dart b/lib/core/windows/pipe_commands.dart deleted file mode 100644 index a463314629..0000000000 --- a/lib/core/windows/pipe_commands.dart +++ /dev/null @@ -1,23 +0,0 @@ -enum ServiceCommand { - setupAdapter, - startTunnel, - stopTunnel, - connectToServer, - isVPNRunning, - status, - getUserData, - fetchUserData, -} - -extension ServiceCommandWire on ServiceCommand { - String get wire => switch (this) { - ServiceCommand.setupAdapter => 'SetupAdapter', - ServiceCommand.startTunnel => 'StartTunnel', - ServiceCommand.stopTunnel => 'StopTunnel', - ServiceCommand.connectToServer => 'ConnectToServer', - ServiceCommand.isVPNRunning => 'IsVPNRunning', - ServiceCommand.status => 'Status', - ServiceCommand.getUserData => 'GetUserData', - ServiceCommand.fetchUserData => 'FetchUserData', - }; -} diff --git a/lib/features/account/account.dart b/lib/features/account/account.dart index be42c5da51..4868fc1d6e 100644 --- a/lib/features/account/account.dart +++ b/lib/features/account/account.dart @@ -13,7 +13,7 @@ import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/lantern/lantern_service.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; -import 'package:lantern/lantern/protos/protos/auth.pb.dart'; +import 'package:lantern/core/models/user.dart'; @RoutePage(name: 'Account') class Account extends HookConsumerWidget { @@ -45,7 +45,7 @@ class Account extends HookConsumerWidget { final user = ref.watch(homeProvider).value; final isExpired = ref.watch(isUserExpiredProvider); final isPro = ref.watch(isUserProProvider); - final appSettings = ref.watch(appSettingProvider); + final email = ref.watch(userEmailProvider); final isUserFree = !isExpired && !isPro; final theme = TextTheme.of(buildContext); @@ -90,12 +90,12 @@ class Account extends HookConsumerWidget { AppCard( padding: EdgeInsets.zero, child: AppTile( - label: appSettings.email.toLowerCase(), + label: email.toLowerCase(), icon: AppImagePaths.email, contentPadding: EdgeInsets.only(left: 16), onPressed: kDebugMode ? () { - copyToClipboard(appSettings.email); + copyToClipboard(email); } : null, trailing: AppTextButton( @@ -103,7 +103,7 @@ class Account extends HookConsumerWidget { onPressed: () { appRouter.push( SignInPassword( - email: appSettings.email, + email: email, fromChangeEmail: true, ), ); @@ -189,7 +189,7 @@ class Account extends HookConsumerWidget { } Widget? planTrailingWidget( - UserResponse user, + UserResponseModel user, BuildContext buildContext, WidgetRef ref, ) { @@ -218,7 +218,7 @@ class Account extends HookConsumerWidget { Future onManageSubscriptionTap( WidgetRef ref, BuildContext buildContext, - UserResponse user, + UserResponseModel user, ) async { final provider = user.legacyUserData.subscriptionData.provider; switch (provider) { @@ -319,7 +319,7 @@ class Account extends HookConsumerWidget { } Future _handleSubscriptionChange({ - required UserResponse oldUser, + required UserResponseModel oldUser, required LanternService lanternService, required HomeNotifier notifier, required BuildContext context, @@ -399,8 +399,9 @@ class Account extends HookConsumerWidget { key: AuthKeys.accountLogoutConfirmButton, label: 'logout'.i18n, onPressed: () { - onLogout(context, ref); + // Dismiss dialog first, then run the async logout flow. appRouter.pop(); + onLogout(context, ref); }, ), ], @@ -420,22 +421,30 @@ class Account extends HookConsumerWidget { } Future onLogout(BuildContext context, WidgetRef ref) async { + final email = ref.read(userEmailProvider); + if (email.isEmpty) { + // Not truly logged in — just clear local state and go home. + ref.read(homeProvider.notifier).clearLogoutData(); + appRouter.popUntilRoot(); + return; + } + if (!context.mounted) return; context.showLoadingDialog(); - final appSetting = ref.read(appSettingProvider); final result = await ref .read(lanternServiceProvider) - .logout(appSetting.email); + .logout(email); + if (!context.mounted) return; result.fold( (l) { context.hideLoadingDialog(); appLogger.error('Logout error: ${l.localizedErrorMessage}'); + context.showSnackBar(l.localizedErrorMessage); }, (user) { context.hideLoadingDialog(); - appRouter.popUntilRoot(); ref.read(homeProvider.notifier).clearLogoutData(); ref.read(homeProvider.notifier).updateUserData(user); - + appRouter.popUntilRoot(); appLogger.info('Logout success: $user'); }, ); diff --git a/lib/features/account/delete_account.dart b/lib/features/account/delete_account.dart index ea2f1ac90c..7ec12ace12 100644 --- a/lib/features/account/delete_account.dart +++ b/lib/features/account/delete_account.dart @@ -4,9 +4,9 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:jwt_decoder/jwt_decoder.dart'; import 'package:lantern/core/widgets/oauth_login.dart'; -import 'package:lantern/core/keys/app_keys.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; +import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; import '../../core/common/common.dart'; import '../auth/provider/auth_notifier.dart'; @@ -16,7 +16,7 @@ class DeleteAccount extends StatefulHookConsumerWidget { const DeleteAccount({super.key}); @override - ConsumerState createState() => _DeleteAccountState(); + _DeleteAccountState createState() => _DeleteAccountState(); } class _DeleteAccountState extends ConsumerState { @@ -36,11 +36,9 @@ class _DeleteAccountState extends ConsumerState { final textTheme = Theme.of(context).textTheme; final passwordController = useTextEditingController(); final buttonEnabled = useState(false); - final appSetting = ref.read(appSettingProvider); - final isSSOUser = appSetting.isSSOUser; - final oAuthMethodType = _resolveOAuthMethodType( - appSetting.oAuthLoginProvider, - ); + final isSSOUser = ref.watch(isSSOUserProvider).value ?? false; + final oAuthProviderName = ref.watch(oAuthProviderProvider).value ?? ''; + final oAuthMethodType = _resolveOAuthMethodType(oAuthProviderName); return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -55,11 +53,8 @@ class _DeleteAccountState extends ConsumerState { ), SizedBox(height: defaultSize), Center( - child: Text( - 'delete_account_?'.i18n, - style: textTheme.headlineSmall, - ), - ), + child: Text('delete_account_?'.i18n, + style: textTheme.headlineSmall)), SizedBox(height: defaultSize), Padding( padding: const EdgeInsets.only(left: 16), @@ -75,9 +70,9 @@ class _DeleteAccountState extends ConsumerState { padding: const EdgeInsets.only(left: 16), child: Text( isSSOUser - ? 'confirm_with_account'.i18n.fill([ - appSetting.oAuthLoginProvider.capitalize, - ]) + ? 'confirm_with_account' + .i18n + .fill([oAuthProviderName.capitalize]) : 'delete_account_message_two'.i18n, style: textTheme.bodyLarge!.copyWith( color: context.textSecondary, @@ -87,7 +82,6 @@ class _DeleteAccountState extends ConsumerState { if (!isSSOUser) ...[ SizedBox(height: defaultSize), AppTextField( - fieldKey: AuthKeys.deleteAccountPasswordField, hintText: '', label: 'enter_password_to_confirm'.i18n, obscureText: true, @@ -101,9 +95,9 @@ class _DeleteAccountState extends ConsumerState { SizedBox(height: size24), if (isSSOUser) OAuthLogin( - label: 'verify_with'.i18n.fill([ - appSetting.oAuthLoginProvider.capitalize, - ]), + label: 'verify_with' + .i18n + .fill([oAuthProviderName.capitalize]), methodType: oAuthMethodType, bgColor: context.actionPrimaryBg, foregroundColor: context.actionPrimaryText, @@ -112,7 +106,6 @@ class _DeleteAccountState extends ConsumerState { ) else PrimaryButton( - key: AuthKeys.deleteAccountConfirmButton, label: 'confirm_deletion'.i18n, enabled: buttonEnabled.value, bgColor: AppColors.red7, @@ -121,7 +114,6 @@ class _DeleteAccountState extends ConsumerState { ), SizedBox(height: defaultSize), SecondaryButton( - key: AuthKeys.deleteAccountCancelButton, label: 'cancel'.i18n, isTaller: true, onPressed: () { @@ -135,18 +127,15 @@ class _DeleteAccountState extends ConsumerState { void processOAuthResult(Map payload) { final token = payload['token'] as String? ?? ''; - final oldToken = ref.read(appSettingProvider).oAuthToken; - if (token.isEmpty || oldToken.isEmpty) { + if (token.isEmpty) { appLogger.warning('Missing OAuth token during account deletion'); context.showSnackBarError('error_occurred'.i18n); return; } - Map oldTokenData; Map newTokenData; try { - oldTokenData = JwtDecoder.decode(oldToken); newTokenData = JwtDecoder.decode(token); } catch (e, st) { appLogger.error( @@ -158,7 +147,9 @@ class _DeleteAccountState extends ConsumerState { return; } - if (oldTokenData['email'] != newTokenData['email']) { + final currentEmail = ref.read(userEmailProvider); + if (currentEmail.isNotEmpty && + newTokenData['email'] != currentEmail) { context.showSnackBarError('oauth_different_account'.i18n); return; } @@ -170,7 +161,7 @@ class _DeleteAccountState extends ConsumerState { context.showLoadingDialog(); final email = ref.read(userEmailProvider); - final isSSOUser = ref.read(appSettingProvider).isSSOUser; + final isSSOUser = ref.read(isSSOUserProvider).value ?? false; final result = await ref .read(authProvider.notifier) @@ -179,24 +170,17 @@ class _DeleteAccountState extends ConsumerState { result.fold( (failure) { - if (!mounted) { - return; - } - appLogger.error( - 'Account deletion failed: ${failure.localizedErrorMessage}', - ); + appLogger + .error('Account deletion failed: ${failure.localizedErrorMessage}'); context.hideLoadingDialog(); context.showSnackBarError(failure.localizedErrorMessage); }, - (_) { - if (!mounted) { - return; - } + (userResponse) async { context.hideLoadingDialog(); - ref.read(homeProvider.notifier).clearLogoutData(); + ref.read(appSettingProvider.notifier).setUserLoggedIn(false); appLogger.info( - 'Account deletion successful, clearing user data and navigating to root', - ); + 'Account deletion successful, clearing user data and navigating to root'); + ref.read(homeProvider.notifier).updateUserData(userResponse); showAccountDeletionSuccessDialog(); }, ); @@ -209,28 +193,27 @@ class _DeleteAccountState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ SizedBox(height: 24), - AppImage(path: AppImagePaths.greenCheck, useThemeColor: false), - SizedBox(height: 16), - Text( - 'account_deleted'.i18n, - style: Theme.of( - context, - ).textTheme.headlineSmall!.copyWith(color: context.textPrimary), + AppImage( + path: AppImagePaths.greenCheck, + useThemeColor: false, ), SizedBox(height: 16), - Text( - 'account_deleted_message'.i18n, - style: Theme.of( - context, - ).textTheme.bodyMedium!.copyWith(color: context.textPrimary), - ), + Text('account_deleted'.i18n, + style: Theme.of(context).textTheme.headlineSmall!.copyWith( + color: context.textPrimary, + )), + SizedBox(height: 16), + Text('account_deleted_message'.i18n, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: context.textPrimary, + )), ], ), action: [ AppTextButton( label: 'close'.i18n, onPressed: () => appRouter.popUntilRoot(), - ), + ) ], ); } diff --git a/lib/features/auth/add_email.dart b/lib/features/auth/add_email.dart index d68711693a..28251fe99d 100644 --- a/lib/features/auth/add_email.dart +++ b/lib/features/auth/add_email.dart @@ -9,6 +9,7 @@ import 'package:lantern/core/widgets/oauth_login.dart'; import 'package:lantern/core/keys/app_keys.dart'; import 'package:lantern/features/auth/provider/auth_notifier.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; + import 'package:lantern/features/home/provider/home_notifier.dart'; @RoutePage(name: 'AddEmail') @@ -282,7 +283,6 @@ class _AddEmailState extends ConsumerState { //sign up successful //start forgot password flow context.hideLoadingDialog(); - ref.read(appSettingProvider.notifier).setEmail(email); startForgotPasswordFlow(email, tempPassword); }, ); @@ -330,11 +330,7 @@ class _AddEmailState extends ConsumerState { 'OAuth login successful, for user email ${response.legacyUserData.email}, userD ${response.legacyID}, updating app settings with token and provider: ${type.name}', ); - Map tokenData = JwtDecoder.decode(token); - ref.read(appSettingProvider.notifier) - ..setOAuthTokenAndProvider(token, type.name) - ..setEmail(tokenData['email'] ?? response.id) - ..setUserLoggedIn(true); + ref.read(appSettingProvider.notifier).setUserLoggedIn(true); navigateRoute(type, response.legacyUserData.email); }, ); diff --git a/lib/features/auth/confirm_email.dart b/lib/features/auth/confirm_email.dart index 7e75534b84..011a52260a 100644 --- a/lib/features/auth/confirm_email.dart +++ b/lib/features/auth/confirm_email.dart @@ -6,9 +6,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/common.dart' hide BackButton; import 'package:lantern/core/widgets/app_pin_field.dart'; import 'package:lantern/core/widgets/app_rich_text.dart'; -import 'package:lantern/core/keys/app_keys.dart'; import 'package:lantern/features/auth/provider/auth_notifier.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; +import 'package:lantern/features/home/provider/home_notifier.dart'; @RoutePage(name: 'ConfirmEmail') class ConfirmEmail extends HookConsumerWidget { @@ -58,7 +58,6 @@ class ConfirmEmail extends HookConsumerWidget { ), const SizedBox(height: 8), AppPinField( - inputKey: AuthKeys.confirmEmailCodeField, controller: codeController, onChanged: (String value) { isPinCodeValid.value = value.length == 6; @@ -79,7 +78,6 @@ class ConfirmEmail extends HookConsumerWidget { ), SizedBox(height: 32), PrimaryButton( - key: AuthKeys.confirmEmailContinueButton, label: 'continue'.i18n, enabled: isPinCodeValid.value, isTaller: true, @@ -88,7 +86,6 @@ class ConfirmEmail extends HookConsumerWidget { SizedBox(height: 24), Center( child: AppTextButton( - key: AuthKeys.confirmEmailResendButton, label: 'resend_email'.i18n, onPressed: () => onResendEmail(context, ref), ), @@ -139,7 +136,7 @@ class ConfirmEmail extends HookConsumerWidget { }, (_) { ///reset login status - ref.read(appSettingProvider.notifier).clearAuthSessionData(); + ref.read(appSettingProvider.notifier).setUserLoggedIn(false); context.hideLoadingDialog(); appRouter.pop(); }, @@ -182,8 +179,8 @@ class ConfirmEmail extends HookConsumerWidget { }, (_) { context.hideLoadingDialog(); - //update email in app settings - ref.read(appSettingProvider.notifier).setEmail(email); + //refresh user data to pick up the new email + ref.read(homeProvider.notifier).refreshUser(); AppDialog.dialog( context: context, title: 'change_email'.i18n, diff --git a/lib/features/auth/create_password.dart b/lib/features/auth/create_password.dart index e0a8c2d45f..900faed08b 100644 --- a/lib/features/auth/create_password.dart +++ b/lib/features/auth/create_password.dart @@ -9,6 +9,7 @@ import 'package:lantern/core/keys/app_keys.dart'; import 'package:lantern/features/auth/provider/auth_notifier.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; + @RoutePage(name: 'CreatePassword') class CreatePassword extends HookConsumerWidget { final String email; @@ -110,9 +111,7 @@ class CreatePassword extends HookConsumerWidget { (success) { context.hideLoadingDialog(); appLogger.info('Password created successfully'); - ref.read(appSettingProvider.notifier) - ..setUserLoggedIn(true) - ..setOAuthTokenAndProvider('', SignUpMethodType.email.name); + ref.read(appSettingProvider.notifier).setUserLoggedIn(true); resolveRoutes(context, ref); }, ); diff --git a/lib/features/auth/device_limit_reached.dart b/lib/features/auth/device_limit_reached.dart index 0772287b76..f4bd7caca9 100644 --- a/lib/features/auth/device_limit_reached.dart +++ b/lib/features/auth/device_limit_reached.dart @@ -5,11 +5,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/features/auth/provider/auth_notifier.dart'; -import '../../lantern/protos/protos/auth.pb.dart'; +import 'package:lantern/core/models/user.dart'; @RoutePage(name: 'DeviceLimitReached') class DeviceLimitReached extends HookConsumerWidget { - final List devices; + final List devices; const DeviceLimitReached({ super.key, @@ -19,7 +19,7 @@ class DeviceLimitReached extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final textTheme = Theme.of(context).textTheme; - final selectedDevice = useState(null); + final selectedDevice = useState(null); return BaseScreen( title: 'device_limit_reached'.i18n, body: Column( @@ -52,7 +52,7 @@ class DeviceLimitReached extends HookConsumerWidget { AppTile( contentPadding: EdgeInsets.zero, label: device.name, - trailing: AppRadioButton( + trailing: AppRadioButton( value: device, groupValue: selectedDevice.value, onChanged: (value) { @@ -73,7 +73,7 @@ class DeviceLimitReached extends HookConsumerWidget { isTaller: true, enabled: selectedDevice.value != null, onPressed: () => - removeDeviceAndLogin(ref, selectedDevice.value!.id, context), + removeDeviceAndLogin(ref, selectedDevice.value!.deviceId, context), ), SizedBox(height: 30.0), Center( diff --git a/lib/features/auth/provider/auth_notifier.dart b/lib/features/auth/provider/auth_notifier.dart index 0ed24c4008..9698b72e53 100644 --- a/lib/features/auth/provider/auth_notifier.dart +++ b/lib/features/auth/provider/auth_notifier.dart @@ -1,7 +1,7 @@ import 'package:fpdart/fpdart.dart'; import 'package:lantern/core/utils/failure.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; -import 'package:lantern/lantern/protos/protos/auth.pb.dart'; +import 'package:lantern/core/models/user.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'auth_notifier.g.dart'; @@ -17,12 +17,12 @@ class AuthNotifier extends _$AuthNotifier { return ref.read(lanternServiceProvider).getOAuthLoginUrl(provider); } - Future> oAuthLoginCallback( + Future> oAuthLoginCallback( String authToken) async { return ref.read(lanternServiceProvider).oAuthLoginCallback(authToken); } - Future> signInWithEmail( + Future> signInWithEmail( String email, String password) async { return ref.read(lanternServiceProvider).login( email: email, @@ -73,7 +73,7 @@ class AuthNotifier extends _$AuthNotifier { newEmail: newEmail, password: password, code: code); } - Future> deleteAccount( + Future> deleteAccount( String email, String password, bool isSSO) async { return ref .read(lanternServiceProvider) diff --git a/lib/features/auth/provider/auth_notifier.g.dart b/lib/features/auth/provider/auth_notifier.g.dart index 2d8426f23d..bc5e52f9d3 100644 --- a/lib/features/auth/provider/auth_notifier.g.dart +++ b/lib/features/auth/provider/auth_notifier.g.dart @@ -33,7 +33,7 @@ final class AuthNotifierProvider AuthNotifier create() => AuthNotifier(); } -String _$authNotifierHash() => r'99d7ee9c20a5e4fecf7748c135b3d20e274244ec'; +String _$authNotifierHash() => r'66b44d2581632b51f156ceb934b5674bbb7d66d9'; abstract class _$AuthNotifier extends $AsyncNotifier { FutureOr build(); diff --git a/lib/features/auth/sign_in_email.dart b/lib/features/auth/sign_in_email.dart index f110c6fdde..394cf0d287 100644 --- a/lib/features/auth/sign_in_email.dart +++ b/lib/features/auth/sign_in_email.dart @@ -2,10 +2,8 @@ import 'package:auto_route/annotations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:jwt_decoder/jwt_decoder.dart'; import 'package:lantern/core/widgets/app_rich_text.dart'; import 'package:lantern/core/widgets/oauth_login.dart'; -import 'package:lantern/core/keys/app_keys.dart'; import 'package:lantern/features/auth/provider/auth_notifier.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; @@ -39,7 +37,6 @@ class SignInEmail extends HookConsumerWidget { ), SizedBox(height: defaultSize), AppTextField( - fieldKey: AuthKeys.signInEmailField, hintText: '', prefixIcon: AppImagePaths.email, autofillHints: [AutofillHints.email], @@ -64,7 +61,6 @@ class SignInEmail extends HookConsumerWidget { ), SizedBox(height: 32), PrimaryButton( - key: AuthKeys.signInEmailContinueButton, label: 'sign_in_with_email'.i18n, enabled: emailController.text.isValidEmail(), onPressed: () => signInWithEmail(emailController.text, context), @@ -88,14 +84,13 @@ class SignInEmail extends HookConsumerWidget { DividerSpace(), SizedBox(height: 32), AppRichText( - key: AuthKeys.signInCreateAccountCta, texts: '${'new_to_lantern_pro'.i18n} ', boldTexts: 'create_an_account'.i18n, boldUnderline: true, boldOnPressed: () { appRouter.push(Plans()); }, - ), + ) ], ), ), @@ -103,7 +98,10 @@ class SignInEmail extends HookConsumerWidget { ); } - void signInWithEmail(String email, BuildContext context) { + void signInWithEmail( + String email, + BuildContext context, + ) { if (!email.isValidEmail()) { context.showSnackBarError('invalid_email'.i18n); return; @@ -111,18 +109,13 @@ class SignInEmail extends HookConsumerWidget { appRouter.push(SignInPassword(email: email)); } - Future onOAuthResult( - Map result, - BuildContext context, - WidgetRef ref, - SignUpMethodType type, - ) async { + Future onOAuthResult(Map result, BuildContext context, + WidgetRef ref, SignUpMethodType type) async { final token = result['token']; if (token != null) { context.showLoadingDialog(); - final result = await ref - .read(authProvider.notifier) - .oAuthLoginCallback(token); + final result = + await ref.read(authProvider.notifier).oAuthLoginCallback(token); result.fold( (failure) { context.hideLoadingDialog(); @@ -133,14 +126,8 @@ class SignInEmail extends HookConsumerWidget { ref.read(homeProvider.notifier).updateUserData(response); appLogger.info( - 'OAuth login successful, updating app settings with token and user data provider: ${type.name}', - ); - Map tokenData = JwtDecoder.decode(token); - ref.read(appSettingProvider.notifier) - ..setOAuthTokenAndProvider(token, type.name) - ..setEmail(tokenData['email'] ?? '') - ..setUserLoggedIn(true); - + 'OAuth login successful, updating app settings with token and user data provider: ${type.name}'); + ref.read(appSettingProvider.notifier).setUserLoggedIn(true); appRouter.popUntilRoot(); }, ); diff --git a/lib/features/auth/sign_in_password.dart b/lib/features/auth/sign_in_password.dart index c3e3914dec..4b5e08efc8 100644 --- a/lib/features/auth/sign_in_password.dart +++ b/lib/features/auth/sign_in_password.dart @@ -4,22 +4,19 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/widgets/email_tag.dart'; -import 'package:lantern/core/keys/app_keys.dart'; import 'package:lantern/features/auth/provider/auth_notifier.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; + import 'package:lantern/features/home/provider/home_notifier.dart'; -import 'package:lantern/lantern/protos/protos/auth.pb.dart'; +import 'package:lantern/core/models/user.dart'; @RoutePage(name: 'SignInPassword') class SignInPassword extends StatefulHookConsumerWidget { final String email; final bool fromChangeEmail; - const SignInPassword({ - super.key, - required this.email, - this.fromChangeEmail = false, - }); + const SignInPassword( + {super.key, required this.email, this.fromChangeEmail = false}); @override ConsumerState createState() => _SignInPasswordState(); @@ -50,7 +47,6 @@ class _SignInPasswordState extends ConsumerState { Center(child: EmailTag(email: widget.email)), SizedBox(height: defaultSize), AppTextField( - fieldKey: AuthKeys.signInPasswordField, hintText: '', controller: passwordController, autofocus: true, @@ -67,26 +63,23 @@ class _SignInPasswordState extends ConsumerState { SizedBox(height: 8), if (!widget.fromChangeEmail) Padding( - padding: const EdgeInsets.symmetric(horizontal: defaultSize), - child: Text( - 'if_you_have_not_set_password'.i18n, - textAlign: TextAlign.start, - style: textTheme.labelMedium!.copyWith( - color: context.textDisabled, - ), - ), - ), + padding: + const EdgeInsets.symmetric(horizontal: defaultSize), + child: Text( + 'if_you_have_not_set_password'.i18n, + textAlign: TextAlign.start, + style: textTheme.labelMedium!.copyWith( + color: context.textDisabled, + ), + )), SizedBox(height: 16), if (widget.fromChangeEmail) - Text( - 'confirm_password_to_continue'.i18n, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: context.textSecondary, - ), - ), + Text('confirm_password_to_continue'.i18n, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: context.textSecondary, + )), SizedBox(height: 32), PrimaryButton( - key: AuthKeys.signInPasswordContinueButton, label: 'continue'.i18n, enabled: passwordController.text.isNotEmpty, isTaller: true, @@ -102,7 +95,7 @@ class _SignInPasswordState extends ConsumerState { onPressed: () { appRouter.push(ResetPasswordEmail(email: widget.email)); }, - ), + ) ], ), ), @@ -125,8 +118,7 @@ class _SignInPasswordState extends ConsumerState { if (widget.fromChangeEmail) { /// If the user is changing email, we need to verify the password context.pushRoute( - AddEmail(authFlow: AuthFlow.changeEmail, password: password), - ); + AddEmail(authFlow: AuthFlow.changeEmail, password: password)); return; } context.showLoadingDialog(); @@ -148,8 +140,7 @@ class _SignInPasswordState extends ConsumerState { /// Login has failed reason being user has reached device limit /// start device flow appLogger.warning( - "Login failed for user: ${widget.email}, starting device flow", - ); + "Login failed for user: ${widget.email}, starting device flow"); startDeviceFlow(user.devices.toList(), password, context); return; } @@ -158,27 +149,26 @@ class _SignInPasswordState extends ConsumerState { /// save login state and user email /// update user data in home notifier /// fetch available servers - ref.read(appSettingProvider.notifier) - ..setUserLoggedIn(true) - ..setOAuthTokenAndProvider('', SignUpMethodType.email.name) - ..setEmail(widget.email); - + ref.read(appSettingProvider.notifier).setUserLoggedIn(true); ref.read(homeProvider.notifier).updateUserData(user); appRouter.popUntilRoot(); }, ); } - void startDeviceFlow( - List devices, - String password, - BuildContext context, - ) { - appRouter.push(DeviceLimitReached(devices: devices)).then((value) { - if (value != null && value is bool) { - /// If a device was selected, remove it and now sign in - signInWithPassword(password); - } - }); + void startDeviceFlow(List devices, String password, + BuildContext context) { + appRouter.push(DeviceLimitReached(devices: devices)).then( + (value) async { + if (value != null && value is bool) { + // Give the backend time to propagate the device removal before + // retrying sign-in, otherwise the request may still hit the + // device limit. + await Future.delayed(const Duration(seconds: 1)); + if (!mounted) return; + signInWithPassword(password); + } + }, + ); } } diff --git a/lib/features/developer/developer_mode.dart b/lib/features/developer/developer_mode.dart index 6fec9eccb2..a77cbdc96e 100644 --- a/lib/features/developer/developer_mode.dart +++ b/lib/features/developer/developer_mode.dart @@ -2,18 +2,25 @@ import 'dart:io'; import 'package:auto_route/annotations.dart'; import 'package:flutter/material.dart'; +import 'package:fpdart/fpdart.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/models/developer_daemon_state.dart'; +import 'package:lantern/core/models/user.dart'; import 'package:lantern/core/services/local_storage_service.dart'; import 'package:lantern/core/utils/storage_utils.dart'; import 'package:lantern/core/widgets/info_row.dart'; +import 'package:lantern/core/widgets/section_label.dart'; import 'package:lantern/core/widgets/switch_button.dart'; +import 'package:lantern/features/developer/notifier/developer_daemon_notifier.dart'; import 'package:lantern/features/developer/notifier/developer_mode_notifier.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; import '../../core/services/injection_container.dart' show sl; +enum _DevAction { sendConfig, runURLTests, showState } + @RoutePage(name: 'DeveloperMode') class DeveloperMode extends StatefulHookConsumerWidget { const DeveloperMode({super.key}); @@ -23,97 +30,243 @@ class DeveloperMode extends StatefulHookConsumerWidget { } class _DeveloperModeState extends ConsumerState { + final _countryController = TextEditingController(); + final _versionController = TextEditingController(); + final _featureOverridesController = TextEditingController(); + + // Action tiles currently awaiting an IPC reply — drives spinner + blocks + // double-taps while the call is in flight. + final Set<_DevAction> _runningActions = {}; + + @override + void dispose() { + _countryController.dispose(); + _versionController.dispose(); + _featureOverridesController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - final user = ref.watch(homeProvider).value; + // Seed controllers from the daemon snapshot once the initial fetch + // completes; subsequent state changes don't overwrite user edits. + ref.listen(developerDaemonProvider, (prev, next) { + if ((prev?.loading ?? true) && !next.loading) { + _countryController.text = next.country; + _versionController.text = next.version; + _featureOverridesController.text = next.featureOverrides; + } + }); - final developerMode = ref.watch(developerModeProvider); - appLogger.info('Developer Mode settings: ${developerMode.toJson()}'); - final devNotifier = ref.read(developerModeProvider.notifier); - final appSetting = ref.watch(appSettingProvider); - final appSettingNotifier = ref.watch(appSettingProvider.notifier); - final isStaging = appSetting.environment == 'stage' || - appSetting.environment == 'staging'; + final user = ref.watch(homeProvider).value; + final daemon = ref.watch(developerDaemonProvider); return BaseScreen( title: 'developer_mode'.i18n, - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, + body: ListView( + padding: EdgeInsets.zero, children: [ InfoRow(text: 'developer_mode_note'.i18n), SizedBox(height: defaultSize), - AppCard( - margin: EdgeInsets.zero, - padding: EdgeInsets.zero, - child: Column( - children: [ - AppTile( - label: 'UserId', - trailing: AppTextButton( - label: user?.legacyUserData.userId?.toString() ?? 'N/A', - ), - ), - DividerSpace(), - AppTile( - label: 'Status', - trailing: AppTextButton( - label: user?.legacyUserData.userLevel ?? 'N/A', - ), - ), - DividerSpace(), - ], + _accountCard(user), + SizedBox(height: defaultSize), + _purchaseAndEnvironmentCard(), + SizedBox(height: defaultSize), + _overridesCard(), + SizedBox(height: defaultSize), + _daemonSettingsCard(daemon), + SizedBox(height: defaultSize), + _actionsCard(), + SizedBox(height: defaultSize), + ], + ), + ); + } + + Widget _accountCard(UserResponseModel? user) { + return AppCard( + margin: EdgeInsets.zero, + padding: EdgeInsets.zero, + child: Column( + children: [ + AppTile( + label: 'UserId', + trailing: AppTextButton( + label: user?.legacyUserData.userId.toString() ?? 'N/A', ), ), - SizedBox(height: defaultSize), - AppCard( - padding: EdgeInsets.zero, - child: Column( - children: [ - if (PlatformUtils.isAndroid) - AppTile( - label: 'Test Play Purchase', - trailing: SwitchButton( - value: developerMode.testPlayPurchaseEnabled, - onChanged: (bool? value) { - appLogger.info('Test Play Purchase toggled: $value'); - devNotifier.updateDeveloperSettings( - developerMode.copyWith( - testPlayPurchaseEnabled: value ?? false, - ), - ); - }, - ), - ), - DividerSpace(), - if (!PlatformUtils.isIOS) - AppTile( - label: 'Stage Environment', - trailing: SwitchButton( - value: isStaging, - onChanged: (value) async { - await appSettingNotifier.setEnvironment(value); - if (!context.mounted) return; - AppDialog.dialog( - context: context, - title: 'Restart Required', - content: - 'Please restart the app for the environment change to take effect.', - onPressed: () { - exit(0); - }, - ); - }, + DividerSpace(), + AppTile( + label: 'Status', + trailing: AppTextButton( + label: user?.legacyUserData.userLevel ?? 'N/A', + ), + ), + ], + ), + ); + } + + Widget _purchaseAndEnvironmentCard() { + final developerMode = ref.watch(developerModeProvider); + final devNotifier = ref.read(developerModeProvider.notifier); + final environment = ref.watch( + appSettingProvider.select((s) => s.environment), + ); + final isStaging = environment == 'stage' || environment == 'staging'; + return AppCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + if (PlatformUtils.isAndroid) + AppTile( + label: 'Test Play Purchase', + trailing: SwitchButton( + value: developerMode.testPlayPurchaseEnabled, + onChanged: (bool? value) { + devNotifier.updateDeveloperSettings( + developerMode.copyWith( + testPlayPurchaseEnabled: value ?? false, ), - ), + ); + }, + ), + ), + if (PlatformUtils.isAndroid) DividerSpace(), + if (!PlatformUtils.isIOS) + AppTile( + label: 'Stage Environment', + trailing: SwitchButton( + value: isStaging, + onChanged: (value) async { + await ref + .read(appSettingProvider.notifier) + .setEnvironment(value); + if (!mounted) return; + AppDialog.dialog( + context: context, + title: 'Restart Required', + content: + 'Please restart the app for the environment change to take effect.', + onPressed: () => exit(0), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _overridesCard() { + return AppCard( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel('Radiance env overrides'), + const SizedBox(height: 8), + _envField( + label: 'Country (e.g. IR, CN)', + controller: _countryController, + envKey: kEnvCountry, + ), + const SizedBox(height: 8), + _envField( + label: 'App version', + controller: _versionController, + envKey: kEnvVersion, + ), + const SizedBox(height: 8), + _envField( + label: 'Feature overrides (JSON)', + controller: _featureOverridesController, + envKey: kEnvFeatureOverrides, + ), + ], + ), + ); + } + + Widget _envField({ + required String label, + required TextEditingController controller, + required String envKey, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: AppTextField( + label: label, + hintText: '', + controller: controller, + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: AppTextButton( + label: 'Apply', + onPressed: () => _runAndReport( + () => ref + .read(developerDaemonProvider.notifier) + .patchEnv(envKey, controller.text.trim()), + '$envKey set to "${controller.text.trim()}"', + ), + ), + ), + ], + ); + } + + Widget _daemonSettingsCard(DeveloperDaemonState daemon) { + return AppCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Log level'), + DropdownButton( + value: kDaemonLogLevels.contains(daemon.logLevel) + ? daemon.logLevel + : 'info', + items: kDaemonLogLevels + .map((l) => DropdownMenuItem(value: l, child: Text(l))) + .toList(), + onChanged: daemon.loading + ? null + : (value) { + if (value == null) return; + _runAndReport( + () => ref + .read(developerDaemonProvider.notifier) + .setLogLevel(value), + 'Log level set to $value', + ); + }, + ), ], ), ), - SizedBox(height: defaultSize), - AppCard( - padding: EdgeInsets.zero, - child: AppTile( - label: 'Reset App', - onPressed: () => resetAppData(context), + DividerSpace(), + AppTile( + label: 'Config fetch enabled', + trailing: SwitchButton( + value: daemon.configFetchEnabled, + onChanged: (value) { + if (daemon.loading) return; + _runAndReport( + () => ref + .read(developerDaemonProvider.notifier) + .setConfigFetchEnabled(value), + 'Config fetch ${value ? 'enabled' : 'disabled'}', + ); + }, ), ), ], @@ -121,10 +274,133 @@ class _DeveloperModeState extends ConsumerState { ); } - Future resetAppData(BuildContext context) async { + Widget _actionsCard() { + final daemon = ref.read(developerDaemonProvider.notifier); + return AppCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + _asyncActionTile( + id: _DevAction.sendConfig, + label: 'Send config request', + icon: Icons.cloud_download_outlined, + action: () => + _runAndReport(daemon.sendConfigRequest, 'Config request sent'), + ), + DividerSpace(), + _asyncActionTile( + id: _DevAction.runURLTests, + label: 'Run URL tests', + icon: Icons.speed_outlined, + action: () => + _runAndReport(daemon.runURLTests, 'URL tests triggered'), + ), + DividerSpace(), + _asyncActionTile( + id: _DevAction.showState, + label: 'Show settings & env vars', + icon: Icons.info_outline, + action: () async { + final result = await daemon.fetchStateJson(); + result.match( + (f) => _snackFailure(f), + _showStateDialog, + ); + }, + ), + DividerSpace(), + AppTile( + label: 'Reset App', + icon: Icons.restart_alt, + onPressed: _resetAppData, + ), + ], + ), + ); + } + + Widget _asyncActionTile({ + required _DevAction id, + required String label, + required IconData icon, + required Future Function() action, + }) { + final running = _runningActions.contains(id); + return AppTile( + label: label, + icon: icon, + loading: running, + onPressed: running + ? null + : () async { + setState(() => _runningActions.add(id)); + try { + await action(); + } finally { + if (mounted) setState(() => _runningActions.remove(id)); + } + }, + ); + } + + void _showStateDialog(({String settings, String env}) data) { + if (!mounted) return; + AppDialog.customDialog( + context: context, + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + Text( + 'Settings & env vars', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 16), + SelectableText( + 'Settings:\n${data.settings}\n\nEnv:\n${data.env}', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ], + ), + ), + ), + action: [ + AppTextButton( + label: 'close'.i18n, + onPressed: () => appRouter.maybePop(), + ), + ], + ); + } + + /// Runs [op] and shows [successMessage] or the failure's localized message + /// via snackbar. Used by every notifier-driven action in this screen. + Future _runAndReport( + Future> Function() op, + String successMessage, + ) async { + final result = await op(); + if (!mounted) return; + result.match( + _snackFailure, + (_) => context.showSnackBar(successMessage), + ); + } + + void _snackFailure(Failure f) { + if (!mounted) return; + context.showSnackBar('Failed: ${f.localizedErrorMessage}'); + } + + Future _resetAppData() async { final appDir = await AppStorageUtils.getAppDirectory(); appDir.delete(recursive: true); sl().deleteAll(); + if (!mounted) return; AppDialog.errorDialog( context: context, title: 'Reset', diff --git a/lib/features/developer/notifier/developer_daemon_notifier.dart b/lib/features/developer/notifier/developer_daemon_notifier.dart new file mode 100644 index 0000000000..ceb08968ed --- /dev/null +++ b/lib/features/developer/notifier/developer_daemon_notifier.dart @@ -0,0 +1,127 @@ +import 'dart:convert'; + +import 'package:fpdart/fpdart.dart'; +import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/models/developer_daemon_state.dart'; +import 'package:lantern/lantern/lantern_service_notifier.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'developer_daemon_notifier.g.dart'; + +const List kDaemonLogLevels = [ + 'trace', + 'debug', + 'info', + 'warn', + 'error', + 'fatal', + 'panic', + 'disable', +]; + +/// Radiance env var keys that dev-mode exposes. Mirrors the names in +/// radiance/common/env/env.go. +const String kEnvCountry = 'RADIANCE_COUNTRY'; +const String kEnvVersion = 'RADIANCE_VERSION'; +const String kEnvFeatureOverrides = 'RADIANCE_FEATURE_OVERRIDES'; + +/// Snapshot of dev-mode daemon state plus the IPC calls that mutate it. +/// Auto-disposed so each visit to the developer screen re-fetches fresh +/// state from the native layer. +@riverpod +class DeveloperDaemonNotifier extends _$DeveloperDaemonNotifier { + @override + DeveloperDaemonState build() { + _load(); + return const DeveloperDaemonState(); + } + + Future _load() async { + final svc = ref.read(lanternServiceProvider); + final (settingsResult, envResult) = await ( + svc.getSettings(), + svc.getEnvVars(), + ).wait; + if (!ref.mounted) return; + var next = state; + settingsResult.match((_) {}, (settings) { + final lvl = settings['log_level']; + if (lvl is String && kDaemonLogLevels.contains(lvl)) { + next = next.copyWith(logLevel: lvl); + } + final disabled = settings['config_fetch_disabled']; + if (disabled is bool) { + next = next.copyWith(configFetchEnabled: !disabled); + } + }); + envResult.match((_) {}, (env) { + next = next.copyWith( + country: env[kEnvCountry] ?? '', + version: env[kEnvVersion] ?? '', + featureOverrides: env[kEnvFeatureOverrides] ?? '', + ); + }); + state = next.copyWith(loading: false); + } + + Future> patchEnv(String key, String value) async { + final result = + await ref.read(lanternServiceProvider).patchEnvVars({key: value}); + if (!ref.mounted) return result.map((_) => unit); + return result.map((_) { + state = switch (key) { + kEnvCountry => state.copyWith(country: value), + kEnvVersion => state.copyWith(version: value), + kEnvFeatureOverrides => state.copyWith(featureOverrides: value), + _ => state, + }; + return unit; + }); + } + + Future> setLogLevel(String level) async { + final result = await ref + .read(lanternServiceProvider) + .patchSettings({'log_level': level}); + if (!ref.mounted) return result; + return result.map((_) { + state = state.copyWith(logLevel: level); + return unit; + }); + } + + Future> setConfigFetchEnabled(bool enabled) async { + final result = await ref + .read(lanternServiceProvider) + .patchSettings({'config_fetch_disabled': !enabled}); + if (!ref.mounted) return result; + return result.map((_) { + state = state.copyWith(configFetchEnabled: enabled); + return unit; + }); + } + + Future> sendConfigRequest() => + ref.read(lanternServiceProvider).sendConfigRequest(); + + Future> runURLTests() => + ref.read(lanternServiceProvider).runURLTests(); + + /// Pretty-printed JSON of current settings/env for the dev-mode "Show + /// settings & env vars" dialog. Returns the first IPC error so callers + /// can surface it via the standard failure snackbar. + Future> + fetchStateJson() async { + final svc = ref.read(lanternServiceProvider); + final (settingsResult, envResult) = await ( + svc.getSettings(), + svc.getEnvVars(), + ).wait; + return settingsResult.flatMap((settings) { + return envResult.map((env) { + const encoder = JsonEncoder.withIndent(' '); + return (settings: encoder.convert(settings), env: encoder.convert(env)); + }); + }); + } +} diff --git a/lib/features/developer/notifier/developer_daemon_notifier.g.dart b/lib/features/developer/notifier/developer_daemon_notifier.g.dart new file mode 100644 index 0000000000..a422f51309 --- /dev/null +++ b/lib/features/developer/notifier/developer_daemon_notifier.g.dart @@ -0,0 +1,77 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'developer_daemon_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Snapshot of dev-mode daemon state plus the IPC calls that mutate it. +/// Auto-disposed so each visit to the developer screen re-fetches fresh +/// state from the native layer. + +@ProviderFor(DeveloperDaemonNotifier) +final developerDaemonProvider = DeveloperDaemonNotifierProvider._(); + +/// Snapshot of dev-mode daemon state plus the IPC calls that mutate it. +/// Auto-disposed so each visit to the developer screen re-fetches fresh +/// state from the native layer. +final class DeveloperDaemonNotifierProvider + extends $NotifierProvider { + /// Snapshot of dev-mode daemon state plus the IPC calls that mutate it. + /// Auto-disposed so each visit to the developer screen re-fetches fresh + /// state from the native layer. + DeveloperDaemonNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'developerDaemonProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$developerDaemonNotifierHash(); + + @$internal + @override + DeveloperDaemonNotifier create() => DeveloperDaemonNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(DeveloperDaemonState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$developerDaemonNotifierHash() => + r'f636033cdbb65e7e1d0054fed1242c6eabacf01d'; + +/// Snapshot of dev-mode daemon state plus the IPC calls that mutate it. +/// Auto-disposed so each visit to the developer screen re-fetches fresh +/// state from the native layer. + +abstract class _$DeveloperDaemonNotifier + extends $Notifier { + DeveloperDaemonState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + DeveloperDaemonState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 31fcbb287d..961deb9022 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -13,7 +13,9 @@ import 'package:lantern/features/home/provider/app_event_notifier.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/home/provider/feature_flag_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; +import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; import 'package:lantern/features/vpn/location_setting.dart'; +import 'package:lantern/features/vpn/provider/available_servers_notifier.dart'; import 'package:lantern/features/vpn/provider/server_location_notifier.dart'; import 'package:lantern/features/vpn/vpn_status.dart'; import 'package:lantern/features/vpn/vpn_switch.dart'; @@ -38,6 +40,11 @@ class _HomeState extends ConsumerState { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { + /// Kick off the server fetch as soon as Home mounts so the Smart + /// Location tile can reflect the fastest server without waiting for + /// the user to open the server-selection screen. + ref.read(availableServersProvider); + final appSetting = ref.read(appSettingProvider); final appSettingNotifier = ref.read(appSettingProvider.notifier); if (!appSetting.onboardingCompleted) { @@ -45,7 +52,6 @@ class _HomeState extends ConsumerState { "User has not completed onboarding, navigating to Onboarding Screen", ); appRouter.push(const Onboarding()); - appSettingNotifier.setOnboardingCompleted(true); return; } @@ -77,7 +83,7 @@ class _HomeState extends ConsumerState { final appSetting = ref.read(appSettingProvider); if (appSetting.successfulConnection) { appLogger.info( - "User has successfully connected, checking if needs to show Help Lantern Dialog or not", + "User has successfully connected, checking if need to show Help Lantern Dialog or not", ); if (!appSetting.telemetryDialogDismissed && (featureFlag.getBool(FeatureFlag.metrics) && @@ -178,10 +184,10 @@ class _HomeState extends ConsumerState { Widget _buildSetting(WidgetRef ref) { final routingMode = ref.watch( - appSettingProvider.select((s) => s.routingMode), + radianceSettingsProvider.select((s) => s.routingMode), ); final isSplitTunnelingOn = ref.watch( - appSettingProvider.select((s) => s.isSplitTunnelingOn), + radianceSettingsProvider.select((s) => s.splitTunneling), ); return Container( @@ -203,7 +209,7 @@ class _HomeState extends ConsumerState { VpnStatus(), DividerSpace(), LocationSetting(), - if (!PlatformUtils.isIOS) ...[ + if (!PlatformUtils.isIOS) ...{ DividerSpace(), SettingTile( label: 'routing_mode'.i18n, @@ -223,13 +229,12 @@ class _HomeState extends ConsumerState { ], onTap: () => onSettingTileTap(_SettingTileType.smartRouting), ), - ], + }, if (PlatformUtils.isAndroid || PlatformUtils.isMacOS || PlatformUtils.isWindows) ...{ DividerSpace(), SettingTile( - tileKey: const Key('home.split_tunneling_setting'), label: 'split_tunneling'.i18n, icon: AppImagePaths.callSpilt, value: isSplitTunnelingOn ? 'enabled'.i18n : 'disabled'.i18n, @@ -311,9 +316,7 @@ class _HomeState extends ConsumerState { textColor: context.textDisabled, onPressed: () { context.pop(); - ref - .read(appSettingProvider.notifier) - .updateAnonymousDataConsent(false); + ref.read(radianceSettingsProvider.notifier).setTelemetry(false); }, ), AppTextButton( @@ -321,9 +324,7 @@ class _HomeState extends ConsumerState { textColor: AppColors.blue6, onPressed: () { context.pop(); - ref - .read(appSettingProvider.notifier) - .updateAnonymousDataConsent(true); + ref.read(radianceSettingsProvider.notifier).setTelemetry(true); }, ), ], diff --git a/lib/features/home/provider/app_event_notifier.dart b/lib/features/home/provider/app_event_notifier.dart index 90c05c4701..2be3ca5850 100644 --- a/lib/features/home/provider/app_event_notifier.dart +++ b/lib/features/home/provider/app_event_notifier.dart @@ -58,22 +58,23 @@ class AppEventNotifier extends _$AppEventNotifier { // Otherwise (custom server selected) ignore it — applying it would // silently flip the user's selection back to Smart Location on // routing-mode changes or any other tunnel rebuild. + final currentLocation = ref.read(serverLocationProvider); if (currentLocation.serverType != ServerLocationType.auto.name) { break; } try { final autoLocation = Server.fromJson(jsonDecode(event.message)); - final countryName = autoLocation.location!.country; - final cityName = autoLocation.location!.city; + final countryName = autoLocation.location.country; + final cityName = autoLocation.location.city; final autoServer = ServerLocation( serverType: ServerLocationType.auto.name, serverName: ''.i18n, displayName: '', protocol: '', - city: autoLocation.location!.city, + city: cityName, autoLocation: AutoLocation( - countryCode: autoLocation.location!.countryCode, + countryCode: autoLocation.location.countryCode, country: countryName, displayName: '$countryName - $cityName', tag: autoLocation.tag, diff --git a/lib/features/home/provider/app_event_notifier.g.dart b/lib/features/home/provider/app_event_notifier.g.dart index c4e35e230b..594334cff7 100644 --- a/lib/features/home/provider/app_event_notifier.g.dart +++ b/lib/features/home/provider/app_event_notifier.g.dart @@ -42,7 +42,7 @@ final class AppEventNotifierProvider AppEventNotifier create() => AppEventNotifier(); } -String _$appEventNotifierHash() => r'eb7f00bfa36873512510aa2b787822df6c073b4d'; +String _$appEventNotifierHash() => r'1b50c331c39acb058ef4fa9e220df4642d1884b6'; /// Listens for application-wide events and triggers corresponding actions. /// This can be used for all listening to events that go sends and handling them diff --git a/lib/features/home/provider/app_setting_notifier.dart b/lib/features/home/provider/app_setting_notifier.dart index 9e3d8272b4..97af2d8365 100644 --- a/lib/features/home/provider/app_setting_notifier.dart +++ b/lib/features/home/provider/app_setting_notifier.dart @@ -3,32 +3,49 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:fpdart/fpdart.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/app_setting.dart'; import 'package:lantern/core/services/injection_container.dart' show sl; import 'package:lantern/core/services/local_storage_service.dart'; -import 'package:lantern/core/utils/latest_async_queue.dart'; import 'package:lantern/core/utils/storage_utils.dart'; -import 'package:lantern/lantern/lantern_service.dart'; -import 'package:lantern/lantern/lantern_service_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:window_manager/window_manager.dart'; part 'app_setting_notifier.g.dart'; +/// Name of the marker file placed in the app data directory after first +/// successful initialization. When SharedPreferences survive a data-dir +/// deletion (e.g. NSUserDefaults on macOS), the absence of this file tells +/// us to treat the launch as a fresh install and reset stored settings. +const _initMarkerName = '.app_initialized'; + @Riverpod(keepAlive: true) class AppSettingNotifier extends _$AppSettingNotifier { LocalStorageService get _storage => sl(); - late final LatestAsyncQueue> - _routingModeQueue = LatestAsyncQueue( - worker: _applyRoutingMode, - defaultResult: right(unit), - ); - late final LatestAsyncQueue _blockAdsQueue = LatestAsyncQueue( - worker: _applyBlockAds, - defaultResult: unit, - ); + + /// Must be called from [injectServices] (before `runApp`) so that stale + /// SharedPreferences are cleared before any widget reads the provider. + static Future resetIfFreshInstall(LocalStorageService storage) async { + final settings = storage.getAppSettings(); + if (settings == null || !settings.onboardingCompleted) { + // Either no stored settings or onboarding hasn't been marked done — + // nothing to reset. + return; + } + + final dataDir = await AppStorageUtils.getAppDirectory(); + final marker = File('${dataDir.path}/$_initMarkerName'); + if (marker.existsSync()) return; + + // Settings say onboarding is done, but the data-dir marker is missing. + // This means the user deleted the data directory (clean install) while + // SharedPreferences (NSUserDefaults / registry) survived. + appLogger.info( + 'Stale settings detected (data dir was cleared), resetting to defaults', + ); + await storage.saveAppSettings(const AppSetting()); + await marker.create(); + } @override AppSetting build() { @@ -64,114 +81,16 @@ class AppSettingNotifier extends _$AppSettingNotifier { await _storage.saveAppSettings(updated); } - void togglePro(bool value) => update(state.copyWith(newPro: value)); - void setLocale(String locale) { update(state.copyWith(newLocale: locale)); } - void toggleSplitTunneling(bool value) => - update(state.copyWith(newIsSpiltTunnelingOn: value)); - - Future> setRoutingMode(RoutingMode mode) async { - if (_routingModeQueue.isRunning) { - appLogger.info( - 'Routing mode update in progress. Queued latest request: ${mode.key}', - ); - } - - try { - return await _routingModeQueue.enqueue(mode); - } catch (e, st) { - appLogger.error('Unexpected routing mode update failure', e, st); - return left(e.toFailure()); - } - } - - Future> _applyRoutingMode(RoutingMode mode) async { - if (state.routingMode == mode) { - return right(unit); - } - - final prev = state.routingModeRaw; - appLogger.info('Setting routing mode to: ${mode.key}'); - await update(state.copyWith(routingModeRaw: mode.key)); - - final lantern = ref.read(lanternServiceProvider); - try { - final res = await lantern.setRoutingMode(mode == RoutingMode.smart); - return await res.match((f) async { - appLogger.error('Failed to set routing mode', f); - await update(state.copyWith(routingModeRaw: prev)); - return left(f); - }, (_) async => right(unit)); - } catch (e, st) { - appLogger.error('Unexpected setRoutingMode error', e, st); - await update(state.copyWith(routingModeRaw: prev)); - return left(e.toFailure()); - } - } - void setUserLoggedIn(bool value) => update(state.copyWith(userLoggedIn: value)); - void setOAuthTokenAndProvider(String token, String provider) { - update(state.copyWith(oAuthToken: token, oAuthLoginProvider: provider)); - } - - void setEmail(String email) => update(state.copyWith(email: email)); - - void clearAuthSessionData({bool clearEmail = true}) { - update(state.clearAuthSessionData(clearEmail: clearEmail)); - } - void setSuccessfulConnection(bool value) => update(state.copyWith(successfulConnection: value)); - void setBlockAds(bool value) { - if (_blockAdsQueue.isRunning) { - appLogger.info( - 'Block ads update in progress. Queued latest request: $value', - ); - } - unawaited(_enqueueBlockAds(value)); - } - - Future _enqueueBlockAds(bool value) async { - try { - await _blockAdsQueue.enqueue(value); - } catch (e, st) { - appLogger.error('Unexpected setBlockAdsEnabled error', e, st); - } - } - - Future _applyBlockAds(bool value) async { - if (state.blockAds == value) { - return unit; - } - - final svc = ref.read(lanternServiceProvider); - final prev = state.blockAds; - await update(state.copyWith(blockAds: value)); - - try { - final res = await svc.setBlockAdsEnabled(value); - await res.match((err) async { - appLogger.error('setBlockAdsEnabled failed: ${err.error}'); - await update(state.copyWith(blockAds: prev)); - }, (_) async {}); - } catch (e, st) { - appLogger.error('Unexpected setBlockAdsEnabled failure', e, st); - await update(state.copyWith(blockAds: prev)); - } - return unit; - } - - void updateAnonymousDataConsent(bool value) { - update(state.copyWith(telemetryConsent: value)); - updateTelemetryConsent(value); - } - void updateDataCapThreshold(String threshold) => update(state.copyWith(dataCapThreshold: threshold)); @@ -181,8 +100,20 @@ class AppSettingNotifier extends _$AppSettingNotifier { void setShowTelemetryDialog(bool value) => update(state.copyWith(showTelemetryDialog: value)); - void setOnboardingCompleted(bool value) => - update(state.copyWith(onboardingCompleted: value)); + void setOnboardingCompleted(bool value) { + update(state.copyWith(onboardingCompleted: value)); + if (value) unawaited(_writeInitMarker()); + } + + Future _writeInitMarker() async { + try { + final dataDir = await AppStorageUtils.getAppDirectory(); + final marker = File('${dataDir.path}/$_initMarkerName'); + if (!marker.existsSync()) await marker.create(); + } catch (e, st) { + appLogger.error('Failed to write init marker', e, st); + } + } void setThemeMode(String mode) { update(state.copyWith(themeMode: mode)); @@ -243,52 +174,15 @@ class AppSettingNotifier extends _$AppSettingNotifier { update(state.copyWith(environment: env)); } - Future setSplitTunnelingEnabled(bool enabled) async { - final LanternService svc = ref.read(lanternServiceProvider); - final previous = state.isSplitTunnelingOn; - - update(state.copyWith(newIsSpiltTunnelingOn: enabled)); - appLogger.info('Setting split tunneling: $enabled'); - final res = await svc.setSplitTunnelingEnabled(enabled); - res.match((err) { - appLogger.error('setSplitTunnelingEnabled failed: ${err.error}'); - update(state.copyWith(newIsSpiltTunnelingOn: previous)); - }, (_) {}); - } - - Future updateTelemetryConsent(bool consent) async { - final result = await ref - .read(lanternServiceProvider) - .updateTelemetryEvents(consent); - - result.fold( - (err) { - /// if fail revert the state - update(state.copyWith(telemetryConsent: !consent)); - appLogger.error('updateTelemetryEvents failed: ${err.error}'); - }, - (_) { - appLogger.info('Telemetry consent updated: $consent'); - }, - ); - } - Map _settingsLogFields(AppSetting setting) => { - 'isPro': setting.isPro, - 'isSplitTunnelingOn': setting.isSplitTunnelingOn, 'themeMode': setting.themeMode, 'environment': setting.environment, 'locale': setting.locale, 'userLoggedIn': setting.userLoggedIn, - 'blockAds': setting.blockAds, 'showSplashScreen': setting.showSplashScreen, 'telemetryDialogDismissed': setting.telemetryDialogDismissed, - 'telemetryConsent': setting.telemetryConsent, 'successfulConnection': setting.successfulConnection, - 'routingModeRaw': setting.routingModeRaw, 'dataCapThreshold': setting.dataCapThreshold, 'onboardingCompleted': setting.onboardingCompleted, - 'hasOAuthToken': setting.oAuthToken.isNotEmpty, - 'hasEmail': setting.email.isNotEmpty, }; } diff --git a/lib/features/home/provider/app_setting_notifier.g.dart b/lib/features/home/provider/app_setting_notifier.g.dart index 131b008dfd..1b6adb27d9 100644 --- a/lib/features/home/provider/app_setting_notifier.g.dart +++ b/lib/features/home/provider/app_setting_notifier.g.dart @@ -42,7 +42,7 @@ final class AppSettingNotifierProvider } String _$appSettingNotifierHash() => - r'21865554f3c4f6b3f5b717a70596e75af2fd5f5a'; + r'5cdda57737603a04881abd030c2711fcc8ef4363'; abstract class _$AppSettingNotifier extends $Notifier { AppSetting build(); diff --git a/lib/features/home/provider/home_notifier.dart b/lib/features/home/provider/home_notifier.dart index a474ed964e..4461da9881 100644 --- a/lib/features/home/provider/home_notifier.dart +++ b/lib/features/home/provider/home_notifier.dart @@ -1,10 +1,10 @@ import 'package:fpdart/fpdart.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/extensions/user_data.dart'; +import 'package:lantern/core/models/user.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/plans/provider/referral_notifier.dart'; import 'package:lantern/features/vpn/provider/server_location_notifier.dart'; -import 'package:lantern/lantern/protos/protos/auth.pb.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -13,7 +13,7 @@ part 'home_notifier.g.dart'; @Riverpod(keepAlive: true) class HomeNotifier extends _$HomeNotifier { @override - Future build() async { + Future build() async { /// Check if user data is stored locally /// If yes, load it first to avoid delay in UI final result = await ref.read(lanternServiceProvider).getUserData(); @@ -25,7 +25,7 @@ class HomeNotifier extends _$HomeNotifier { throw Exception('Failed to get user data'); }, (userData) { - appLogger.debug('Got the userdata: $userData'); + appLogger.debug('Got the userdata: ${userData.toJson()}'); _applyUserData(userData); return userData; }, @@ -61,7 +61,7 @@ class HomeNotifier extends _$HomeNotifier { state = AsyncValue.error(failure, StackTrace.current); }, (userData) { - appLogger.debug('Refreshed user data from Go: $userData'); + appLogger.debug('Refreshed user data from Go: ${userData.toJson()}'); _applyUserData(userData); }, ); @@ -69,26 +69,14 @@ class HomeNotifier extends _$HomeNotifier { /// Updates the user data in state and local storage. /// notifies UI about changes. - void updateUserData(UserResponse userData) { + void updateUserData(UserResponseModel userData) { _applyUserData(userData); } - void _applyUserData(UserResponse userData) { + void _applyUserData(UserResponseModel userData) { state = AsyncValue.data(userData); - // Step 1: Sync all user fields to app settings first before any other logic. final isPro = userData.legacyUserData.isPro; - final email = userData.legacyUserData.email.isEmpty - ? userData.id - : userData.legacyUserData.email; - appLogger.info( - 'Syncing user data to app settings — isPro=$isPro email=$email', - ); - ref.read(appSettingProvider.notifier) - ..togglePro(isPro) - ..setEmail(email); - - // Step 2: Now run derived logic that depends on the stored settings. if (!isPro) { resetServerLocation(); } @@ -133,7 +121,7 @@ class HomeNotifier extends _$HomeNotifier { _checkIfUserProAndDeviceIsAdded(user); } - void _checkIfUserProAndDeviceIsAdded(UserResponse user) { + void _checkIfUserProAndDeviceIsAdded(UserResponseModel user) { if (!user.legacyUserData.isPro) { appLogger.info("User is not Pro. Skipping device check."); return; @@ -145,7 +133,7 @@ class HomeNotifier extends _$HomeNotifier { } final userDeviceId = user.legacyUserData.deviceID; final isDeviceAdded = user.legacyUserData.devices.any( - (device) => device.id == userDeviceId, + (device) => device.deviceId == userDeviceId, ); appLogger.info( "current device added for user ${user.legacyUserData.email}: " @@ -164,8 +152,6 @@ class HomeNotifier extends _$HomeNotifier { /// Fetches available servers again. void clearLogoutData() { ref.read(referralProvider.notifier).resetReferral(); - ref.read(appSettingProvider.notifier).clearAuthSessionData(); - resetServerLocation(); - state = AsyncValue.data(UserResponse()); + ref.read(appSettingProvider.notifier).setUserLoggedIn(false); } } diff --git a/lib/features/home/provider/home_notifier.g.dart b/lib/features/home/provider/home_notifier.g.dart index 1bc1091975..cd74822d79 100644 --- a/lib/features/home/provider/home_notifier.g.dart +++ b/lib/features/home/provider/home_notifier.g.dart @@ -13,7 +13,7 @@ part of 'home_notifier.dart'; final homeProvider = HomeNotifierProvider._(); final class HomeNotifierProvider - extends $AsyncNotifierProvider { + extends $AsyncNotifierProvider { HomeNotifierProvider._() : super( from: null, @@ -33,19 +33,20 @@ final class HomeNotifierProvider HomeNotifier create() => HomeNotifier(); } -String _$homeNotifierHash() => r'008a819ad8ed52ef8483e4f8547f347620470f25'; +String _$homeNotifierHash() => r'fdc560f98e46d5c3946ea4341b53e5846119f9e2'; -abstract class _$HomeNotifier extends $AsyncNotifier { - FutureOr build(); +abstract class _$HomeNotifier extends $AsyncNotifier { + FutureOr build(); @$mustCallSuper @override void runBuild() { - final ref = this.ref as $Ref, UserResponse>; + final ref = + this.ref as $Ref, UserResponseModel>; final element = ref.element as $ClassProviderElement< - AnyNotifier, UserResponse>, - AsyncValue, + AnyNotifier, UserResponseModel>, + AsyncValue, Object?, Object? >; diff --git a/lib/features/home/provider/radiance_settings_providers.dart b/lib/features/home/provider/radiance_settings_providers.dart new file mode 100644 index 0000000000..ae9cee0f6d --- /dev/null +++ b/lib/features/home/provider/radiance_settings_providers.dart @@ -0,0 +1,124 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/models/radiance_settings_state.dart'; +import 'package:lantern/lantern/lantern_service_notifier.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'radiance_settings_providers.g.dart'; + +/// Holds radiance-backed VPN preferences in memory. +/// +/// The notifier returns safe defaults synchronously from [build], then kicks +/// off a single background refresh that fetches the real values from the +/// native layer and updates state. Mutations update state in place on success, +/// avoiding an extra native round-trip just to re-read what we just wrote. +@Riverpod(keepAlive: true) +class RadianceSettings extends _$RadianceSettings { + @override + RadianceSettingsState build() { + _refresh(); + return const RadianceSettingsState(); + } + + /// Fetches all settings in parallel and assigns a fresh state from the + /// results. On a per-field fetch failure, falls back to the hardcoded + /// default for that field. + Future _refresh() async { + final svc = ref.read(lanternServiceProvider); + final blockAdsF = svc.isBlockAdsEnabled(); + final routingF = svc.isSmartRoutingEnabled(); + final telemetryF = svc.isTelemetryEnabled(); + final splitF = PlatformUtils.isIOS ? null : svc.isSplitTunnelingEnabled(); + + final results = await Future.wait([ + blockAdsF, + routingF, + telemetryF, + ?splitF, + ]); + if (!ref.mounted) return; + + const defaults = RadianceSettingsState(); + state = RadianceSettingsState( + blockAds: results[0].fold((_) => defaults.blockAds, (v) => v), + routingMode: results[1].fold( + (_) => defaults.routingMode, + (smart) => smart ? RoutingMode.smart : RoutingMode.full, + ), + telemetry: results[2].fold((_) => defaults.telemetry, (v) => v), + splitTunneling: splitF == null + ? defaults.splitTunneling + : results[3].fold((_) => defaults.splitTunneling, (v) => v), + ); + } + + Future setBlockAds(bool value) async { + final svc = ref.read(lanternServiceProvider); + final result = await svc.setBlockAdsEnabled(value); + if (!ref.mounted) return; + result.fold( + (err) => appLogger.error('setBlockAdsEnabled failed: ${err.error}'), + (_) => state = state.copyWith(blockAds: value), + ); + } + + Future> setRoutingMode(RoutingMode mode) async { + final svc = ref.read(lanternServiceProvider); + final result = await svc.setRoutingMode(mode == RoutingMode.smart); + if (!ref.mounted) return right(unit); + return result.fold( + (err) { + appLogger.error('setRoutingMode failed: ${err.error}'); + return left(err); + }, + (_) { + state = state.copyWith(routingMode: mode); + return right(unit); + }, + ); + } + + Future setSplitTunneling(bool value) async { + final svc = ref.read(lanternServiceProvider); + final result = await svc.setSplitTunnelingEnabled(value); + if (!ref.mounted) return; + result.fold( + (err) => appLogger.error('setSplitTunnelingEnabled failed: ${err.error}'), + (_) => state = state.copyWith(splitTunneling: value), + ); + } + + Future setTelemetry(bool consent) async { + final svc = ref.read(lanternServiceProvider); + final result = await svc.updateTelemetryEvents(consent); + if (!ref.mounted) return; + result.fold( + (err) => appLogger.error('updateTelemetryEvents failed: ${err.error}'), + (_) => state = state.copyWith(telemetry: consent), + ); + } +} + +/// Fetches whether user logged in via OAuth from radiance. +@riverpod +Future isOAuthLogin(Ref ref) async { + final svc = ref.read(lanternServiceProvider); + final result = await svc.isOAuthLogin(); + return result.fold((_) => false, (v) => v); +} + +/// Fetches OAuth provider name from radiance. +@riverpod +Future oAuthProvider(Ref ref) async { + final svc = ref.read(lanternServiceProvider); + final result = await svc.getOAuthProvider(); + return result.fold((_) => '', (v) => v); +} + +/// Whether the user is an SSO user (OAuth login with a provider set). +@riverpod +Future isSSOUser(Ref ref) async { + final isOAuth = await ref.watch(isOAuthLoginProvider.future); + final provider = await ref.watch(oAuthProviderProvider.future); + return isOAuth && provider.isNotEmpty; +} diff --git a/lib/features/home/provider/radiance_settings_providers.g.dart b/lib/features/home/provider/radiance_settings_providers.g.dart new file mode 100644 index 0000000000..1e1c0b0df6 --- /dev/null +++ b/lib/features/home/provider/radiance_settings_providers.g.dart @@ -0,0 +1,201 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'radiance_settings_providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Holds radiance-backed VPN preferences in memory. +/// +/// The notifier returns safe defaults synchronously from [build], then kicks +/// off a single background refresh that fetches the real values from the +/// native layer and updates state. Mutations update state in place on success, +/// avoiding an extra native round-trip just to re-read what we just wrote. + +@ProviderFor(RadianceSettings) +final radianceSettingsProvider = RadianceSettingsProvider._(); + +/// Holds radiance-backed VPN preferences in memory. +/// +/// The notifier returns safe defaults synchronously from [build], then kicks +/// off a single background refresh that fetches the real values from the +/// native layer and updates state. Mutations update state in place on success, +/// avoiding an extra native round-trip just to re-read what we just wrote. +final class RadianceSettingsProvider + extends $NotifierProvider { + /// Holds radiance-backed VPN preferences in memory. + /// + /// The notifier returns safe defaults synchronously from [build], then kicks + /// off a single background refresh that fetches the real values from the + /// native layer and updates state. Mutations update state in place on success, + /// avoiding an extra native round-trip just to re-read what we just wrote. + RadianceSettingsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'radianceSettingsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$radianceSettingsHash(); + + @$internal + @override + RadianceSettings create() => RadianceSettings(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(RadianceSettingsState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$radianceSettingsHash() => r'a194e30ee94b62d3b20a2ab03a6878e1aa045516'; + +/// Holds radiance-backed VPN preferences in memory. +/// +/// The notifier returns safe defaults synchronously from [build], then kicks +/// off a single background refresh that fetches the real values from the +/// native layer and updates state. Mutations update state in place on success, +/// avoiding an extra native round-trip just to re-read what we just wrote. + +abstract class _$RadianceSettings extends $Notifier { + RadianceSettingsState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + RadianceSettingsState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + +/// Fetches whether user logged in via OAuth from radiance. + +@ProviderFor(isOAuthLogin) +final isOAuthLoginProvider = IsOAuthLoginProvider._(); + +/// Fetches whether user logged in via OAuth from radiance. + +final class IsOAuthLoginProvider + extends $FunctionalProvider, bool, FutureOr> + with $FutureModifier, $FutureProvider { + /// Fetches whether user logged in via OAuth from radiance. + IsOAuthLoginProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'isOAuthLoginProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$isOAuthLoginHash(); + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return isOAuthLogin(ref); + } +} + +String _$isOAuthLoginHash() => r'7711849921b77b27fab46efaeeccc33b0ae56811'; + +/// Fetches OAuth provider name from radiance. + +@ProviderFor(oAuthProvider) +final oAuthProviderProvider = OAuthProviderProvider._(); + +/// Fetches OAuth provider name from radiance. + +final class OAuthProviderProvider + extends $FunctionalProvider, String, FutureOr> + with $FutureModifier, $FutureProvider { + /// Fetches OAuth provider name from radiance. + OAuthProviderProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'oAuthProviderProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$oAuthProviderHash(); + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return oAuthProvider(ref); + } +} + +String _$oAuthProviderHash() => r'9d243b3a7155010f71c948211aa732c579fc63e1'; + +/// Whether the user is an SSO user (OAuth login with a provider set). + +@ProviderFor(isSSOUser) +final isSSOUserProvider = IsSSOUserProvider._(); + +/// Whether the user is an SSO user (OAuth login with a provider set). + +final class IsSSOUserProvider + extends $FunctionalProvider, bool, FutureOr> + with $FutureModifier, $FutureProvider { + /// Whether the user is an SSO user (OAuth login with a provider set). + IsSSOUserProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'isSSOUserProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$isSSOUserHash(); + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return isSSOUser(ref); + } +} + +String _$isSSOUserHash() => r'07a9fd3a10783d8b12a5c837224b732f7432f46c'; diff --git a/lib/features/logs/log_line.dart b/lib/features/logs/log_line.dart index fc65a9eb0e..1f1b07d73f 100644 --- a/lib/features/logs/log_line.dart +++ b/lib/features/logs/log_line.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:lantern/core/common/app_semantic_colors.dart'; import 'package:lantern/core/common/app_text_styles.dart'; +import 'package:lantern/core/common/common.dart'; import 'package:lantern/features/logs/parsed_log.dart'; class LogLineWidget extends StatefulWidget { @@ -36,36 +36,32 @@ class _LogLineWidgetState extends State { if (parsed == null) { return Text( widget.line, - style: AppTextStyles.monospace( - color: context.textPrimary, - ), + style: AppTextStyles.monospace(color: context.textPrimary), ); } - final levelColor = getLevelColor(parsed.level); - final idColor = colorForId(parsed.id); + final brightness = Theme.of(context).brightness; + final levelColor = getLevelColor(parsed.level, brightness); + final pkgColor = colorForId(parsed.pkg, brightness); return RichText( text: TextSpan( - style: AppTextStyles.monospace( - fontSize: 13, - ), + style: AppTextStyles.monospace(fontSize: 13), children: [ TextSpan( text: parsed.level.toUpperCase(), - style: TextStyle( - color: levelColor, - fontWeight: FontWeight.bold, - ), + style: TextStyle(color: levelColor, fontWeight: FontWeight.bold), ), const TextSpan(text: ' '), TextSpan( - text: '[${parsed.id} ${parsed.duration}] ', - style: TextStyle(color: idColor), + text: parsed.duration == null + ? '[${parsed.pkg}] ' + : '[${parsed.pkg} ${parsed.duration}] ', + style: TextStyle(color: pkgColor), ), TextSpan( text: parsed.message, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: context.textPrimary), ), ], ), diff --git a/lib/features/logs/logs.dart b/lib/features/logs/logs.dart index ced7c5e197..38ebad606d 100644 --- a/lib/features/logs/logs.dart +++ b/lib/features/logs/logs.dart @@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/app_text_styles.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/utils/storage_utils.dart'; -import 'package:lantern/core/widgets/info_row.dart'; import 'package:lantern/core/widgets/loading_indicator.dart'; import 'package:lantern/features/logs/log_line.dart'; import 'package:lantern/features/logs/provider/diagnostic_log_notifier.dart'; @@ -123,10 +122,6 @@ class _LogsState extends ConsumerState { body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - InfoRow( - text: 'cannot_view_logs'.i18n, - ), - const SizedBox(height: defaultSize), Expanded( child: Container( decoration: ShapeDecoration( diff --git a/lib/features/logs/parsed_log.dart b/lib/features/logs/parsed_log.dart index 4322d3589b..398bc5046f 100644 --- a/lib/features/logs/parsed_log.dart +++ b/lib/features/logs/parsed_log.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; class ParsedLog { final String level; - final String id; - final String duration; + final String pkg; + final String? duration; final String message; - ParsedLog(this.level, this.id, this.duration, this.message); + ParsedLog(this.level, this.pkg, this.duration, this.message); } final _logFieldRegex = RegExp(r'(\w+)=(".*?"|\S+)'); @@ -14,42 +14,43 @@ final _logFieldRegex = RegExp(r'(\w+)=(".*?"|\S+)'); ParsedLog? parseLogLine(String line) { final fields = { for (final m in _logFieldRegex.allMatches(line)) - m.group(1)!: m.group(2)!.replaceAll('"', '') + m.group(1)!: m.group(2)!.replaceAll('"', ''), }; final level = fields['level']; - final service = fields['service']; - final duration = fields['duration']; + final pkg = fields['pkg']; final msg = fields['msg']; - if ([level, service, duration, msg].any((e) => e == null)) { + if (level == null || pkg == null || msg == null) { return null; } - return ParsedLog(level!, service!, duration!, msg!); + return ParsedLog(level, pkg, fields['duration'], msg); } -Color getLevelColor(String level) { +Color getLevelColor(String level, Brightness brightness) { + final isDark = brightness == Brightness.dark; switch (level.toUpperCase()) { case 'DEBUG': case 'TRACE': - return Colors.grey.shade400; + return isDark ? Colors.grey.shade400 : Colors.grey.shade600; case 'INFO': - return Colors.cyan; + return isDark ? Colors.cyan.shade300 : Colors.cyan.shade700; case 'WARN': case 'WARNING': - return Colors.orange; + return isDark ? Colors.orange.shade300 : Colors.orange.shade800; case 'ERROR': case 'FATAL': case 'PANIC': - return Colors.redAccent; + return isDark ? Colors.redAccent.shade100 : Colors.red.shade700; default: - return Colors.white; + return isDark ? Colors.white : Colors.black87; } } -Color colorForId(String id) { +Color colorForId(String id, Brightness brightness) { final hash = int.tryParse(id) ?? id.hashCode; final colorIndex = hash % Colors.primaries.length; - return Colors.primaries[colorIndex].shade300; + final swatch = Colors.primaries[colorIndex]; + return brightness == Brightness.dark ? swatch.shade300 : swatch.shade700; } diff --git a/lib/features/logs/provider/diagnostic_log_notifier.dart b/lib/features/logs/provider/diagnostic_log_notifier.dart index 170e91e226..cc1d77964e 100644 --- a/lib/features/logs/provider/diagnostic_log_notifier.dart +++ b/lib/features/logs/provider/diagnostic_log_notifier.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:lantern/core/services/logger_service.dart'; +import 'package:lantern/lantern/lantern_core_service.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -7,8 +10,12 @@ part 'diagnostic_log_notifier.g.dart'; @Riverpod() class DiagnosticLogNotifier extends _$DiagnosticLogNotifier { @override - Stream> build() async* { - yield* ref.read(lanternServiceProvider).watchLogs(""); + Stream> build() { + final radianceBatches = ref.read(lanternServiceProvider).watchLogs(""); + final flutterBatches = flutterLogLinesStream.map((line) => [line]); + return accumulateLogBatches( + _mergeStreams([radianceBatches, flutterBatches]), + ); } Future> diagnosticLogFilePath() async { @@ -20,3 +27,23 @@ class DiagnosticLogNotifier extends _$DiagnosticLogNotifier { }, (paths) => paths); } } + +Stream _mergeStreams(List> streams) { + final controller = StreamController(); + final subs = >[]; + controller.onListen = () { + for (final s in streams) { + subs.add(s.listen( + controller.add, + onError: controller.addError, + )); + } + }; + controller.onCancel = () async { + for (final sub in subs) { + await sub.cancel(); + } + subs.clear(); + }; + return controller.stream; +} diff --git a/lib/features/logs/provider/diagnostic_log_notifier.g.dart b/lib/features/logs/provider/diagnostic_log_notifier.g.dart index d400f8f4b7..61a20ead03 100644 --- a/lib/features/logs/provider/diagnostic_log_notifier.g.dart +++ b/lib/features/logs/provider/diagnostic_log_notifier.g.dart @@ -34,7 +34,7 @@ final class DiagnosticLogNotifierProvider } String _$diagnosticLogNotifierHash() => - r'949ff4928db76e5c3081f548a9f170b8268e6747'; + r'0a9a50436bb0f1542af4c7d451b2f4f97f693abc'; abstract class _$DiagnosticLogNotifier extends $StreamNotifier> { Stream> build(); diff --git a/lib/features/macos_extension/provider/macos_extension_notifier.g.dart b/lib/features/macos_extension/provider/macos_extension_notifier.g.dart index 1233d9dfc9..19dd583fea 100644 --- a/lib/features/macos_extension/provider/macos_extension_notifier.g.dart +++ b/lib/features/macos_extension/provider/macos_extension_notifier.g.dart @@ -42,7 +42,7 @@ final class MacosExtensionNotifierProvider } String _$macosExtensionNotifierHash() => - r'842302433894032ee9670f1910f52ffef193b2b4'; + r'7b678f457254f0d2be1b97db1ee0226a95958469'; abstract class _$MacosExtensionNotifier extends $Notifier { MacOSExtensionState build(); diff --git a/lib/features/onboarding/onboarding.dart b/lib/features/onboarding/onboarding.dart index 3d7f348a32..1e5833cd34 100644 --- a/lib/features/onboarding/onboarding.dart +++ b/lib/features/onboarding/onboarding.dart @@ -7,6 +7,7 @@ import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/widgets/info_row.dart'; import '../home/provider/app_setting_notifier.dart'; +import '../home/provider/radiance_settings_providers.dart'; @RoutePage(name: 'Onboarding') class Onboarding extends StatefulHookConsumerWidget { @@ -22,9 +23,13 @@ class _OnboardingState extends ConsumerState { final textTheme = TextTheme.of(context); final controller = useState(FlutterCarouselController()); final pageIndex = useState(0); + final selectedRouteMode = useState(RoutingMode.smart); final appSetting = ref.read(appSettingProvider); - void onboardingCompleted() { + Future onboardingCompleted() async { + await ref + .read(radianceSettingsProvider.notifier) + .setRoutingMode(selectedRouteMode.value); ref.read(appSettingProvider.notifier).setOnboardingCompleted(true); final shouldShowExtensionDialog = appSetting.showSplashScreen && PlatformUtils.isMacOS; @@ -106,7 +111,8 @@ class _OnboardingState extends ConsumerState { ], ), slide2(context), - if (!PlatformUtils.isIOS) slide3(context), + if (!PlatformUtils.isIOS) + slide3(context, selectedRouteMode), ], ), ), @@ -254,35 +260,11 @@ class _OnboardingState extends ConsumerState { ); } - Widget slide3(BuildContext context) { + Widget slide3( + BuildContext context, + ValueNotifier selectedMode, + ) { final textTheme = TextTheme.of(context); - final routeMode = - ref.watch(appSettingProvider.select((value) => value.routingMode)); - useEffect(() { - Future(() { - final routeMode = - ref.read(appSettingProvider.select((v) => v.routingMode)); - - if (routeMode == RoutingMode.full) { - ref - .read(appSettingProvider.notifier) - .setRoutingMode(RoutingMode.smart); - } - }); - - return null; - }, const []); - - Future onRouteChange(RoutingMode mode) async { - final result = - await ref.read(appSettingProvider.notifier).setRoutingMode(mode); - result.fold( - (failure) { - context.showSnackBar('failed_to_update_routing_mode'.i18n); - }, - (_) {}, - ); - } return Column( children: [ @@ -295,16 +277,21 @@ class _OnboardingState extends ConsumerState { ), SizedBox(height: 24.0), GestureDetector( - onTap: () => onRouteChange(RoutingMode.smart), - child: RouteModeContainer( - mode: RoutingMode.smart, - isSelected: routeMode == RoutingMode.smart)), + behavior: HitTestBehavior.opaque, + onTap: () => selectedMode.value = RoutingMode.smart, + child: RouteModeContainer( + mode: RoutingMode.smart, + isSelected: selectedMode.value == RoutingMode.smart, + ), + ), SizedBox(height: 16.0), GestureDetector( - onTap: () => onRouteChange(RoutingMode.full), + behavior: HitTestBehavior.opaque, + onTap: () => selectedMode.value = RoutingMode.full, child: RouteModeContainer( - mode: RoutingMode.full, - isSelected: routeMode == RoutingMode.full), + mode: RoutingMode.full, + isSelected: selectedMode.value == RoutingMode.full, + ), ), Spacer(), InfoRow( diff --git a/lib/features/plans/provider/plans_notifier.dart b/lib/features/plans/provider/plans_notifier.dart index 687cef81e1..44d8701ac3 100644 --- a/lib/features/plans/provider/plans_notifier.dart +++ b/lib/features/plans/provider/plans_notifier.dart @@ -20,29 +20,63 @@ class PlansNotifier extends _$PlansNotifier { state = const AsyncLoading(); final cached = _storage.getPlans(); if (cached != null) { + appLogger.info('Found cached plans, refreshing in background'); unawaited(_refreshInBackground()); state = AsyncData(cached); return cached; } + appLogger.info('No cached plans, fetching from server'); return fetchPlans(); } Future fetchPlans({bool fromBackground = false}) async { - if (!fromBackground) { + return _fetchPlansWithRetry(fromBackground: fromBackground, attempt: 0); + } + + Future _fetchPlansWithRetry({ + required bool fromBackground, + required int attempt, + }) async { + appLogger.info( + '[PlansNotifier] _fetchPlansWithRetry(fromBackground: $fromBackground, attempt: $attempt)', + ); + if (!fromBackground && attempt == 0) { state = const AsyncLoading(); } final result = await ref.read(lanternServiceProvider).plans(); return result.fold( (error) { + appLogger.error( + '[PlansNotifier] Plans fetch error: $error (fromBackground: $fromBackground, attempt: $attempt)', + ); if (fromBackground) { - appLogger.error('Error fetching plans in background: $error'); return state.value ?? (throw Exception('Plans fetch failed')); } + // Retry up to 2 times with increasing delay — the first attempt + // often fails at startup before radiance is fully ready. + if (attempt < 2) { + appLogger.info( + '[PlansNotifier] Retrying plans fetch (${attempt + 1}/2) after ${2 * (attempt + 1)}s delay...', + ); + return Future.delayed( + Duration(seconds: 2 * (attempt + 1)), + () => _fetchPlansWithRetry( + fromBackground: false, + attempt: attempt + 1, + ), + ); + } + appLogger.error( + '[PlansNotifier] All retry attempts exhausted, setting error state', + ); state = AsyncError(error, StackTrace.current); throw Exception('Plans fetch failed'); }, (remote) { + appLogger.info( + '[PlansNotifier] Plans fetched successfully: ${remote.plans.length} plans', + ); unawaited(_storage.savePlans(remote)); return remote; }, @@ -50,12 +84,21 @@ class PlansNotifier extends _$PlansNotifier { } Future _refreshInBackground() async { - appLogger.info('Refreshing plans in background'); + appLogger.info('[PlansNotifier] _refreshInBackground started'); final remotePlans = await fetchPlans(fromBackground: true); + appLogger.info( + '[PlansNotifier] Background refresh complete, updating state', + ); state = AsyncData(remotePlans); } - void setSelectedPlan(Plan plan) => userSelectedPlan = plan; + void setSelectedPlan(Plan plan) { + appLogger.info('[PlansNotifier] setSelectedPlan: ${plan.id}'); + userSelectedPlan = plan; + } - Plan getSelectedPlan() => userSelectedPlan!; + Plan getSelectedPlan() { + appLogger.info('[PlansNotifier] getSelectedPlan: ${userSelectedPlan?.id}'); + return userSelectedPlan!; + } } diff --git a/lib/features/plans/provider/plans_notifier.g.dart b/lib/features/plans/provider/plans_notifier.g.dart index 319c1ae016..3a090ae791 100644 --- a/lib/features/plans/provider/plans_notifier.g.dart +++ b/lib/features/plans/provider/plans_notifier.g.dart @@ -33,7 +33,7 @@ final class PlansNotifierProvider PlansNotifier create() => PlansNotifier(); } -String _$plansNotifierHash() => r'e1e0b9efe2723c8f501c487561a811af87fd7780'; +String _$plansNotifierHash() => r'7265639d8213bcabb7d7493c97b1389209751670'; abstract class _$PlansNotifier extends $AsyncNotifier { FutureOr build(); diff --git a/lib/features/private_server/join_private_server.dart b/lib/features/private_server/join_private_server.dart index cfc35b689a..847e902b15 100644 --- a/lib/features/private_server/join_private_server.dart +++ b/lib/features/private_server/join_private_server.dart @@ -224,7 +224,7 @@ class _JoinPrivateServerState extends ConsumerState { context.showLoadingDialog(); final result = await ref .read(privateServerProvider.notifier) - .addServerBasedOnURLs(urls, true, serverName); + .addServerBasedOnURLs(urls, true); result.fold( (error) { appLogger.error("Failed to join private server: $error"); diff --git a/lib/features/private_server/manage_private_server.dart b/lib/features/private_server/manage_private_server.dart index 46cd977749..433679266c 100644 --- a/lib/features/private_server/manage_private_server.dart +++ b/lib/features/private_server/manage_private_server.dart @@ -42,12 +42,12 @@ class _ManagePrivateServerState extends ConsumerState { body: Center(child: Text(err.toString())), ), data: (servers) { - final allServers = servers.user.locations.values.toList(); + final allServers = servers.userServers; final joinedServers = allServers - .where((loc) => servers.user.credentials[loc.tag]?.isJoined == true) + .where((s) => s.credentials?.isJoined == true) .toList(); final myServers = allServers - .where((loc) => servers.user.credentials[loc.tag]?.isJoined != true) + .where((s) => s.credentials?.isJoined != true) .toList(); return BaseScreen( @@ -103,7 +103,7 @@ class _ManagePrivateServerState extends ConsumerState { ); } - Widget buildMyServer(List myServers) { + Widget buildMyServer(List myServers) { return Column( children: [ const SizedBox(height: 8), @@ -122,7 +122,7 @@ class _ManagePrivateServerState extends ConsumerState { } Widget _buildListView( - List myServers, { + List myServers, { required bool showShareAccessKey, }) { return ListView.builder( @@ -137,8 +137,8 @@ class _ManagePrivateServerState extends ConsumerState { children: [ AppTile( label: item.tag, - subtitle: Text(item.city), - icon: Flag(countryCode: item.countryCode), + subtitle: Text(item.location.city), + icon: Flag(countryCode: item.location.countryCode), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -169,26 +169,10 @@ class _ManagePrivateServerState extends ConsumerState { ); } - void onTapShareAccessKey(Location_ location) { - final servers = ref.read(availableServersProvider).value; - - if (servers == null) { - appLogger.error('Servers data is null, cannot share access key'); - return; - } - - final matchingOutbounds = - servers.user.outbounds.where((o) => o.tag == location.tag); - if (matchingOutbounds.isEmpty) { - appLogger.error( - 'No outbound found for tag: ${location.tag}, cannot share access key'); - return; - } - final userServer = matchingOutbounds.first; - - final credential = servers.user.credentials[location.tag]; + void onTapShareAccessKey(Server server) { + final credential = server.credentials; if (credential == null || credential.accessToken.isEmpty) { - appLogger.error('No access token for tag: ${location.tag}'); + appLogger.error('No access token for tag: ${server.tag}'); AppDialog.errorDialog( context: context, title: 'error'.i18n, @@ -198,26 +182,26 @@ class _ManagePrivateServerState extends ConsumerState { } final privateServer = PrivateServer( - serverName: userServer.tag, - externalIp: userServer.server, + serverName: server.tag, + externalIp: server.serverIP, port: credential.port, accessToken: credential.accessToken, - serverLocationName: location.city, - serverCountryCode: location.countryCode, - protocol: location.protocol, + serverLocationName: server.location.city, + serverCountryCode: server.location.countryCode, + protocol: server.type, isJoined: credential.isJoined, ); - final cachedKey = _accessKeyCache[location.tag]; + final cachedKey = _accessKeyCache[server.tag]; if (cachedKey != null) { - appLogger.info('Reusing cached access key for tag: ${location.tag}'); + appLogger.info('Reusing cached access key for tag: ${server.tag}'); try { final tokenData = JwtDecoder.decode(cachedKey); sharePrivateAccessKey(privateServer, tokenData); return; } catch (e) { appLogger.warning( - 'Cached access key invalid for tag: ${location.tag}, regenerating'); - _accessKeyCache.remove(location.tag); + 'Cached access key invalid for tag: ${server.tag}, regenerating'); + _accessKeyCache.remove(server.tag); } } diff --git a/lib/features/private_server/private_server_setup.dart b/lib/features/private_server/private_server_setup.dart index 365247c98e..9890789f5d 100644 --- a/lib/features/private_server/private_server_setup.dart +++ b/lib/features/private_server/private_server_setup.dart @@ -5,9 +5,11 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:fpdart/fpdart.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/models/private_server_status.dart'; import 'package:lantern/features/private_server/provider/private_server_notifier.dart'; import 'package:lantern/features/private_server/provider_card.dart'; import 'package:lantern/features/private_server/provider_carousel.dart'; +import 'package:lantern/features/vpn/provider/vpn_notifier.dart'; @RoutePage(name: 'PrivateServerSetup') class PrivateServerSetup extends StatefulHookConsumerWidget { @@ -18,62 +20,103 @@ class PrivateServerSetup extends StatefulHookConsumerWidget { } class _PrivateServerSetupState extends ConsumerState { - @override - Widget build(BuildContext context) { - final serverState = ref.watch(privateServerProvider); - final isGCPEnabled = false; + final CloudProvider _selectedProvider = CloudProvider.digitalOcean; - final selectedIdx = useState(0); - final CloudProvider selectedProvider = CloudProvider.digitalOcean; - final route = ModalRoute.of(context); - useEffect(() { - if (route == null || !route.isCurrent) return null; + /// Handle a non-openBrowser server state update. + /// Returns true if the status was recognized and acted on. + bool _handleServerState(PrivateServerStatus serverState) { + if (!context.mounted) { + appLogger.warning( + "Received private server state update while context not mounted: ${serverState.status}", + ); + return true; + } - if (serverState.status == 'openBrowser') { - UrlUtils.openWebview( - serverState.data!, - onWebviewResult: (ok) { - if (ok) context.showLoadingDialog(); - }, - ); - } - if (serverState.status == 'EventTypeOAuthError') { + switch (serverState.status) { + case 'EventTypeOAuthError': + context.hideLoadingDialog(); context.showSnackBar('private_server_setup_error'.i18n); - } - if (serverState.status == 'EventTypeOnlyCompartment') { + return true; + case 'EventTypeOAuthCancelled': context.hideLoadingDialog(); - appRouter.push(PrivateServerDetails( - accounts: [], provider: selectedProvider, isPreFilled: true)); - } - if (serverState.status == 'EventTypeAccounts') { + return true; + case 'EventTypeNoProjects': + case 'error': + context.hideLoadingDialog(); + context.showSnackBar( + serverState.error ?? 'private_server_setup_error'.i18n, + ); + return true; + case 'EventTypeOnlyCompartment': + context.hideLoadingDialog(); + appRouter.push( + PrivateServerDetails( + accounts: [], + provider: _selectedProvider, + isPreFilled: true, + ), + ); + return true; + case 'EventTypeAccounts': context.hideLoadingDialog(); final accounts = serverState.data!.split(', '); - appRouter.push(PrivateServerDetails( - accounts: accounts, provider: selectedProvider)); - } - if (serverState.status == 'EventTypeValidationError') { - if (!context.mounted) { - return; - } - - /// User has created new account but it does not have billing set up yet + appRouter.push( + PrivateServerDetails(accounts: accounts, provider: _selectedProvider), + ); + return true; + case 'EventTypeValidationError': if (serverState.error?.contains('account is not active') ?? false) { WidgetsBinding.instance.addPostFrameCallback((_) { context.hideLoadingDialog(); + ref.read(privateServerProvider.notifier).resetPrivateServerState(); appRouter.push(PrivateServerAddBilling()); }); - return; + return true; } WidgetsBinding.instance.addPostFrameCallback((_) { context.hideLoadingDialog(); + ref.read(privateServerProvider.notifier).resetPrivateServerState(); appLogger.error( - "Private server deployment failed.", serverState.error); + "Private server deployment failed.", + serverState.error, + ); AppDialog.errorDialog( context: context, title: 'error'.i18n, content: serverState.error!, ); }); + return true; + default: + return false; + } + } + + @override + Widget build(BuildContext context) { + final serverState = ref.watch(privateServerProvider); + appLogger.info("Current private server state: ${serverState.status}"); + final isGCPEnabled = false; + final selectedIdx = useState(0); + useEffect(() { + if (serverState.status == 'openBrowser') { + UrlUtils.openWebview( + serverState.data!, + onWebviewResult: (ok) { + if (ok) { + context.showLoadingDialog(); + // Events from Go may have arrived while the webview was open. + // Re-check the current notifier state so they aren't missed. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + final current = ref.read(privateServerProvider); + _handleServerState(current); + }); + } + }, + ); + } else { + _handleServerState(serverState); } return null; }, [serverState.status]); @@ -161,16 +204,24 @@ class _PrivateServerSetupState extends ConsumerState { } Future _continue( - CloudProvider provider, WidgetRef ref, BuildContext context) async { + CloudProvider provider, + WidgetRef ref, + BuildContext context, + ) async { + // The cloud provider OAuth webview may fail to load when VPN is + // active (the tunnel can block outbound traffic to the provider). + // Disconnect first so the webview can reach the OAuth endpoint. + final vpnStatus = ref.read(vpnProvider); + if (vpnStatus == VPNStatus.connected || vpnStatus == VPNStatus.connecting) { + await ref.read(vpnProvider.notifier).stopVPN(); + } + final Either result; if (provider == CloudProvider.googleCloud) { result = await ref.read(privateServerProvider.notifier).googleCloud(); } else { result = await ref.read(privateServerProvider.notifier).digitalOcean(); } - result.fold( - (f) => context.showSnackBar(f.localizedErrorMessage), - (_) {}, - ); + result.fold((f) => context.showSnackBar(f.localizedErrorMessage), (_) {}); } } diff --git a/lib/features/private_server/provider/private_server_notifier.dart b/lib/features/private_server/provider/private_server_notifier.dart index a555d5739e..0cd887639d 100644 --- a/lib/features/private_server/provider/private_server_notifier.dart +++ b/lib/features/private_server/provider/private_server_notifier.dart @@ -69,12 +69,11 @@ class PrivateServerNotifier extends _$PrivateServerNotifier { ); } - Future> addServerBasedOnURLs( - String urls, bool skipCertVerification, String serverName) async { + Future>> addServerBasedOnURLs( + String urls, bool skipCertVerification) async { return ref.read(lanternServiceProvider).addServerBasedOnURLs( urls: urls, skipCertVerification: skipCertVerification, - serverName: serverName, ); } @@ -133,11 +132,6 @@ class PrivateServerNotifier extends _$PrivateServerNotifier { case 'EventTypeValidationError': appLogger.error("Validation error: ${status.error}"); state = status; - - ///reset state to initial once server is added - await Future.delayed(const Duration(milliseconds: 500), () { - resetPrivateServerState(); - }); break; default: state = status; diff --git a/lib/features/private_server/provider/private_server_notifier.g.dart b/lib/features/private_server/provider/private_server_notifier.g.dart index 5bc4dd6265..746384944b 100644 --- a/lib/features/private_server/provider/private_server_notifier.g.dart +++ b/lib/features/private_server/provider/private_server_notifier.g.dart @@ -42,7 +42,7 @@ final class PrivateServerNotifierProvider } String _$privateServerNotifierHash() => - r'31693e8bd82962ed3074d1b94e240638e99f14e4'; + r'35dc4a5be8bae2ad1bd9b87571c002320460581b'; abstract class _$PrivateServerNotifier extends $Notifier { PrivateServerStatus build(); diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index fe6b2d03be..8db7a17a7b 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -1,8 +1,6 @@ import 'package:auto_route/auto_route.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lantern/core/common/app_build_info.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/extensions/user_data.dart'; import 'package:lantern/core/localization/localization_constants.dart'; @@ -179,7 +177,7 @@ class _SettingState extends ConsumerState { ], ), ), - if (kDebugMode || AppBuildInfo.buildType == 'nightly') ...{ + if (AppBuildInfo.isDevModeEnabled) ...{ SizedBox(height: defaultSize), AppCard( padding: EdgeInsets.zero, diff --git a/lib/features/setting/smart_routing.dart b/lib/features/setting/smart_routing.dart index 7c22d0acc2..fd85149531 100644 --- a/lib/features/setting/smart_routing.dart +++ b/lib/features/setting/smart_routing.dart @@ -3,8 +3,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/widgets/info_row.dart'; - -import '../home/provider/app_setting_notifier.dart'; +import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; @RoutePage(name: 'SmartRouting') class SmartRouting extends HookConsumerWidget { @@ -13,21 +12,24 @@ class SmartRouting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final textTheme = Theme.of(context).textTheme; - final appSetting = ref.watch(appSettingProvider); - final selected = appSetting.routingMode; + final selected = ref.watch( + radianceSettingsProvider.select((s) => s.routingMode), + ); Future select(RoutingMode mode) async { - final result = - await ref.read(appSettingProvider.notifier).setRoutingMode(mode); + final result = await ref + .read(radianceSettingsProvider.notifier) + .setRoutingMode(mode); result.fold( (failure) { context.showSnackBar('failed_to_update_routing_mode'.i18n); }, - (_) {}, + (_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) appRouter.pop(); + }); + }, ); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) appRouter.pop(); - }); } return BaseScreen( diff --git a/lib/features/setting/vpn_setting.dart b/lib/features/setting/vpn_setting.dart index 357d69dc41..8c108473a9 100644 --- a/lib/features/setting/vpn_setting.dart +++ b/lib/features/setting/vpn_setting.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/widgets/split_tunneling_tile.dart'; import 'package:lantern/core/widgets/switch_button.dart'; -import 'package:lantern/features/home/provider/app_setting_notifier.dart'; +import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; @RoutePage(name: 'VPNSetting') class VPNSetting extends HookConsumerWidget { @@ -22,12 +22,20 @@ class VPNSetting extends HookConsumerWidget { Widget _buildBody(BuildContext context, WidgetRef ref) { final textTheme = Theme.of(context).textTheme; final isUserPro = ref.watch(isUserProProvider); - final preferences = ref.read(appSettingProvider); - final notifier = ref.read(appSettingProvider.notifier); final isPrivateServerFound = ref.watch(isPrivateServerFoundProvider); - final splitTunnelingEnabled = - ref.read(appSettingProvider).isSplitTunnelingOn; - final routingMode = preferences.routingMode; + final splitTunnelingEnabled = ref.watch( + radianceSettingsProvider.select((s) => s.splitTunneling), + ); + final routingMode = ref.watch( + radianceSettingsProvider.select((s) => s.routingMode), + ); + final blockAds = ref.watch( + radianceSettingsProvider.select((s) => s.blockAds), + ); + final telemetryConsent = ref.watch( + radianceSettingsProvider.select((s) => s.telemetry), + ); + return ListView( padding: const EdgeInsets.all(0), shrinkWrap: true, @@ -87,14 +95,15 @@ class VPNSetting extends HookConsumerWidget { ), icon: AppImagePaths.blockAds, trailing: SwitchButton( - value: preferences.blockAds, + value: blockAds, onChanged: (bool? value) { if (!isUserPro) { appRouter.push(Plans()); return; } - var newValue = value ?? false; - notifier.setBlockAds(newValue); + ref + .read(radianceSettingsProvider.notifier) + .setBlockAds(value ?? false); }, ), onPressed: () { @@ -102,8 +111,9 @@ class VPNSetting extends HookConsumerWidget { appRouter.push(Plans()); return; } - var newValue = !preferences.blockAds; - notifier.setBlockAds(newValue); + ref + .read(radianceSettingsProvider.notifier) + .setBlockAds(!blockAds); }, ), ), @@ -165,10 +175,12 @@ class VPNSetting extends HookConsumerWidget { ), ), trailing: SwitchButton( - value: preferences.telemetryConsent, + value: telemetryConsent, onChanged: (value) { appLogger.info('Anonymous usage data consent changed: $value'); - notifier.updateAnonymousDataConsent(value); + ref + .read(radianceSettingsProvider.notifier) + .setTelemetry(value); }, ), ), diff --git a/lib/features/split_tunneling/provider/app_icon_provider.g.dart b/lib/features/split_tunneling/provider/app_icon_provider.g.dart index e0aad4dfdc..f3d437af52 100644 --- a/lib/features/split_tunneling/provider/app_icon_provider.g.dart +++ b/lib/features/split_tunneling/provider/app_icon_provider.g.dart @@ -116,7 +116,7 @@ final class AppIconBytesProvider } } -String _$appIconBytesHash() => r'583f5284e1ccc6aac98594cc04eaf80151cfd260'; +String _$appIconBytesHash() => r'd76567ad24e310c25e470b1977c1dd82e1ebbc72'; final class AppIconBytesFamily extends $Family with $FunctionalFamilyOverride, AppIconKey> { diff --git a/lib/features/split_tunneling/provider/apps_notifier.g.dart b/lib/features/split_tunneling/provider/apps_notifier.g.dart index 6379dfbac3..85504eddab 100644 --- a/lib/features/split_tunneling/provider/apps_notifier.g.dart +++ b/lib/features/split_tunneling/provider/apps_notifier.g.dart @@ -34,7 +34,7 @@ final class SplitTunnelingAppsProvider } String _$splitTunnelingAppsHash() => - r'1ac6edd52bfdfd089e6c5e557699f8170e3dc00a'; + r'1e0ed7ac024b4f872533845f2c8ab8275a0bd04c'; abstract class _$SplitTunnelingApps extends $AsyncNotifier> { FutureOr> build(); diff --git a/lib/features/split_tunneling/provider/website_notifier.dart b/lib/features/split_tunneling/provider/website_notifier.dart index 551e79ae64..4fe46ae9d3 100644 --- a/lib/features/split_tunneling/provider/website_notifier.dart +++ b/lib/features/split_tunneling/provider/website_notifier.dart @@ -15,11 +15,28 @@ class SplitTunnelingWebsites extends _$SplitTunnelingWebsites { late final LanternService _lanternService = ref.read(lanternServiceProvider); @override - Set build() { - unawaited(_reloadFromCore()); - return {}; + FutureOr> build() async { + final result = await _lanternService.getSplitTunnelItems( + SplitTunnelFilterType.domainSuffix, + ); + + return result.match( + (failure) { + appLogger.error( + 'Failed to load split-tunnel websites: ${failure.error}', + ); + return {}; + }, + (items) => items + .map((item) => item.trim().toLowerCase()) + .where((domain) => domain.isNotEmpty) + .map((domain) => Website(domain: domain)) + .toSet(), + ); } + Set _current() => state.value ?? {}; + Future refreshFromCore() => _reloadFromCore(); Future _reloadFromCore() async { @@ -32,20 +49,23 @@ class SplitTunnelingWebsites extends _$SplitTunnelingWebsites { 'Failed to load split-tunnel websites: ${failure.error}', ), (items) { - state = items - .map((item) => item.trim().toLowerCase()) - .where((domain) => domain.isNotEmpty) - .map((domain) => Website(domain: domain)) - .toSet(); + state = AsyncData( + items + .map((item) => item.trim().toLowerCase()) + .where((domain) => domain.isNotEmpty) + .map((domain) => Website(domain: domain)) + .toSet(), + ); }, ); } Future> addWebsites(List websites) async { final failures = []; + final current = _current(); final newWebsites = websites.where( (website) => - !state.any( + !current.any( (saved) => saved.domain.toLowerCase() == website.domain.toLowerCase(), ) && @@ -86,7 +106,8 @@ class SplitTunnelingWebsites extends _$SplitTunnelingWebsites { return null; } - if (!state.any((saved) => saved.domain.toLowerCase() == normalizedDomain)) { + final current = _current(); + if (!current.any((saved) => saved.domain.toLowerCase() == normalizedDomain)) { return null; } diff --git a/lib/features/split_tunneling/provider/website_notifier.g.dart b/lib/features/split_tunneling/provider/website_notifier.g.dart index 226029ae66..cc9dd919c7 100644 --- a/lib/features/split_tunneling/provider/website_notifier.g.dart +++ b/lib/features/split_tunneling/provider/website_notifier.g.dart @@ -13,7 +13,7 @@ part of 'website_notifier.dart'; final splitTunnelingWebsitesProvider = SplitTunnelingWebsitesProvider._(); final class SplitTunnelingWebsitesProvider - extends $NotifierProvider> { + extends $AsyncNotifierProvider> { SplitTunnelingWebsitesProvider._() : super( from: null, @@ -31,30 +31,22 @@ final class SplitTunnelingWebsitesProvider @$internal @override SplitTunnelingWebsites create() => SplitTunnelingWebsites(); - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(Set value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider>(value), - ); - } } String _$splitTunnelingWebsitesHash() => - r'39da356a931b1305da390b67cd181082292fddb4'; + r'b787523e773ed95e8914c848bc448a3a4dd4ce17'; -abstract class _$SplitTunnelingWebsites extends $Notifier> { - Set build(); +abstract class _$SplitTunnelingWebsites extends $AsyncNotifier> { + FutureOr> build(); @$mustCallSuper @override void runBuild() { - final ref = this.ref as $Ref, Set>; + final ref = this.ref as $Ref>, Set>; final element = ref.element as $ClassProviderElement< - AnyNotifier, Set>, - Set, + AnyNotifier>, Set>, + AsyncValue>, Object?, Object? >; diff --git a/lib/features/split_tunneling/split_tunneling.dart b/lib/features/split_tunneling/split_tunneling.dart index dcf1948ad2..5381e18d5e 100644 --- a/lib/features/split_tunneling/split_tunneling.dart +++ b/lib/features/split_tunneling/split_tunneling.dart @@ -4,9 +4,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/app_text_styles.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/app_data.dart'; +import 'package:lantern/core/models/website.dart'; import 'package:lantern/core/widgets/split_tunneling_tile.dart'; import 'package:lantern/core/widgets/switch_button.dart'; -import 'package:lantern/features/home/provider/app_setting_notifier.dart'; +import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; import 'package:lantern/features/split_tunneling/provider/apps_notifier.dart'; import 'package:lantern/features/split_tunneling/provider/website_notifier.dart'; @@ -16,28 +17,28 @@ class SplitTunneling extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final preferences = ref.watch(appSettingProvider); final textTheme = Theme.of(context).textTheme; - final splitTunnelingEnabled = preferences.isSplitTunnelingOn; + final splitTunnelingEnabled = ref.watch( + radianceSettingsProvider.select((s) => s.splitTunneling), + ); final enabledApps = (ref.watch(splitTunnelingAppsProvider).value ?? const {}) .toList(growable: false); - final enabledWebsites = (ref.watch( - splitTunnelingWebsitesProvider, - )).toList(growable: false); - - final notifier = ref.read(appSettingProvider.notifier); + final enabledWebsites = + (ref.watch(splitTunnelingWebsitesProvider).value ?? const {}) + .toList(growable: false); void toggleSplitTunneling() { - notifier.setSplitTunnelingEnabled(!splitTunnelingEnabled); + ref + .read(radianceSettingsProvider.notifier) + .setSplitTunneling(!splitTunnelingEnabled); } return BaseScreen( title: 'split_tunneling'.i18n, body: Column( - key: const Key('split_tunneling.screen'), crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(height: defaultSize), @@ -46,7 +47,6 @@ class SplitTunneling extends HookConsumerWidget { child: Column( children: [ AppTile( - tileKey: const Key('split_tunneling.enable_tile'), label: 'split_tunneling'.i18n, tileTextStyle: AppTextStyles.bodyMedium.copyWith( fontWeight: FontWeight.w600, @@ -64,13 +64,11 @@ class SplitTunneling extends HookConsumerWidget { ), onPressed: toggleSplitTunneling, trailing: SwitchButton( - key: const Key('split_tunneling.enable_toggle'), value: splitTunnelingEnabled, onChanged: (bool? value) { - final v = value ?? false; ref - .read(appSettingProvider.notifier) - .setSplitTunnelingEnabled(v); + .read(radianceSettingsProvider.notifier) + .setSplitTunneling(value ?? false); }, activeColor: AppColors.green5, ), @@ -78,7 +76,6 @@ class SplitTunneling extends HookConsumerWidget { if (splitTunnelingEnabled) ...{ DividerSpace(), SplitTunnelingTile( - tileKey: const Key('split_tunneling.apps_tile'), icon: AppImagePaths.keypad, label: 'apps'.i18n, actionText: '${enabledApps.length} Added', @@ -86,13 +83,12 @@ class SplitTunneling extends HookConsumerWidget { ), DividerSpace(), SplitTunnelingTile( - tileKey: const Key('split_tunneling.websites_tile'), icon: AppImagePaths.world, label: 'websites'.i18n, actionText: '${enabledWebsites.length} Added', onPressed: () => appRouter.push(WebsiteSplitTunneling()), ), - }, + } ], ), ), diff --git a/lib/features/split_tunneling/website_domain_input.dart b/lib/features/split_tunneling/website_domain_input.dart index 4381428085..a8c19e9c5e 100644 --- a/lib/features/split_tunneling/website_domain_input.dart +++ b/lib/features/split_tunneling/website_domain_input.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -12,12 +14,14 @@ class WebsiteDomainInput extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final textController = useTextEditingController(); - final enabledWebsites = ref.watch(splitTunnelingWebsitesProvider); + final enabledWebsites = + ref.watch(splitTunnelingWebsitesProvider).value ?? const {}; void showSnackbar(String message) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + // All call sites below surface validation or backend errors; use the + // design-system error snackbar rather than Material's default unstyled + // SnackBar. + context.showSnackBarError(message); } // validate URL and extract the domain before adding it to the @@ -113,7 +117,7 @@ class WebsiteDomainInput extends HookConsumerWidget { key: const Key('split_tunneling.website.add_button'), label: 'add'.i18n, textColor: context.textPrimary, - onPressed: validateAndExtractDomain, + onPressed: () => unawaited(validateAndExtractDomain()), ), ], ), diff --git a/lib/features/split_tunneling/website_split_tunneling.dart b/lib/features/split_tunneling/website_split_tunneling.dart index 78182d4e48..0429387695 100644 --- a/lib/features/split_tunneling/website_split_tunneling.dart +++ b/lib/features/split_tunneling/website_split_tunneling.dart @@ -29,7 +29,8 @@ class WebsiteSplitTunneling extends HookConsumerWidget { final textTheme = Theme.of(context).textTheme; final searchQuery = ref.watch(searchQueryProvider); - final enabledWebsites = ref.watch(splitTunnelingWebsitesProvider); + final enabledWebsites = + ref.watch(splitTunnelingWebsitesProvider).value ?? const {}; matchesSearch(website) => searchQuery.isEmpty || website.domain.toLowerCase().contains(searchQuery.toLowerCase()); diff --git a/lib/features/support/app_version.dart b/lib/features/support/app_version.dart index 6364671d10..cc4954b3c6 100644 --- a/lib/features/support/app_version.dart +++ b/lib/features/support/app_version.dart @@ -1,20 +1,21 @@ import 'package:flutter/material.dart'; -import 'package:lantern/core/common/app_build_info.dart'; import 'package:lantern/core/common/common.dart'; +import 'package:package_info_plus/package_info_plus.dart'; class AppVersion extends StatelessWidget { const AppVersion({super.key}); @override Widget build(BuildContext context) { - final theme = Theme.of(context).textTheme; + final textTheme = Theme.of(context).textTheme; - return FutureBuilder( - future: resolveAppVersionLabel(), + return FutureBuilder( + future: PackageInfo.fromPlatform(), builder: (context, snap) { - final label = snap.data ?? '…'; + final version = snap.data?.version ?? '…'; + final build = snap.data?.buildNumber ?? '…'; return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), decoration: BoxDecoration( color: context.bgSurface, borderRadius: BorderRadius.circular(8), @@ -23,12 +24,19 @@ class AppVersion extends StatelessWidget { bottom: BorderSide(color: context.borderDefault, width: 1), ), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Column( children: [ - Text('lantern_version'.i18n, style: theme.bodyMedium), - Text(label, - style: theme.titleSmall!.copyWith(color: context.textLink)), + _InfoRow( + label: 'lantern_version'.i18n, + value: version, + textTheme: textTheme, + ), + Divider(height: 1, color: context.borderDefault), + _InfoRow( + label: 'Build', + value: build, + textTheme: textTheme, + ), ], ), ); @@ -36,3 +44,32 @@ class AppVersion extends StatelessWidget { ); } } + +class _InfoRow extends StatelessWidget { + const _InfoRow({ + required this.label, + required this.value, + required this.textTheme, + }); + + final String label; + final String value; + final TextTheme textTheme; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: textTheme.bodyMedium), + Text( + value, + style: textTheme.titleSmall!.copyWith(color: context.textLink), + ), + ], + ), + ); + } +} diff --git a/lib/features/system_tray/provider/system_tray_notifier.dart b/lib/features/system_tray/provider/system_tray_notifier.dart index 740a489aa9..06c6bcf0ea 100644 --- a/lib/features/system_tray/provider/system_tray_notifier.dart +++ b/lib/features/system_tray/provider/system_tray_notifier.dart @@ -1,9 +1,9 @@ import 'dart:io'; -import 'package:lantern/core/models/app_setting.dart'; import 'package:lantern/core/models/available_servers.dart'; +import 'package:lantern/core/models/radiance_settings_state.dart'; import 'package:lantern/core/models/server_location.dart'; -import 'package:lantern/features/home/provider/app_setting_notifier.dart'; +import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; import 'package:lantern/features/vpn/provider/available_servers_notifier.dart'; import 'package:lantern/features/vpn/provider/vpn_notifier.dart'; import 'package:lantern/features/window/provider/window_notifier.dart'; @@ -21,7 +21,7 @@ part 'system_tray_notifier.g.dart'; class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { VPNStatus _currentStatus = VPNStatus.disconnected; bool _isUserPro = false; - List _locations = []; + List _locations = []; RoutingMode _currentRoutingMode = RoutingMode.full; ServerLocation? _serverLocation; @@ -49,7 +49,7 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { void _initializeState() { _currentStatus = ref.read(vpnProvider); _isUserPro = ref.read(isUserProProvider); - _currentRoutingMode = ref.read(appSettingProvider).routingMode; + _currentRoutingMode = ref.read(radianceSettingsProvider).routingMode; _serverLocation = ref.read(serverLocationProvider); } @@ -81,11 +81,11 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { next, ) async { final data = next.value; - _locations = data?.lantern.locations.values.toList() ?? []; + _locations = data?.lanternServers ?? []; _locations.sort((a, b) { - final cmp = a.country.compareTo(b.country); + final cmp = a.location.country.compareTo(b.location.country); if (cmp != 0) return cmp; - return a.city.compareTo(b.city); + return a.location.city.compareTo(b.location.city); }); await updateTrayMenu(); }); @@ -99,8 +99,11 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { } void _listenToRoutingMode() { - ref.listen(appSettingProvider, (previous, next) async { - if (previous?.routingMode != next.routingMode) { + ref.listen(radianceSettingsProvider, ( + previous, + next, + ) async { + if (next.routingMode != _currentRoutingMode) { _currentRoutingMode = next.routingMode; await updateTrayMenu(); } @@ -117,19 +120,19 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { } /// Handle location selection from tray menu - Future _onLocationSelected(Location_ location) async { + Future _onLocationSelected(Server server) async { if (!_checkMacOSExtension()) return; final result = await ref .read(vpnProvider.notifier) - .connectToServer(ServerLocationType.lanternLocation, location.tag); + .connectToServer(ServerLocationType.lanternLocation, server.tag); result.fold( (failure) => appLogger.error( 'Failed to connect: ${failure.localizedErrorMessage}', ), (success) { - appLogger.info('Connecting to ${location.country} - ${location.city}'); - _saveServerLocation(location); + appLogger.info('Connecting to ${server.location.country} - ${server.location.city}'); + _saveServerLocation(server); }, ); } @@ -138,7 +141,7 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { Future _onSmartLocationSelected() async { if (!_checkMacOSExtension()) return; - await ref + ref .read(serverLocationProvider.notifier) .updateServerLocation(initialServerLocation()); await ref.read(vpnProvider.notifier).startVPN(force: true); @@ -146,7 +149,7 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { /// Handle routing mode selection from tray menu Future _onRoutingModeSelected(RoutingMode mode) async { - await ref.read(appSettingProvider.notifier).setRoutingMode(mode); + await ref.read(radianceSettingsProvider.notifier).setRoutingMode(mode); } /// Returns true if OK to proceed, false if blocked by missing extension @@ -162,11 +165,9 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { return true; } - Future _saveServerLocation(Location_ location) async { - final serverLocation = ServerLocation.fromLanternLocation(server: location); - await ref - .read(serverLocationProvider.notifier) - .updateServerLocation(serverLocation); + void _saveServerLocation(Server server) { + final serverLocation = ServerLocation.fromServer(server: server); + ref.read(serverLocationProvider.notifier).updateServerLocation(serverLocation); } /// Build the current location display string (flag emoji + city) @@ -253,15 +254,15 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { ), MenuItem.separator(), // Server list - ..._locations.map((location) { - final displayName = location.city.isNotEmpty - ? '${location.country} - ${location.city}' - : location.country; + ..._locations.map((server) { + final displayName = server.location.city.isNotEmpty + ? '${server.location.country} - ${server.location.city}' + : server.location.country; return MenuItem( - key: 'location_${location.tag}', + key: 'location_${server.tag}', label: displayName, - icon: AppImagePaths.safeFlagPath(location.countryCode), - onClick: (_) => _onLocationSelected(location), + icon: AppImagePaths.safeFlagPath(server.location.countryCode), + onClick: (_) => _onLocationSelected(server), ); }), ], diff --git a/lib/features/system_tray/provider/system_tray_notifier.g.dart b/lib/features/system_tray/provider/system_tray_notifier.g.dart index 26939f7b7d..73c4fe8db0 100644 --- a/lib/features/system_tray/provider/system_tray_notifier.g.dart +++ b/lib/features/system_tray/provider/system_tray_notifier.g.dart @@ -34,7 +34,7 @@ final class SystemTrayNotifierProvider } String _$systemTrayNotifierHash() => - r'63fb171a34e7ef783d7bb6675e511dec7638f041'; + r'122a9e5f09b63b70026545f19ee378a018c3b533'; abstract class _$SystemTrayNotifier extends $AsyncNotifier { FutureOr build(); diff --git a/lib/features/vpn/provider/available_servers_notifier.dart b/lib/features/vpn/provider/available_servers_notifier.dart index 3afd0915d4..fcfe42b396 100644 --- a/lib/features/vpn/provider/available_servers_notifier.dart +++ b/lib/features/vpn/provider/available_servers_notifier.dart @@ -1,7 +1,8 @@ import 'package:fpdart/fpdart.dart'; +import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/available_servers.dart'; -import 'package:lantern/core/services/logger_service.dart'; -import 'package:lantern/core/utils/failure.dart'; +import 'package:lantern/core/models/server_location.dart'; +import 'package:lantern/features/vpn/provider/server_location_notifier.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -15,10 +16,12 @@ class AvailableServersNotifier extends _$AvailableServersNotifier { return result.fold( (failure) { appLogger.error( - 'Error getting available servers: ${failure.localizedErrorMessage}'); - throw Exception('Failed to available servers'); + 'Error getting available servers: ${failure.localizedErrorMessage}', + ); + throw Exception('Failed to load available servers'); }, (servers) { + _pushFastestToSmartLocation(servers); return servers; }, ); @@ -26,6 +29,7 @@ class AvailableServersNotifier extends _$AvailableServersNotifier { /// Fetches the available servers from the Lantern. Future> fetchAvailableServers() async { + appLogger.debug('Fetching available servers from Lantern...'); return await ref.read(lanternServiceProvider).getLanternAvailableServers(); } @@ -36,11 +40,49 @@ class AvailableServersNotifier extends _$AvailableServersNotifier { result.fold( (failure) { appLogger.error( - 'Error getting available servers: ${failure.localizedErrorMessage}'); + 'Error getting available servers: ${failure.localizedErrorMessage}', + ); }, (servers) { state = AsyncValue.data(servers); + _pushFastestToSmartLocation(servers); }, ); } + + /// Pushes the fastest Lantern server to the Smart Location if the current selection is auto + void _pushFastestToSmartLocation(AvailableServers servers) { + final fastest = servers.fastestLanternServer; + if (fastest == null) return; + + final current = ref.read(serverLocationProvider); + if (current.serverType.toServerLocationType != ServerLocationType.auto) { + return; + } + if (current.autoLocation?.tag == fastest.tag) return; + + final country = fastest.location.country; + final city = fastest.location.city; + appLogger.debug( + 'Pushing fastest server to Smart Location: ' + 'tag=${fastest.tag} delay=${fastest.urlTestResult?.delay}ms', + ); + ref + .read(serverLocationProvider.notifier) + .updateServerLocation( + ServerLocation( + serverType: ServerLocationType.auto.name, + serverName: '', + displayName: '', + protocol: '', + city: city, + autoLocation: AutoLocation( + countryCode: fastest.location.countryCode, + country: country, + displayName: '$country - $city', + tag: fastest.tag, + ), + ), + ); + } } diff --git a/lib/features/vpn/provider/available_servers_notifier.g.dart b/lib/features/vpn/provider/available_servers_notifier.g.dart index e190f339c8..e205907b22 100644 --- a/lib/features/vpn/provider/available_servers_notifier.g.dart +++ b/lib/features/vpn/provider/available_servers_notifier.g.dart @@ -34,7 +34,7 @@ final class AvailableServersNotifierProvider } String _$availableServersNotifierHash() => - r'9a4057f26566ec3d510e90ce0bf61e269efbd9ac'; + r'26bece60313d270153decda379543391d9829fbd'; abstract class _$AvailableServersNotifier extends $AsyncNotifier { diff --git a/lib/features/vpn/provider/server_location_notifier.dart b/lib/features/vpn/provider/server_location_notifier.dart index 21ecce8919..ed3ad3030c 100644 --- a/lib/features/vpn/provider/server_location_notifier.dart +++ b/lib/features/vpn/provider/server_location_notifier.dart @@ -8,26 +8,137 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'server_location_notifier.g.dart'; -@Riverpod() +@Riverpod(keepAlive: true) class ServerLocationNotifier extends _$ServerLocationNotifier { LocalStorageService get _storage => sl(); @override ServerLocation build() { - return _storage.getServerLocation() ?? _defaultLocation(); + final cached = _storage.getServerLocation(); + final initial = cached ?? _defaultLocation(); + appLogger.debug( + 'ServerLocationNotifier.build() cached=${cached != null} ' + 'type=${initial.serverType} city=${initial.city}', + ); + // Use cached value for instant display, then refresh from radiance. + fetchServerLocation(); + return initial; } - Future updateServerLocation(ServerLocation entity) async { + /// Fetches the current server location from radiance. + /// If the VPN isn't connected, uses the cached value. + /// If auto-selected, fetches the auto location; otherwise fetches the + /// explicitly selected server. + Future fetchServerLocation() async { + final status = ref.read(vpnProvider); + if (status != VPNStatus.connected) { + appLogger.debug( + 'fetchServerLocation: VPN not connected ($status), using cached value', + ); + return; + } + + final cached = _storage.getServerLocation(); + final isAuto = + cached == null || + cached.serverType.toServerLocationType == ServerLocationType.auto; + + appLogger.debug('fetchServerLocation: VPN connected, isAuto=$isAuto'); + + if (isAuto) { + await _fetchAutoLocation(); + } else { + await _fetchSelectedLocation(); + } + } + + Future _fetchAutoLocation() async { + appLogger.debug('Fetching auto server location from radiance...'); + final result = await ref + .read(lanternServiceProvider) + .getAutoServerLocation(); + if (!ref.mounted) return; + result.fold( + (error) { + // Expected when VPN isn't connected yet — auto location is only + // available after the tunnel starts. The cached value (from the + // last session) is used until the server-location event arrives. + appLogger.debug('Auto server location not available yet: $error'); + }, + (autoServer) { + final countryName = autoServer.location.country; + final cityName = autoServer.location.city; + final location = ServerLocation( + serverType: ServerLocationType.auto.name, + serverName: '', + displayName: '', + protocol: '', + city: cityName, + autoLocation: AutoLocation( + countryCode: autoServer.location.countryCode, + country: countryName, + displayName: '$countryName - $cityName', + tag: autoServer.tag, + ), + ); + appLogger.debug('Fetched auto server location: ${location.toJson()}'); + state = location; + _storage.saveServerLocation(location); + }, + ); + } + + Future _fetchSelectedLocation() async { + appLogger.debug('Fetching selected server location from radiance...'); + final result = await ref + .read(lanternServiceProvider) + .getSelectedServerLocation(); + if (!ref.mounted) return; + result.fold( + (error) { + appLogger.error( + 'Failed to fetch selected server from radiance: $error', + error, + ); + }, + (location) { + appLogger.debug( + 'Fetched selected server location: ${location.toJson()}', + ); + state = location; + _storage.saveServerLocation(location); + }, + ); + } + + void updateServerLocation(ServerLocation entity) { final current = state; + final ServerLocation updated; if (entity.serverType != ServerLocationType.auto.name) { //Preserve auto location metadata when switching to a non-auto server, // so we can show user smart location - final updated = entity.copyWith(autoLocation: current.autoLocation); - state = updated; - await _storage.saveServerLocation(updated); + updated = entity.copyWith(autoLocation: current.autoLocation); } else { - state = entity; - await _storage.saveServerLocation(entity); + updated = entity; + } + appLogger.debug( + 'updateServerLocation: type=${updated.serverType} ' + 'name=${updated.serverName} city=${updated.city}', + ); + state = updated; + _storage.saveServerLocation(updated); + } + + Future refreshAutoLocationIfNeeded() async { + final status = ref.read(vpnProvider); + final current = state; + final isAuto = + current.serverType.toServerLocationType == ServerLocationType.auto; + + appLogger.debug('refreshAutoLocationIfNeeded: vpn=$status isAuto=$isAuto'); + + if (status == VPNStatus.connected && isAuto) { + await _fetchAutoLocation(); } } @@ -42,43 +153,6 @@ class ServerLocationNotifier extends _$ServerLocationNotifier { await _storage.saveServerLocation(updated); } - Future ifNeededGetAutoServerLocation() async { - final status = ref.read(vpnProvider); - final current = state; - - if (status == VPNStatus.connected && - current.serverType.toServerLocationType == ServerLocationType.auto) { - final result = await ref - .read(lanternServiceProvider) - .getAutoServerLocation(); - await result.fold( - (error) async { - appLogger.error("Failed to fetch auto server location: $error"); - }, - (autoLocation) async { - final countryName = autoLocation.location!.country; - final cityName = autoLocation.location!.city; - - await updateServerLocation( - ServerLocation( - serverType: ServerLocationType.auto.name, - serverName: '', - displayName: '', - protocol: '', - city: cityName, - autoLocation: AutoLocation( - countryCode: autoLocation.location!.countryCode, - country: countryName, - displayName: '$countryName - $cityName', - tag: autoLocation.tag, - ), - ), - ); - }, - ); - } - } - static ServerLocation _defaultLocation() => ServerLocation( serverType: ServerLocationType.auto.name, serverName: '', diff --git a/lib/features/vpn/provider/server_location_notifier.g.dart b/lib/features/vpn/provider/server_location_notifier.g.dart index 9c8d5922bf..2f76e7a226 100644 --- a/lib/features/vpn/provider/server_location_notifier.g.dart +++ b/lib/features/vpn/provider/server_location_notifier.g.dart @@ -20,7 +20,7 @@ final class ServerLocationNotifierProvider argument: null, retry: null, name: r'serverLocationProvider', - isAutoDispose: true, + isAutoDispose: false, dependencies: null, $allTransitiveDependencies: null, ); @@ -42,7 +42,7 @@ final class ServerLocationNotifierProvider } String _$serverLocationNotifierHash() => - r'cf58012d44d48e3d21c9a56c90c4ae80d724aec6'; + r'5c7a7067d0de3cbc812ec866f955a78c607fe14d'; abstract class _$ServerLocationNotifier extends $Notifier { ServerLocation build(); diff --git a/lib/features/vpn/provider/vpn_notifier.dart b/lib/features/vpn/provider/vpn_notifier.dart index 8462daeb8b..0855e832c3 100644 --- a/lib/features/vpn/provider/vpn_notifier.dart +++ b/lib/features/vpn/provider/vpn_notifier.dart @@ -30,9 +30,10 @@ class VpnNotifier extends _$VpnNotifier { (nextStatus == VPNStatus.connected || nextStatus == VPNStatus.disconnected); - if (previous != null && - previous.value != null && - previousStatus != nextStatus) { + final isFirstEvent = previous == null || previous.value == null; + final statusChanged = !isFirstEvent && previousStatus != nextStatus; + + if (statusChanged) { if (previousStatus != VPNStatus.connecting && nextStatus == VPNStatus.disconnected) { if (!suppressConnectionNotifications) { @@ -46,32 +47,27 @@ class VpnNotifier extends _$VpnNotifier { 'Suppressed vpn_disconnected notification (origin=$nextOrigin)', ); } - } else if (nextStatus == VPNStatus.connected) { - if (PlatformUtils.isMobile) { - HapticFeedback.mediumImpact(); - } + } + } - /// Mark successful connection in app settings - ref.read(appSettingProvider.notifier).setSuccessfulConnection(true); + if (nextStatus == VPNStatus.connected && + (statusChanged || isFirstEvent)) { + if (statusChanged && PlatformUtils.isMobile) { + HapticFeedback.mediumImpact(); + } - // Server location is updated via the "server-location" push event - // from the Go side (handled by AppEventNotifier), not by polling - // getAutoServerLocation here. This avoids a race where the NE - // reports "connected" before the Go tunnel is fully ready. + /// Mark successful connection in app settings + ref.read(appSettingProvider.notifier).setSuccessfulConnection(true); - if (!suppressConnectionNotifications) { - sl().showNotification( - id: NotificationEvent.vpnConnected.id, - title: 'app_name'.i18n, - body: 'vpn_connected'.i18n, - ); - } else { - appLogger.debug( - 'Suppressed vpn_connected notification (origin=$nextOrigin)', - ); - } + if (statusChanged && !suppressConnectionNotifications) { + sl().showNotification( + id: NotificationEvent.vpnConnected.id, + title: 'app_name'.i18n, + body: 'vpn_connected'.i18n, + ); } } + state = nextStatus; }); return VPNStatus.disconnected; @@ -91,7 +87,10 @@ class VpnNotifier extends _$VpnNotifier { /// If a specific server location is set, it will connect to that server /// valid server location types are: auto,lanternLocation,privateServer - Future> startVPN({bool force = false, bool skipConflictCheck = false}) async { + Future> startVPN({ + bool force = false, + bool skipConflictCheck = false, + }) async { final lantern = ref.read(lanternServiceProvider); if (!skipConflictCheck) { diff --git a/lib/features/vpn/provider/vpn_notifier.g.dart b/lib/features/vpn/provider/vpn_notifier.g.dart index bd3f283e98..e898034220 100644 --- a/lib/features/vpn/provider/vpn_notifier.g.dart +++ b/lib/features/vpn/provider/vpn_notifier.g.dart @@ -41,7 +41,7 @@ final class VpnNotifierProvider } } -String _$vpnNotifierHash() => r'9d5685dcb24bd12386049fdbc1f7aea0db2ebe46'; +String _$vpnNotifierHash() => r'53c87694351274e3474caf5c7e1a7bf9b482b390'; abstract class _$VpnNotifier extends $Notifier { VPNStatus build(); diff --git a/lib/features/vpn/server_selection.dart b/lib/features/vpn/server_selection.dart index 63646cb7bd..8386c6129d 100644 --- a/lib/features/vpn/server_selection.dart +++ b/lib/features/vpn/server_selection.dart @@ -16,7 +16,7 @@ import 'package:lantern/features/vpn/provider/vpn_notifier.dart'; import 'package:lantern/features/vpn/provider/vpn_status_notifier.dart'; import 'package:lantern/features/vpn/single_city_server_view.dart'; -typedef OnServerSelected = Function(Location_ selectedServer); +typedef OnServerSelected = Function(Server selectedServer); @RoutePage(name: 'ServerSelection') class ServerSelection extends StatefulHookConsumerWidget { @@ -70,8 +70,7 @@ class _ServerSelectionState extends ConsumerState { } final selectedServer = selected; - final isPrivateServerFound = - availableServers.requireValue.user.outbounds.isNotEmpty; + final isPrivateServerFound = availableServers.requireValue.hasUserServers; return BaseScreen( key: const Key('server_selection.screen'), @@ -159,7 +158,7 @@ class _ServerSelectionState extends ConsumerState { Widget _buildSmartLocation(ServerLocation serverLocation) { final autoLocation = serverLocation.autoLocation; - final displayName = autoLocation?.displayName ?? 'smart_location'.i18n; + final displayName = autoLocation?.displayName ?? 'fastest_server'.i18n; final flag = autoLocation?.countryCode ?? ''; final protocol = autoLocation?.protocol ?? ''; return Column( @@ -201,6 +200,26 @@ class _ServerSelectionState extends ConsumerState { } Future onSmartLocation() async { + if (PlatformUtils.isMacOS) { + final macosExtensionStatus = ref.read(macosExtensionProvider); + if (!macosExtensionStatus.isReady) { + appRouter.push(const MacOSExtensionDialog()); + return; + } + } + + final serverLocation = ref.read(serverLocationProvider); + + final type = serverLocation.serverType.toServerLocationType; + if (type == ServerLocationType.auto) { + appLogger.debug( + 'Already in smart location, no need to switch, Just pop the screen', + ); + appRouter.popUntilRoot(); + return; + } + + /// User clicking here mean user want to switch to auto server regardless of VPN state final result = await ref.read(vpnProvider.notifier).startVPN(force: true); result.fold( @@ -314,7 +333,7 @@ class _ServerLocationListViewState padding: EdgeInsets.zero, child: availableServers.when( data: (data) { - final locations = data.lantern.locations.values.toList(); + final locations = data.lanternServers; if (locations.isEmpty) { return const Center(child: Text("No locations available")); @@ -345,7 +364,7 @@ class _ServerLocationListViewState return SingleCityServerView( key: ValueKey(serverData.tag), onServerSelected: onServerSelected, - location: serverData, + server: serverData, isSelected: selectedTag == serverData.tag, ); } @@ -384,7 +403,7 @@ class _ServerLocationListViewState ); } - Future onServerSelected(Location_ selectedServer) async { + Future onServerSelected(Server selectedServer) async { if (PlatformUtils.isMacOS) { /// Check for if extension permission is granted before connecting to server, if not show the permission dialog first final macosExtensionStatus = ref.read(macosExtensionProvider); @@ -401,63 +420,72 @@ class _ServerLocationListViewState selectedServer.tag, ); - result.fold((failure) { - if (failure is VpnConflictFailure) { - AppDialog.vpnConflictDialog( - context: context, - onConnectAnyway: () async { - appRouter.maybePop(); - final retryResult = await ref - .read(vpnProvider.notifier) - .connectToServer( - ServerLocationType.lanternLocation, - selectedServer.tag, - skipConflictCheck: true, - ); - retryResult.fold( - (failure) => context.showSnackBar(failure.localizedErrorMessage), - (_) => _onLanternServerConnected(ref, selectedServer), - ); - }, - ); - } else { - context.showSnackBar(failure.localizedErrorMessage); - } - }, (_) => _onLanternServerConnected(ref, selectedServer)); - } - - void _onLanternServerConnected(WidgetRef ref, Location_ selectedServer) { - final vpnStatus = ref.read(vpnProvider); - - Future syncAndPop() async { - await ref - .read(serverLocationProvider.notifier) - .updateServerLocation( - ServerLocation.fromLanternLocation(server: selectedServer), + result.fold( + (failure) { + if (failure is VpnConflictFailure) { + AppDialog.vpnConflictDialog( + context: context, + onConnectAnyway: () async { + appRouter.maybePop(); + final retryResult = await ref + .read(vpnProvider.notifier) + .connectToServer( + ServerLocationType.lanternLocation, + selectedServer.tag, + skipConflictCheck: true, + ); + retryResult.fold( + (failure) => + context.showSnackBar(failure.localizedErrorMessage), + (_) { + ref + .read(serverLocationProvider.notifier) + .updateServerLocation( + ServerLocation.fromServer(server: selectedServer), + ); + appRouter.popUntilRoot(); + }, + ); + }, ); - appRouter.popUntilRoot(); - } + } else { + context.showSnackBar(failure.localizedErrorMessage); + } + }, + (_) async { + final vpnStatus = ref.read(vpnProvider); - if (vpnStatus == VPNStatus.connected) { - syncAndPop(); - return; - } + void syncAndPop() { + ref + .read(serverLocationProvider.notifier) + .updateServerLocation( + ServerLocation.fromServer(server: selectedServer), + ); + appRouter.popUntilRoot(); + } - ref.listenManual>(vPNStatusProvider, ( - previous, - next, - ) async { - if (next is AsyncData && - next.value.status == VPNStatus.connected) { - await syncAndPop(); - } - }); + if (vpnStatus == VPNStatus.connected) { + syncAndPop(); + return; + } + + ref.listenManual>(vPNStatusProvider, ( + previous, + next, + ) { + if (next is AsyncData && + next.value.status == VPNStatus.connected) { + syncAndPop(); + } + }); + }, + ); } } class _CountryCityListView extends StatefulWidget { final String country; - final List locations; + final List locations; final String selectedServerTag; final OnServerSelected onServerSelected; @@ -477,8 +505,8 @@ class _CountryCityListViewState extends State<_CountryCityListView> { @override Widget build(BuildContext context) { - final countryCode = widget.locations.first.countryCode; - final country = widget.locations.first.country; + final countryCode = widget.locations.first.location.countryCode; + final country = widget.locations.first.location.country; if (PlatformUtils.isDesktop) { return Theme( @@ -502,16 +530,16 @@ class _CountryCityListViewState extends State<_CountryCityListView> { }, trailing: ExpansionChevron(isExpanded: _isExpanded), shape: const RoundedRectangleBorder(side: BorderSide.none), - children: widget.locations.map((loc) { + children: widget.locations.map((server) { return AppTile( dense: true, minHeight: 58, contentPadding: const EdgeInsets.only(left: 53, right: 14), - label: loc.city, - subtitle: loc.protocol.isEmpty + label: server.location.city, + subtitle: server.type.isEmpty ? null : Text( - loc.protocol.capitalize, + server.type.capitalize, maxLines: 1, style: Theme.of(context).textTheme.labelMedium!.copyWith( color: context.textSecondary, @@ -520,7 +548,7 @@ class _CountryCityListViewState extends State<_CountryCityListView> { tileTextStyle: Theme.of( context, ).textTheme.bodyMedium!.copyWith(color: context.textPrimary), - onPressed: () => _onLocationSelected(context, loc), + onPressed: () => _onLocationSelected(context, server), ); }).toList(), ), @@ -539,8 +567,8 @@ class _CountryCityListViewState extends State<_CountryCityListView> { ); } - void _onLocationSelected(BuildContext context, Location_ location) { - widget.onServerSelected(location); + void _onLocationSelected(BuildContext context, Server server) { + widget.onServerSelected(server); } void _showCountryBottomSheet(BuildContext context) { @@ -557,8 +585,8 @@ class _CountryCityListViewState extends State<_CountryCityListView> { separatorBuilder: (_, __) => const DividerSpace(padding: EdgeInsets.zero), itemBuilder: (_, index) { - final loc = widget.locations[index]; - final isSelected = widget.selectedServerTag == loc.tag; + final server = widget.locations[index]; + final isSelected = widget.selectedServerTag == server.tag; return SingleCityServerView( nested: true, @@ -566,7 +594,7 @@ class _CountryCityListViewState extends State<_CountryCityListView> { Navigator.of(bottomSheetContext).pop(); widget.onServerSelected(selected); }, - location: loc, + server: server, isSelected: isSelected, ); }, @@ -607,8 +635,7 @@ class _PrivateServerLocationListViewState ); } - final userLocations = availableServers.requireValue.user.locations.values - .toList(); + final userLocations = availableServers.requireValue.userServers; final selectedTag = selected.serverName; @@ -647,30 +674,30 @@ class _PrivateServerLocationListViewState itemCount: userLocations.length, separatorBuilder: (_, __) => const DividerSpace(), itemBuilder: (context, index) { - final loc = userLocations[index]; - final isSelected = selectedTag == loc.tag; + final server = userLocations[index]; + final isSelected = selectedTag == server.tag; return AppTile( - tileKey: Key('server_selection.private_server.${loc.tag}'), + tileKey: Key('server_selection.private_server.${server.tag}'), onPressed: () { if (isSelected) { appLogger.debug('Already selected this server'); context.showSnackBar('server_already_selected'.i18n); return; } - onPrivateServerSelected(loc); + onPrivateServerSelected(server); }, icon: Flag( - countryCode: loc.countryCode, + countryCode: server.location.countryCode, size: const Size(40, 28), ), - label: loc.tag, + label: server.tag, subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Text( - '${loc.city} - ${loc.protocol}', + '${server.location.city} - ${server.type}', style: _textTheme!.labelMedium!.copyWith( color: context.textTertiary, ), @@ -689,65 +716,83 @@ class _PrivateServerLocationListViewState ); } - Future onPrivateServerSelected(Location_ location) async { + Future onPrivateServerSelected(Server server) async { context.showLoadingDialog(); final result = await ref .read(vpnProvider.notifier) - .connectToServer(ServerLocationType.privateServer, location.tag); - - result.fold((failure) { - context.hideLoadingDialog(); - if (failure is VpnConflictFailure) { - AppDialog.vpnConflictDialog( - context: context, - onConnectAnyway: () async { - appRouter.maybePop(); - final retryResult = await ref - .read(vpnProvider.notifier) - .connectToServer( - ServerLocationType.privateServer, - location.tag, - skipConflictCheck: true, - ); - retryResult.fold((failure) { - context.hideLoadingDialog(); - context.showSnackBar(failure.localizedErrorMessage); - }, (_) => _onPrivateServerConnected(ref, location)); - }, - ); - } else { - context.showSnackBar(failure.localizedErrorMessage); - } - }, (_) => _onPrivateServerConnected(ref, location)); - } + .connectToServer(ServerLocationType.privateServer, server.tag); - void _onPrivateServerConnected(WidgetRef ref, Location_ location) async { - context.hideLoadingDialog(); - context.showSnackBar('connected_to_private_server'.i18n); - - await ref - .read(serverLocationProvider.notifier) - .updateServerLocation( - ServerLocation( - serverType: ServerLocationType.privateServer.name, - serverName: location.tag, - country: location.country, - city: location.city, - countryCode: location.countryCode, - protocol: location.protocol, - ), - ); - appRouter.popUntilRoot(); + result.fold( + (failure) { + context.hideLoadingDialog(); + if (failure is VpnConflictFailure) { + AppDialog.vpnConflictDialog( + context: context, + onConnectAnyway: () async { + appRouter.maybePop(); + final retryResult = await ref + .read(vpnProvider.notifier) + .connectToServer( + ServerLocationType.privateServer, + server.tag, + skipConflictCheck: true, + ); + retryResult.fold( + (failure) { + context.hideLoadingDialog(); + context.showSnackBar(failure.localizedErrorMessage); + }, + (_) { + context.hideLoadingDialog(); + context.showSnackBar('connected_to_private_server'.i18n); + ref + .read(serverLocationProvider.notifier) + .updateServerLocation( + ServerLocation( + serverType: ServerLocationType.privateServer.name, + serverName: server.tag, + country: server.location.country, + city: server.location.city, + countryCode: server.location.countryCode, + protocol: server.type, + ), + ); + appRouter.popUntilRoot(); + }, + ); + }, + ); + } else { + context.showSnackBar(failure.localizedErrorMessage); + } + }, + (_) { + context.hideLoadingDialog(); + context.showSnackBar('connected_to_private_server'.i18n); + + ref + .read(serverLocationProvider.notifier) + .updateServerLocation( + ServerLocation( + serverType: ServerLocationType.privateServer.name, + serverName: server.tag, + country: server.location.country, + city: server.location.city, + countryCode: server.location.countryCode, + protocol: server.type, + ), + ); + appRouter.popUntilRoot(); + }, + ); } } -Map> _groupLocationsByCountry( - List locations, -) { - final Map> result = {}; - for (final loc in locations) { - result.putIfAbsent(loc.country, () => []).add(loc); +Map> _groupLocationsByCountry(List servers) { + final Map> result = {}; + for (final server in servers) { + result.putIfAbsent(server.location.country, () => []).add(server); } return result; } diff --git a/lib/features/vpn/single_city_server_view.dart b/lib/features/vpn/single_city_server_view.dart index 899f9b368b..6a29fa7010 100644 --- a/lib/features/vpn/single_city_server_view.dart +++ b/lib/features/vpn/single_city_server_view.dart @@ -7,7 +7,7 @@ import '../../core/common/common.dart'; // single_city_server_view.dart class SingleCityServerView extends StatefulWidget { - final Location_ location; + final Server server; final OnServerSelected onServerSelected; final bool isSelected; final bool nested; @@ -15,7 +15,7 @@ class SingleCityServerView extends StatefulWidget { const SingleCityServerView({ super.key, required this.onServerSelected, - required this.location, + required this.server, this.isSelected = false, this.nested = false, }); @@ -30,20 +30,20 @@ class _SingleCityServerViewState extends State { final textTheme = Theme.of(context).textTheme; return AppTile( label: widget.nested - ? widget.location.city - : '${widget.location.country} - ${widget.location.city}', + ? widget.server.location.city + : '${widget.server.location.country} - ${widget.server.location.city}', selected: widget.isSelected, - subtitle: widget.location.protocol.isEmpty + subtitle: widget.server.type.isEmpty ? null : Text( - widget.location.protocol.capitalize, + widget.server.type.capitalize, style: textTheme.labelMedium!.copyWith( color: context.textTertiary, ), ), - icon: Flag(countryCode: widget.location.countryCode), + icon: Flag(countryCode: widget.server.location.countryCode), onPressed: () { - widget.onServerSelected(widget.location); + widget.onServerSelected(widget.server); }, ); } diff --git a/lib/features/vpn/vpn_status.dart b/lib/features/vpn/vpn_status.dart index 74c511a493..5998073299 100644 --- a/lib/features/vpn/vpn_status.dart +++ b/lib/features/vpn/vpn_status.dart @@ -75,6 +75,7 @@ class VpnStatus extends HookConsumerWidget { if (!PlatformUtils.isMacOS) { return false; } + // Don't show the warning while the status is still being determined. if (systemExtensionStatus.status == SystemExtensionStatus.unknown) { return false; } diff --git a/lib/lantern/lantern_core_service.dart b/lib/lantern/lantern_core_service.dart index cf68781eea..62f1ad2f8b 100644 --- a/lib/lantern/lantern_core_service.dart +++ b/lib/lantern/lantern_core_service.dart @@ -5,12 +5,13 @@ import 'package:lantern/core/common/common.dart' hide DeveloperMode; import 'package:lantern/core/models/app_data.dart'; import 'package:lantern/core/models/app_event.dart'; import 'package:lantern/core/models/available_servers.dart'; +import 'package:lantern/core/models/server_location.dart'; import 'package:lantern/core/models/datacap_info.dart'; import 'package:lantern/core/models/lantern_status.dart'; import 'package:lantern/core/models/macos_extension_state.dart'; import 'package:lantern/core/models/plan_data.dart'; import 'package:lantern/core/models/private_server_status.dart'; -import 'package:lantern/lantern/protos/protos/auth.pb.dart'; +import 'package:lantern/core/models/user.dart'; import '../core/services/app_purchase.dart'; @@ -67,12 +68,23 @@ abstract class LanternCoreService { Future> getAutoServerLocation(); + Future> getSelectedServerLocation(); + Future> featureFlag(); Future> setBlockAdsEnabled(bool enabled); Future> isBlockAdsEnabled(); + Future> isSmartRoutingEnabled(); + + Future> isTelemetryEnabled(); + + Future> isOAuthLogin(); + + Future> getOAuthProvider(); + + ///Payments methods Future> stipeSubscriptionPaymentRedirect( {required BillingType type, @@ -144,25 +156,25 @@ abstract class LanternCoreService { ///OAuth methods Future> getOAuthLoginUrl(String provider); - Future> oAuthLoginCallback(String token); + Future> oAuthLoginCallback(String token); Future> activationCode( {required String email, required String resellerCode}); ///User management methods - Future> login( + Future> login( {required String email, required String password}); Future> signUp( {required String email, required String password}); - Future> getUserData(); + Future> getUserData(); - Future> fetchUserData(); + Future> fetchUserData(); Future> getDataCapInfo(); - Future> logout(String email); + Future> logout(String email); //Change email Future> startChangeEmail( @@ -187,7 +199,7 @@ abstract class LanternCoreService { }); //Delete account - Future> deleteAccount( + Future> deleteAccount( {required String email, required String password, bool isSSO = false}); //Device Remove @@ -219,10 +231,10 @@ abstract class LanternCoreService { required String accessToken, required String serverName}); - Future> addServerBasedOnURLs( - {required String urls, - required bool skipCertVerification, - required String serverName}); + Future>> addServerBasedOnURLs({ + required String urls, + required bool skipCertVerification, + }); Future> cancelDeployment(); @@ -263,4 +275,19 @@ abstract class LanternCoreService { Future>> getSplitTunnelItems( SplitTunnelFilterType type, ); + + /// Developer-mode helpers exposing radiance settings/env controls. + Future> patchSettings(Map updates); + + Future>> getSettings(); + + Future>> patchEnvVars( + Map updates, + ); + + Future>> getEnvVars(); + + Future> runURLTests(); + + Future> sendConfigRequest(); } diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index ac1662dd96..afc4ed8e1a 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -17,15 +17,14 @@ import 'package:lantern/core/models/private_server_status.dart'; import 'package:lantern/core/services/app_purchase.dart'; import 'package:lantern/core/utils/app_data_utils.dart'; import 'package:lantern/core/utils/storage_utils.dart'; -import 'package:lantern/core/windows/pipe_client.dart'; import 'package:lantern/lantern/lantern_core_service.dart'; import 'package:lantern/lantern/lantern_generated_bindings.dart'; import 'package:lantern/lantern/lantern_service.dart'; -import 'package:lantern/lantern/lantern_windows_service.dart'; -import 'package:lantern/lantern/protos/protos/auth.pb.dart'; +import 'package:lantern/core/models/user.dart'; import 'package:path/path.dart' as p; import '../core/models/available_servers.dart'; +import '../core/models/server_location.dart'; import '../core/models/macos_extension_state.dart'; import '../core/models/plan_data.dart'; import '../core/utils/compute_worker.dart'; @@ -43,40 +42,15 @@ const Set _ffiOkResults = {'ok', 'true'}; /// This is meant to be used only by [LanternService]. class LanternFFIService implements LanternCoreService { static final LanternBindings _ffiService = _gen(); - static const String _windowsServiceName = 'LanternSvc'; - - /// Windows IPC is optional. If it fails to init (missing token, timeout, etc), - /// we keep going and fall back to the non-IPC paths. - LanternServiceWindows? _windowsService; - Future? _windowsServiceInitInFlight; - DateTime? _windowsServiceLastInitFailureAt; - String? _windowsServiceLastInitFailureMessage; - static const Duration _windowsServiceRetryCooldown = Duration(seconds: 15); - static const Duration _windowsServiceStartWait = Duration(seconds: 6); - static const Duration _windowsServicePollInterval = Duration( - milliseconds: 300, - ); - static const Duration _windowsInitRetryInterval = Duration(seconds: 3); - static const int _windowsWarmupMaxAttempts = 8; - static const Duration _windowsWarmupMaxDelay = Duration(seconds: 30); - StreamSubscription? _windowsStatusSubscription; - LanternStatus _lastWindowsStatus = LanternStatus.fromJson({ - 'status': 'disconnected', - 'error': null, - }); - final StreamController _windowsStatusController = - StreamController.broadcast(); Stream _status = _defaultStatusStream(); Stream _privateServerStatus = const Stream.empty(); Stream _appEvents = const Stream.empty(); - static const Duration _appsCacheMaxAge = Duration(hours: 6); - static const Duration _appsCatalogRefreshInterval = Duration(minutes: 5); - Future>? _appsScanInFlight; - List _lastAppsSnapshot = const []; - DateTime? _lastAppsScanAt; + Stream> _logBatches = const Stream>.empty(); + + static final RegExp _newlineRegex = RegExp(r'\r?\n'); static Stream _defaultStatusStream() { // Keep a predictable default (matches the Windows status mapping behavior). @@ -144,6 +118,7 @@ class LanternFFIService implements LanternCoreService { _status = _defaultStatusStream(); _privateServerStatus = const Stream.empty(); _appEvents = const Stream.empty(); + _logBatches = const Stream>.empty(); try { final setupResult = await _setupRadiance(); @@ -151,18 +126,13 @@ class LanternFFIService implements LanternCoreService { appLogger.error('Radiance setup failed: $err'); }, (_) {}); - if (Platform.isWindows) { - // Keep startup responsive. IPC warmup runs in the background. - unawaited(_startWindowsServiceWarmup()); + _status = statusReceivePort.map((event) { + final Map result = jsonDecode(event); + return LanternStatus.fromJson(result); + }); - if (!_isolateInitialized.isCompleted) { - await _initializeCommandIsolate(); - } - } else { - _status = statusReceivePort.map((event) { - final Map result = jsonDecode(event); - return LanternStatus.fromJson(result); - }); + if (Platform.isWindows && !_isolateInitialized.isCompleted) { + await _initializeCommandIsolate(); } // These streams exist even if Windows IPC doesn't. @@ -176,9 +146,15 @@ class LanternFFIService implements LanternCoreService { return AppEvent.fromJson(result); }); - if (Platform.isWindows) { - unawaited(_primeAppsCatalog()); - } + _logBatches = loggingReceivePort + .cast() + .map( + (s) => s + .split(_newlineRegex) + .where((l) => l.isNotEmpty) + .toList(growable: false), + ) + .where((batch) => batch.isNotEmpty); } catch (e, st) { appLogger.error('Error while setting up radiance', e, st); } @@ -246,328 +222,6 @@ class LanternFFIService implements LanternCoreService { } } - Stream _watchWindowsStatus() async* { - yield _lastWindowsStatus; - yield* _windowsStatusController.stream; - } - - void _publishWindowsStatus(LanternStatus status) { - _lastWindowsStatus = status; - if (!_windowsStatusController.isClosed) { - _windowsStatusController.add(status); - } - } - - Future _attachWindowsStatusStream( - LanternServiceWindows windowsService, - ) async { - final previous = _windowsStatusSubscription; - _windowsStatusSubscription = null; - if (previous != null) { - await previous.cancel(); - } - _windowsStatusSubscription = windowsService.watchVPNStatus().listen( - _publishWindowsStatus, - onError: (Object error, StackTrace stackTrace) { - appLogger.error('Windows status stream failed', error, stackTrace); - }, - ); - } - - Future _startWindowsServiceWarmup() async { - var retryDelay = _windowsInitRetryInterval; - for ( - var attempt = 1; - Platform.isWindows && attempt <= _windowsWarmupMaxAttempts; - attempt++ - ) { - final windowsService = await _getOrInitWindowsService(forceRetry: true); - if (windowsService != null) { - return; - } - - final serviceState = await _readWindowsServiceState(); - if (serviceState == _WindowsServiceState.missing) { - appLogger.warning( - 'Windows IPC warmup stopped: service $_windowsServiceName is missing', - ); - return; - } - - if (attempt == _windowsWarmupMaxAttempts) { - appLogger.warning( - 'Windows IPC warmup did not complete after ' - '$_windowsWarmupMaxAttempts attempts; stopping warmup', - ); - return; - } - - appLogger.warning( - 'Windows IPC warmup did not complete; retrying in ' - '${retryDelay.inMilliseconds}ms ' - '(attempt $attempt of $_windowsWarmupMaxAttempts)', - ); - await Future.delayed(retryDelay); - retryDelay = _nextWarmupRetryDelay(retryDelay); - } - } - - Duration _nextWarmupRetryDelay(Duration current) { - final doubledMs = current.inMilliseconds * 2; - final maxMs = _windowsWarmupMaxDelay.inMilliseconds; - final nextMs = doubledMs > maxMs ? maxMs : doubledMs; - return Duration(milliseconds: nextMs); - } - - Future<_WindowsServiceState> _readWindowsServiceState() async { - try { - final result = await Process.run('sc.exe', [ - 'query', - _windowsServiceName, - ]); - final text = '${result.stdout}\n${result.stderr}'.toUpperCase(); - if (result.exitCode == 1060 || - text.contains('FAILED 1060') || - text.contains('DOES NOT EXIST')) { - return _WindowsServiceState.missing; - } - if (text.contains('STATE') && text.contains('START_PENDING')) { - return _WindowsServiceState.startPending; - } - if (text.contains('STATE') && text.contains('STOP_PENDING')) { - return _WindowsServiceState.stopPending; - } - if (text.contains('STATE') && text.contains('RUNNING')) { - return _WindowsServiceState.running; - } - if (text.contains('STATE') && text.contains('STOPPED')) { - return _WindowsServiceState.stopped; - } - return result.exitCode == 0 - ? _WindowsServiceState.stopped - : _WindowsServiceState.unknown; - } catch (e, st) { - appLogger.error('Failed to query Windows service state', e, st); - return _WindowsServiceState.unknown; - } - } - - Future _waitForWindowsServiceRunning(Duration timeout) async { - final deadline = DateTime.now().add(timeout); - while (DateTime.now().isBefore(deadline)) { - final state = await _readWindowsServiceState(); - switch (state) { - case _WindowsServiceState.running: - return true; - case _WindowsServiceState.missing: - return false; - case _WindowsServiceState.startPending: - case _WindowsServiceState.stopPending: - case _WindowsServiceState.stopped: - case _WindowsServiceState.unknown: - break; - } - await Future.delayed(_windowsServicePollInterval); - } - return false; - } - - Future _waitForWindowsServiceStopped(Duration timeout) async { - final deadline = DateTime.now().add(timeout); - while (DateTime.now().isBefore(deadline)) { - final state = await _readWindowsServiceState(); - switch (state) { - case _WindowsServiceState.stopped: - return true; - case _WindowsServiceState.missing: - return false; - case _WindowsServiceState.startPending: - case _WindowsServiceState.stopPending: - case _WindowsServiceState.running: - case _WindowsServiceState.unknown: - break; - } - await Future.delayed(_windowsServicePollInterval); - } - return false; - } - - Future _prepareWindowsService() async { - final state = await _readWindowsServiceState(); - switch (state) { - case _WindowsServiceState.running: - return true; - case _WindowsServiceState.missing: - _windowsServiceLastInitFailureMessage = - 'Windows service LanternSvc is missing.'; - return false; - case _WindowsServiceState.startPending: - final running = await _waitForWindowsServiceRunning( - _windowsServiceStartWait, - ); - if (!running) { - _windowsServiceLastInitFailureMessage = - 'Windows service LanternSvc did not reach running state.'; - } - return running; - case _WindowsServiceState.stopped: - case _WindowsServiceState.stopPending: - try { - if (state == _WindowsServiceState.stopPending) { - final stopped = await _waitForWindowsServiceStopped( - _windowsServiceStartWait, - ); - if (!stopped) { - _windowsServiceLastInitFailureMessage = - 'Windows service LanternSvc did not reach stopped state.'; - return false; - } - } - final startResult = await Process.run('sc.exe', [ - 'start', - _windowsServiceName, - ]); - if (startResult.exitCode != 0) { - _windowsServiceLastInitFailureMessage = - 'Windows service LanternSvc could not start.'; - appLogger.warning( - 'Failed to start Windows service', - startResult.stdout, - StackTrace.current, - ); - return false; - } - final running = await _waitForWindowsServiceRunning( - _windowsServiceStartWait, - ); - if (!running) { - _windowsServiceLastInitFailureMessage = - 'Windows service LanternSvc did not reach running state.'; - } - return running; - } catch (e, st) { - _windowsServiceLastInitFailureMessage = - 'Windows service LanternSvc start command failed.'; - appLogger.error('Failed to start Windows service', e, st); - return false; - } - case _WindowsServiceState.unknown: - appLogger.warning( - 'Windows service state is unknown; proceeding with IPC attempt', - ); - return true; - } - } - - String _describeWindowsIpcFailure(Object error) { - if (error is PipeTokenException) { - return switch (error.kind) { - PipeTokenErrorKind.missing => - 'IPC token file is missing at ${error.path}.', - PipeTokenErrorKind.empty => 'IPC token file is empty at ${error.path}.', - PipeTokenErrorKind.unreadable => - 'IPC token file could not be read at ${error.path}.', - }; - } - if (error is PipeTransportException) { - if (error.timedOut) { - return 'Windows IPC pipe open timed out.'; - } - return 'Windows IPC transport failed (${error.code}).'; - } - return error.toString(); - } - - String _windowsIpcUnavailableMessage() { - final details = _windowsServiceLastInitFailureMessage; - if (details == null || details.trim().isEmpty) { - return 'The Windows VPN service did not initialize (IPC unavailable).'; - } - return 'The Windows VPN service is unavailable: $details'; - } - - Future _initializeWindowsService() async { - final tokenPath = p.join( - Platform.environment['ProgramData'] ?? r'C:\ProgramData', - 'Lantern', - 'ipc-token', - ); - final pipe = PipeClient( - tokenPath: tokenPath, - timeoutMs: 1500, - tokenWaitMs: 1500, - ); - - // Create locally first; only assign to the field after init succeeds. - final ws = LanternServiceWindows(pipe); - - try { - await ws.init(); - _windowsService = ws; - _windowsServiceLastInitFailureAt = null; - _windowsServiceLastInitFailureMessage = null; - await _attachWindowsStatusStream(ws); - } catch (e, st) { - appLogger.error('LanternServiceWindows.init() threw', e, st); - _windowsService = null; - rethrow; // init() will catch and keep going; this keeps the original stack. - } - } - - Future _getOrInitWindowsService({ - bool forceRetry = false, - }) async { - final existing = _windowsService; - if (existing != null) { - return existing; - } - - if (!forceRetry) { - final lastFailureAt = _windowsServiceLastInitFailureAt; - if (lastFailureAt != null && - DateTime.now().difference(lastFailureAt) < - _windowsServiceRetryCooldown) { - return null; - } - } - - final inFlight = _windowsServiceInitInFlight; - if (inFlight != null) { - return inFlight; - } - - final initFuture = () async { - try { - final ready = await _prepareWindowsService(); - if (!ready) { - _windowsService = null; - _windowsServiceLastInitFailureAt = DateTime.now(); - return null; - } - await _initializeWindowsService(); - } catch (e, st) { - appLogger.error('Windows IPC re-init failed', e, st); - _windowsService = null; - _windowsServiceLastInitFailureAt = DateTime.now(); - _windowsServiceLastInitFailureMessage = _describeWindowsIpcFailure(e); - } finally { - _windowsServiceInitInFlight = null; - } - return _windowsService; - }(); - - _windowsServiceInitInFlight = initFuture; - return initFuture; - } - - Future _markWindowsStatusOrigin(VPNStatusOrigin origin) async { - if (!Platform.isWindows) { - return; - } - final ws = await _getOrInitWindowsService(); - ws?.setNextStatusOrigin(origin); - } - @override Stream watchAppEvents() { return _appEvents; @@ -592,7 +246,6 @@ class LanternFFIService implements LanternCoreService { @override Future> setRoutingMode(bool mode) async { try { - await _markWindowsStatusOrigin(VPNStatusOrigin.settingsMutation); final result = await runInBackground(() async { return _ffiService.setSmartRoutingEnabled(mode ? 1 : 0).toDartString(); }); @@ -607,50 +260,54 @@ class LanternFFIService implements LanternCoreService { @override Stream> appsDataStream() async* { - final String dataDir = _ffiService.getAppDataDir().toDartString(); - final enabledKeys = await _getEnabledAppKeys(); - var latestEmitted = const []; - - final memoryApps = _appsFromSnapshot(enabledKeys); - if (memoryApps.isNotEmpty) { - yield memoryApps; - latestEmitted = memoryApps; - } - - final cachedApps = await _loadCachedApps(dataDir, enabledKeys); - if (cachedApps.isNotEmpty && memoryApps.isEmpty) { - appLogger.debug( - 'Loaded ${cachedApps.length} apps from cache before full scan', - ); - yield cachedApps; - latestEmitted = cachedApps; - } + try { + // Installed apps still loaded from Go (already is) + final String dataDir = (_ffiService.getAppDataDir().toDartString()); - final hasImmediateApps = memoryApps.isNotEmpty || cachedApps.isNotEmpty; - if (hasImmediateApps) { - try { - final scannedApps = await _scanInstalledApps(dataDir, enabledKeys); - if (_appsSnapshotChanged(latestEmitted, scannedApps)) { - yield scannedApps; + final String jsonApps = await runInBackground(() async { + final ptr = dataDir.toNativeUtf8(); + try { + return _ffiService.loadInstalledApps(ptr.cast()).toDartString(); + } finally { + malloc.free(ptr); } - } catch (e, st) { - appLogger.error("Failed to refresh installed apps", e, st); - } - return; - } + }); - try { - final scannedApps = await _scanInstalledApps(dataDir, enabledKeys); - if (scannedApps.isEmpty && cachedApps.isEmpty && memoryApps.isEmpty) { + if (jsonApps.isEmpty) { yield []; return; } - yield scannedApps; + + // Enabled apps from Go (NOT LocalStorage) + final enabledJson = await runInBackground(() async { + return _ffiService.getEnabledApps().toDartString(); + }); + checkAPIError(enabledJson); + + final enabledKeys = (jsonDecode(enabledJson) as List) + .cast() + .toSet(); + + final decoded = jsonDecode(jsonApps) as List; + final rawApps = decoded.cast>(); + + yield rawApps.map((raw) { + final name = (raw["name"] as String? ?? "").trim(); + final bundleId = (raw["bundleId"] as String? ?? "").trim(); + final key = bundleId.isNotEmpty ? bundleId : name; + + return AppData( + name: name, + bundleId: bundleId, + appPath: raw["appPath"] as String? ?? '', + iconPath: raw["iconPath"] as String? ?? '', + iconBytes: iconToBytes(raw["icon"] ?? raw["iconBytes"]), + isEnabled: enabledKeys.contains(key), + ); + }).toList(); } catch (e, st) { appLogger.error("Failed to fetch installed apps", e, st); - if (cachedApps.isEmpty && memoryApps.isEmpty) { - yield []; - } + yield []; } } @@ -697,246 +354,6 @@ class LanternFFIService implements LanternCoreService { } } - List _appsFromSnapshot(Set enabledKeys) { - if (_lastAppsSnapshot.isEmpty) { - return const []; - } - return _applyEnabledState(_lastAppsSnapshot, enabledKeys); - } - - bool _appsSnapshotChanged(List previous, List next) { - if (previous.length != next.length) { - return true; - } - - final previousFingerprints = previous.map(_appFingerprint).toList()..sort(); - final nextFingerprints = next.map(_appFingerprint).toList()..sort(); - for (var i = 0; i < previousFingerprints.length; i++) { - if (previousFingerprints[i] != nextFingerprints[i]) { - return true; - } - } - - return false; - } - - String _appFingerprint(AppData app) { - return [ - _normalizeSplitTunnelKey(app.bundleId), - _normalizeSplitTunnelKey(app.appPath), - _normalizeSplitTunnelKey(app.iconPath), - app.name.trim().toLowerCase(), - app.isEnabled ? '1' : '0', - app.removed ? '1' : '0', - ].join('|'); - } - - Future _primeAppsCatalog() async { - final dataDir = _ffiService.getAppDataDir().toDartString(); - if (_isAppsCatalogFresh()) { - return; - } - try { - await _scanInstalledApps(dataDir, const {}); - } catch (e, st) { - appLogger.error('Failed to prewarm apps catalog', e, st); - } - } - - bool _isAppsCatalogFresh() { - final last = _lastAppsScanAt; - if (last == null) { - return false; - } - return DateTime.now().difference(last) <= _appsCatalogRefreshInterval; - } - - Future> _scanInstalledApps( - String dataDir, - Set enabledKeys, - ) async { - final inFlight = _appsScanInFlight; - if (inFlight != null) { - final snapshot = await inFlight; - return _applyEnabledState(snapshot, enabledKeys); - } - - final scanFuture = _scanInstalledAppsRaw(dataDir); - _appsScanInFlight = scanFuture; - try { - final snapshot = await scanFuture; - _lastAppsSnapshot = snapshot - .map((app) => app.copyWith(isEnabled: false)) - .toList(); - _lastAppsScanAt = DateTime.now(); - return _applyEnabledState(snapshot, enabledKeys); - } finally { - _appsScanInFlight = null; - } - } - - Future> _scanInstalledAppsRaw(String dataDir) async { - final String jsonApps = await runInBackground(() async { - final ptr = dataDir.toNativeUtf8(); - try { - return _ffiService.loadInstalledApps(ptr.cast()).toDartString(); - } finally { - malloc.free(ptr); - } - }); - - checkAPIError(jsonApps); - if (jsonApps.isEmpty) { - return const []; - } - return _parseAppsJson(jsonApps); - } - - Future> _getEnabledAppKeys() async { - try { - final enabledJson = await runInBackground(() async { - return _ffiService.getEnabledApps().toDartString(); - }); - checkAPIError(enabledJson); - final dynamic decoded = jsonDecode(enabledJson); - if (decoded is! List) { - return const {}; - } - return decoded - .map((item) => _normalizeSplitTunnelKey(item?.toString() ?? '')) - .where((item) => item.isNotEmpty) - .toSet(); - } catch (e, st) { - appLogger.error('Failed to load enabled split-tunnel apps', e, st); - return const {}; - } - } - - Future> _loadCachedApps( - String dataDir, - Set enabledKeys, - ) async { - try { - final cachePath = p.join(dataDir, 'apps_cache.json'); - final file = File(cachePath); - if (!file.existsSync()) { - return const []; - } - final stats = await file.stat(); - if (DateTime.now().difference(stats.modified) > _appsCacheMaxAge) { - appLogger.debug('Skipping stale apps cache at $cachePath'); - return const []; - } - final cacheRaw = await file.readAsString(); - if (cacheRaw.trim().isEmpty) { - return const []; - } - return _parseAppsJson(cacheRaw, enabledKeys); - } catch (e, st) { - appLogger.error('Failed to load cached apps', e, st); - return const []; - } - } - - List _applyEnabledState( - List apps, - Set enabledKeys, - ) { - return apps.map((app) { - final matchKey = _splitTunnelMatchKey( - app.bundleId, - app.appPath, - app.name, - ); - return app.copyWith(isEnabled: enabledKeys.contains(matchKey)); - }).toList(); - } - - List _parseAppsJson( - String jsonApps, [ - Set enabledKeys = const {}, - ]) { - final dynamic decoded = jsonDecode(jsonApps); - if (decoded is! List) { - return const []; - } - - final deduped = {}; - for (final entry in decoded) { - if (entry is! Map) { - continue; - } - - final raw = entry.cast(); - final name = (raw["name"] as String? ?? "").trim(); - final bundleId = (raw["bundleId"] as String? ?? "").trim(); - final appPath = (raw["appPath"] as String? ?? "").trim(); - final iconPath = (raw["iconPath"] as String? ?? '').trim(); - - final matchKey = _splitTunnelMatchKey(bundleId, appPath, name); - if (matchKey.isEmpty) { - continue; - } - - final app = AppData( - name: name, - bundleId: bundleId, - appPath: appPath, - iconPath: iconPath, - iconBytes: iconToBytes(raw["icon"] ?? raw["iconBytes"]), - isEnabled: enabledKeys.contains(matchKey), - ); - - final identity = _appIdentityKey(app); - if (identity.isEmpty) { - continue; - } - - final existing = deduped[identity]; - if (existing == null || - ((existing.iconBytes?.isEmpty ?? true) && - (app.iconBytes?.isNotEmpty ?? false))) { - deduped[identity] = app; - } - } - - final apps = deduped.values.toList(); - apps.sort((a, b) { - final an = a.name.trim().toLowerCase(); - final bn = b.name.trim().toLowerCase(); - if (an.isEmpty && bn.isEmpty) return 0; - if (an.isEmpty) return 1; - if (bn.isEmpty) return -1; - return an.compareTo(bn); - }); - return apps; - } - - String _splitTunnelMatchKey(String bundleId, String appPath, String name) { - if (bundleId.isNotEmpty) return _normalizeSplitTunnelKey(bundleId); - if (appPath.isNotEmpty) return _normalizeSplitTunnelKey(appPath); - return _normalizeSplitTunnelKey(name); - } - - String _normalizeSplitTunnelKey(String key) { - final trimmed = key.trim(); - if (trimmed.isEmpty) { - return ''; - } - if (Platform.isWindows) { - return trimmed.toLowerCase(); - } - return trimmed; - } - - String _appIdentityKey(AppData app) { - final bundleId = app.bundleId.trim().toLowerCase(); - if (bundleId.isNotEmpty) return bundleId; - final appPath = app.appPath.trim().toLowerCase(); - if (appPath.isNotEmpty) return appPath; - return app.name.trim().toLowerCase(); - } - // Split tunneling static void _commandIsolateEntry(SendPort sendPort) { final commandPort = ReceivePort(); @@ -1085,14 +502,28 @@ class LanternFFIService implements LanternCoreService { : _ffiService.removeSplitTunnelItem; final result = fn(tPtr.cast(), vPtr.cast()); - if (result != nullptr) { - final error = result.cast().toDartString(); - malloc.free(result); - appLogger.error('$action split tunnel error: $error'); - return left(Failure(error: error, localizedErrorMessage: error)); + if (result == nullptr) { + return right(unit); } - - return right(unit); + final resultStr = result.cast().toDartString(); + malloc.free(result); + // The Go FFI returns a non-null C string like "ok" on success; only + // treat unexpected payloads as errors. + if (_ffiOkResults.contains(resultStr)) { + return right(unit); + } + // Errors may arrive as raw strings or as JSON {"error": "..."}. + var errMsg = resultStr; + try { + final decoded = jsonDecode(resultStr); + if (decoded is Map && decoded['error'] != null) { + errMsg = decoded['error'].toString(); + } + } catch (_) { + // Not JSON — fall through with the raw string. + } + appLogger.error('$action split tunnel error: $errMsg'); + return left(Failure(error: errMsg, localizedErrorMessage: errMsg)); } catch (e) { return left( Failure( @@ -1126,7 +557,7 @@ class LanternFFIService implements LanternCoreService { description.toCharPtr, device.toCharPtr, model.toCharPtr, - "".toCharPtr, + logFilePath.toCharPtr, ) .toDartString(); }); @@ -1140,43 +571,10 @@ class LanternFFIService implements LanternCoreService { @override Future> startVPN() async { - if (Platform.isWindows) { - appLogger.debug('Starting VPN on Windows via IPC'); - - try { - final result = runInBackground(() async { - return _ffiService.startAutoLocationListener().toDartString(); - }); - result.then((value) { - appLogger.debug("auto location listener started: $value"); - }); - } catch (e) { - appLogger.error("error starting auto location listener: $e"); - } - - final ws = await _getOrInitWindowsService(forceRetry: true); - if (ws == null) { - return left( - Failure( - error: 'Windows service unavailable', - localizedErrorMessage: _windowsIpcUnavailableMessage(), - ), - ); - } - - ws.setNextStatusOrigin(VPNStatusOrigin.userAction); - return ws.connect(); - } - - final ffiPaths = await PlatformFfiUtils.getFfiPlatformPaths(); try { appLogger.debug('Starting VPN'); final result = _ffiService - .startVPN( - ffiPaths.logFilePathPtr.cast(), - ffiPaths.dataDirPtr.cast(), - ffiPaths.localePtr.cast(), - ) + .startVPN() .cast() .toDartString(); if (result.isNotEmpty && !_ffiOkResults.contains(result)) { @@ -1187,8 +585,6 @@ class LanternFFIService implements LanternCoreService { } catch (e) { appLogger.error('Error starting VPN: $e'); return Left(e.toFailure()); - } finally { - ffiPaths.free(); } } @@ -1233,45 +629,10 @@ class LanternFFIService implements LanternCoreService { String location, String tag, ) async { - if (Platform.isWindows) { - try { - // Do not await here to avoid blocking - final result = runInBackground(() async { - return _ffiService.stopAutoLocationListener().toDartString(); - }); - result.then((value) { - appLogger.debug("auto location listener stops : $value"); - }); - } catch (e) { - appLogger.error("error stopping auto location listener: $e"); - } - - final ws = await _getOrInitWindowsService(forceRetry: true); - if (ws == null) { - return left( - Failure( - error: 'Windows service unavailable', - localizedErrorMessage: - 'Cannot connect to a server: ${_windowsIpcUnavailableMessage()}', - ), - ); - } - - ws.setNextStatusOrigin(VPNStatusOrigin.userAction); - return ws.connectToServer(location, tag); - } - - final ffiPaths = await PlatformFfiUtils.getFfiPlatformPaths(); try { final result = await runInBackground(() async { return _ffiService - .connectToServer( - location.toCharPtr, - tag.toCharPtr, - ffiPaths.logFilePathPtr.cast(), - ffiPaths.dataDirPtr.cast(), - ffiPaths.localePtr.cast(), - ) + .connectToServer(tag.toCharPtr) .toDartString(); }); checkAPIError(result); @@ -1279,8 +640,6 @@ class LanternFFIService implements LanternCoreService { } catch (e, stackTrace) { appLogger.error('Error connecting to server', e, stackTrace); return Left(e.toFailure()); - } finally { - ffiPaths.free(); } } @@ -1289,32 +648,6 @@ class LanternFFIService implements LanternCoreService { try { appLogger.debug('Stopping VPN'); - if (Platform.isWindows) { - // Best-effort: stop the listener without blocking the UI. - try { - final result = runInBackground(() async { - return _ffiService.stopAutoLocationListener().toDartString(); - }); - result.then((value) { - appLogger.debug("auto location listener stops : $value"); - }); - } catch (e) { - appLogger.error("error stopping auto location listener: $e"); - } - - final ws = _windowsService; - if (ws == null) { - // If IPC never came up, treat this as already stopped. - appLogger.warning( - 'stopVPN(): Windows service not initialized; treating as already stopped', - ); - return right('ok'); - } - - ws.setNextStatusOrigin(VPNStatusOrigin.userAction); - return ws.disconnect(); - } - final result = _ffiService.stopVPN().cast().toDartString(); if (result.isNotEmpty && !_ffiOkResults.contains(result)) { return left(Failure(error: result, localizedErrorMessage: result)); @@ -1330,13 +663,6 @@ class LanternFFIService implements LanternCoreService { @override Future> isVPNConnected() async { try { - if (Platform.isWindows) { - final ws = await _getOrInitWindowsService(); - if (ws == null) { - return right(false); - } - return ws.isVPNConnected(); - } final connectedInt = _ffiService.isVPNConnected(); final connected = connectedInt != 0; return right(connected); @@ -1356,32 +682,10 @@ class LanternFFIService implements LanternCoreService { } @override - Stream> watchLogs(String path) { - if (PlatformUtils.isWindows) { - appLogger.info('[watchLogs] awaiting Windows service init'); - return Stream.fromFuture(_getOrInitWindowsService()).asyncExpand((ws) { - if (ws == null) { - appLogger.error( - '[watchLogs] Windows service is null — returning empty stream', - ); - return const Stream>.empty(); - } - appLogger.info( - '[watchLogs] Windows service ready, starting log pipe stream', - ); - return accumulateLogBatches(ws.watchLogs()); - }); - } - throw UnimplementedError(); - } + Stream> watchLogs(String path) => _logBatches; @override - Stream watchVPNStatus() { - if (Platform.isWindows) { - return _watchWindowsStatus(); - } - return _status; - } + Stream watchVPNStatus() => _status; @override Future> startInAppPurchaseFlow({ @@ -1443,6 +747,7 @@ class LanternFFIService implements LanternCoreService { final result = await runInBackground(() async { return _ffiService.plans().toDartString(); }); + checkAPIError(result); final map = jsonDecode(result); final plans = PlansData.fromJson(map); @@ -1482,13 +787,14 @@ class LanternFFIService implements LanternCoreService { } @override - Future> oAuthLoginCallback(String token) async { + Future> oAuthLoginCallback(String token) async { try { final result = await runInBackground(() async { return _ffiService.oAuthLoginCallback(token.toCharPtr).toDartString(); }); - final decodedResult = base64Decode(result); - final user = UserResponse.fromBuffer(decodedResult); + checkAPIError(result); + final map = jsonDecode(result) as Map; + final user = UserResponseModel.fromJson(map); return Right(user); } catch (e, stackTrace) { appLogger.error('error oauth callback', e, stackTrace); @@ -1497,16 +803,14 @@ class LanternFFIService implements LanternCoreService { } @override - Future> getUserData() async { - // if (Platform.isWindows) { - // return _windowsService.getUserData(); - // } + Future> getUserData() async { try { final result = await runInBackground(() async { return _ffiService.getUserData().toDartString(); }); - final decodedResult = base64Decode(result); - final user = UserResponse.fromBuffer(decodedResult); + checkAPIError(result); + final map = jsonDecode(result) as Map; + final user = UserResponseModel.fromJson(map); return Right(user); } catch (e, stackTrace) { appLogger.error('Error getting user data', e, stackTrace); @@ -1520,13 +824,14 @@ class LanternFFIService implements LanternCoreService { } @override - Future> fetchUserData() async { + Future> fetchUserData() async { try { final result = await runInBackground(() async { return _ffiService.fetchUserData().toDartString(); }); - final decodedResult = base64Decode(result); - final user = UserResponse.fromBuffer(decodedResult); + checkAPIError(result); + final map = jsonDecode(result) as Map; + final user = UserResponseModel.fromJson(map); return Right(user); } catch (e, stackTrace) { appLogger.error('error fetchUser data', e, stackTrace); @@ -1566,14 +871,14 @@ class LanternFFIService implements LanternCoreService { } @override - Future> logout(String email) async { + Future> logout(String email) async { try { final result = await runInBackground(() async { return _ffiService.logout(email.toCharPtr).toDartString(); }); checkAPIError(result); - final decodedResult = base64Decode(result); - final user = UserResponse.fromBuffer(decodedResult); + final map = jsonDecode(result) as Map; + final user = UserResponseModel.fromJson(map); return Right(user); } catch (e, stackTrace) { appLogger.error('error while logout', e, stackTrace); @@ -1582,7 +887,7 @@ class LanternFFIService implements LanternCoreService { } @override - Future> login({ + Future> login({ required String email, required String password, }) async { @@ -1593,8 +898,8 @@ class LanternFFIService implements LanternCoreService { .toDartString(); }); checkAPIError(result); - final decodedResult = base64Decode(result); - final user = UserResponse.fromBuffer(decodedResult); + final map = jsonDecode(result) as Map; + final user = UserResponseModel.fromJson(map); return Right(user); } catch (e, stackTrace) { appLogger.error('error while login', e, stackTrace); @@ -1679,7 +984,7 @@ class LanternFFIService implements LanternCoreService { } @override - Future> deleteAccount({ + Future> deleteAccount({ required String email, required String password, bool isSSO = false, @@ -1687,12 +992,12 @@ class LanternFFIService implements LanternCoreService { try { final result = await runInBackground(() async { return _ffiService - .deleteAccount(email.toCharPtr, password.toCharPtr, isSSO ? 1 : 0) + .deleteAccount(email.toCharPtr, password.toCharPtr) .toDartString(); }); checkAPIError(result); - final decodedResult = base64Decode(result); - final user = UserResponse.fromBuffer(decodedResult); + final map = jsonDecode(result) as Map; + final user = UserResponseModel.fromJson(map); return Right(user); } catch (e, stackTrace) { appLogger.error('Error deleting account', e, stackTrace); @@ -1861,10 +1166,9 @@ class LanternFFIService implements LanternCoreService { } @override - Future> addServerBasedOnURLs({ + Future>> addServerBasedOnURLs({ required String urls, required bool skipCertVerification, - required String serverName, }) async { try { final result = await runInBackground(() async { @@ -1872,12 +1176,12 @@ class LanternFFIService implements LanternCoreService { .addServerBasedOnURLs( urls.toCharPtr, skipCertVerification ? 1 : 0, - serverName.toCharPtr, ) .toDartString(); }); checkAPIError(result); - return Right(unit); + final tags = (jsonDecode(result) as List).cast(); + return Right(tags); } catch (e, stackTrace) { appLogger.error('Error adding server based on URLs', e, stackTrace); return Left(e.toFailure()); @@ -2010,29 +1314,8 @@ class LanternFFIService implements LanternCoreService { return _ffiService.getAvailableServers().toDartString(); }); checkAPIError(result); - final servers = AvailableServers.fromJson(jsonDecode(result)); - void applyProtocols(Lantern lantern) { - final outboundsByTag = { - for (var outbound in lantern.outbounds) outbound.tag: outbound.type, - }; - lantern.locations.forEach((key, value) { - final protoValue = outboundsByTag[key]; - if (protoValue != null) { - value.protocol = protoValue; - } else { - try { - // If not found, try to extract from tag. - value.protocol = value.tag.split('-').first; - } catch (_) { - // If anything goes wrong, just leave it blank. - value.protocol = ''; - } - } - }); - } - - applyProtocols(servers.lantern); - applyProtocols(servers.user); + final servers = + AvailableServers.fromJson(jsonDecode(result) as List); return Right(servers); } catch (e, stackTrace) { appLogger.error('Error getting available servers', e, stackTrace); @@ -2127,10 +1410,42 @@ class LanternFFIService implements LanternCoreService { } } + @override + Future> getSelectedServerLocation() async { + try { + final result = await runInBackground(() async { + return _ffiService.getSelectedServerJSON().toDartString(); + }); + checkAPIError(result); + // Normalize a missing selection to an empty JSON object so callers + // fall into the "auto" branch below instead of throwing. + final raw = result.isEmpty ? '{}' : result; + final json = jsonDecode(raw) as Map; + final serverJson = json['server'] as Map?; + if (serverJson == null) { + return Right(ServerLocation( + serverType: ServerLocationType.auto.name, + serverName: '', + )); + } + final server = Server.fromJson(serverJson); + final isLantern = server.isLantern; + return Right(ServerLocation.fromServer( + server: server, + ).copyWith( + serverType: isLantern + ? ServerLocationType.lanternLocation.name + : ServerLocationType.privateServer.name, + )); + } catch (e, stackTrace) { + appLogger.error('Error while getting selected server', e, stackTrace); + return Left(e.toFailure()); + } + } + @override Future> setBlockAdsEnabled(bool enabled) async { try { - await _markWindowsStatusOrigin(VPNStatusOrigin.settingsMutation); final result = await runInBackground(() async { return _ffiService .setBlockAdsEnabled(enabled ? 1 : 0) @@ -2156,6 +1471,50 @@ class LanternFFIService implements LanternCoreService { } } + @override + Future> isSmartRoutingEnabled() async { + try { + final res = _ffiService.isSmartRoutingEnabled(); + return right(res != 0); + } catch (e, st) { + appLogger.error('isSmartRoutingEnabled error: $e', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> isTelemetryEnabled() async { + try { + final res = _ffiService.isTelemetryEnabled(); + return right(res != 0); + } catch (e, st) { + appLogger.error('isTelemetryEnabled error: $e', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> isOAuthLogin() async { + try { + final res = _ffiService.isOAuthLogin(); + return right(res != 0); + } catch (e, st) { + appLogger.error('isOAuthLogin error: $e', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> getOAuthProvider() async { + try { + final res = _ffiService.getOAuthProvider().toDartString(); + return right(res); + } catch (e, st) { + appLogger.error('getOAuthProvider error: $e', e, st); + return Left(e.toFailure()); + } + } + @override Future> triggerSystemExtension() { throw Exception("This is not supported on desktop"); @@ -2274,15 +1633,116 @@ class LanternFFIService implements LanternCoreService { // TODO: implement diagnosticLogFiles throw UnimplementedError(); } -} -enum _WindowsServiceState { - running, - stopped, - startPending, - stopPending, - missing, - unknown, + @override + Future> patchSettings( + Map updates, + ) async { + try { + final payload = jsonEncode(updates); + final result = await runInBackground(() async { + final ptr = payload.toNativeUtf8(); + try { + return _ffiService.patchSettings(ptr.cast()).toDartString(); + } finally { + malloc.free(ptr); + } + }); + checkAPIError(result); + return right(unit); + } catch (e, st) { + appLogger.error('patchSettings error', e, st); + return Left(e.toFailure()); + } + } + + @override + Future>> getSettings() async { + try { + final result = await runInBackground(() async { + return _ffiService.getSettings().toDartString(); + }); + checkAPIError(result); + final decoded = jsonDecode(result); + if (decoded is! Map) return right({}); + return right(Map.from(decoded)); + } catch (e, st) { + appLogger.error('getSettings error', e, st); + return Left(e.toFailure()); + } + } + + @override + Future>> patchEnvVars( + Map updates, + ) async { + try { + final payload = jsonEncode(updates); + final result = await runInBackground(() async { + final ptr = payload.toNativeUtf8(); + try { + return _ffiService.patchEnvVars(ptr.cast()).toDartString(); + } finally { + malloc.free(ptr); + } + }); + checkAPIError(result); + final decoded = jsonDecode(result); + if (decoded is! Map) return right({}); + return right( + decoded.map((k, v) => MapEntry(k.toString(), v?.toString() ?? '')), + ); + } catch (e, st) { + appLogger.error('patchEnvVars error', e, st); + return Left(e.toFailure()); + } + } + + @override + Future>> getEnvVars() async { + try { + final result = await runInBackground(() async { + return _ffiService.getEnvVars().toDartString(); + }); + checkAPIError(result); + final decoded = jsonDecode(result); + if (decoded is! Map) return right({}); + return right( + decoded.map((k, v) => MapEntry(k.toString(), v?.toString() ?? '')), + ); + } catch (e, st) { + appLogger.error('getEnvVars error', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> runURLTests() async { + try { + final result = await runInBackground(() async { + return _ffiService.runURLTests().toDartString(); + }); + checkAPIError(result); + return right(unit); + } catch (e, st) { + appLogger.error('runURLTests error', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> sendConfigRequest() async { + try { + final result = await runInBackground(() async { + return _ffiService.updateConfig().toDartString(); + }); + checkAPIError(result); + return right(unit); + } catch (e, st) { + appLogger.error('updateConfig error', e, st); + return Left(e.toFailure()); + } + } } void checkAPIError(dynamic result) { diff --git a/lib/lantern/lantern_generated_bindings.dart b/lib/lantern/lantern_generated_bindings.dart index 5a78500f4e..f80e9d21d8 100644 --- a/lib/lantern/lantern_generated_bindings.dart +++ b/lib/lantern/lantern_generated_bindings.dart @@ -5266,6 +5266,17 @@ class LanternBindings { late final __FCmulcr = __FCmulcrPtr .asFunction<_Fcomplex Function(_Fcomplex, double)>(); + ffi.Pointer getAppDataDir() { + return _getAppDataDir(); + } + + late final _getAppDataDirPtr = + _lookup Function()>>( + 'getAppDataDir', + ); + late final _getAppDataDir = _getAppDataDirPtr + .asFunction Function()>(); + ffi.Pointer setup( ffi.Pointer _logDir, ffi.Pointer _dataDir, @@ -5340,6 +5351,35 @@ class LanternBindings { late final _updateTelemetryConsent = _updateTelemetryConsentPtr .asFunction Function(int)>(); + int isTelemetryEnabled() { + return _isTelemetryEnabled(); + } + + late final _isTelemetryEnabledPtr = + _lookup>('isTelemetryEnabled'); + late final _isTelemetryEnabled = _isTelemetryEnabledPtr + .asFunction(); + + int isOAuthLogin() { + return _isOAuthLogin(); + } + + late final _isOAuthLoginPtr = _lookup>( + 'isOAuthLogin', + ); + late final _isOAuthLogin = _isOAuthLoginPtr.asFunction(); + + ffi.Pointer getOAuthProvider() { + return _getOAuthProvider(); + } + + late final _getOAuthProviderPtr = + _lookup Function()>>( + 'getOAuthProvider', + ); + late final _getOAuthProvider = _getOAuthProviderPtr + .asFunction Function()>(); + ffi.Pointer availableFeatures() { return _availableFeatures(); } @@ -5518,6 +5558,17 @@ class LanternBindings { ) >(); + ffi.Pointer getSelectedServerJSON() { + return _getSelectedServerJSON(); + } + + late final _getSelectedServerJSONPtr = + _lookup Function()>>( + 'getSelectedServerJSON', + ); + late final _getSelectedServerJSON = _getSelectedServerJSONPtr + .asFunction Function()>(); + ffi.Pointer getAutoLocation() { return _getAutoLocation(); } @@ -5542,39 +5593,56 @@ class LanternBindings { late final _isTagAvailable = _isTagAvailablePtr .asFunction Function(ffi.Pointer)>(); - ffi.Pointer startAutoLocationListener() { - return _startAutoLocationListener(); + ffi.Pointer getAvailableServers() { + return _getAvailableServers(); } - late final _startAutoLocationListenerPtr = + late final _getAvailableServersPtr = _lookup Function()>>( - 'startAutoLocationListener', + 'getAvailableServers', ); - late final _startAutoLocationListener = _startAutoLocationListenerPtr + late final _getAvailableServers = _getAvailableServersPtr .asFunction Function()>(); - ffi.Pointer stopAutoLocationListener() { - return _stopAutoLocationListener(); + ffi.Pointer startVPN() { + return _startVPN(); } - late final _stopAutoLocationListenerPtr = - _lookup Function()>>( - 'stopAutoLocationListener', - ); - late final _stopAutoLocationListener = _stopAutoLocationListenerPtr + late final _startVPNPtr = + _lookup Function()>>('startVPN'); + late final _startVPN = _startVPNPtr .asFunction Function()>(); - ffi.Pointer getAvailableServers() { - return _getAvailableServers(); + ffi.Pointer stopVPN() { + return _stopVPN(); } - late final _getAvailableServersPtr = - _lookup Function()>>( - 'getAvailableServers', - ); - late final _getAvailableServers = _getAvailableServersPtr + late final _stopVPNPtr = + _lookup Function()>>('stopVPN'); + late final _stopVPN = _stopVPNPtr .asFunction Function()>(); + ffi.Pointer connectToServer(ffi.Pointer _tag) { + return _connectToServer(_tag); + } + + late final _connectToServerPtr = + _lookup< + ffi.NativeFunction< + ffi.Pointer Function(ffi.Pointer) + > + >('connectToServer'); + late final _connectToServer = _connectToServerPtr + .asFunction Function(ffi.Pointer)>(); + + int isVPNConnected() { + return _isVPNConnected(); + } + + late final _isVPNConnectedPtr = + _lookup>('isVPNConnected'); + late final _isVPNConnected = _isVPNConnectedPtr.asFunction(); + ffi.Pointer getUserData() { return _getUserData(); } @@ -5904,9 +5972,8 @@ class LanternBindings { ffi.Pointer deleteAccount( ffi.Pointer _email, ffi.Pointer _password, - int _isSSO, ) { - return _deleteAccount(_email, _password, _isSSO); + return _deleteAccount(_email, _password); } late final _deleteAccountPtr = @@ -5915,7 +5982,6 @@ class LanternBindings { ffi.Pointer Function( ffi.Pointer, ffi.Pointer, - ffi.Int, ) > >('deleteAccount'); @@ -5924,7 +5990,6 @@ class LanternBindings { ffi.Pointer Function( ffi.Pointer, ffi.Pointer, - int, ) >(); @@ -6155,29 +6220,18 @@ class LanternBindings { ffi.Pointer addServerBasedOnURLs( ffi.Pointer _urls, int _skipCertVerification, - ffi.Pointer _serverName, ) { - return _addServerBasedOnURLs(_urls, _skipCertVerification, _serverName); + return _addServerBasedOnURLs(_urls, _skipCertVerification); } late final _addServerBasedOnURLsPtr = _lookup< ffi.NativeFunction< - ffi.Pointer Function( - ffi.Pointer, - ffi.Int, - ffi.Pointer, - ) + ffi.Pointer Function(ffi.Pointer, ffi.Int) > >('addServerBasedOnURLs'); late final _addServerBasedOnURLs = _addServerBasedOnURLsPtr - .asFunction< - ffi.Pointer Function( - ffi.Pointer, - int, - ffi.Pointer, - ) - >(); + .asFunction Function(ffi.Pointer, int)>(); ffi.Pointer setBlockAdsEnabled(int enabled) { return _setBlockAdsEnabled(enabled); @@ -6280,17 +6334,6 @@ class LanternBindings { ) >(); - ffi.Pointer getAppDataDir() { - return _getAppDataDir(); - } - - late final _getAppDataDirPtr = - _lookup Function()>>( - 'getAppDataDir', - ); - late final _getAppDataDir = _getAppDataDirPtr - .asFunction Function()>(); - ffi.Pointer getEnabledApps() { return _getEnabledApps(); } @@ -6302,82 +6345,75 @@ class LanternBindings { late final _getEnabledApps = _getEnabledAppsPtr .asFunction Function()>(); - ffi.Pointer startVPN( - ffi.Pointer _logDir, - ffi.Pointer _dataDir, - ffi.Pointer _locale, - ) { - return _startVPN(_logDir, _dataDir, _locale); + ffi.Pointer patchSettings(ffi.Pointer patchJSON) { + return _patchSettings(patchJSON); } - late final _startVPNPtr = + late final _patchSettingsPtr = _lookup< ffi.NativeFunction< - ffi.Pointer Function( - ffi.Pointer, - ffi.Pointer, - ffi.Pointer, - ) + ffi.Pointer Function(ffi.Pointer) > - >('startVPN'); - late final _startVPN = _startVPNPtr - .asFunction< - ffi.Pointer Function( - ffi.Pointer, - ffi.Pointer, - ffi.Pointer, - ) - >(); + >('patchSettings'); + late final _patchSettings = _patchSettingsPtr + .asFunction Function(ffi.Pointer)>(); - ffi.Pointer stopVPN() { - return _stopVPN(); + ffi.Pointer getSettings() { + return _getSettings(); } - late final _stopVPNPtr = - _lookup Function()>>('stopVPN'); - late final _stopVPN = _stopVPNPtr + late final _getSettingsPtr = + _lookup Function()>>( + 'getSettings', + ); + late final _getSettings = _getSettingsPtr .asFunction Function()>(); - ffi.Pointer connectToServer( - ffi.Pointer _location, - ffi.Pointer _tag, - ffi.Pointer _logDir, - ffi.Pointer _dataDir, - ffi.Pointer _locale, - ) { - return _connectToServer(_location, _tag, _logDir, _dataDir, _locale); + ffi.Pointer patchEnvVars(ffi.Pointer patchJSON) { + return _patchEnvVars(patchJSON); } - late final _connectToServerPtr = + late final _patchEnvVarsPtr = _lookup< ffi.NativeFunction< - ffi.Pointer Function( - ffi.Pointer, - ffi.Pointer, - ffi.Pointer, - ffi.Pointer, - ffi.Pointer, - ) + ffi.Pointer Function(ffi.Pointer) > - >('connectToServer'); - late final _connectToServer = _connectToServerPtr - .asFunction< - ffi.Pointer Function( - ffi.Pointer, - ffi.Pointer, - ffi.Pointer, - ffi.Pointer, - ffi.Pointer, - ) - >(); + >('patchEnvVars'); + late final _patchEnvVars = _patchEnvVarsPtr + .asFunction Function(ffi.Pointer)>(); - int isVPNConnected() { - return _isVPNConnected(); + ffi.Pointer getEnvVars() { + return _getEnvVars(); } - late final _isVPNConnectedPtr = - _lookup>('isVPNConnected'); - late final _isVPNConnected = _isVPNConnectedPtr.asFunction(); + late final _getEnvVarsPtr = + _lookup Function()>>( + 'getEnvVars', + ); + late final _getEnvVars = _getEnvVarsPtr + .asFunction Function()>(); + + ffi.Pointer runURLTests() { + return _runURLTests(); + } + + late final _runURLTestsPtr = + _lookup Function()>>( + 'runURLTests', + ); + late final _runURLTests = _runURLTestsPtr + .asFunction Function()>(); + + ffi.Pointer updateConfig() { + return _updateConfig(); + } + + late final _updateConfigPtr = + _lookup Function()>>( + 'updateConfig', + ); + late final _updateConfig = _updateConfigPtr + .asFunction Function()>(); } typedef va_list = ffi.Pointer; diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index de931ac8b9..2b7ceba129 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -13,13 +13,14 @@ import 'package:lantern/core/models/datacap_info.dart'; import 'package:lantern/core/models/macos_extension_state.dart'; import 'package:lantern/core/models/plan_data.dart'; import 'package:lantern/core/models/private_server_status.dart'; +import 'package:lantern/core/models/server_location.dart'; +import 'package:lantern/core/models/user.dart'; import 'package:lantern/core/services/app_purchase.dart'; import 'package:lantern/core/services/injection_container.dart'; import 'package:lantern/core/utils/app_data_utils.dart'; import 'package:lantern/core/utils/enabled_apps.dart'; import 'package:lantern/lantern/lantern_core_service.dart'; import 'package:lantern/lantern/lantern_ffi_service.dart'; -import 'package:lantern/lantern/protos/protos/auth.pb.dart'; import '../core/models/lantern_status.dart'; import '../core/services/injection_container.dart' show sl; @@ -194,9 +195,8 @@ class LanternPlatformService implements LanternCoreService { } @override - Stream> watchLogs(String path) => accumulateLogBatches( - logsChannel.receiveBroadcastStream().map(_coerceLogBatch), - ); + Stream> watchLogs(String path) => + logsChannel.receiveBroadcastStream().map(_coerceLogBatch); List _coerceLogBatch(dynamic event) { if (event is List) { @@ -288,6 +288,52 @@ class LanternPlatformService implements LanternCoreService { } } + @override + Future> isSmartRoutingEnabled() async { + try { + final res = await _methodChannel.invokeMethod( + 'isSmartRoutingEnabled', + ); + return right(res ?? false); + } catch (e, st) { + appLogger.error('isSmartRoutingEnabled failed', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> isTelemetryEnabled() async { + try { + final res = await _methodChannel.invokeMethod('isTelemetryEnabled'); + return right(res ?? false); + } catch (e, st) { + appLogger.error('isTelemetryEnabled failed', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> isOAuthLogin() async { + try { + final res = await _methodChannel.invokeMethod('isOAuthLogin'); + return right(res ?? false); + } catch (e, st) { + appLogger.error('isOAuthLogin failed', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> getOAuthProvider() async { + try { + final res = await _methodChannel.invokeMethod('getOAuthProvider'); + return right(res ?? ''); + } catch (e, st) { + appLogger.error('getOAuthProvider failed', e, st); + return Left(e.toFailure()); + } + } + List _mapToAppData( Iterable> rawApps, { required EnabledAppsSnapshot enabled, @@ -785,13 +831,16 @@ class LanternPlatformService implements LanternCoreService { } @override - Future> oAuthLoginCallback(String token) async { + Future> oAuthLoginCallback( + String token, + ) async { try { final bytes = await _methodChannel.invokeMethod( 'oauthLoginCallback', token, ); - return Right(UserResponse.fromBuffer(bytes)); + final map = jsonDecode(utf8.decode(bytes)); + return Right(UserResponseModel.fromJson(map)); } catch (e, stackTrace) { appLogger.error('Error handling OAuth login callback', e, stackTrace); return Left( @@ -807,10 +856,11 @@ class LanternPlatformService implements LanternCoreService { /// /// Get user data from local storage @override - Future> getUserData() async { + Future> getUserData() async { try { final bytes = await _methodChannel.invokeMethod('getUserData'); - return Right(UserResponse.fromBuffer(bytes)); + final map = jsonDecode(utf8.decode(bytes)); + return Right(UserResponseModel.fromJson(map)); } catch (e, stackTrace) { appLogger.error('Error while getUserData user data', e, stackTrace); return Left( @@ -824,10 +874,11 @@ class LanternPlatformService implements LanternCoreService { /// Fetch user data from server @override - Future> fetchUserData() async { + Future> fetchUserData() async { try { - final userBytes = await _methodChannel.invokeMethod('fetchUserData'); - return Right(UserResponse.fromBuffer(userBytes)); + final bytes = await _methodChannel.invokeMethod('fetchUserData'); + final map = jsonDecode(utf8.decode(bytes)); + return Right(UserResponseModel.fromJson(map)); } catch (e, stackTrace) { appLogger.error("error fetching user data", e, stackTrace); return Left( @@ -844,6 +895,7 @@ class LanternPlatformService implements LanternCoreService { try { appLogger.debug('Fetching data cap info from platform service'); final json = await _methodChannel.invokeMethod('getDataCapInfo'); + appLogger.debug('Raw data cap info JSON: $json'); final map = jsonDecode(json!); final dataCap = DataCapUsageResponse.fromJson(map); return Right(dataCap); @@ -923,7 +975,7 @@ class LanternPlatformService implements LanternCoreService { /// Authentication methods @override - Future> login({ + Future> login({ required String email, required String password, }) async { @@ -932,7 +984,8 @@ class LanternPlatformService implements LanternCoreService { 'email': email, 'password': password, }); - return Right(UserResponse.fromBuffer(bytes)); + final map = jsonDecode(utf8.decode(bytes)); + return Right(UserResponseModel.fromJson(map)); } catch (e) { appLogger.error('Error logging', e); return Left(e.toFailure()); @@ -940,10 +993,11 @@ class LanternPlatformService implements LanternCoreService { } @override - Future> logout(String email) async { + Future> logout(String email) async { try { final bytes = await _methodChannel.invokeMethod('logout', email); - return Right(UserResponse.fromBuffer(bytes)); + final map = jsonDecode(utf8.decode(bytes)); + return Right(UserResponseModel.fromJson(map)); } catch (e, stackTrace) { appLogger.error('Error logging out', e, stackTrace); return Left(e.toFailure()); @@ -1018,7 +1072,7 @@ class LanternPlatformService implements LanternCoreService { } @override - Future> deleteAccount({ + Future> deleteAccount({ required String email, required String password, bool isSSO = false, @@ -1029,7 +1083,8 @@ class LanternPlatformService implements LanternCoreService { 'password': password, 'isSSO': isSSO, }); - return Right(UserResponse.fromBuffer(bytes)); + final map = jsonDecode(utf8.decode(bytes)); + return Right(UserResponseModel.fromJson(map)); } catch (e, stackTrace) { appLogger.error('Error deleting account', e, stackTrace); return Left(e.toFailure()); @@ -1191,18 +1246,21 @@ class LanternPlatformService implements LanternCoreService { } @override - Future> addServerBasedOnURLs({ + Future>> addServerBasedOnURLs({ required String urls, required bool skipCertVerification, - required String serverName, }) async { try { - await _methodChannel.invokeMethod('addServerBasedOnURLs', { - 'urls': urls, - 'skipValidation': skipCertVerification, - 'serverName': serverName, - }); - return Right(unit); + final result = await _methodChannel.invokeMethod( + 'addServerBasedOnURLs', + { + 'urls': urls, + 'skipValidation': skipCertVerification, + 'serverName': '', + }, + ); + final tags = (jsonDecode(result ?? '[]') as List).cast(); + return Right(tags); } catch (e, stackTrace) { appLogger.error('Error adding server based on URLs', e, stackTrace); return Left(e.toFailure()); @@ -1275,34 +1333,49 @@ class LanternPlatformService implements LanternCoreService { } } + @override + Future> getSelectedServerLocation() async { + try { + final result = await _methodChannel.invokeMethod( + 'getSelectedServerJSON', + ); + // Normalize a missing selection to an empty JSON object so callers + // fall into the "auto" branch below instead of throwing. + final raw = (result == null || result.isEmpty) ? '{}' : result; + final json = jsonDecode(raw) as Map; + final serverJson = json['server'] as Map?; + if (serverJson == null) { + return Right( + ServerLocation( + serverType: ServerLocationType.auto.name, + serverName: '', + ), + ); + } + final server = Server.fromJson(serverJson); + return Right( + ServerLocation.fromServer(server: server).copyWith( + serverType: server.isLantern + ? ServerLocationType.lanternLocation.name + : ServerLocationType.privateServer.name, + ), + ); + } catch (e, stackTrace) { + appLogger.error('Error fetching selected server', e, stackTrace); + return Left(e.toFailure()); + } + } + @override Future> getLanternAvailableServers() async { try { final result = await _methodChannel.invokeMethod( 'getLanternAvailableServers', ); - final servers = AvailableServers.fromJson(jsonDecode(result)); - - void applyProtocols(Lantern lantern) { - final outboundsByTag = { - for (var outbound in lantern.outbounds) outbound.tag: outbound.type, - }; - lantern.locations.forEach((key, value) { - final protoValue = outboundsByTag[key]; - if (protoValue != null) { - value.protocol = protoValue; - } else { - try { - value.protocol = value.tag.split('-').first; - } catch (e) { - value.protocol = ''; - } - } - }); - } - - applyProtocols(servers.lantern); - applyProtocols(servers.user); + appLogger.info("Servers JSON: $result"); + final servers = AvailableServers.fromJson( + jsonDecode(result) as List, + ); return Right(servers); } catch (e, stackTrace) { appLogger.error( @@ -1482,4 +1555,35 @@ class LanternPlatformService implements LanternCoreService { return Left(e.toFailure()); } } + + @override + Future> patchSettings(Map updates) => + _unsupportedOnMobile('patchSettings'); + + @override + Future>> getSettings() => + _unsupportedOnMobile('getSettings'); + + @override + Future>> patchEnvVars( + Map updates, + ) => _unsupportedOnMobile('patchEnvVars'); + + @override + Future>> getEnvVars() => + _unsupportedOnMobile('getEnvVars'); + + @override + Future> runURLTests() => + _unsupportedOnMobile('runURLTests'); + + @override + Future> sendConfigRequest() => + _unsupportedOnMobile('sendConfigRequest'); + + Future> _unsupportedOnMobile(String name) async { + final msg = '$name is not supported '; + appLogger.warning(msg); + return left(Failure(error: msg, localizedErrorMessage: msg)); + } } diff --git a/lib/lantern/lantern_service.dart b/lib/lantern/lantern_service.dart index 70b222ea7f..c8fb82210d 100644 --- a/lib/lantern/lantern_service.dart +++ b/lib/lantern/lantern_service.dart @@ -5,6 +5,7 @@ import 'package:lantern/core/models/app_data.dart'; import 'package:lantern/core/models/app_event.dart'; import 'package:lantern/core/models/datacap_info.dart'; import 'package:lantern/core/models/lantern_status.dart'; +import 'package:lantern/core/models/server_location.dart'; import 'package:lantern/core/models/macos_extension_state.dart'; import 'package:lantern/core/models/plan_data.dart'; import 'package:lantern/core/models/private_server_status.dart'; @@ -12,7 +13,7 @@ import 'package:lantern/core/services/app_purchase.dart'; import 'package:lantern/lantern/lantern_core_service.dart'; import 'package:lantern/lantern/lantern_ffi_service.dart'; import 'package:lantern/lantern/lantern_platform_service.dart'; -import 'package:lantern/lantern/protos/protos/auth.pb.dart'; +import 'package:lantern/core/models/user.dart'; import '../core/common/common.dart' hide DeveloperMode; import '../core/models/available_servers.dart'; @@ -284,7 +285,7 @@ class LanternService implements LanternCoreService { } @override - Future> oAuthLoginCallback(String token) { + Future> oAuthLoginCallback(String token) { if (PlatformUtils.isFFISupported) { return _ffiService.oAuthLoginCallback(token); } @@ -292,7 +293,7 @@ class LanternService implements LanternCoreService { } @override - Future> getUserData() { + Future> getUserData() { if (PlatformUtils.isFFISupported) { return _ffiService.getUserData(); } @@ -316,7 +317,7 @@ class LanternService implements LanternCoreService { } @override - Future> fetchUserData() { + Future> fetchUserData() { if (PlatformUtils.isFFISupported) { return _ffiService.fetchUserData(); } @@ -341,7 +342,7 @@ class LanternService implements LanternCoreService { } @override - Future> logout(String email) { + Future> logout(String email) { if (PlatformUtils.isFFISupported) { return _ffiService.logout(email); } @@ -369,7 +370,7 @@ class LanternService implements LanternCoreService { } @override - Future> login({ + Future> login({ required String email, required String password, }) { @@ -430,7 +431,7 @@ class LanternService implements LanternCoreService { } @override - Future> deleteAccount({ + Future> deleteAccount({ required String email, required String password, bool isSSO = false, @@ -558,22 +559,19 @@ class LanternService implements LanternCoreService { } @override - Future> addServerBasedOnURLs({ + Future>> addServerBasedOnURLs({ required String urls, required bool skipCertVerification, - required String serverName, }) { if (PlatformUtils.isFFISupported) { return _ffiService.addServerBasedOnURLs( urls: urls, skipCertVerification: skipCertVerification, - serverName: serverName, ); } return _platformService.addServerBasedOnURLs( urls: urls, skipCertVerification: skipCertVerification, - serverName: serverName, ); } @@ -697,6 +695,14 @@ class LanternService implements LanternCoreService { return _platformService.getAutoServerLocation(); } + @override + Future> getSelectedServerLocation() { + if (PlatformUtils.isFFISupported) { + return _ffiService.getSelectedServerLocation(); + } + return _platformService.getSelectedServerLocation(); + } + @override Future> triggerSystemExtension() { if (PlatformUtils.isFFISupported) { @@ -767,6 +773,38 @@ class LanternService implements LanternCoreService { return _platformService.setBlockAdsEnabled(enabled); } + @override + Future> isSmartRoutingEnabled() { + if (PlatformUtils.isFFISupported) { + return _ffiService.isSmartRoutingEnabled(); + } + return _platformService.isSmartRoutingEnabled(); + } + + @override + Future> isTelemetryEnabled() { + if (PlatformUtils.isFFISupported) { + return _ffiService.isTelemetryEnabled(); + } + return _platformService.isTelemetryEnabled(); + } + + @override + Future> isOAuthLogin() { + if (PlatformUtils.isFFISupported) { + return _ffiService.isOAuthLogin(); + } + return _platformService.isOAuthLogin(); + } + + @override + Future> getOAuthProvider() { + if (PlatformUtils.isFFISupported) { + return _ffiService.getOAuthProvider(); + } + return _platformService.getOAuthProvider(); + } + @override Future> attachReferralCode(String code) { if (PlatformUtils.isFFISupported) { @@ -819,4 +857,54 @@ class LanternService implements LanternCoreService { } return _platformService.checkVpnConflict(); } + + @override + Future> patchSettings(Map updates) { + if (PlatformUtils.isFFISupported) { + return _ffiService.patchSettings(updates); + } + return _platformService.patchSettings(updates); + } + + @override + Future>> getSettings() { + if (PlatformUtils.isFFISupported) { + return _ffiService.getSettings(); + } + return _platformService.getSettings(); + } + + @override + Future>> patchEnvVars( + Map updates, + ) { + if (PlatformUtils.isFFISupported) { + return _ffiService.patchEnvVars(updates); + } + return _platformService.patchEnvVars(updates); + } + + @override + Future>> getEnvVars() { + if (PlatformUtils.isFFISupported) { + return _ffiService.getEnvVars(); + } + return _platformService.getEnvVars(); + } + + @override + Future> runURLTests() { + if (PlatformUtils.isFFISupported) { + return _ffiService.runURLTests(); + } + return _platformService.runURLTests(); + } + + @override + Future> sendConfigRequest() { + if (PlatformUtils.isFFISupported) { + return _ffiService.sendConfigRequest(); + } + return _platformService.sendConfigRequest(); + } } diff --git a/lib/lantern/lantern_windows_service.dart b/lib/lantern/lantern_windows_service.dart deleted file mode 100644 index 0e3e2d9164..0000000000 --- a/lib/lantern/lantern_windows_service.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'dart:async'; - -import 'package:fpdart/fpdart.dart'; -import 'package:lantern/core/common/common.dart'; -import 'package:lantern/core/models/lantern_status.dart'; -import 'package:lantern/core/windows/pipe_client.dart'; -import 'package:lantern/core/windows/pipe_commands.dart'; - -class LanternServiceWindows { - LanternServiceWindows(this._rpcPipe); - - static const Duration _statusOriginTtl = Duration(seconds: 15); - - final PipeClient _rpcPipe; - - // dedicated streaming pipes - PipeClient? _statusPipe; - PipeClient? _logsPipe; - VPNStatusOrigin _pendingStatusOrigin = VPNStatusOrigin.unknown; - DateTime _pendingStatusOriginExpiresAt = DateTime.fromMillisecondsSinceEpoch( - 0, - ); - - Future init() async { - try { - appLogger.info('[WS] RPC connect()…'); - await _rpcPipe.connect(); - appLogger.info('[WS] RPC connected. token=${_rpcPipe.token}'); - } catch (e, st) { - appLogger.error('[WS] RPC connect() failed', e, st); - rethrow; - } - try { - _statusPipe = PipeClient(token: _rpcPipe.token); - appLogger.info('[WS] watchStatus() stream created'); - } catch (e, st) { - appLogger.error('[WS] watchStatus() setup failed', e, st); - rethrow; - } - } - - Future dispose() async { - await _statusPipe?.close(); - await _rpcPipe.close(); - } - - Future> connect() async { - try { - await _rpcPipe.call(ServiceCommand.startTunnel.wire); - return right('ok'); - } catch (e) { - appLogger.error('[WS] connect() failed', e); - return Left(e.toFailure()); - } - } - - Future> disconnect() async { - try { - await _rpcPipe.call(ServiceCommand.stopTunnel.wire); - return right('ok'); - } catch (e) { - return Left(e.toFailure()); - } - } - - Future> connectToServer( - String location, - String tag, - ) async { - try { - await _rpcPipe.call(ServiceCommand.connectToServer.wire, { - 'location': location, - 'tag': tag, - }); - return right('ok'); - } catch (e) { - appLogger.error( - '[WS] connectToServer() failed for location=$location, tag=$tag', - e, - ); - return Left(e.toFailure()); - } - } - - Future> isVPNConnected() async { - try { - final res = await _rpcPipe.call(ServiceCommand.isVPNRunning.wire); - final running = (res['running'] as bool?) ?? false; - return right(running); - } catch (e) { - return Left(e.toFailure()); - } - } - - void setNextStatusOrigin(VPNStatusOrigin origin) { - _pendingStatusOrigin = origin; - _pendingStatusOriginExpiresAt = DateTime.now().add(_statusOriginTtl); - } - - Stream watchVPNStatus() { - _statusPipe ??= PipeClient(token: _rpcPipe.token); - return _statusPipe! - .watchStatus() - .map((evt) { - final data = evt; - final raw = data['state'] as String? ?? 'Disconnected'; - final error = data['error']; - final origin = _resolveStatusOrigin(data['origin']); - final status = LanternStatus.fromJson({ - 'status': raw.toLowerCase(), - 'error': error, - 'origin': origin, - }); - - final terminal = - status.status == VPNStatus.connected || - status.status == VPNStatus.disconnected || - status.status == VPNStatus.error; - if (_pendingStatusOrigin != VPNStatusOrigin.unknown && - terminal && - _shouldClearPendingOrigin(status.status)) { - _clearPendingStatusOrigin(); - } - - return status; - }) - .handleError((error, st) { - appLogger.error('[WS] watchStatus() stream error', error, st); - }); - } - - String _resolveStatusOrigin(dynamic originFromEvent) { - if (originFromEvent is String && originFromEvent.isNotEmpty) { - return originFromEvent; - } - - if (DateTime.now().isAfter(_pendingStatusOriginExpiresAt)) { - _clearPendingStatusOrigin(); - } - return _pendingStatusOrigin.wireValue; - } - - void _clearPendingStatusOrigin() { - _pendingStatusOrigin = VPNStatusOrigin.unknown; - _pendingStatusOriginExpiresAt = DateTime.fromMillisecondsSinceEpoch(0); - } - - bool _shouldClearPendingOrigin(VPNStatus status) { - if (_pendingStatusOrigin == VPNStatusOrigin.settingsMutation && - status == VPNStatus.disconnected) { - // Settings changes can trigger a reconnect sequence where disconnected - // is transient before connected. Keep origin until the sequence settles. - return false; - } - return true; - } - - Stream> watchLogs() { - _logsPipe ??= PipeClient(token: _rpcPipe.token); - return _logsPipe!.watchLogs(); - } -} diff --git a/lib/main.dart b/lib/main.dart index 5be2d683b5..9d31b7e12a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -47,8 +47,24 @@ Future main() async { } // Auto-updater is desktop-only (no-op on mobile) and already guarded - // internally by kDebugMode and platform checks. - await sl().init(); + // internally by kDebugMode and platform checks. Do not await: Sparkle's + // setFeedURL / setScheduledCheckInterval are synchronous bridge calls that + // can block first paint when the feed URL is slow to resolve or the + // framework is touching keychain state. The first actual update check is + // already deferred 45 s inside init(). + // + // Guard the sl() lookup: if injectServices() threw above, Updater + // (registered at injection_container.dart:40) may not be in the registry, + // and the synchronous lookup would throw and prevent runApp. + try { + if (sl.isRegistered()) { + unawaited(sl().init()); + } else { + appLogger.warning('Updater not registered, skipping init'); + } + } catch (e, st) { + appLogger.error('Failed to start Updater.init', e, st); + } runApp( ProviderScope( diff --git a/linux/packaging/arch/postinstall.sh b/linux/packaging/arch/postinstall.sh index 53ce68b9ff..07625d8018 100755 --- a/linux/packaging/arch/postinstall.sh +++ b/linux/packaging/arch/postinstall.sh @@ -1,7 +1,4 @@ #!/bin/sh set -e -UNIT="lanternd.service" - -systemctl daemon-reload >/dev/null 2>&1 || true -systemctl enable --now "$UNIT" >/dev/null 2>&1 || true +/usr/lib/lantern/lanternd install --log-level=trace >/dev/null 2>&1 || true diff --git a/linux/packaging/arch/postremove.sh b/linux/packaging/arch/postremove.sh index e0f4d7a653..f47e71a058 100755 --- a/linux/packaging/arch/postremove.sh +++ b/linux/packaging/arch/postremove.sh @@ -1,8 +1,4 @@ #!/bin/sh set -e -UNIT="lanternd.service" - -systemctl disable "$UNIT" >/dev/null 2>&1 || true -systemctl daemon-reload >/dev/null 2>&1 || true -systemctl reset-failed "$UNIT" >/dev/null 2>&1 || true +systemctl reset-failed lanternd.service >/dev/null 2>&1 || true diff --git a/linux/packaging/arch/preremove.sh b/linux/packaging/arch/preremove.sh index 2a8074cd62..267e82bd15 100755 --- a/linux/packaging/arch/preremove.sh +++ b/linux/packaging/arch/preremove.sh @@ -1,5 +1,4 @@ #!/bin/sh set -e -UNIT="lanternd.service" -systemctl stop "$UNIT" >/dev/null 2>&1 || true +/usr/lib/lantern/lanternd uninstall >/dev/null 2>&1 || true diff --git a/linux/packaging/deb/scripts/postinst b/linux/packaging/deb/scripts/postinst index e3e94690d6..439d5a950d 100755 --- a/linux/packaging/deb/scripts/postinst +++ b/linux/packaging/deb/scripts/postinst @@ -7,26 +7,9 @@ warn() { case "$1" in configure) - if ! getent group lantern >/dev/null 2>&1; then - groupadd --system lantern >/dev/null 2>&1 || true - fi - if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] && id "$SUDO_USER" >/dev/null 2>&1; then - usermod -a -G lantern "$SUDO_USER" >/dev/null 2>&1 || true - fi - if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then - systemctl daemon-reload - if ! systemctl enable --now lanternd.service; then - warn "failed to enable/start lanternd.service" - warn "run: sudo systemctl status lanternd.service" - exit 1 - fi - if ! systemctl is-active --quiet lanternd.service; then - warn "lanternd.service is not active after install" - warn "run: sudo journalctl -u lanternd.service -n 200 --no-pager" - exit 1 - fi - else - warn "systemd not detected; lanternd.service was not auto-started" + if ! /usr/lib/lantern/lanternd install --log-level=trace; then + warn "failed to install lanternd service" + exit 1 fi ;; esac diff --git a/linux/packaging/deb/scripts/postrm b/linux/packaging/deb/scripts/postrm index 87d803b231..fcdfa81fea 100755 --- a/linux/packaging/deb/scripts/postrm +++ b/linux/packaging/deb/scripts/postrm @@ -4,8 +4,6 @@ set -e case "$1" in remove | purge) if command -v systemctl >/dev/null 2>&1; then - systemctl disable lanternd.service >/dev/null 2>&1 || true - systemctl daemon-reload >/dev/null 2>&1 || true systemctl reset-failed lanternd.service >/dev/null 2>&1 || true fi ;; diff --git a/linux/packaging/deb/scripts/prerm b/linux/packaging/deb/scripts/prerm index eb3903e515..0b193f482e 100755 --- a/linux/packaging/deb/scripts/prerm +++ b/linux/packaging/deb/scripts/prerm @@ -3,9 +3,7 @@ set -e case "$1" in remove | deconfigure) - if command -v systemctl >/dev/null 2>&1; then - systemctl stop lanternd.service >/dev/null 2>&1 || true - fi + /usr/lib/lantern/lanternd uninstall >/dev/null 2>&1 || true ;; esac diff --git a/linux/packaging/nfpm.yaml b/linux/packaging/nfpm.yaml index 9dc110ec67..493cffa419 100644 --- a/linux/packaging/nfpm.yaml +++ b/linux/packaging/nfpm.yaml @@ -11,21 +11,16 @@ section: x11 priority: optional contents: - # Flutter release bundle + # Flutter release bundle (includes lanternd) - src: "${LINUX_BUNDLE_SRC}" dst: /usr/lib/lantern type: tree expand: true - # lanternd service binary - - src: "${LANTERND_SRC}" - dst: "${LANTERND_DST}" - expand: true - - # systemd unit - - src: "${SYSTEMD_UNIT_SRC}" - dst: /usr/lib/systemd/system/lanternd.service - expand: true + # symlink so lantern is on PATH + - src: /usr/lib/lantern/lantern + dst: /usr/bin/lantern + type: symlink # desktop entry - src: linux/packaging/desktop/lantern.desktop @@ -35,11 +30,6 @@ contents: - src: assets/images/lantern_app_icon.png dst: /usr/share/icons/hicolor/128x128/apps/lantern.png - # symlink so lantern is on PATH - - src: /usr/lib/lantern/lantern - dst: /usr/bin/lantern - type: symlink - scripts: postinstall: linux/packaging/deb/scripts/postinst preremove: linux/packaging/deb/scripts/prerm diff --git a/macos/PacketTunnel/PacketTunnelProvider.swift b/macos/PacketTunnel/PacketTunnelProvider.swift index d20f3d3848..7f15aa2088 100644 --- a/macos/PacketTunnel/PacketTunnelProvider.swift +++ b/macos/PacketTunnel/PacketTunnelProvider.swift @@ -35,15 +35,13 @@ public class PacketTunnelProvider: ExtensionProvider { switch method { case "PrivateServer": appLogger.info("Received connectServer command with params: \(params)") - guard let server = params["server"] as? String, - let location = params["location"] as? String - else { + guard let server = params["server"] as? String else { return respond(["error": "Missing parameters"]) } - appLogger.info("Connecting to server \(server) at location \(location)") - connectToServer(location: location, serverName: server) { success, errorMessage in + appLogger.info("Connecting to server \(server)") + connectToServer(serverName: server) { success, errorMessage in if success { - respond(["result": "Connected to \(server) at \(location)"]) + respond(["result": "Connected to \(server)"]) } else { respond(["error": errorMessage ?? "Unknown error"]) } @@ -51,13 +49,14 @@ public class PacketTunnelProvider: ExtensionProvider { break case "Lantern": appLogger.info("Received Lantern command") - startVPN(completion: { success, errorMessage in - if success { - respond(["result": "Lantern VPN started"]) - } else { - respond(["error": errorMessage ?? "Unknown error"]) + appLogger.info("VPN already active connecting to Lantern/auto") + connectToServer(serverName: "auto") { success, errorMessage in + if success { + respond(["result": "Connected to auto tag"]) + } else { + respond(["error": errorMessage ?? "Unknown error"]) + } } - }) default: respond(["error": "Unknown method"]) diff --git a/macos/PacketTunnel/SingBox/ExtensionProvider.swift b/macos/PacketTunnel/SingBox/ExtensionProvider.swift index 977071e819..b69ce7ac81 100644 --- a/macos/PacketTunnel/SingBox/ExtensionProvider.swift +++ b/macos/PacketTunnel/SingBox/ExtensionProvider.swift @@ -29,20 +29,26 @@ public class ExtensionProvider: NEPacketTunnelProvider { if platformInterface == nil { platformInterface = ExtensionPlatformInterface(self) } + + // Start the IPC server before any VPN operations + var ipcError: NSError? + MobileStartIPCServer(platformInterface, opts(), &ipcError) + if let ipcError { + appLogger.error("error starting IPC server: \(ipcError.localizedDescription)") + throw ipcError + } + let tunnelType = options?["netEx.Type"] as? String switch tunnelType { case "Lantern": appLogger.info("(lantern-tunnel) user initiated connection") startVPN() case "PrivateServer": - guard - let serverName = options?["netEx.ServerName"] as? String, - let location = options?["netEx.Location"] as? String - else { - writeFatalError("Missing netEx.ServerName or netEx.Location") + guard let serverName = options?["netEx.ServerName"] as? String else { + writeFatalError("Missing netEx.ServerName") return } - connectToServer(location: location, serverName: serverName) + connectToServer(serverName: serverName) default: // Fallback or unknown type appLogger.info("(lantern-tunnel) unknown tunnel type \(String(describing: tunnelType))") @@ -61,7 +67,7 @@ public class ExtensionProvider: NEPacketTunnelProvider { appLogger.log("(lantern-tunnel) quick connect") var error: NSError? - MobileStartVPN(platformInterface, opts(), &error) + MobileStartVPN(&error) if error != nil { appLogger.error("error while starting tunnel \(error?.localizedDescription ?? "")") // Inform system and close tunnel @@ -76,11 +82,11 @@ public class ExtensionProvider: NEPacketTunnelProvider { } func connectToServer( - location: String, serverName: String, completion: ((Bool, String?) -> Void)? = nil + serverName: String, completion: ((Bool, String?) -> Void)? = nil ) { appLogger.log("(lantern-tunnel) connecting to server") var error: NSError? - MobileConnectToServer(location, serverName, platformInterface, opts(), &error) + MobileConnectToServer(serverName, &error) if error != nil { appLogger.error("error while connecting to server \(error?.localizedDescription ?? "")") cancelTunnelWithError(error) @@ -100,9 +106,9 @@ public class ExtensionProvider: NEPacketTunnelProvider { if error != nil { appLogger.log("error while stopping tunnel \(error?.localizedDescription ?? "")") } - MobileCloseIPC(&error) + MobileCloseIPCServer(&error) if error != nil { - appLogger.log("error closing IPC \(error?.localizedDescription ?? "")") + appLogger.log("error closing IPC server \(error?.localizedDescription ?? "")") } appLogger.log("(lantern-tunnel) tunnel closed") platformInterface.reset() @@ -125,7 +131,15 @@ public class ExtensionProvider: NEPacketTunnelProvider { reasserting = false } stopService() - startVPN() + + // Don't cancelTunnelWithError on failure; this extension hosts the IPC server. + var error: NSError? + MobileStartVPN(&error) + if let error { + appLogger.error("(lantern-tunnel) restart failed: \(error.localizedDescription)") + return + } + appLogger.log("(lantern-tunnel) tunnel restarted successfully") } func postServiceClose() { diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index a7ad6e725a..7562a7da3b 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -21,6 +21,8 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 030897EB2F9F8A27001CBEBF /* FlutterEventListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030897E82F9F8A27001CBEBF /* FlutterEventListener.swift */; }; + 030897EC2F9F8A27001CBEBF /* PrivateServerListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030897EA2F9F8A27001CBEBF /* PrivateServerListener.swift */; }; 03545F012E8ABA7B0051E2F5 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 293C30602DEF68B300691115 /* Logger.swift */; }; 038636162EE69F7D004A11CF /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 038636152EE69F5D004A11CF /* libresolv.tbd */; }; 0386361F2EE6A026004A11CF /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 038636152EE69F5D004A11CF /* libresolv.tbd */; }; @@ -135,6 +137,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 030897E82F9F8A27001CBEBF /* FlutterEventListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlutterEventListener.swift; sourceTree = ""; }; + 030897E92F9F8A27001CBEBF /* ImageUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUtils.swift; sourceTree = ""; }; + 030897EA2F9F8A27001CBEBF /* PrivateServerListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateServerListener.swift; sourceTree = ""; }; 038636152EE69F5D004A11CF /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; }; 03A255842E71B26A0054C0D7 /* SystemExtensionStatusEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemExtensionStatusEventHandler.swift; sourceTree = ""; }; 03CC8B852E9408BC00EC8D51 /* FlutterEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlutterEventHandler.swift; sourceTree = ""; }; @@ -187,32 +192,6 @@ EDC1670D3723C38E9F4EBE3E /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ -/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 5F53D0342E4410F100740586 /* Exceptions for "Utils" folder in "Runner" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - FlutterEventListener.swift, - PrivateServerListener.swift, - ); - target = 33CC10EC2044A3C60003C045 /* Runner */; - }; -/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ - -/* Begin PBXFileSystemSynchronizedRootGroup section */ - 03D236712E3A06E20004EC53 /* Utils */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - 5F53D0342E4410F100740586 /* Exceptions for "Utils" folder in "Runner" target */, - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = Utils; - sourceTree = ""; - }; -/* End PBXFileSystemSynchronizedRootGroup section */ - /* Begin PBXFrameworksBuildPhase section */ 29952B012DE79CB200640E7F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -370,7 +349,6 @@ 33FAB671232836740065AC1E /* Runner */ = { isa = PBXGroup; children = ( - 03D236712E3A06E20004EC53 /* Utils */, 293C305D2DEF67EB00691115 /* VPN */, 293C30572DEF649500691115 /* Handlers */, 33CC10F02044A3C60003C045 /* AppDelegate.swift */, @@ -386,6 +364,9 @@ 5F53D0132E42440F00740586 /* Utils */ = { isa = PBXGroup; children = ( + 030897E82F9F8A27001CBEBF /* FlutterEventListener.swift */, + 030897E92F9F8A27001CBEBF /* ImageUtils.swift */, + 030897EA2F9F8A27001CBEBF /* PrivateServerListener.swift */, 5F53D0122E42440F00740586 /* PrivateServerListener.swift */, ); path = Utils; @@ -708,6 +689,8 @@ 293C30612DEF68B300691115 /* Logger.swift in Sources */, 03CC8B862E9408BC00EC8D51 /* FlutterEventHandler.swift in Sources */, 039A061F2E24DB6000B64255 /* KeychainService.swift in Sources */, + 030897EB2F9F8A27001CBEBF /* FlutterEventListener.swift in Sources */, + 030897EC2F9F8A27001CBEBF /* PrivateServerListener.swift in Sources */, 5F7422212E7C1FB000FC72BE /* LogsEventHandler.swift in Sources */, 5F7B9A5A2F0C13AC0019E3E0 /* AppStreamHandler.swift in Sources */, 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, diff --git a/macos/Runner/Handlers/LogsEventHandler.swift b/macos/Runner/Handlers/LogsEventHandler.swift index e9f417fd24..750cffef32 100644 --- a/macos/Runner/Handlers/LogsEventHandler.swift +++ b/macos/Runner/Handlers/LogsEventHandler.swift @@ -1,12 +1,18 @@ import FlutterMacOS import Foundation +import Liblantern final class LogsEventHandler: NSObject, FlutterPlugin, FlutterStreamHandler { static let name = "org.getlantern.lantern/logs" private var channel: FlutterEventChannel? private var eventSink: FlutterEventSink? - private var tailer: LogTailer? + private var subscription: MobileLogSubscription? + private var listener: LogEntryListener? + + deinit { + subscription?.cancel() + } static func register(with registrar: FlutterPluginRegistrar) { let inst = LogsEventHandler() @@ -17,127 +23,48 @@ final class LogsEventHandler: NSObject, FlutterPlugin, FlutterStreamHandler { func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + subscription?.cancel() + subscription = nil eventSink = events - try? FileManager.default.createDirectory( - at: FilePath.logsDirectory, withIntermediateDirectories: true) - - let logFile = FilePath.logsDirectory.appendingPathComponent("lantern.log") - - if let last = try? LogTailer.readLastLines(path: logFile.path, maxLines: 200) { - events(last) + let listener = LogEntryListener { [weak self] entry in + let trimmed = entry.trimmingCharacters(in: .newlines) + guard !trimmed.isEmpty else { return } + DispatchQueue.main.async { + self?.eventSink?([trimmed]) + } } - - tailer = LogTailer(path: logFile.path) { [weak self] newLines in - self?.eventSink?(newLines) + self.listener = listener + + var error: NSError? + subscription = MobileTailLogs(listener, &error) + if let error = error { + self.listener = nil + return FlutterError( + code: "tail_logs_failed", + message: error.localizedDescription, + details: nil) } - return nil } func onCancel(withArguments arguments: Any?) -> FlutterError? { - tailer?.stop() - tailer = nil + subscription?.cancel() + subscription = nil + listener = nil eventSink = nil return nil } } -final class LogTailer { - private let path: String - private var fd: Int32 = -1 - private var src: DispatchSourceFileSystemObject? - private var handle: FileHandle? - private var offset: UInt64 = 0 - private let onLines: ([String]) -> Void +private final class LogEntryListener: NSObject, UtilsLogListenerProtocol { + private let onEntry: (String) -> Void - init?(path: String, onLines: @escaping ([String]) -> Void) { - self.path = path - self.onLines = onLines - - if !FileManager.default.fileExists(atPath: path) { - FileManager.default.createFile(atPath: path, contents: nil) - } - handle = FileHandle(forReadingAtPath: path) - - fd = open(path, O_EVTONLY) - guard fd >= 0 else { return nil } - - if let size = (try? FileManager.default.attributesOfItem(atPath: path)[.size]) as? UInt64 { - offset = size - try? handle?.seek(toOffset: offset) - } - - let q = DispatchQueue.global(qos: .utility) - let s = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fd, eventMask: [.write, .extend, .rename, .delete], queue: q) - s.setEventHandler { [weak self] in self?.handleEvent() } - s.setCancelHandler { [weak self] in if let fd = self?.fd, fd >= 0 { close(fd) } } - s.resume() - src = s - } - - func stop() { - src?.cancel() - src = nil - try? handle?.close() - handle = nil - } - - private func reopenHandleIfNeeded(resetOffset: Bool) { - if !FileManager.default.fileExists(atPath: path) { - FileManager.default.createFile(atPath: path, contents: nil) - } - if handle == nil { - handle = FileHandle(forReadingAtPath: path) - } - if resetOffset { - offset = 0 - } - try? handle?.seek(toOffset: offset) - } - - private func handleEvent() { - guard let src = src else { return } - let ev = src.data - - if ev.contains(.rename) || ev.contains(.delete) { - src.suspend() - try? handle?.close() - handle = nil - // reopen and reset offset to start of new file - reopenHandleIfNeeded(resetOffset: true) - src.resume() - return - } - - do { - // If the handle is missing, try to reopen - if handle == nil { - reopenHandleIfNeeded(resetOffset: false) - } - guard let handle else { return } - try handle.seek(toOffset: offset) - let data = try handle.readToEnd() ?? Data() - guard !data.isEmpty else { return } - offset += UInt64(data.count) - let text = String(decoding: data, as: UTF8.self) - let lines = text.split(whereSeparator: \.isNewline).map(String.init) - if !lines.isEmpty { onLines(lines) } - } catch { - } + init(onEntry: @escaping (String) -> Void) { + self.onEntry = onEntry } - static func readLastLines(path: String, maxLines: Int) throws -> [String] { - let handle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path)) - defer { try? handle.close() } - let fileSize = try handle.seekToEnd() - let readSize = min(fileSize, 64 * 1024) - try handle.seek(toOffset: fileSize - readSize) - let data = try handle.readToEnd() ?? Data() - let lines = String(decoding: data, as: UTF8.self) - .split(whereSeparator: \.isNewline) - .map(String.init) - return Array(lines.suffix(maxLines)) + func onLogEntry(_ entry: String?) { + onEntry(entry ?? "") } } diff --git a/macos/Runner/Handlers/MethodHandler.swift b/macos/Runner/Handlers/MethodHandler.swift index 2032ff08e4..27b4c4e710 100644 --- a/macos/Runner/Handlers/MethodHandler.swift +++ b/macos/Runner/Handlers/MethodHandler.swift @@ -159,6 +159,9 @@ class MethodHandler { case "digitalOcean": self.digitalOcean(result: result) + case "googleCloud": + self.googleCloud(result: result) + case "selectAccount": let account = call.arguments as? String ?? "" self.selectAccount(result: result, account: account) @@ -217,6 +220,9 @@ class MethodHandler { case "getAutoServerLocation": self.getAutoServerLocation(result: result) + case "getSelectedServerJSON": + self.getSelectedServerJSON(result: result) + // Utils case "featureFlag": self.featureFlags(result: result) @@ -229,6 +235,11 @@ class MethodHandler { guard let data = self.decodeDict(from: call.arguments, result: result) else { return } self.reportIssue(result: result, data: data) + case "isBlockAdsEnabled": + Task { + await MainActor.run { result(MobileIsBlockAdsEnabled()) } + } + case "setBlockAdsEnabled": let data = call.arguments as? [String: Any] let enabled = data?["enabled"] as? Bool ?? false @@ -304,6 +315,26 @@ class MethodHandler { case "checkVpnConflict": self.checkVpnConflict(result: result) + case "isSmartRoutingEnabled": + Task { + await MainActor.run { result(MobileIsSmartRoutingEnabled()) } + } + + case "isTelemetryEnabled": + Task { + await MainActor.run { result(MobileIsTelemetryEnabled()) } + } + + case "isOAuthLogin": + Task { + await MainActor.run { result(MobileIsOAuthLogin()) } + } + + case "getOAuthProvider": + Task { + await MainActor.run { result(MobileGetOAuthProvider()) } + } + default: result(FlutterMethodNotImplemented) } @@ -314,11 +345,6 @@ class MethodHandler { Task { do { try await vpnManager.startTunnel() - var error: NSError? - MobileStartAutoLocationListener(&error) - if let error { - appLogger.error("Error getting auto location: \(error.localizedDescription)") - } await MainActor.run { result("VPN started successfully.") } @@ -344,16 +370,10 @@ class MethodHandler { private func connectToServer(result: @escaping FlutterResult, data: [String: Any]) { Task { do { - var error: NSError? - MobileStopAutoLocationListener(&error) - if let error { - appLogger.error("Error stopping auto location listener: \(error.localizedDescription)") - } - let location = data["location"] as? String ?? "" let serverName = data["serverName"] as? String ?? "" - try await self.vpnManager.connectToServer(location: location, serverName: serverName) + try await self.vpnManager.connectToServer(serverName: serverName) await MainActor.run { - result("VPN connected successfully to \(serverName) at \(location).") + result("VPN connected successfully to \(serverName).") } } catch { await MainActor.run { @@ -372,11 +392,6 @@ class MethodHandler { private func stopVPN(result: @escaping FlutterResult) { Task { do { - var error: NSError? - MobileStopAutoLocationListener(&error) - if let error { - appLogger.error("Error stopping auto location listener: \(error.localizedDescription)") - } try await vpnManager.stopTunnel() await MainActor.run { result("VPN stopped successfully.") @@ -506,13 +521,15 @@ class MethodHandler { private func oauthLoginCallback(result: @escaping FlutterResult, token: String) { Task { var error: NSError? - let data = MobileOAuthLoginCallback(token, &error) + let json = MobileOAuthLoginCallback(token, &error) if let error { await self.handleFlutterError(error, result: result, code: "OAUTH_LOGIN_CALLBACK") return } await MainActor.run { - result(data) + // Dart side expects bytes to utf8.decode — convert the gomobile-returned + // string back to Data to preserve the Flutter contract. + result(json.data(using: .utf8)) } } } @@ -520,13 +537,13 @@ class MethodHandler { private func getUserData(result: @escaping FlutterResult) { Task { var error: NSError? - let data = MobileUserData(&error) + let json = MobileUserData(&error) if let error { await self.handleFlutterError(error, result: result, code: "USER_DATA_ERROR") return } await MainActor.run { - result(data) + result(json.data(using: .utf8)) } } } @@ -548,13 +565,13 @@ class MethodHandler { private func fetchUserData(result: @escaping FlutterResult) { Task { var error: NSError? - let bytes = MobileFetchUserData(&error) + let json = MobileFetchUserData(&error) if let error { await self.handleFlutterError(error, result: result, code: "FETCH_USER_DATA_ERROR") return } await MainActor.run { - result(bytes) + result(json.data(using: .utf8)) } } } @@ -562,13 +579,13 @@ class MethodHandler { func acknowledgeInAppPurchase(token: String, planId: String, result: @escaping FlutterResult) { Task { var error: NSError? - let data = MobileAcknowledgeApplePurchase(token, planId, &error) + let json = MobileAcknowledgeApplePurchase(token, planId, &error) if let error { await self.handleFlutterError(error, result: result, code: "ACKNOWLEDGE_FAILED") return } await MainActor.run { - result(data) + result(json.data(using: .utf8)) } } } @@ -633,7 +650,7 @@ class MethodHandler { return } await MainActor.run { - result(payload) + result(payload.data(using: .utf8)) } } } @@ -661,7 +678,7 @@ class MethodHandler { return } await MainActor.run { - result(payload) + result(payload.data(using: .utf8)) } } } @@ -670,15 +687,14 @@ class MethodHandler { Task { let email = data["email"] as? String ?? "" let password = data["password"] as? String ?? "" - let isSSO = data["isSSO"] as? Bool ?? false var error: NSError? - let payload = MobileDeleteAccount(email, password, isSSO, &error) + let payload = MobileDeleteAccount(email, password, &error) if let error { await self.handleFlutterError(error, result: result, code: "DELETE_ACCOUNT_FAILED") return } await MainActor.run { - result(payload) + result(payload.data(using: .utf8)) } } } @@ -780,6 +796,20 @@ class MethodHandler { } } + func googleCloud(result: @escaping FlutterResult) { + Task { + var error: NSError? + MobileGoogleCloudPrivateServer(PrivateServerListener.shared, &error) + if let error { + await self.handleFlutterError(error, result: result, code: "GOOGLE_CLOUD_ERROR") + return + } + await MainActor.run { + result("ok") + } + } + } + func selectAccount(result: @escaping FlutterResult, account: String) { Task { var error: NSError? @@ -913,15 +943,14 @@ class MethodHandler { Task { let urls = data["urls"] as? String ?? "" let skipVerification = data["skipValidation"] as? Bool ?? false - let serverName = data["serverName"] as? String ?? "" var error: NSError? - MobileAddServerBasedOnURLs(urls, skipVerification, serverName, &error) + let tags = MobileAddServerBasedOnURLs(urls, skipVerification, &error) if let error { await self.handleFlutterError(error, result: result, code: "ADD_SERVER_BASED_ON_URLS_ERROR") return } - await self.replyOK(result) + await MainActor.run { result(tags) } } } @@ -980,15 +1009,9 @@ class MethodHandler { func featureFlags(result: @escaping FlutterResult) { Task { - let flags = MobileAvailableFeatures() - guard let flags else { - await MainActor.run { - result("{}") - } - return - } + let flags = MobileAvailableFeatures() ?? "" await MainActor.run { - result(String(data: flags, encoding: .utf8)) + result(flags.isEmpty ? "{}" : flags) } } } @@ -1013,12 +1036,24 @@ class MethodHandler { await self.handleFlutterError(error, result: result, code: "GET_LANTERN_SERVERS_ERROR") return } - guard let servers else { - await MainActor.run { result("[]") } + await MainActor.run { + let s = servers ?? "" + result(s.isEmpty ? "[]" : s) + } + } + } + + func getSelectedServerJSON(result: @escaping FlutterResult) { + Task { + var error: NSError? + let json = MobileGetSelectedServerJSON(&error) + if let error { + await self.handleFlutterError(error, result: result, code: "GET_SELECTED_SERVER_ERROR") return } await MainActor.run { - result(String(data: servers, encoding: .utf8)) + let s = json ?? "" + result(s.isEmpty ? "{}" : s) } } } diff --git a/macos/Runner/Handlers/PrivateServerEventHandler.swift b/macos/Runner/Handlers/PrivateServerEventHandler.swift index 52f4cf6eed..c51e72aae1 100644 --- a/macos/Runner/Handlers/PrivateServerEventHandler.swift +++ b/macos/Runner/Handlers/PrivateServerEventHandler.swift @@ -26,12 +26,12 @@ class PrivateServerEventHandler: NSObject, FlutterPlugin, FlutterStreamHandler { appLogger.info("PrivateServerEvent onListen called") cancellable = PrivateServerListener.shared.$eventSink .compactMap { $0 } + .receive(on: DispatchQueue.main) .sink { event in appLogger.info("PrivateServerEvent received event: \(event)") if !event.isEmpty { events(event) } - } return nil } diff --git a/macos/Runner/VPN/VPNManager.swift b/macos/Runner/VPN/VPNManager.swift index 26ea940aaf..9a77bf43e7 100644 --- a/macos/Runner/VPN/VPNManager.swift +++ b/macos/Runner/VPN/VPNManager.swift @@ -48,6 +48,32 @@ class VPNManager: VPNBase { } appLogger.log("VPNManager initialized") + Task { await syncStatus() } + } + + /// Loads an existing VPN profile from preferences and reads its current + /// connection status. This ensures the in-memory state reflects the system + /// state — for example when the VPN was connected via System Settings + /// before the app launched. + /// + /// Unlike setupVPN(), this does NOT create a new profile if none exists, + /// avoiding the system VPN permission prompt on first launch. + func syncStatus() async { + do { + let managers = try await NETunnelProviderManager.loadAllFromPreferences() + guard let existing = managers.first else { + // No VPN profile configured yet — nothing to sync. + return + } + self.manager = existing + let systemStatus = manager.connection.status + if systemStatus != connectionStatus { + appLogger.info("Syncing VPN status: \(connectionStatus) -> \(systemStatus)") + connectionStatus = systemStatus + } + } catch { + appLogger.error("Failed to sync VPN status: \(error.localizedDescription)") + } } deinit { @@ -133,7 +159,6 @@ class VPNManager: VPNBase { } func connectToServer( - location: String, serverName: String, ) async throws { await self.setupVPN() @@ -141,7 +166,6 @@ class VPNManager: VPNBase { "netEx.Type": "PrivateServer" as NSString, "netEx.StartReason": "Private server Initiated" as NSString, "netEx.ServerName": serverName as NSString, - "netEx.Location": location as NSString, ] if manager.connection.status == .connected || manager.connection.status == .connecting { @@ -149,7 +173,7 @@ class VPNManager: VPNBase { do { let result = try await triggerExtensionMethod( methodName: "PrivateServer", - params: ["server": serverName, "location": location] + params: ["server": serverName] ) return } catch { @@ -170,6 +194,7 @@ class VPNManager: VPNBase { /// Terminates the VPN connection and updates the configuration. func stopTunnel() async throws { appLogger.log("Stopping tunnel..") + await syncStatus() guard connectionStatus == .connected else { appLogger.log("In unexpected state: \(connectionStatus)") return diff --git a/scripts/android/android-reproduce b/scripts/android/android-reproduce new file mode 100755 index 0000000000..25b504f9cd --- /dev/null +++ b/scripts/android/android-reproduce @@ -0,0 +1,384 @@ +#!/usr/bin/env bash +# android-reproduce — Reproduce a Freshdesk ticket on an Android emulator +# +# Usage: +# android-reproduce [apk-path] +# +# Examples: +# android-reproduce /tmp/ticket-172722 # auto-downloads APK +# android-reproduce /tmp/ticket-172722 ~/Downloads/lantern.apk # uses provided APK +# +# The ticket-dir should contain files downloaded by /analyze-ticket: +# config.json — user's sing-box config (pushed to emulator) +# servers.json — user's proxy server list (pushed to emulator) +# split-tunnel.json — user's split-tunnel rules (pushed to emulator) +# +# What it does: +# 1. Extracts the user's country and app version from config.json +# 2. Downloads the matching APK from GitHub releases (if not provided) +# 3. Pushes config.json, servers.json, split-tunnel.json to the emulator +# so the app uses the exact same proxies, DNS rules, and rule sets +# 4. Sets RADIANCE_COUNTRY to match the user's region +# 5. Installs, restarts, and streams logs +# +# Prerequisites: +# - Run /analyze-ticket first to download attachments +# - Android SDK (same as android-test) +# - gh CLI (for auto-downloading APK from GitHub releases) + +set -euo pipefail + +PKG="org.getlantern.lantern" + +# --- Parse args --- +if [ $# -lt 1 ]; then + echo "Usage: android-reproduce [apk-path]" + echo "" + echo "Examples:" + echo " android-reproduce /tmp/ticket-172722 # auto-downloads APK" + echo " android-reproduce /tmp/ticket-172722 ~/Downloads/lantern.apk # uses provided APK" + echo "" + echo "Run /analyze-ticket first to download the config files." + exit 1 +fi + +TICKET_DIR="$1" +APK="${2:-}" + +# --- Locate config files --- +# /analyze-ticket puts files either directly in ticket-dir or in ticket-dir/logs/ +CONFIG_JSON="" +for candidate in "$TICKET_DIR/config.json" "$TICKET_DIR/logs/config.json"; do + if [ -f "$candidate" ]; then + CONFIG_JSON="$candidate" + break + fi +done + +if [ -z "$CONFIG_JSON" ]; then + echo "Error: config.json not found in $TICKET_DIR" + echo "Run /analyze-ticket first to download it." + exit 1 +fi + +echo "=== Ticket reproduction setup ===" +echo "Ticket dir: $TICKET_DIR" +echo "Config: $CONFIG_JSON" + +# --- Extract country from config --- +COUNTRY="" +if command -v python3 >/dev/null 2>&1; then + COUNTRY=$(python3 -c " +import json, sys +try: + with open('$CONFIG_JSON') as f: + cfg = json.load(f) + # Try nested ConfigResponse format (from Freshdesk attachment) + cr = cfg.get('ConfigResponse', cfg) + print(cr.get('country', '')) +except: + pass +" 2>/dev/null) +elif command -v jq >/dev/null 2>&1; then + COUNTRY=$(jq -r '.ConfigResponse.country // .country // ""' "$CONFIG_JSON" 2>/dev/null) +fi + +if [ -n "$COUNTRY" ]; then + echo "Country: $COUNTRY" +else + echo "Country: (not detected — config will still be pushed)" +fi + +# --- Extract device info from flutter.log --- +SDK_INT="" +DEVICE_MODEL="" +OS_VERSION="" +if command -v python3 >/dev/null 2>&1; then + mapfile -t _DEVICE_INFO < <(python3 -c " +import os, re, glob +sdk_int = ''; model = ''; osver = '' +for log_dir in ['$TICKET_DIR/logs/logs', '$TICKET_DIR/logs', '$TICKET_DIR']: + for f in glob.glob(os.path.join(log_dir, 'flutter.log')): + with open(f) as fh: + for line in fh: + if 'Device info:' in line: + m = re.search(r'sdkInt:\s*(\d+)', line) + if m: sdk_int = m.group(1) + m = re.search(r'model:\s*(\S+)', line) + if m: model = m.group(1).rstrip(',') + m = re.search(r'osVersion:\s*(\S+)', line) + if m: osver = m.group(1).rstrip(',') + break +print(sdk_int) +print(model) +print(osver) +" 2>/dev/null) + SDK_INT="${_DEVICE_INFO[0]:-}" + DEVICE_MODEL="${_DEVICE_INFO[1]:-}" + OS_VERSION="${_DEVICE_INFO[2]:-}" +fi + +if [ -n "$SDK_INT" ]; then + echo "Android: API $SDK_INT (Android ${OS_VERSION:-?})" +fi +if [ -n "$DEVICE_MODEL" ]; then + echo "Device: $DEVICE_MODEL" +fi + +# --- Extract version and auto-download APK if needed --- +if [ -z "$APK" ]; then + # Try to find the version from config.json metadata or ticket files + VERSION="" + if command -v python3 >/dev/null 2>&1; then + VERSION=$(python3 -c " +import json, sys, os, glob +# Try to find version from flutter.log (most reliable) +for log_dir in ['$TICKET_DIR/logs/logs', '$TICKET_DIR/logs', '$TICKET_DIR']: + for f in glob.glob(os.path.join(log_dir, 'flutter.log')): + with open(f) as fh: + for line in fh: + if 'version:' in line.lower(): + # Extract version like '9.0.25' + import re + m = re.search(r'version:\s*(\d+\.\d+\.\d+)', line) + if m: + print(m.group(1)) + sys.exit(0) +# Fall back to looking for version in config request logs +for log_dir in ['$TICKET_DIR/logs/logs', '$TICKET_DIR/logs', '$TICKET_DIR']: + for f in glob.glob(os.path.join(log_dir, 'lantern.log')): + with open(f) as fh: + for line in fh: + if 'appVersion' in line or 'app_version' in line: + import re + m = re.search(r'(\d+\.\d+\.\d+)', line) + if m: + print(m.group(1)) + sys.exit(0) +" 2>/dev/null) + fi + + if [ -z "$VERSION" ]; then + echo "Error: No APK provided and couldn't detect version from ticket logs." + echo "Provide the APK path as the second argument." + exit 1 + fi + + RELEASE_TAG="v${VERSION}-beta-android" + APK="$TICKET_DIR/lantern-${VERSION}.apk" + + if [ -f "$APK" ]; then + echo "APK: $APK (cached from previous download)" + else + echo "Downloading APK for v${VERSION} from GitHub releases..." + if ! command -v gh >/dev/null 2>&1; then + echo "Error: gh CLI not found. Install it or provide the APK path manually." + exit 1 + fi + if gh release download "$RELEASE_TAG" --repo getlantern/lantern \ + --pattern "lantern-installer-beta.apk" \ + --output "$APK" 2>/dev/null; then + echo "APK: $APK (downloaded from $RELEASE_TAG)" + else + echo "Error: Failed to download APK from release $RELEASE_TAG" + echo "Available Android releases:" + gh release list --repo getlantern/lantern --limit 5 | grep android || true + echo "" + echo "Provide the APK path manually as the second argument." + exit 1 + fi + fi +fi + +# --- Extract server locations for display --- +if command -v python3 >/dev/null 2>&1; then + python3 -c " +import json +with open('$CONFIG_JSON') as f: + cfg = json.load(f) +cr = cfg.get('ConfigResponse', cfg) +locs = cr.get('outbound_locations', cr.get('servers', {})) +if isinstance(locs, dict): + cities = sorted(set(v.get('city', '?') for v in locs.values())) + print('Locations: ' + ', '.join(cities)) +elif isinstance(locs, list): + cities = sorted(set(s.get('city', '?') for s in locs)) + print('Locations: ' + ', '.join(cities)) +" 2>/dev/null || true +fi +echo "APK: $APK" +echo "=================================" +echo "" + +# --- Build env overrides --- +ENV_OVERRIDES=() +if [ -n "$COUNTRY" ]; then + ENV_OVERRIDES+=("RADIANCE_COUNTRY=$COUNTRY") +fi + +# --- Find Android SDK tools --- +if [ -n "${ANDROID_HOME:-}" ]; then + ADB="${ANDROID_HOME}/platform-tools/adb" + EMULATOR="${ANDROID_HOME}/emulator/emulator" + SDKMANAGER="${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" + AVDMANAGER="${ANDROID_HOME}/cmdline-tools/latest/bin/avdmanager" +elif [ -n "${ANDROID_SDK_ROOT:-}" ]; then + ADB="${ANDROID_SDK_ROOT}/platform-tools/adb" + EMULATOR="${ANDROID_SDK_ROOT}/emulator/emulator" + SDKMANAGER="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager" + AVDMANAGER="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/avdmanager" +else + ADB="$(command -v adb 2>/dev/null || true)" + EMULATOR="$(command -v emulator 2>/dev/null || true)" + SDKMANAGER="$(command -v sdkmanager 2>/dev/null || true)" + AVDMANAGER="$(command -v avdmanager 2>/dev/null || true)" +fi +for tool in ADB EMULATOR SDKMANAGER AVDMANAGER; do + if [ -z "${!tool}" ] || [ ! -x "${!tool}" ]; then + echo "Error: $(echo $tool | tr A-Z a-z) not found. Set ANDROID_HOME or add Android SDK to PATH." + exit 1 + fi +done + +# --- Ensure emulator with matching API level --- +# Each unique API level gets its own AVD (e.g. lantern-api29, lantern-api34). +# AVDs accumulate over time as you reproduce tickets from different Android versions. +TARGET_API="${SDK_INT:-35}" + +ARCH=$(uname -m) +case "$ARCH" in + arm64|aarch64) IMG_ABI="arm64-v8a" ;; + x86_64) IMG_ABI="x86_64" ;; + *) IMG_ABI="arm64-v8a" ;; +esac + +# Find the closest available Google APIs image (step down if exact isn't available) +ORIGINAL_API="$TARGET_API" +while [ "$TARGET_API" -ge 21 ]; do + IMAGE="system-images;android-${TARGET_API};google_apis;${IMG_ABI}" + if "$SDKMANAGER" --list 2>/dev/null | grep -q "$IMAGE"; then + break + fi + TARGET_API=$((TARGET_API - 1)) +done +if [ "$TARGET_API" -lt 21 ]; then + echo "Error: No Google APIs system image found for any API level." >&2 + exit 1 +fi +if [ "$TARGET_API" != "$ORIGINAL_API" ]; then + echo "Note: API $ORIGINAL_API image not available, using closest: API $TARGET_API" +fi + +AVD_NAME="lantern-api${TARGET_API}" + +# Download image + create AVD if it doesn't exist yet +if ! "$EMULATOR" -list-avds 2>/dev/null | grep -q "^${AVD_NAME}$"; then + echo "Creating AVD '$AVD_NAME' (API $TARGET_API) to match user's device..." + "$SDKMANAGER" "$IMAGE" 2>&1 | tail -3 + echo "no" | "$AVDMANAGER" create avd -n "$AVD_NAME" -k "$IMAGE" --force 2>&1 | tail -2 +fi + +# Detect or start emulator +APPEAR_TIMEOUT="${APPEAR_TIMEOUT:-120}" +BOOT_TIMEOUT="${BOOT_TIMEOUT:-300}" + +TARGET_SERIAL=$("$ADB" devices 2>/dev/null | awk '/^emulator-/{print $1; exit}') +if [ -z "$TARGET_SERIAL" ]; then + echo "Starting emulator: $AVD_NAME (API $TARGET_API)..." + "$EMULATOR" -avd "$AVD_NAME" -no-snapshot-load -no-audio -gpu host & + START_TIME=$(date +%s) + while :; do + TARGET_SERIAL=$("$ADB" devices 2>/dev/null | awk '/^emulator-/{print $1; exit}') + if [ -n "$TARGET_SERIAL" ]; then break; fi + if [ $(( $(date +%s) - START_TIME )) -ge "$APPEAR_TIMEOUT" ]; then + echo "Error: Timed out waiting for emulator." >&2; exit 1 + fi + sleep 2 + done + "$ADB" -s "$TARGET_SERIAL" wait-for-device + START_TIME=$(date +%s) + while [ "$("$ADB" -s "$TARGET_SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do + if [ $(( $(date +%s) - START_TIME )) -ge "$BOOT_TIMEOUT" ]; then + echo "Error: Timed out waiting for boot." >&2; exit 1 + fi + sleep 2 + done + echo "Emulator booted: $TARGET_SERIAL" +else + echo "Emulator already running: $TARGET_SERIAL" +fi + +ADB_CMD=("$ADB" -s "$TARGET_SERIAL") +APP_DATA="/data/data/$PKG/.lantern" + +# Install APK FIRST so the app's data directory exists before we push configs. +echo "Installing $APK..." +"${ADB_CMD[@]}" install -r -t "$APK" 2>&1 | tail -1 + +# Launch once briefly so the data directory is fully created, then stop. +"${ADB_CMD[@]}" shell am start -n "$PKG/org.getlantern.lantern.MainActivity" 2>/dev/null || true +sleep 3 +"${ADB_CMD[@]}" shell am force-stop "$PKG" 2>/dev/null || true + +echo "Pushing user's config files to emulator..." + +# Root the emulator (Google APIs images) +"${ADB_CMD[@]}" root 2>/dev/null || true +sleep 2 +"${ADB_CMD[@]}" wait-for-device + +# Determine the base directory where config files live in the ticket dir. +# /analyze-ticket puts them directly in the ticket dir, but they may also +# be inside a logs/ subdirectory. +CONFIG_BASE="$(dirname "$CONFIG_JSON")" + +# Push config files +for file in config.json servers.json split-tunnel.json; do + SRC="" + for candidate in "$CONFIG_BASE/$file" "$TICKET_DIR/$file"; do + if [ -f "$candidate" ]; then SRC="$candidate"; break; fi + done + if [ -n "$SRC" ]; then + "${ADB_CMD[@]}" push "$SRC" "/data/local/tmp/$file" + "${ADB_CMD[@]}" shell "mkdir -p $APP_DATA && cp /data/local/tmp/$file $APP_DATA/$file && chown \$(stat -c '%U:%G' /data/data/$PKG) $APP_DATA/$file" 2>/dev/null + echo " Pushed $file (from $SRC)" + fi +done + +# Push .env with overrides +if [ ${#ENV_OVERRIDES[@]} -gt 0 ]; then + ENV_FILE=$(mktemp) + trap 'rm -f "${ENV_FILE:-}"' EXIT INT TERM + for kv in "${ENV_OVERRIDES[@]}"; do + echo "$kv" >> "$ENV_FILE" + done + "${ADB_CMD[@]}" push "$ENV_FILE" /data/local/tmp/.env + "${ADB_CMD[@]}" shell "mkdir -p $APP_DATA && cp /data/local/tmp/.env $APP_DATA/.env && chown \$(stat -c '%U:%G' /data/data/$PKG) $APP_DATA/.env" 2>/dev/null + echo " Pushed .env (RADIANCE_COUNTRY=$COUNTRY)" +fi + +# Unroot +"${ADB_CMD[@]}" unroot 2>/dev/null || true +sleep 1 +"${ADB_CMD[@]}" wait-for-device + +# Restart the app with the user's config +echo "Restarting Lantern with user's config..." +"${ADB_CMD[@]}" shell am force-stop "$PKG" 2>/dev/null || true +sleep 1 +"${ADB_CMD[@]}" shell am start -n "$PKG/org.getlantern.lantern.MainActivity" 2>/dev/null || \ + "${ADB_CMD[@]}" shell monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 2>/dev/null + +echo "" +echo "=== Reproducing ticket with user's exact config ===" +echo " Country: ${COUNTRY:-unknown}" +echo " Config: $CONFIG_JSON" +echo " APK: $APK" +echo "=== Streaming logs (Ctrl+C to stop) ===" +echo "" + +"${ADB_CMD[@]}" logcat -c +exec "${ADB_CMD[@]}" logcat -v time \ + -s "GoLog:*" "radiance:*" "sing-box:*" "lantern:*" "flutter:*" \ + "LanternService:*" "VpnService:*" "System.err:*" \ + "AndroidRuntime:*" "lantern-box:*" diff --git a/scripts/android/android-test b/scripts/android/android-test new file mode 100755 index 0000000000..b52f7155bb --- /dev/null +++ b/scripts/android/android-test @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# android-test — Quick Android emulator test harness for Lantern +# +# Usage: +# android-test [ENV_KEY=VALUE ...] +# +# Examples: +# android-test ~/Downloads/lantern-9.0.25.apk RADIANCE_COUNTRY=BG +# android-test ~/Downloads/lantern.apk RADIANCE_COUNTRY=CN RADIANCE_FEATURE_OVERRIDES=dns_ruleset_host_bypass +# android-test ~/Downloads/lantern.apk # no overrides, just install + logs +# +# Prerequisites: +# - Android SDK (set $ANDROID_HOME, $ANDROID_SDK_ROOT, or have adb/emulator in $PATH) +# - If no AVDs exist, the script auto-creates "lantern-test" (requires sdkmanager/avdmanager; +# downloads a ~2GB Google APIs system image on first run) +# +# What it does: +# 1. Starts the emulator (if not already running) +# 2. Waits for boot +# 3. Installs the APK +# 4. Pushes a .env file with your overrides to the app's data dir +# 5. Force-stops and restarts the app so it picks up the .env +# 6. Streams logcat filtered to Lantern/radiance/sing-box output +# +# The .env file is read by radiance/common/env/env.go init() from the +# app's working directory. On Android this requires either a debug APK +# (run-as works) or root (adb root on Google APIs images, or su on +# user-rooted devices). If none of these work, the script reports the +# failure and continues without overrides. + +set -euo pipefail + +PKG="org.getlantern.lantern" +ACTIVITY="org.getlantern.lantern.MainActivity" + +# Find tools — prefer $ANDROID_HOME, fall back to $PATH +if [ -n "${ANDROID_HOME:-}" ]; then + ADB="${ANDROID_HOME}/platform-tools/adb" + EMULATOR="${ANDROID_HOME}/emulator/emulator" +elif [ -n "${ANDROID_SDK_ROOT:-}" ]; then + ADB="${ANDROID_SDK_ROOT}/platform-tools/adb" + EMULATOR="${ANDROID_SDK_ROOT}/emulator/emulator" +else + ADB="$(command -v adb 2>/dev/null || true)" + EMULATOR="$(command -v emulator 2>/dev/null || true)" +fi + +if [ -z "$ADB" ] || [ ! -x "$ADB" ]; then + echo "Error: adb not found. Set ANDROID_HOME or add Android SDK to PATH." + exit 1 +fi +if [ -z "$EMULATOR" ] || [ ! -x "$EMULATOR" ]; then + echo "Error: emulator not found. Set ANDROID_HOME or add Android SDK to PATH." + exit 1 +fi + +# --- Parse args --- +if [ $# -lt 1 ]; then + echo "Usage: android-test [ENV_KEY=VALUE ...]" + echo "" + echo "Examples:" + echo " android-test lantern.apk RADIANCE_COUNTRY=BG" + echo " android-test lantern.apk RADIANCE_COUNTRY=CN RADIANCE_FEATURE_OVERRIDES=dns_ruleset_host_bypass" + exit 1 +fi + +APK="$1"; shift +ENV_ARGS=("$@") + +# --- Ensure we have a rootable AVD --- +# Prefer lantern-test (Google APIs, rootable), fall back to first available. +AVD=$("$EMULATOR" -list-avds 2>/dev/null | grep "^lantern-test$" || "$EMULATOR" -list-avds 2>/dev/null | head -1) +if [ -z "$AVD" ]; then + echo "No AVDs found. Setting up lantern-test automatically..." + + # Find sdkmanager and avdmanager + if [ -n "${ANDROID_HOME:-}" ]; then + SDKMANAGER="${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" + AVDMANAGER="${ANDROID_HOME}/cmdline-tools/latest/bin/avdmanager" + elif [ -n "${ANDROID_SDK_ROOT:-}" ]; then + SDKMANAGER="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager" + AVDMANAGER="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/avdmanager" + else + SDKMANAGER="$(command -v sdkmanager 2>/dev/null || true)" + AVDMANAGER="$(command -v avdmanager 2>/dev/null || true)" + fi + + if [ -z "$SDKMANAGER" ] || [ ! -x "$SDKMANAGER" ]; then + echo "Error: sdkmanager not found. Install Android SDK command-line tools first." + exit 1 + fi + if [ -z "$AVDMANAGER" ] || [ ! -x "$AVDMANAGER" ]; then + echo "Error: avdmanager not found. Install Android SDK command-line tools first." + exit 1 + fi + + # Detect host architecture for the system image + ARCH=$(uname -m) + case "$ARCH" in + arm64|aarch64) IMG_ABI="arm64-v8a" ;; + x86_64) IMG_ABI="x86_64" ;; + *) IMG_ABI="arm64-v8a" ;; + esac + + IMAGE="system-images;android-35;google_apis;${IMG_ABI}" + echo "Installing system image: $IMAGE" + "$SDKMANAGER" "$IMAGE" + + echo "Creating AVD: lantern-test" + echo "no" | "$AVDMANAGER" create avd -n lantern-test -k "$IMAGE" --force + + AVD="lantern-test" + echo "AVD created: $AVD" +fi + +# --- Select / start emulator target --- +# Use -s throughout so multiple connected devices don't break adb. +# The emulator is intentionally left running after the script exits so +# subsequent runs don't pay the boot cost again. +APPEAR_TIMEOUT="${APPEAR_TIMEOUT:-120}" +BOOT_TIMEOUT="${BOOT_TIMEOUT:-300}" + +TARGET_SERIAL=$("$ADB" devices 2>/dev/null | awk '/^emulator-/{print $1; exit}') +if [ -z "$TARGET_SERIAL" ]; then + echo "Starting emulator: $AVD" + "$EMULATOR" -avd "$AVD" -no-snapshot-load -no-audio -gpu host & + echo "Waiting for emulator to appear..." + START_TIME=$(date +%s) + while :; do + TARGET_SERIAL=$("$ADB" devices 2>/dev/null | awk '/^emulator-/{print $1; exit}') + if [ -n "$TARGET_SERIAL" ]; then break; fi + if [ $(( $(date +%s) - START_TIME )) -ge "$APPEAR_TIMEOUT" ]; then + echo "Error: Timed out after ${APPEAR_TIMEOUT}s waiting for emulator to appear." >&2 + exit 1 + fi + sleep 2 + done + echo "Waiting for emulator to boot..." + "$ADB" -s "$TARGET_SERIAL" wait-for-device + START_TIME=$(date +%s) + while [ "$("$ADB" -s "$TARGET_SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do + if [ $(( $(date +%s) - START_TIME )) -ge "$BOOT_TIMEOUT" ]; then + echo "Error: Timed out after ${BOOT_TIMEOUT}s waiting for emulator to boot." >&2 + exit 1 + fi + sleep 2 + done + echo "Emulator booted: $TARGET_SERIAL" +else + echo "Emulator already running: $TARGET_SERIAL" +fi +ADB_CMD=("$ADB" -s "$TARGET_SERIAL") + +# --- Install APK --- +echo "Installing $APK..." +"${ADB_CMD[@]}" install -r -t "$APK" 2>&1 | tail -1 + +# --- Push .env file --- +if [ ${#ENV_ARGS[@]} -gt 0 ]; then + ENV_FILE=$(mktemp) + trap 'rm -f "${ENV_FILE:-}"' EXIT INT TERM + + for kv in "${ENV_ARGS[@]}"; do + echo "$kv" >> "$ENV_FILE" + done + echo "" + echo "=== .env contents ===" + cat "$ENV_FILE" + echo "=====================" + echo "" + + # Push the .env to the app's .lantern data directory. On Android, the + # Go code's cwd is "/" so the init()-time .env read fails. radiance's + # common.Init calls env.LoadFromDir(dataDir) which reads from the + # .lantern subdir instead. + APP_DATA="/data/data/$PKG/.lantern" + "${ADB_CMD[@]}" push "$ENV_FILE" /data/local/tmp/.env + PUSHED=false + + # Method 1: adb root (Google APIs emulator images — no Play Store) + if ! $PUSHED; then + if "${ADB_CMD[@]}" root 2>/dev/null | grep -q "already running\|restarting"; then + sleep 2 # wait for adbd to restart + "${ADB_CMD[@]}" wait-for-device + if "${ADB_CMD[@]}" shell "mkdir -p $APP_DATA && cp /data/local/tmp/.env $APP_DATA/.env && chown \$(stat -c '%U:%G' /data/data/$PKG) $APP_DATA/.env" 2>/dev/null; then + echo ".env pushed to $APP_DATA via adb root" + PUSHED=true + "${ADB_CMD[@]}" unroot 2>/dev/null || true + sleep 1 + "${ADB_CMD[@]}" wait-for-device + fi + fi + fi + + # Method 2: run-as (debug/debuggable APKs) + if ! $PUSHED; then + if "${ADB_CMD[@]}" shell "run-as $PKG mkdir -p .lantern && run-as $PKG cp /data/local/tmp/.env .lantern/.env" 2>/dev/null; then + echo ".env pushed to $APP_DATA via run-as (debug APK)" + PUSHED=true + fi + fi + + # Method 3: su (user-rooted devices) + if ! $PUSHED; then + if "${ADB_CMD[@]}" shell "su -c \"mkdir -p $APP_DATA && cp /data/local/tmp/.env $APP_DATA/.env && chown \$(stat -c %u:%g /data/data/$PKG) $APP_DATA/.env\"" 2>/dev/null; then + echo ".env pushed to $APP_DATA via su" + PUSHED=true + fi + fi + + if ! $PUSHED; then + echo "" + echo "ERROR: Cannot push .env — APK is not debuggable and device is not rooted." + echo "Options:" + echo " 1. Use a debug APK build" + echo " 2. Use a Google APIs emulator image (rootable, no Play Store):" + echo " sdkmanager 'system-images;android-35;google_apis;arm64-v8a'" + echo " avdmanager create avd -n lantern-test -k 'system-images;android-35;google_apis;arm64-v8a'" + echo "" + echo "Continuing without overrides..." + fi +fi + +# --- Restart app --- +echo "Restarting Lantern..." +"${ADB_CMD[@]}" shell am force-stop "$PKG" 2>/dev/null || true +sleep 1 +"${ADB_CMD[@]}" shell am start -n "$PKG/$ACTIVITY" 2>/dev/null || \ + "${ADB_CMD[@]}" shell monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 2>/dev/null + +# --- Stream logs --- +echo "" +echo "=== Streaming logs (Ctrl+C to stop) ===" +echo "" +"${ADB_CMD[@]}" logcat -c # clear old logs +exec "${ADB_CMD[@]}" logcat -v time \ + -s "GoLog:*" "radiance:*" "sing-box:*" "lantern:*" "flutter:*" \ + "LanternService:*" "VpnService:*" "System.err:*" \ + "AndroidRuntime:*" "lantern-box:*" diff --git a/scripts/ci/format.sh b/scripts/ci/format.sh index 24ba3ae094..6f941487d6 100755 --- a/scripts/ci/format.sh +++ b/scripts/ci/format.sh @@ -40,6 +40,7 @@ FULL_INSTALLER_NAME="${INSTALLER_BASE_NAME}" [[ -n "$BUILD_TYPE" && "$BUILD_TYPE" != "production" ]] && FULL_INSTALLER_NAME="${FULL_INSTALLER_NAME}-${BUILD_TYPE}" VERSION_URL="https://s3.amazonaws.com/${BUCKET}/releases/${BUILD_TYPE}/${VERSION}" +LATEST_URL="https://s3.amazonaws.com/${BUCKET}/releases/${BUILD_TYPE}/latest" # Check if a platform should be included should_include() { @@ -77,27 +78,27 @@ release-notes) echo "" if should_include "macos"; then - echo "- [macOS (.dmg)](${VERSION_URL}/${FULL_INSTALLER_NAME}.dmg)" + echo "- [macOS (.dmg)](${LATEST_URL}/${FULL_INSTALLER_NAME}.dmg) ([permalink](${VERSION_URL}/${FULL_INSTALLER_NAME}.dmg))" fi if should_include "windows"; then - echo "- [Windows (.exe)](${VERSION_URL}/${FULL_INSTALLER_NAME}.exe)" + echo "- [Windows (.exe)](${LATEST_URL}/${FULL_INSTALLER_NAME}.exe) ([permalink](${VERSION_URL}/${FULL_INSTALLER_NAME}.exe))" fi if should_include "android"; then - echo "- [Android (.apk)](${VERSION_URL}/${FULL_INSTALLER_NAME}.apk)" + echo "- [Android (.apk)](${LATEST_URL}/${FULL_INSTALLER_NAME}.apk) ([permalink](${VERSION_URL}/${FULL_INSTALLER_NAME}.apk))" fi if should_include "linux"; then if include_linux_amd64; then - echo "- [Linux AMD64 (.deb)](${VERSION_URL}/${FULL_INSTALLER_NAME}.deb)" - echo "- [Linux AMD64 (.rpm)](${VERSION_URL}/${FULL_INSTALLER_NAME}.rpm)" - echo "- [Linux AMD64 (.pkg.tar.zst)](${VERSION_URL}/${FULL_INSTALLER_NAME}.pkg.tar.zst)" + echo "- [Linux AMD64 (.deb)](${LATEST_URL}/${FULL_INSTALLER_NAME}.deb) ([permalink](${VERSION_URL}/${FULL_INSTALLER_NAME}.deb))" + echo "- [Linux AMD64 (.rpm)](${LATEST_URL}/${FULL_INSTALLER_NAME}.rpm) ([permalink](${VERSION_URL}/${FULL_INSTALLER_NAME}.rpm))" + echo "- [Linux AMD64 (.pkg.tar.zst)](${LATEST_URL}/${FULL_INSTALLER_NAME}.pkg.tar.zst) ([permalink](${VERSION_URL}/${FULL_INSTALLER_NAME}.pkg.tar.zst))" fi if include_linux_arm64; then - echo "- [Linux ARM64 (.deb)](${VERSION_URL}/${FULL_INSTALLER_NAME}-arm64.deb)" - echo "- [Linux ARM64 (.rpm)](${VERSION_URL}/${FULL_INSTALLER_NAME}-arm64.rpm)" - echo "- [Linux ARM64 (.pkg.tar.zst)](${VERSION_URL}/${FULL_INSTALLER_NAME}-arm64.pkg.tar.zst)" + echo "- [Linux ARM64 (.deb)](${LATEST_URL}/${FULL_INSTALLER_NAME}-arm64.deb) ([permalink](${VERSION_URL}/${FULL_INSTALLER_NAME}-arm64.deb))" + echo "- [Linux ARM64 (.rpm)](${LATEST_URL}/${FULL_INSTALLER_NAME}-arm64.rpm) ([permalink](${VERSION_URL}/${FULL_INSTALLER_NAME}-arm64.rpm))" + echo "- [Linux ARM64 (.pkg.tar.zst)](${LATEST_URL}/${FULL_INSTALLER_NAME}-arm64.pkg.tar.zst) ([permalink](${VERSION_URL}/${FULL_INSTALLER_NAME}-arm64.pkg.tar.zst))" fi fi @@ -123,27 +124,27 @@ slack) text="${text}\n*Downloads:*" if should_include "macos"; then - text="${text}\n• macOS <${VERSION_URL}/${FULL_INSTALLER_NAME}.dmg|${FULL_INSTALLER_NAME}.dmg>" + text="${text}\n• macOS <${LATEST_URL}/${FULL_INSTALLER_NAME}.dmg|${FULL_INSTALLER_NAME}.dmg> (<${VERSION_URL}/${FULL_INSTALLER_NAME}.dmg|permalink>)" fi if should_include "windows"; then - text="${text}\n• Windows <${VERSION_URL}/${FULL_INSTALLER_NAME}.exe|${FULL_INSTALLER_NAME}.exe>" + text="${text}\n• Windows <${LATEST_URL}/${FULL_INSTALLER_NAME}.exe|${FULL_INSTALLER_NAME}.exe> (<${VERSION_URL}/${FULL_INSTALLER_NAME}.exe|permalink>)" fi if should_include "android"; then - text="${text}\n• Android <${VERSION_URL}/${FULL_INSTALLER_NAME}.apk|${FULL_INSTALLER_NAME}.apk>" + text="${text}\n• Android <${LATEST_URL}/${FULL_INSTALLER_NAME}.apk|${FULL_INSTALLER_NAME}.apk> (<${VERSION_URL}/${FULL_INSTALLER_NAME}.apk|permalink>)" fi if should_include "linux"; then if include_linux_amd64; then - text="${text}\n• Linux AMD64 <${VERSION_URL}/${FULL_INSTALLER_NAME}.deb|${FULL_INSTALLER_NAME}.deb>" - text="${text}\n• Linux AMD64 <${VERSION_URL}/${FULL_INSTALLER_NAME}.rpm|${FULL_INSTALLER_NAME}.rpm>" - text="${text}\n• Linux AMD64 <${VERSION_URL}/${FULL_INSTALLER_NAME}.pkg.tar.zst|${FULL_INSTALLER_NAME}.pkg.tar.zst>" + text="${text}\n• Linux AMD64 <${LATEST_URL}/${FULL_INSTALLER_NAME}.deb|${FULL_INSTALLER_NAME}.deb> (<${VERSION_URL}/${FULL_INSTALLER_NAME}.deb|permalink>)" + text="${text}\n• Linux AMD64 <${LATEST_URL}/${FULL_INSTALLER_NAME}.rpm|${FULL_INSTALLER_NAME}.rpm> (<${VERSION_URL}/${FULL_INSTALLER_NAME}.rpm|permalink>)" + text="${text}\n• Linux AMD64 <${LATEST_URL}/${FULL_INSTALLER_NAME}.pkg.tar.zst|${FULL_INSTALLER_NAME}.pkg.tar.zst> (<${VERSION_URL}/${FULL_INSTALLER_NAME}.pkg.tar.zst|permalink>)" fi if include_linux_arm64; then - text="${text}\n• Linux ARM64 <${VERSION_URL}/${FULL_INSTALLER_NAME}-arm64.deb|${FULL_INSTALLER_NAME}-arm64.deb>" - text="${text}\n• Linux ARM64 <${VERSION_URL}/${FULL_INSTALLER_NAME}-arm64.rpm|${FULL_INSTALLER_NAME}-arm64.rpm>" - text="${text}\n• Linux ARM64 <${VERSION_URL}/${FULL_INSTALLER_NAME}-arm64.pkg.tar.zst|${FULL_INSTALLER_NAME}-arm64.pkg.tar.zst>" + text="${text}\n• Linux ARM64 <${LATEST_URL}/${FULL_INSTALLER_NAME}-arm64.deb|${FULL_INSTALLER_NAME}-arm64.deb> (<${VERSION_URL}/${FULL_INSTALLER_NAME}-arm64.deb|permalink>)" + text="${text}\n• Linux ARM64 <${LATEST_URL}/${FULL_INSTALLER_NAME}-arm64.rpm|${FULL_INSTALLER_NAME}-arm64.rpm> (<${VERSION_URL}/${FULL_INSTALLER_NAME}-arm64.rpm|permalink>)" + text="${text}\n• Linux ARM64 <${LATEST_URL}/${FULL_INSTALLER_NAME}-arm64.pkg.tar.zst|${FULL_INSTALLER_NAME}-arm64.pkg.tar.zst> (<${VERSION_URL}/${FULL_INSTALLER_NAME}-arm64.pkg.tar.zst|permalink>)" fi fi diff --git a/scripts/ci/verify_linux_package.sh b/scripts/ci/verify_linux_package.sh index 8c1b2d1822..600f46be6f 100755 --- a/scripts/ci/verify_linux_package.sh +++ b/scripts/ci/verify_linux_package.sh @@ -44,13 +44,10 @@ require_grep() { fi } -require_file "$TMP_DIR/root/usr/sbin/lanternd" -require_file "$TMP_DIR/root/usr/lib/systemd/system/lanternd.service" +require_file "$TMP_DIR/root/usr/lib/lantern/lanternd" +require_file "$TMP_DIR/root/usr/lib/lantern/lantern" require_file "$TMP_DIR/control/postinst" -require_grep "ExecStart=/usr/sbin/lanternd" "$TMP_DIR/root/usr/lib/systemd/system/lanternd.service" -require_grep "groupadd --system lantern" "$TMP_DIR/control/postinst" -require_grep "systemctl enable --now lanternd.service" "$TMP_DIR/control/postinst" -require_grep "systemctl is-active --quiet lanternd.service" "$TMP_DIR/control/postinst" +require_grep "lanternd install" "$TMP_DIR/control/postinst" echo "linux package verification passed: $DEB_PATH" diff --git a/scripts/run-windows-dev.ps1 b/scripts/run-windows-dev.ps1 index e5a7066cda..1b4a8dffb1 100644 --- a/scripts/run-windows-dev.ps1 +++ b/scripts/run-windows-dev.ps1 @@ -91,7 +91,7 @@ function Remove-ServiceIfPresent { throw "Service '$Name' still exists after delete." } -function Install-And-StartService { +function Install-LanterndService { param( [string]$Name, [string]$BinaryPath @@ -100,12 +100,11 @@ function Install-And-StartService { throw "Service binary not found: $BinaryPath" } - $quotedPath = "`"$BinaryPath`"" - Write-Step "Creating service '$Name'" - & sc.exe create $Name binPath= $quotedPath start= auto DisplayName= "Lantern Service (dev)" | Out-Host - - Write-Step "Starting service '$Name'" - & sc.exe start $Name | Out-Host + Write-Step "Installing service '$Name' from '$BinaryPath'" + & $BinaryPath install | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "Failed to install service '$Name' from $BinaryPath" + } $deadline = (Get-Date).AddSeconds(20) while ((Get-Date) -lt $deadline) { @@ -173,20 +172,6 @@ function Stop-StaleBuildProcesses { } } -function Validate-IpcToken { - $tokenPath = Join-Path $env:ProgramData "Lantern\ipc-token" - if (-not (Test-Path $tokenPath)) { - throw "IPC token file not found: $tokenPath" - } - - $token = (Get-Content -Path $tokenPath -Raw).Trim() - if ([string]::IsNullOrWhiteSpace($token)) { - throw "IPC token file is empty: $tokenPath" - } - - Write-Host "IPC token is present at $tokenPath" -ForegroundColor Green -} - function Clear-AppDiscoveryCache { $paths = @( (Join-Path $env:PUBLIC "Lantern\data\apps_cache.json"), @@ -244,9 +229,8 @@ if (-not $SkipBuildStateReset) { Reset-WindowsBuildState -RepoRoot $repoRoot } -$serviceBinary = Join-Path $repoRoot "bin\windows-amd64\lanternsvc.exe" +$serviceBinary = Join-Path $repoRoot "bin\windows-amd64\lanternd.exe" $dllBinary = Join-Path $repoRoot "bin\windows-amd64\liblantern.dll" -$wintunBinary = Join-Path $repoRoot "windows\third_party\wintun\bin\amd64\wintun.dll" if ($Release) { $targetDir = Join-Path $repoRoot "build\windows\x64\runner\Release" @@ -255,6 +239,7 @@ if ($Release) { $targetDir = Join-Path $repoRoot "build\windows\x64\runner\Debug" $appExe = Join-Path $targetDir "lantern.exe" } +$serviceOutputBinary = Join-Path $targetDir "lanternd.exe" Remove-ServiceIfPresent -Name $ServiceName @@ -262,8 +247,8 @@ Invoke-Step "Fetching dependencies and generating code" { make pubget gen } -Invoke-Step "Building Windows native artifacts (liblantern.dll + service + wintun)" { - make windows-amd64 windows-service-build-amd64 wintun-amd64 +Invoke-Step "Building Windows native artifacts (liblantern.dll + lanternd)" { + make windows-amd64 lanternd-windows-amd64 } Invoke-Step "Building Windows app" { @@ -274,20 +259,25 @@ Invoke-Step "Building Windows app" { } } +Invoke-Step "Copying lanternd into app output folder" { + if ($Release) { + make copy-lanternd-release + } else { + make copy-lanternd-debug + } +} + Write-Step "Copying native artifacts into app output folder" New-Item -ItemType Directory -Force -Path $targetDir | Out-Null Copy-Item -Force $dllBinary (Join-Path $targetDir "liblantern.dll") -Copy-Item -Force $serviceBinary (Join-Path $targetDir "lanternsvc.exe") -Copy-Item -Force $wintunBinary (Join-Path $targetDir "wintun.dll") Write-Step "Build artifact diagnostics" Show-BuildArtifactInfo -Path $dllBinary -Label "liblantern.dll" -Show-BuildArtifactInfo -Path $serviceBinary -Label "lanternsvc.exe" +Show-BuildArtifactInfo -Path $serviceOutputBinary -Label "lanternd.exe" Show-BuildArtifactInfo -Path $appExe -Label "lantern.exe" -Install-And-StartService -Name $ServiceName -BinaryPath $serviceBinary +Install-LanterndService -Name $ServiceName -BinaryPath $serviceOutputBinary Show-ServiceStatus -Name $ServiceName -Validate-IpcToken Show-ServiceLogs -TailLines $LogTailLines Show-AppDiscoveryContext Clear-AppDiscoveryCache diff --git a/scripts/windows/service_install.ps1 b/scripts/windows/service_install.ps1 index c22954e46c..eddff27f95 100644 --- a/scripts/windows/service_install.ps1 +++ b/scripts/windows/service_install.ps1 @@ -1,35 +1,20 @@ param( - [string]$Name = "LanternSvc", - [string]$Exe = "$PSScriptRoot\..\..\bin\windows-amd64\lanternsvc.exe", - [string]$Args = "--service", - [string]$DisplayName = "Lantern Service (dev)" + [string]$Exe = "$PSScriptRoot\..\..\bin\windows-amd64\lanternd.exe" ) $ErrorActionPreference = "Stop" $ExeFull = (Resolve-Path $Exe).Path if (-not (Test-Path $ExeFull)) { - throw "Service binary not found at $ExeFull" + throw "lanternd binary not found at $ExeFull" } -$svc = Get-Service -Name $Name -ErrorAction SilentlyContinue -if ($svc) { - if ($svc.Status -ne 'Stopped') { sc.exe stop $Name | Out-Null } - sc.exe delete $Name | Out-Null - Start-Sleep -Milliseconds 500 +Write-Host "Installing lanternd service via: $ExeFull install" +& $ExeFull install +if ($LASTEXITCODE -ne 0) { + throw "lanternd install failed with exit code $LASTEXITCODE" } -$binPath = "`"$ExeFull`" $Args" - -sc.exe create $Name binPath= "$binPath" obj= LocalSystem start= demand DisplayName= "$DisplayName" | Out-Null - -sc.exe failure $Name reset= 60 actions= restart/5000/restart/5000/""/5000 | Out-Null -sc.exe failureflag $Name 1 | Out-Null -sc.exe description $Name "Lantern dev service" | Out-Null - -# Start service -sc.exe start $Name - -Write-Host "`nService created and started." -sc.exe qc $Name -sc.exe query $Name \ No newline at end of file +Write-Host "`nService installed and started." +sc.exe qc LanternSvc +sc.exe query LanternSvc \ No newline at end of file diff --git a/test/features/auth/provider/auth_notifier_test.dart b/test/features/auth/provider/auth_notifier_test.dart index be51c8dcea..cab8aafb48 100644 --- a/test/features/auth/provider/auth_notifier_test.dart +++ b/test/features/auth/provider/auth_notifier_test.dart @@ -1,18 +1,23 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fpdart/fpdart.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/models/user.dart'; import 'package:lantern/core/utils/failure.dart'; import 'package:lantern/features/auth/provider/auth_notifier.dart'; import 'package:lantern/lantern/lantern_service.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; -import 'package:lantern/lantern/protos/protos/auth.pb.dart'; + +UserResponseModel _successUser() => const UserResponseModel( + legacyID: 0, + legacyToken: '', + emailConfirmed: false, + success: true, +); class _FakeLanternService implements LanternService { String? loginEmail; String? loginPassword; - Either loginResult = right( - UserResponse()..success = true, - ); + Either loginResult = right(_successUser()); String? signUpEmail; String? signUpPassword; @@ -21,12 +26,10 @@ class _FakeLanternService implements LanternService { String? deleteEmail; String? deletePassword; bool? deleteIsSSO; - Either deleteResult = right( - UserResponse()..success = true, - ); + Either deleteResult = right(_successUser()); @override - Future> login({ + Future> login({ required String email, required String password, }) async { @@ -46,7 +49,7 @@ class _FakeLanternService implements LanternService { } @override - Future> deleteAccount({ + Future> deleteAccount({ required String email, required String password, bool isSSO = false, @@ -64,8 +67,7 @@ class _FakeLanternService implements LanternService { void main() { group('AuthNotifier', () { test('signInWithEmail forwards credentials and returns success', () async { - final fakeService = _FakeLanternService() - ..loginResult = right(UserResponse()..success = true); + final fakeService = _FakeLanternService()..loginResult = right(_successUser()); final container = ProviderContainer( overrides: [lanternServiceProvider.overrideWithValue(fakeService)], ); @@ -102,7 +104,7 @@ void main() { result.match( (err) => expect(err.localizedErrorMessage, equals('Invalid credentials')), - (_) => fail('Expected Left(Failure), got Right(UserResponse)'), + (_) => fail('Expected Left(Failure), got Right(UserResponseModel)'), ); }); @@ -126,7 +128,7 @@ void main() { test('deleteAccount forwards args and returns service result', () async { final fakeService = _FakeLanternService() - ..deleteResult = right(UserResponse()..success = true); + ..deleteResult = right(_successUser()); final container = ProviderContainer( overrides: [lanternServiceProvider.overrideWithValue(fakeService)], ); diff --git a/windows/packaging/exe/inno_setup.iss b/windows/packaging/exe/inno_setup.iss index 4b2a083c8b..11916e0a77 100644 --- a/windows/packaging/exe/inno_setup.iss +++ b/windows/packaging/exe/inno_setup.iss @@ -18,9 +18,6 @@ var Dependency_List: array of TDependency_Entry; Dependency_NeedToRestart, Dependency_ForceX86: Boolean; Dependency_DownloadPage: TDownloadWizardPage; - PreInstallCleanupDone: Boolean; - -procedure PreInstallLanternCleanup; forward; procedure Dependency_Add(const Filename, Parameters, Title, URL, Checksum: String; const ForceSuccess, RestartAfter: Boolean); var @@ -154,10 +151,6 @@ begin end; end; - if Result = '' then begin - PreInstallLanternCleanup; - end; - Dependency_DownloadPage.Hide; end; end; @@ -251,11 +244,7 @@ end; #define SourceDirMacro "{{SOURCE_DIR}}" #define SvcName "LanternSvc" -#define SvcDisplayName "Lantern Service" -#define UiExeName "{{EXECUTABLE_NAME}}" -#define SvcExeName "lanternsvc.exe" #define ProgramDataDir "{commonappdata}\Lantern" -#define TokenFile "{commonappdata}\Lantern\ipc-token" [Setup] AppId={{APP_ID}} @@ -301,13 +290,16 @@ Name: "{autoprograms}\\{{DISPLAY_NAME}}"; Filename: "{app}\\{{EXECUTABLE_NAME}}" Name: "{autodesktop}\\{{DISPLAY_NAME}}"; Filename: "{app}\\{{EXECUTABLE_NAME}}"; Tasks: desktopicon [Run] +; Install LanternSvc service (creates Windows service, sets recovery actions, starts it) +Filename: "{code:LanterndExecutablePath}"; Parameters: "install"; Flags: runhidden + ; Launch Lantern app UI Filename: "{app}\{{EXECUTABLE_NAME}}"; Description: "{cm:LaunchProgram,{{DISPLAY_NAME}}}"; \ Flags: runasoriginaluser nowait postinstall skipifsilent [UninstallRun] -Filename: "{sys}\sc.exe"; Parameters: "stop ""{#SvcName}"""; Flags: runhidden -Filename: "{sys}\sc.exe"; Parameters: "delete ""{#SvcName}"""; Flags: runhidden +Filename: "{sys}\taskkill.exe"; Parameters: "/F /IM {{EXECUTABLE_NAME}}"; Flags: runhidden +Filename: "{code:LanterndExecutablePath}"; Parameters: "uninstall"; Flags: runhidden [UninstallDelete] Type: filesandordirs; Name: "{#ProgramDataDir}" @@ -315,18 +307,9 @@ Type: filesandordirs; Name: "{#ProgramDataDir}" [Code] const ServiceDeleteTimeoutMs = 20000; - ServiceStartTimeoutMs = 30000; - TokenReadyTimeoutMs = 30000; ServicePollIntervalMs = 250; - ServiceStopTimeoutMs = 20000; - ServiceNotRunningExitCode = 1062; - ServiceDoesNotExistExitCode = 1060; - ServiceAlreadyRunningExitCode = 1056; - ServiceExistsExitCode = 1073; UninstallRegSubKey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#SetupSetting("AppId")}_is1'; -function WaitForServiceStopped(const TimeoutMs: Integer): Boolean; forward; - function IsAbsoluteWindowsPath(const Path: String): Boolean; begin Result := @@ -430,164 +413,11 @@ begin end; end; -function ExecCmd(const Parameters: String; var ExitCode: Integer): Boolean; -begin - Result := Exec( - ExpandConstant('{sys}\cmd.exe'), - Parameters, - '', - SW_HIDE, - ewWaitUntilTerminated, - ExitCode - ); - if Result then begin - Log('cmd.exe ' + Parameters + ' (exit=' + IntToStr(ExitCode) + ')'); - end else begin - Log('failed to launch cmd.exe ' + Parameters); - end; -end; - -procedure TryKillProcessImage(const ImageName: String); -var - ExitCode: Integer; -begin - if not ExecCmd('/C taskkill /F /T /IM "' + ImageName + '" >NUL 2>&1', ExitCode) then begin - Log('failed to launch taskkill for ' + ImageName); - exit; - end; - Log('taskkill image "' + ImageName + '" exit=' + IntToStr(ExitCode)); -end; - -procedure StopLanternProcesses; -begin - Log('Stopping old Lantern UI/service processes'); - TryKillProcessImage('{#UiExeName}'); - TryKillProcessImage('{#SvcExeName}'); -end; - -function LegacyUserInstallDir: String; -begin - Result := ExpandConstant('{localappdata}\Programs\{#SetupSetting("AppName")}'); -end; - -function PathStartsWith(const Path: String; const Prefix: String): Boolean; -var - NormalizedPath: String; - NormalizedPrefix: String; -begin - NormalizedPath := LowerCase(RemoveBackslashUnlessRoot(Trim(Path))); - NormalizedPrefix := LowerCase(RemoveBackslashUnlessRoot(Trim(Prefix))); - if (NormalizedPath = '') or (NormalizedPrefix = '') then begin - Result := False; - exit; - end; - - if NormalizedPath = NormalizedPrefix then begin - Result := True; - exit; - end; - - Result := - (Length(NormalizedPath) > Length(NormalizedPrefix)) and - (Copy(NormalizedPath, 1, Length(NormalizedPrefix)) = NormalizedPrefix) and - (NormalizedPath[Length(NormalizedPrefix) + 1] = '\'); -end; - -procedure TryDeleteFileIfExists(const Path: String); -begin - if not FileExists(Path) then begin - exit; - end; - if DeleteFile(Path) then begin - Log('Removed stale shortcut: ' + Path); - end else begin - Log('Failed to remove stale shortcut: ' + Path); - end; -end; - -procedure RemoveLegacyUserShortcuts; -begin - TryDeleteFileIfExists(ExpandConstant('{userprograms}\{#SetupSetting("AppName")}.lnk')); - TryDeleteFileIfExists(ExpandConstant('{userdesktop}\{#SetupSetting("AppName")}.lnk')); - TryDeleteFileIfExists( - ExpandConstant('{userappdata}\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\{#SetupSetting("AppName")}.lnk') - ); - TryDeleteFileIfExists( - ExpandConstant('{userappdata}\Microsoft\Internet Explorer\Quick Launch\User Pinned\StartMenu\{#SetupSetting("AppName")}.lnk') - ); -end; - -procedure RemoveLegacyUserUninstallEntryIfPresent(const UserInstallDir: String); -var - UninstallString: String; - UninstallExePath: String; -begin - if not RegQueryStringValue(HKCU, UninstallRegSubKey, 'UninstallString', UninstallString) then begin - exit; - end; - - UninstallExePath := ExtractExecutablePath(UninstallString); - if not PathStartsWith(UninstallExePath, UserInstallDir) then begin - exit; - end; - - Log('Removing legacy per-user uninstall entry: ' + UninstallRegSubKey); - if not RegDeleteKeyIncludingSubkeys(HKCU, UninstallRegSubKey) then begin - Log('Failed to remove legacy per-user uninstall entry'); - end; -end; - -procedure RemoveLegacyUserInstallIfPresent; -var - UserInstallDir: String; -begin - UserInstallDir := LegacyUserInstallDir; - if not DirExists(UserInstallDir) then begin - exit; - end; - - Log('Removing legacy per-user Lantern install: ' + UserInstallDir); - RemoveLegacyUserUninstallEntryIfPresent(UserInstallDir); - RemoveLegacyUserShortcuts; - - if DelTree(UserInstallDir, True, True, True) then begin - Log('Removed legacy per-user Lantern install'); - end else begin - Log('Failed to fully remove legacy per-user Lantern install'); - end; -end; - -procedure FailInstall(const Message: String); -begin - Log('Installation failed: ' + Message); - RaiseException(Message); -end; - procedure StopAndDeleteService; var ExitCode: Integer; begin - ExitCode := -1; - if not ExecSc('stop "{#SvcName}"', ExitCode) then begin - Log('Unable to run sc stop for {#SvcName}; continuing cleanup'); - end; - if ExitCode = 0 then begin - if not WaitForServiceStopped(ServiceStopTimeoutMs) then begin - Log('{#SvcName} did not report STOPPED in time; forcing process termination'); - TryKillProcessImage('{#SvcExeName}'); - end; - end else if ExitCode = ServiceNotRunningExitCode then begin - Log('{#SvcName} already stopped'); - end else if ExitCode = ServiceDoesNotExistExitCode then begin - Log('{#SvcName} does not exist'); - end else begin - Log('sc stop returned exit ' + IntToStr(ExitCode) + '; forcing process termination'); - TryKillProcessImage('{#SvcExeName}'); - if not WaitForServiceStopped(ServiceStopTimeoutMs) then begin - Log('{#SvcName} did not report STOPPED in time after forced termination'); - end; - end; - + ExecSc('stop "{#SvcName}"', ExitCode); ExecSc('delete "{#SvcName}"', ExitCode); end; @@ -600,7 +430,7 @@ begin while ElapsedMs <= TimeoutMs do begin if ExecSc('query "{#SvcName}"', ExitCode) then begin // SERVICE_DOES_NOT_EXIST - if ExitCode = ServiceDoesNotExistExitCode then begin + if ExitCode = 1060 then begin Result := True; exit; end; @@ -611,257 +441,28 @@ begin Result := False; end; -function IsServiceState(const StateCode: String; const StateName: String): Boolean; -var - ExitCode: Integer; -begin - Result := ExecCmd( - '/C sc.exe query {#SvcName} | findstr /R /C:"STATE *: *' + - StateCode + ' *' + StateName + '" >NUL', - ExitCode - ) and (ExitCode = 0); -end; - -function IsServiceRunning: Boolean; -begin - Result := IsServiceState('4', 'RUNNING'); -end; - -function IsServiceStopped: Boolean; -begin - Result := IsServiceState('1', 'STOPPED'); -end; - -function WaitForServiceRunning(const TimeoutMs: Integer): Boolean; -var - ElapsedMs: Integer; -begin - ElapsedMs := 0; - while ElapsedMs <= TimeoutMs do begin - if IsServiceRunning then begin - Result := True; - exit; - end; - Sleep(ServicePollIntervalMs); - ElapsedMs := ElapsedMs + ServicePollIntervalMs; - end; - Result := False; -end; - -function WaitForServiceStopped(const TimeoutMs: Integer): Boolean; -var - ElapsedMs: Integer; -begin - ElapsedMs := 0; - while ElapsedMs <= TimeoutMs do begin - if IsServiceStopped then begin - Result := True; - exit; - end; - Sleep(ServicePollIntervalMs); - ElapsedMs := ElapsedMs + ServicePollIntervalMs; - end; - Result := False; -end; - -function HasNonEmptyTokenFile: Boolean; -var - TokenValue: AnsiString; - TokenFilePath: String; -begin - TokenFilePath := ExpandConstant('{#TokenFile}'); - Result := False; - if not FileExists(TokenFilePath) then begin - exit; - end; - if not LoadStringFromFile(TokenFilePath, TokenValue) then begin - exit; - end; - Result := Trim(String(TokenValue)) <> ''; -end; - -function WaitForTokenFile(const TimeoutMs: Integer): Boolean; -var - ElapsedMs: Integer; -begin - ElapsedMs := 0; - while ElapsedMs <= TimeoutMs do begin - if HasNonEmptyTokenFile then begin - Result := True; - exit; - end; - Sleep(ServicePollIntervalMs); - ElapsedMs := ElapsedMs + ServicePollIntervalMs; - end; - Result := False; -end; - -function ServiceExecutablePath(_Param: String): String; -var - Arm64ServicePath: String; -begin - Arm64ServicePath := ExpandConstant('{app}\arm64\lanternsvc.exe'); - if IsArm64 and FileExists(Arm64ServicePath) then - Result := Arm64ServicePath - else - Result := ExpandConstant('{app}\lanternsvc.exe'); -end; - -procedure CreateOrUpdateService(const ServicePath: String); -var - ExitCode: Integer; - CreateParams: String; - ConfigParams: String; -begin - CreateParams := - 'create "{#SvcName}" binPath= "' + ServicePath + - '" start= delayed-auto DisplayName= "{#SvcDisplayName}"'; - if not ExecSc(CreateParams, ExitCode) then begin - FailInstall('Unable to run sc create for {#SvcName}.'); - end; - - if ExitCode = 0 then begin - Log('Windows service {#SvcName} created'); - exit; - end; - - if ExitCode = 5 then begin - FailInstall( - 'Administrator privileges are required to install {#SvcName}. ' + - 'Please re-run the installer as administrator.' - ); - end; - - if ExitCode <> ServiceExistsExitCode then begin - FailInstall( - 'sc create returned exit ' + IntToStr(ExitCode) + - ' while configuring {#SvcName}.' - ); - end; - - Log('Windows service {#SvcName} already exists, applying updated config'); - ConfigParams := - 'config "{#SvcName}" binPath= "' + ServicePath + - '" start= delayed-auto DisplayName= "{#SvcDisplayName}"'; - if not ExecSc(ConfigParams, ExitCode) then begin - FailInstall('Unable to run sc config for {#SvcName}.'); - end; - if ExitCode <> 0 then begin - FailInstall('sc config returned exit ' + IntToStr(ExitCode) + '.'); - end; -end; - -procedure ConfigureServiceRecovery; -var - ExitCode: Integer; -begin - if not ExecSc('failure "{#SvcName}" reset= 60 actions= restart/5000/restart/5000/""""/5000', ExitCode) then begin - FailInstall('Unable to configure service recovery settings.'); - end; - if ExitCode <> 0 then begin - FailInstall('Failed to configure service recovery (exit=' + IntToStr(ExitCode) + ').'); - end; - if not ExecSc('failureflag "{#SvcName}" 1', ExitCode) then begin - FailInstall('Unable to set service failure flag.'); - end; - if ExitCode <> 0 then begin - FailInstall('Failed to set service failure flag (exit=' + IntToStr(ExitCode) + ').'); - end; - if not ExecSc('description "{#SvcName}" "Lantern Windows service"', ExitCode) then begin - FailInstall('Unable to set service description.'); - end; - if ExitCode <> 0 then begin - FailInstall('Failed to set service description (exit=' + IntToStr(ExitCode) + ').'); - end; -end; - -procedure StartServiceAndValidate; -var - ExitCode: Integer; -begin - if FileExists(ExpandConstant('{#TokenFile}')) then begin - if DeleteFile(ExpandConstant('{#TokenFile}')) then - Log('Deleted stale token file before service start') - else - Log('Failed to delete stale token file before service start'); - end; - - if not ExecSc('query "{#SvcName}"', ExitCode) then begin - FailInstall('Unable to query {#SvcName} after install.'); - end; - if ExitCode <> 0 then begin - FailInstall('Service {#SvcName} was not created (sc query exit=' + IntToStr(ExitCode) + ').'); - end; - - if not ExecSc('stop "{#SvcName}"', ExitCode) then begin - FailInstall('Unable to stop {#SvcName} before restart.'); - end; - if (ExitCode <> 0) and (ExitCode <> ServiceNotRunningExitCode) then begin - FailInstall('sc stop returned exit ' + IntToStr(ExitCode) + ' for {#SvcName}.'); - end; - if not WaitForServiceStopped(ServiceStopTimeoutMs) then begin - FailInstall('{#SvcName} did not stop before restart.'); - end; - - if not ExecSc('start "{#SvcName}"', ExitCode) then begin - FailInstall('Unable to start {#SvcName}.'); - end; - if (ExitCode <> 0) and (ExitCode <> ServiceAlreadyRunningExitCode) then begin - FailInstall('sc start returned exit ' + IntToStr(ExitCode) + ' for {#SvcName}.'); - end; - - if not WaitForServiceRunning(ServiceStartTimeoutMs) then begin - FailInstall('{#SvcName} did not reach Running state after install.'); - end; - if not WaitForTokenFile(TokenReadyTimeoutMs) then begin - FailInstall('IPC token file missing or empty at {#TokenFile}.'); - end; -end; - -procedure ProvisionWindowsService; -var - ServicePath: String; -begin - ServicePath := ServiceExecutablePath(''); - if (ServicePath = '') or (not FileExists(ServicePath)) then begin - FailInstall('Service executable not found at ' + ServicePath + '.'); - end; - - Log('Provisioning Windows service from ' + ServicePath); - CreateOrUpdateService(ServicePath); - ConfigureServiceRecovery; - StartServiceAndValidate; -end; - -procedure PreInstallLanternCleanup; +procedure CurStepChanged(CurStep: TSetupStep); begin - if PreInstallCleanupDone then begin - Log('PrepareToInstall cleanup already completed'); + if CurStep <> ssInstall then begin exit; end; - Log('PrepareToInstall cleanup started'); - StopLanternProcesses; - RemoveLegacyUserInstallIfPresent; + Log('Pre-install service cleanup started'); StopAndDeleteService; if not WaitForServiceDelete(ServiceDeleteTimeoutMs) then begin - FailInstall('{#SvcName} could not be removed before install.'); + Log('Timed out waiting for service deletion; continuing install'); end; - PreInstallCleanupDone := True; end; -procedure CurStepChanged(CurStep: TSetupStep); +function LanterndExecutablePath(_Param: String): String; +var + Arm64Path: String; begin - if CurStep = ssInstall then begin - Log('Pre-install service cleanup started'); - PreInstallLanternCleanup; - exit; - end; - - if CurStep = ssPostInstall then begin - Log('Post-install service validation started'); - ProvisionWindowsService; - end; + Arm64Path := ExpandConstant('{app}\arm64\lanternd.exe'); + if IsArm64 and FileExists(Arm64Path) then + Result := Arm64Path + else + Result := ExpandConstant('{app}\lanternd.exe'); end; function InitializeSetup: Boolean;