From ad974bcbacd7e5f21ac6dd6e4ae1a0e6a36f919b Mon Sep 17 00:00:00 2001 From: atavism Date: Wed, 25 Feb 2026 06:31:07 -0800 Subject: [PATCH 1/5] linux: packaging and runtime updates (#8494) * linux: packaging and runtime no-sudo foundation * code review updates * sync radiance dependency with main * code review updates * revert ffi readiness gate --- Makefile | 57 +++++- README-dev.md | 41 ++++ lantern-core/core.go | 11 +- lantern-core/ffi/ffi.go | 73 -------- lantern-core/ffi/ffi_linux.go | 236 ++++++++++++++++++++++++ lantern-core/ffi/ffi_nonlinux.go | 72 ++++++++ lantern-core/vpn_tunnel/vpn_tunnel.go | 7 + lib/lantern/lantern_ffi_service.dart | 56 ++++-- lib/main.dart | 14 +- linux/CMakeLists.txt | 3 + linux/packaging/arch/postinstall.sh | 7 + linux/packaging/arch/postremove.sh | 8 + linux/packaging/arch/preremove.sh | 5 + linux/packaging/deb/make_config.yaml | 11 +- linux/packaging/deb/scripts/postinst | 34 ++++ linux/packaging/deb/scripts/postrm | 14 ++ linux/packaging/deb/scripts/prerm | 12 ++ linux/packaging/desktop/lantern.desktop | 10 + linux/packaging/nfpm.yaml | 75 ++++++++ scripts/ci/verify_linux_package.sh | 47 +++++ 20 files changed, 692 insertions(+), 101 deletions(-) create mode 100644 lantern-core/ffi/ffi_linux.go create mode 100644 lantern-core/ffi/ffi_nonlinux.go create mode 100755 linux/packaging/arch/postinstall.sh create mode 100755 linux/packaging/arch/postremove.sh create mode 100755 linux/packaging/arch/preremove.sh create mode 100755 linux/packaging/deb/scripts/postinst create mode 100755 linux/packaging/deb/scripts/postrm create mode 100755 linux/packaging/deb/scripts/prerm create mode 100644 linux/packaging/desktop/lantern.desktop create mode 100644 linux/packaging/nfpm.yaml create mode 100755 scripts/ci/verify_linux_package.sh diff --git a/Makefile b/Makefile index 3e1fd37468..2b4ba9fe65 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,16 @@ LINUX_LIB_ARM64 := $(BIN_DIR)/linux-arm64/$(LANTERN_LIB_NAME).so LINUX_LIB_BUILD := $(BIN_DIR)/linux/$(LINUX_LIB) LINUX_INSTALLER_DEB := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE)).deb LINUX_INSTALLER_RPM := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE)).rpm +LINUX_INSTALLER_ARCH := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE)).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_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 @@ -168,7 +178,6 @@ install-macos-deps: install-gomobile brew tap joshdk/tap brew install joshdk/tap/retry brew install imagemagick || true - dart pub global activate flutter_distributor .PHONY: macos macos: $(MACOS_FRAMEWORK_BUILD) @@ -242,7 +251,8 @@ macos-release: clean macos pubget gen build-macos-release sign-app package-macos .PHONY: install-linux-deps install-linux-deps: - dart pub global activate flutter_distributor + @command -v nfpm >/dev/null 2>&1 || \ + { echo "Installing nfpm..."; go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.45.0; } .PHONY: linux-arm64 linux-arm64: $(LINUX_LIB_ARM64) @@ -261,6 +271,31 @@ linux: linux-amd64 mkdir -p $(BIN_DIR)/linux cp $(LINUX_LIB_AMD64) $(LINUX_LIB_BUILD) +.PHONY: linux-service-amd64 linux-service-arm64 stage-linux-service + +linux-service-amd64: $(GO_SOURCES) + $(call MKDIR_P,$(dir $(LINUX_SERVICE_BUILD_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)" + +linux-service-arm64: $(GO_SOURCES) + $(call MKDIR_P,$(dir $(LINUX_SERVICE_BUILD_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)" + +stage-linux-service: linux-service-amd64 + @echo "Staging systemd unit + service binary $(LINUX_PKG_ROOT)..." + $(call MKDIR_P,$(LINUX_SERVICE_DST)) + $(call COPY_FILE,$(LINUX_SERVICE_BUILD_AMD64),$(LINUX_SERVICE_DST)/$(LINUX_SERVICE_NAME)) + $(call MKDIR_P,$(LINUX_PKG_SYSTEMD_DIR)) + $(call COPY_FILE,$(LINUX_SYSTEMD_UNIT_SRC),$(LINUX_SYSTEMD_UNIT_DST)) + .PHONY: linux-debug linux-debug: @echo "Building Flutter app (debug) for Linux..." @@ -272,12 +307,20 @@ linux-release: clean linux pubget gen flutter build linux --release $(DART_DEFINES) cp $(LINUX_LIB_BUILD) build/linux/x64/release/bundle + $(MAKE) stage-linux-service + patchelf --set-rpath '$$ORIGIN/lib' build/linux/x64/release/bundle/lantern || true - flutter_distributor package --build-dart-define=BUILD_TYPE=$(BUILD_TYPE) \ - --build-dart-define=VERSION=$(VERSION) --platform linux --targets "deb,rpm" --skip-clean + @echo "Packaging deb, rpm, and archlinux with nfpm..." + VERSION=$(APP_VERSION) LANTERND_SRC=$(LINUX_SERVICE_DST)/$(LINUX_SERVICE_NAME) SYSTEMD_UNIT_SRC=$(LINUX_SYSTEMD_UNIT_DST) \ + nfpm package -f $(LINUX_PKG_ROOT)/nfpm.yaml -p deb -t $(LINUX_INSTALLER_DEB) + VERSION=$(APP_VERSION) LANTERND_SRC=$(LINUX_SERVICE_DST)/$(LINUX_SERVICE_NAME) SYSTEMD_UNIT_SRC=$(LINUX_SYSTEMD_UNIT_DST) \ + nfpm package -f $(LINUX_PKG_ROOT)/nfpm.yaml -p rpm -t $(LINUX_INSTALLER_RPM) + VERSION=$(APP_VERSION) LANTERND_SRC=$(LINUX_SERVICE_DST)/$(LINUX_SERVICE_NAME) SYSTEMD_UNIT_SRC=$(LINUX_SYSTEMD_UNIT_DST) \ + nfpm package -f $(LINUX_PKG_ROOT)/nfpm.yaml -p archlinux -t $(LINUX_INSTALLER_ARCH) - mv $(DIST_OUT)/$(APP_VERSION)/lantern-$(APP_VERSION)-linux.rpm $(LINUX_INSTALLER_RPM) - mv $(DIST_OUT)/$(APP_VERSION)/lantern-$(APP_VERSION)-linux.deb $(LINUX_INSTALLER_DEB) +.PHONY: verify-linux-package +verify-linux-package: + ./scripts/ci/verify_linux_package.sh $(LINUX_INSTALLER_DEB) # Windows Build .PHONY: build-lanternsvc-windows windows-service-build \ @@ -448,7 +491,6 @@ android-release-ci: android pubget gen android-apk-release android-aab-release install-ios-deps: install-gomobile npm install -g appdmg - dart pub global activate flutter_distributor .PHONY: ios ios: $(IOS_FRAMEWORK_BUILD) @@ -571,4 +613,3 @@ delete-data: # You can install the dart protoc support by running 'dart pub global activate protoc_plugin' protos: @protoc --dart_out=lib/lantern/protos protos/auth.proto - diff --git a/README-dev.md b/README-dev.md index 2f5ae7ccc9..5b6aa26b15 100644 --- a/README-dev.md +++ b/README-dev.md @@ -29,6 +29,47 @@ make macos flutter run -d macos ``` +# Build and run the app on Linux (systemd daemon, no sudo) + +1. Build Linux artifacts + +```bash +make linux-release +``` + +2. Install the `.deb` (requires root only for install) + +```bash +sudo apt install ./lantern-installer-*.deb +``` + +3. Check daemon status + +```bash +systemctl status lanternd.service +``` + +4. Run Lantern app as your normal user + +```bash +flutter run -d linux +``` + +Troubleshooting: + +```bash +journalctl -u lanternd.service -n 200 --no-pager +``` + +Uninstall / cleanup: + +```bash +sudo systemctl disable --now lanternd.service +sudo apt remove lantern +sudo rm -f /usr/lib/systemd/system/lanternd.service /usr/lib/lantern/lanternd +sudo systemctl daemon-reload +``` + # Build and run the app on Windows Quick dev loop (run backend in a console) diff --git a/lantern-core/core.go b/lantern-core/core.go index e7df5b1402..3806305398 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -3,6 +3,7 @@ package lanterncore import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "os" @@ -199,9 +200,15 @@ func (lc *LanternCore) initialize(opts *utils.Opts, eventEmitter utils.FlutterEv } if runtime.GOOS == "linux" { + slog.Debug("Setting IPC settings path for Linux", "path", settings.GetString(settings.DataPathKey)) if err := ipc.SetSettingsPath(context.Background(), settings.GetString(settings.DataPathKey)); err != nil { - slog.Error("Failed to set IPC settings path", "error", err) - return fmt.Errorf("failed to set IPC settings path: %w", err) + // lanternd may not be ready yet during app startup; defer this until daemon is reachable. + if errors.Is(err, ipc.ErrIPCNotRunning) || errors.Is(err, ipc.ErrServiceIsNotReady) { + slog.Warn("Skipping IPC settings path update because lanternd is not ready", "error", err) + } else { + slog.Error("Failed to set IPC settings path", "error", err) + return fmt.Errorf("failed to set IPC settings path: %w", err) + } } } diff --git a/lantern-core/ffi/ffi.go b/lantern-core/ffi/ffi.go index 27acd5b73c..591b29b03f 100644 --- a/lantern-core/ffi/ffi.go +++ b/lantern-core/ffi/ffi.go @@ -252,41 +252,6 @@ func reportIssue(emailC, typeC, descC, deviceC, modelC, logPathC *C.char) *C.cha return C.CString("ok") } -// startVPN initializes and starts the VPN server if it is not already running. -// -//export startVPN -func startVPN(_logDir, _dataDir, _locale *C.char) *C.char { - slog.Debug("startVPN called") - 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) - slog.Debug("VPN server started successfully") - return C.CString("ok") -} - -// stopVPN stops the VPN server if it is running. -// -//export stopVPN -func stopVPN() *C.char { - slog.Debug("stopVPN called") - 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) - slog.Debug("VPN server stopped successfully") - return C.CString("ok") -} - // getAutoLocation returns the auto location in JSON format. // //export getAutoLocation @@ -346,29 +311,6 @@ func getAvailableServers() *C.char { return C.CString(string(c.GetAvailableServers())) } -// connectToServer sets the private server with the given tag. -// connectToServer connects to a specific VPN server identified by the location type and tag. -// connectToServer will open and start the VPN tunnel if it is not already running. -// -//export connectToServer -func connectToServer(_location, _tag, _logDir, _dataDir, _locale *C.char) *C.char { - tag := C.GoString(_tag) - locationType := C.GoString(_location) - - // Valid location types are: - // auto, - // privateServer, - // lanternLocation; - 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("Private server set with tag", "tag", tag) - return C.CString("ok") -} - func sendStatusToPort(status VPNStatus) { slog.Debug("sendStatusToPort called", "status", status) if statusPort == 0 { @@ -384,21 +326,6 @@ func sendStatusToPort(status VPNStatus) { } -// isVPNConnected checks if the VPN server is running and connected. -// -//export isVPNConnected -func isVPNConnected() C.int { - connected := vpn_tunnel.IsVPNRunning() - slog.Debug("isVPNConnected called, connected:", "connected", connected) - if connected { - sendStatusToPort(Connected) - return 1 - } else { - sendStatusToPort(Disconnected) - return 0 - } -} - // APIS // Get user data from the local config // diff --git a/lantern-core/ffi/ffi_linux.go b/lantern-core/ffi/ffi_linux.go new file mode 100644 index 0000000000..23cd25ce35 --- /dev/null +++ b/lantern-core/ffi/ffi_linux.go @@ -0,0 +1,236 @@ +//go:build linux && !android && !ios && !macos + +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/ipc" +) + +const ( + linuxServiceName = "lanternd" + linuxSocketPath = "/var/run/lantern/lanternd.sock" +) + +var ( + linuxStatusOnce sync.Once + linuxLastStatusMu sync.Mutex + linuxLastStatus string +) + +func requireLanternServiceAvailable() error { + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + st, err := ipc.GetStatus(ctx) + if err == nil && st != "" { + return nil + } + + if diag := systemdDiag(linuxServiceName); diag != "" { + return fmt.Errorf("%s not reachable (%s): %s", linuxServiceName, linuxSocketPath, diag) + } + return fmt.Errorf("%s not reachable (%s)", linuxServiceName, linuxSocketPath) +} + +func systemdDiag(unit string) string { + ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + defer cancel() + + u := unit + if filepath.Ext(u) == "" { + u = unit + ".service" + } + + out, err := exec.CommandContext(ctx, "systemctl", "is-active", u).CombinedOutput() + if err != nil && len(out) == 0 { + return "" + } + + switch strings.TrimSpace(string(out)) { + case "active": + return "systemd says active, but IPC is not responding" + case "inactive": + return "systemd says inactive" + case "failed": + return "systemd says failed" + case "activating": + return "systemd says activating" + case "deactivating": + return "systemd says deactivating" + default: + 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 string, err error) string { + if err != nil { + return string(Disconnected) + } + switch state { + case ipc.StatusRunning: + return string(Connected) + case ipc.StatusConnecting, ipc.StatusInitializing: + return string(Connecting) + case ipc.StatusClosing: + return string(Disconnecting) + case ipc.StatusClosed: + 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()) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := ipc.StartService(ctx, "", ""); 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) + } + + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + + if err := ipc.StartService(ctx, 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)) + } + + if tag == "" { + return C.CString("ok") + } + + if err := ipc.SelectOutbound(ctx, group, tag); err != nil { + return SendError(fmt.Errorf("select outbound 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 new file mode 100644 index 0000000000..22beb24c9b --- /dev/null +++ b/lantern-core/ffi/ffi_nonlinux.go @@ -0,0 +1,72 @@ +//go:build !linux && !android && !ios && !macos + +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" +) + +//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 +} diff --git a/lantern-core/vpn_tunnel/vpn_tunnel.go b/lantern-core/vpn_tunnel/vpn_tunnel.go index 049de7ccb6..0388e996c4 100644 --- a/lantern-core/vpn_tunnel/vpn_tunnel.go +++ b/lantern-core/vpn_tunnel/vpn_tunnel.go @@ -2,6 +2,7 @@ package vpn_tunnel import ( "fmt" + "runtime" "sync/atomic" "log/slog" @@ -85,6 +86,9 @@ func GetSelectedServer() string { } func CloseIPC() error { + if runtime.GOOS == "linux" { + return nil + } if svr := ipcServer.Swap(nil); svr != nil { return svr.Close() } @@ -92,6 +96,9 @@ func CloseIPC() error { } func initIPC(opts *utils.Opts, platIfce rvpn.PlatformInterface) error { + if runtime.GOOS == "linux" { + return nil + } if ipcServer.Load() != nil { return nil } diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index c1527cdb89..0e9978bca5 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'dart:ffi'; import 'dart:io'; import 'dart:isolate'; -import 'dart:ui' show PlatformDispatcher; import 'package:ffi/ffi.dart'; import 'package:flutter/foundation.dart'; @@ -78,16 +77,25 @@ class LanternFFIService implements LanternCoreService { static LanternBindings _gen() { final String basePath = p.dirname(Platform.resolvedExecutable); - String fullPath = ""; + final String fullPath; + appLogger.debug('resolved executable: "${Platform.resolvedExecutable}"'); if (Platform.isWindows) { - fullPath = p.join(basePath, "$_libName.dll"); - if (!File(fullPath).existsSync()) { - fullPath = p.join(basePath, "bin", "$_libName.dll"); - } - if (!File(fullPath).existsSync()) { - fullPath = p.join(basePath, "bin", "windows", "$_libName.dll"); - } + final candidates = [ + p.join(basePath, "$_libName.dll"), + p.join(basePath, "bin", "$_libName.dll"), + p.join(basePath, "bin", "windows", "$_libName.dll"), + ]; + fullPath = _firstExisting(candidates); + } else if (Platform.isLinux) { + final envPath = Platform.environment['LANTERN_LIB_PATH']; + final candidates = [ + if (envPath != null && envPath.isNotEmpty) envPath, + p.join(basePath, "$_libName.so"), + p.join(basePath, "lib", "$_libName.so"), + "/usr/lib/lantern/$_libName.so", + ]; + fullPath = _firstExisting(candidates); } else { fullPath = p.join(basePath, "$_libName.so"); } @@ -97,6 +105,18 @@ class LanternFFIService implements LanternCoreService { return LanternBindings(lib); } + static String _firstExisting(List candidates) { + for (final candidate in candidates) { + if (File(candidate).existsSync()) { + return candidate; + } + } + appLogger.warning( + 'Native library not found in candidates: ${candidates.join(', ')}', + ); + return candidates.first; + } + Future init() async { // Set safe defaults up front so callers always have something to listen to. _status = _defaultStatusStream(); @@ -104,7 +124,10 @@ class LanternFFIService implements LanternCoreService { _appEvents = const Stream.empty(); try { - await _setupRadiance(); + final setupResult = await _setupRadiance(); + setupResult.fold((err) { + appLogger.error('Radiance setup failed: $err'); + }, (_) {}); if (Platform.isWindows) { /// Start windows IPC service. @@ -165,7 +188,7 @@ class LanternFFIService implements LanternCoreService { String env = await _radianceEnv(); try { final appSetting = sl().getAppSetting(); - if (appSetting != null) { + if (appSetting != null) { consent = appSetting.telemetryConsent ? 1 : 0; } } catch (_) { @@ -176,7 +199,8 @@ class LanternFFIService implements LanternCoreService { final dataDir = await AppStorageUtils.getAppDirectory(); final logDir = await AppStorageUtils.getAppLogDirectory(); - appLogger.info("Radiance configuration - env: $env, dataDir: ${dataDir.path}, logDir: $logDir, telemetryConsent: $consent"); + appLogger.info( + "Radiance configuration - env: $env, dataDir: ${dataDir.path}, logDir: $logDir, telemetryConsent: $consent"); final dataDirPtr = dataDir.path.toCharPtr; final logDirPtr = logDir.toCharPtr; @@ -201,9 +225,15 @@ class LanternFFIService implements LanternCoreService { .toDartString(); checkAPIError(result); + if (result != 'ok' && result != 'true') { + throw PlatformException( + code: 'radiance_setup_failed', + message: result, + ); + } return right(unit); } catch (e, st) { - appLogger.error('Failed to get data cap info: $e', e, st); + appLogger.error('Failed to set up radiance: $e', e, st); return Left(e.toFailure().localizedErrorMessage); } } diff --git a/lib/main.dart b/lib/main.dart index 8e2fb70253..54f3e30390 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -95,13 +95,21 @@ Future _setupSentry({required AppRunner runner}) async { } Future _configureLocalTimeZone() async { - if (kIsWeb || Platform.isLinux) { + if (kIsWeb) { return; } + tz.initializeTimeZones(); - final timeZoneName = await FlutterTimezone.getLocalTimezone(); - tz.setLocalLocation(tz.getLocation(timeZoneName.identifier)); + try { + final timeZoneName = await FlutterTimezone.getLocalTimezone(); + tz.setLocalLocation(tz.getLocation(timeZoneName.identifier)); + } catch (e) { + appLogger.warning( + 'Failed to configure local timezone, falling back to UTC: $e', + ); + tz.setLocalLocation(tz.UTC); + } } Future _loadAppSecrets() async { diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 3d657f108a..e5531a1420 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -105,6 +105,9 @@ install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" set(LANTERN_DIR "..") install(FILES "${LANTERN_DIR}/bin/linux/liblantern.so" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) +# Keep a root-level copy for FFI loading from Platform.resolvedExecutable dir. +install(FILES "${LANTERN_DIR}/bin/linux/liblantern.so" DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) install(FILES "${bundled_library}" diff --git a/linux/packaging/arch/postinstall.sh b/linux/packaging/arch/postinstall.sh new file mode 100755 index 0000000000..53ce68b9ff --- /dev/null +++ b/linux/packaging/arch/postinstall.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -e + +UNIT="lanternd.service" + +systemctl daemon-reload >/dev/null 2>&1 || true +systemctl enable --now "$UNIT" >/dev/null 2>&1 || true diff --git a/linux/packaging/arch/postremove.sh b/linux/packaging/arch/postremove.sh new file mode 100755 index 0000000000..e0f4d7a653 --- /dev/null +++ b/linux/packaging/arch/postremove.sh @@ -0,0 +1,8 @@ +#!/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 diff --git a/linux/packaging/arch/preremove.sh b/linux/packaging/arch/preremove.sh new file mode 100755 index 0000000000..2a8074cd62 --- /dev/null +++ b/linux/packaging/arch/preremove.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +UNIT="lanternd.service" +systemctl stop "$UNIT" >/dev/null 2>&1 || true diff --git a/linux/packaging/deb/make_config.yaml b/linux/packaging/deb/make_config.yaml index ef871c77e4..9a53c9564f 100644 --- a/linux/packaging/deb/make_config.yaml +++ b/linux/packaging/deb/make_config.yaml @@ -10,7 +10,6 @@ installed_size: 6604 essential: false icon: ./assets/images/launch_icon.png - keywords: - Lantern @@ -19,4 +18,12 @@ generic_name: Lantern categories: - Network -startup_notify: true \ No newline at end of file +startup_notify: true + +postinstall_scripts: + - systemctl daemon-reload + - systemctl enable --now lanternd.service || true + +postuninstall_scripts: + - systemctl disable lanternd.service || true + - systemctl daemon-reload diff --git a/linux/packaging/deb/scripts/postinst b/linux/packaging/deb/scripts/postinst new file mode 100755 index 0000000000..e3e94690d6 --- /dev/null +++ b/linux/packaging/deb/scripts/postinst @@ -0,0 +1,34 @@ +#!/bin/sh +set -e + +warn() { + echo "lantern postinst: $*" >&2 +} + +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" + fi + ;; +esac + +exit 0 diff --git a/linux/packaging/deb/scripts/postrm b/linux/packaging/deb/scripts/postrm new file mode 100755 index 0000000000..87d803b231 --- /dev/null +++ b/linux/packaging/deb/scripts/postrm @@ -0,0 +1,14 @@ +#!/bin/sh +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 + ;; +esac + +exit 0 diff --git a/linux/packaging/deb/scripts/prerm b/linux/packaging/deb/scripts/prerm new file mode 100755 index 0000000000..eb3903e515 --- /dev/null +++ b/linux/packaging/deb/scripts/prerm @@ -0,0 +1,12 @@ +#!/bin/sh +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 + ;; +esac + +exit 0 diff --git a/linux/packaging/desktop/lantern.desktop b/linux/packaging/desktop/lantern.desktop new file mode 100644 index 0000000000..0bc40cb257 --- /dev/null +++ b/linux/packaging/desktop/lantern.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=Lantern +GenericName=Lantern +Comment=Lantern Internet Freedom Tool +Icon=lantern +Exec=lantern %U +Categories=Network; +Keywords=Lantern;VPN;Proxy;Privacy; +StartupNotify=true diff --git a/linux/packaging/nfpm.yaml b/linux/packaging/nfpm.yaml new file mode 100644 index 0000000000..f00e4d244a --- /dev/null +++ b/linux/packaging/nfpm.yaml @@ -0,0 +1,75 @@ +name: lantern +arch: "${GOARCH}" +platform: linux +version: "${VERSION}" +maintainer: teamlantern +description: Lantern +vendor: teamlantern +homepage: https://getlantern.org +license: Other +section: x11 +priority: optional + +contents: + # Flutter release bundle + - src: build/linux/x64/release/bundle/ + dst: /usr/lib/lantern + type: tree + + # lanternd service binary + - src: "${LANTERND_SRC}" + dst: /usr/sbin + expand: true + + # systemd unit + - src: "${SYSTEMD_UNIT_SRC}" + dst: /usr/lib/systemd/system/lanternd.service + expand: true + + # desktop entry + - src: linux/packaging/desktop/lantern.desktop + dst: /usr/share/applications/lantern.desktop + + # app icon + - 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 + postremove: linux/packaging/deb/scripts/postrm + +overrides: + deb: + depends: + - libgtk-3-0 + - libayatana-appindicator3-1 + - libblkid1 + - liblzma5 + - libwebkit2gtk-4.1-0 + rpm: + depends: + - gtk3 + - xz-libs + - libblkid + - webkit2gtk4.1 + archlinux: + depends: + - gtk3 + - xz + - util-linux-libs + - webkit2gtk-4.1 + scripts: + postinstall: linux/packaging/arch/postinstall.sh + preremove: linux/packaging/arch/preremove.sh + postremove: linux/packaging/arch/postremove.sh + +rpm: + group: applications/Internet +archlinux: + packager: teamlantern diff --git a/scripts/ci/verify_linux_package.sh b/scripts/ci/verify_linux_package.sh new file mode 100755 index 0000000000..93bb6fc3c8 --- /dev/null +++ b/scripts/ci/verify_linux_package.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " + exit 2 +fi + +DEB_PATH="$1" +if [[ ! -f "$DEB_PATH" ]]; then + echo "deb package not found: $DEB_PATH" + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +dpkg-deb -x "$DEB_PATH" "$TMP_DIR/root" +dpkg-deb -e "$DEB_PATH" "$TMP_DIR/control" + +require_file() { + local path="$1" + if [[ ! -f "$path" ]]; then + echo "missing file in package: $path" + exit 1 + fi +} + +require_grep() { + local pattern="$1" + local path="$2" + if ! grep -Eq "$pattern" "$path"; then + echo "expected pattern '$pattern' not found in: $path" + exit 1 + fi +} + +require_file "$TMP_DIR/root/usr/sbin/lanternd" +require_file "$TMP_DIR/root/usr/lib/systemd/system/lanternd.service" +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" + +echo "linux package verification passed: $DEB_PATH" From 429bed61cf2115252dc85812b4a96b493a68a7a2 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 25 Feb 2026 14:14:31 -0700 Subject: [PATCH 2/5] Update to latest radiance with fixes for dialing directly --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index bc4c31cc8e..7a8e5013f6 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 github.com/alecthomas/assert/v2 v2.3.0 github.com/getlantern/lantern-server-provisioner v0.0.0-20251031121934-8ea031fccfa9 - github.com/getlantern/radiance v0.0.0-20260221215045-6049f134d863 + github.com/getlantern/radiance v0.0.0-20260225211011-60649d634494 github.com/sagernet/sing-box v1.12.22 golang.org/x/mobile v0.0.0-20250711185624-d5bb5ecc55c0 golang.org/x/sys v0.40.0 @@ -166,7 +166,7 @@ require ( github.com/getlantern/appdir v0.0.0-20250324200952-507a0625eb01 // indirect github.com/getlantern/common v1.2.1-0.20260121160752-d8ee5791108f // indirect github.com/getlantern/dnstt v0.0.0-20260112160750-05100563bd0d // indirect - github.com/getlantern/fronted v0.0.0-20260219001615-7eabaa834efe // indirect + github.com/getlantern/fronted v0.0.0-20260225205111-41c9e534027a // indirect github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect github.com/getlantern/iptool v0.0.0-20230112135223-c00e863b2696 // indirect github.com/getlantern/keepcurrent v0.0.0-20240126172110-2e0264ca385d // indirect diff --git a/go.sum b/go.sum index 044c08d72b..9c40728dd2 100644 --- a/go.sum +++ b/go.sum @@ -205,8 +205,8 @@ github.com/getlantern/errors v1.0.4 h1:i2iR1M9GKj4WuingpNqJ+XQEw6i6dnAgKAmLj6ZB3 github.com/getlantern/errors v1.0.4/go.mod h1:/Foq8jtSDGP8GOXzAjeslsC4Ar/3kB+UiQH+WyV4pzY= github.com/getlantern/fdcount v0.0.0-20210503151800-5decd65b3731 h1:v+vJ3LgV4nW4xRPZo+xkADDflXLpRbG+Lv69XKWFjTQ= github.com/getlantern/fdcount v0.0.0-20210503151800-5decd65b3731/go.mod h1:XZwE+iIlAgr64OFbXKFNCllBwV4wEipPx8Hlo2gZdbM= -github.com/getlantern/fronted v0.0.0-20260219001615-7eabaa834efe h1:Q4fwCDDqgw21GGitBPXol68wbAJGBNBolNsa8MS5wXk= -github.com/getlantern/fronted v0.0.0-20260219001615-7eabaa834efe/go.mod h1:1a+iv1xzGxZWj/vCHzr8Z3dF9H1sNTuMSPHUqRsgbl0= +github.com/getlantern/fronted v0.0.0-20260225205111-41c9e534027a h1:mZVn1e2boHzKk4JgKwQ4Eqhn+omowFWzPduxGHCmYRs= +github.com/getlantern/fronted v0.0.0-20260225205111-41c9e534027a/go.mod h1:1a+iv1xzGxZWj/vCHzr8Z3dF9H1sNTuMSPHUqRsgbl0= github.com/getlantern/golog v0.0.0-20210606115803-bce9f9fe5a5f/go.mod h1:ZyIjgH/1wTCl+B+7yH1DqrWp6MPJqESmwmEQ89ZfhvA= github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 h1:NlQedYmPI3pRAXJb+hLVVDGqfvvXGRPV8vp7XOjKAZ0= github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65/go.mod h1:+ZU1h+iOVqWReBpky6d5Y2WL0sF2Llxu+QcxJFs2+OU= @@ -242,8 +242,8 @@ github.com/getlantern/osversion v0.0.0-20240418205916-2e84a4a4e175 h1:JWH5BB2o0e github.com/getlantern/osversion v0.0.0-20240418205916-2e84a4a4e175/go.mod h1:h3S9LBmmzN/xM+lwYZHE4abzTtCTtidKtG+nxZcCZX0= github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 h1:rtDmW8YLAuT8r51ApR5z0d8/qjhHu3TW+divQ2C98Ac= github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535/go.mod h1:WKJEdjMOD4IuTRYwjQHjT4bmqDl5J82RShMLxPAvi0Q= -github.com/getlantern/radiance v0.0.0-20260221215045-6049f134d863 h1:nlx+23+ieMbmXA12ZY3IMuedCsAKgp/s2Fauf8M10mk= -github.com/getlantern/radiance v0.0.0-20260221215045-6049f134d863/go.mod h1:JwM46TRAnU3PCmdhj7gar3bpHH5SQTufj7d2LSdi2tk= +github.com/getlantern/radiance v0.0.0-20260225211011-60649d634494 h1:KEzJJdbi36nxgGfMLcpHuBTa2suwgdz3xI2Ie0Sv4Ko= +github.com/getlantern/radiance v0.0.0-20260225211011-60649d634494/go.mod h1:KrVcQYh39cZCsKPX6Bp+m76+bRjS0VX11q4XHZeBPP4= github.com/getlantern/samizdat v0.0.2 h1:PkMu6jsfUz7DLZUH2xh548XfzgPASmq5CajZyUKj/9Y= github.com/getlantern/samizdat v0.0.2/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0= github.com/getlantern/sing v0.7.18-lantern h1:QKGgIUA3LwmKYP/7JlQTRkxj9jnP4cX2Q/B+nd8XEjo= From bb1ba821debd61b4e1db47bc14bce3d67891a714 Mon Sep 17 00:00:00 2001 From: atavism Date: Thu, 26 Feb 2026 09:14:41 -0800 Subject: [PATCH 3/5] linux: add CI smoke coverage (#8496) * linux: add CI installed-binary and UI connect smoke * fix tray quit handler * fix lanternd install path in nfpm package * guard vpn notifier delayed callback after dispose * treat ffi ok result as vpn success * ci(linux): assert lanternd receives start/stop IPC in UI smoke * ci(linux): add simple optional public IP check to VPN smoke * ci(linux): use linux-release-ci to avoid clean rebuilds * docs: add linux smoke test run command * test: temporarily log IP values in linux smoke * Revert "test: temporarily log IP values in linux smoke" This reverts commit a4797a53ab16f09a806ed04c7786c5f3fd6f9c09. * Code review updates * test: use VPNStatus in linux smoke state checks * test: add non-sensitive IP check logs --- .github/workflows/build-linux.yml | 139 +++++-- Makefile | 6 +- README-dev.md | 6 + integration_test/utils/widget_wait_utils.dart | 55 +++ .../vpn/linux_connect_smoke_test.dart | 347 ++++++++++++++++++ lib/core/common/app_build_info.dart | 5 + lib/features/home/home.dart | 19 +- lib/features/onboarding/onboarding.dart | 26 +- .../provider/system_tray_notifier.dart | 64 +++- .../system_tray/system_tray_wrapper.dart | 4 + lib/features/vpn/provider/vpn_notifier.dart | 5 +- lib/features/vpn/vpn_status.dart | 14 +- lib/features/vpn/vpn_switch.dart | 2 + lib/lantern/lantern_ffi_service.dart | 11 +- linux/packaging/nfpm.yaml | 2 +- 15 files changed, 646 insertions(+), 59 deletions(-) create mode 100644 integration_test/utils/widget_wait_utils.dart create mode 100644 integration_test/vpn/linux_connect_smoke_test.dart diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index fe27af4a76..26185c87fc 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -46,28 +46,51 @@ jobs: restore-keys: | ${{ runner.os }}-flutter- + - name: Cache APT archives + uses: actions/cache@v4 + timeout-minutes: 5 + continue-on-error: true + with: + path: ~/.cache/apt/archives + key: ${{ runner.os }}-apt-archives-v1-${{ hashFiles('.github/workflows/build-linux.yml') }} + restore-keys: | + ${{ runner.os }}-apt-archives-v1- + - name: Install Linux dependencies run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - curl \ - git \ - unzip \ - clang \ - cmake \ - pkg-config \ - libgtk-3-dev \ - liblzma-dev \ - xz-utils \ - ninja-build \ - lld \ - libstdc++-12-dev \ - libgl1-mesa-dev \ - libegl1-mesa-dev \ - libayatana-appindicator3-dev \ - libcurl4-openssl-dev \ - libwebkit2gtk-4.1-dev + set -euxo pipefail + APT_ARCHIVE_DIR="$HOME/.cache/apt/archives" + mkdir -p "$APT_ARCHIVE_DIR/partial" + required_packages=( + clang + cmake + pkg-config + libgtk-3-dev + liblzma-dev + libgl1-mesa-dev + libegl1-mesa-dev + libayatana-appindicator3-dev + libcurl4-openssl-dev + libwebkit2gtk-4.1-dev + patchelf + xvfb + ) + + missing_packages=() + for pkg in "${required_packages[@]}"; do + if ! dpkg -s "$pkg" >/dev/null 2>&1; then + missing_packages+=("$pkg") + fi + done + + if [ "${#missing_packages[@]}" -gt 0 ]; then + sudo apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + -o dir::cache::archives="$APT_ARCHIVE_DIR" \ + "${missing_packages[@]}" + else + echo "All Linux build dependencies are already installed" + fi - name: Install the ninja build tool uses: seanmiddleditch/gha-setup-ninja@master @@ -77,6 +100,7 @@ jobs: with: channel: stable flutter-version-file: pubspec.yaml + cache: true - name: Install dependencies run: make install-linux-deps @@ -93,12 +117,85 @@ jobs: encodedString: ${{ secrets.APP_ENV }} - name: Build Linux release - run: make linux-release + run: make linux-release-ci env: BUILD_TYPE: ${{ inputs.build_type }} VERSION: ${{ inputs.version }} INSTALLER_NAME: ${{ inputs.installer_base_name }} + - name: Verify Linux package contents + run: | + ./scripts/ci/verify_linux_package.sh "${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.deb" + + - name: Install .deb and verify postinst started daemon + shell: bash + run: | + set -euxo pipefail + deb="./${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.deb" + test -f "$deb" + sudo apt-get install -y "$deb" + + for i in $(seq 1 30); do + if systemctl is-active --quiet lanternd.service; then + break + fi + sleep 1 + done + + if ! systemctl is-active --quiet lanternd.service; then + sudo systemctl status lanternd.service --no-pager || true + sudo journalctl -u lanternd.service -n 200 --no-pager || true + echo "lanternd.service is not active after package install" + exit 1 + fi + + if ! systemctl is-enabled --quiet lanternd.service; then + echo "lanternd.service is not enabled after package install" + 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 + + - name: Installed binary launch smoke + shell: bash + 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=$? + if [[ "$code" -ne 124 ]]; then + cat /tmp/lantern-installed-smoke.log || true + echo "Installed /usr/bin/lantern did not stay up under xvfb" + exit 1 + fi + + - name: Linux UI connect/disconnect integration + shell: bash + env: + LANG: en_US.UTF-8 + LC_ALL: en_US.UTF-8 + run: | + set -euxo pipefail + TEST_START_UTC="$(date -u '+%Y-%m-%d %H:%M:%S')" + sg lantern -c "env PATH=$PATH HOME=$HOME xvfb-run -a flutter test integration_test/vpn/linux_connect_smoke_test.dart -d linux --dart-define=DISABLE_SYSTEM_TRAY=true --dart-define=ENABLE_IP_CHECK=true" + + sudo journalctl -u lanternd.service --since "$TEST_START_UTC" --no-pager > /tmp/lanternd-journal-ui-smoke.log + + if ! grep -Eq 'IPC request.*path=/service/start' /tmp/lanternd-journal-ui-smoke.log; then + echo "Missing /service/start 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" + tail -n 200 /tmp/lanternd-journal-ui-smoke.log || true + exit 1 + fi + - name: Upload Linux build uses: actions/upload-artifact@v4 with: diff --git a/Makefile b/Makefile index 2b4ba9fe65..dabc4e9e03 100644 --- a/Makefile +++ b/Makefile @@ -301,8 +301,10 @@ linux-debug: @echo "Building Flutter app (debug) for Linux..." flutter build linux --debug -.PHONY: linux-release -linux-release: clean linux pubget gen +.PHONY: linux-release linux-release-ci +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) diff --git a/README-dev.md b/README-dev.md index 5b6aa26b15..ce02f89d0f 100644 --- a/README-dev.md +++ b/README-dev.md @@ -163,6 +163,12 @@ flutter test integration_test flutter test integration_test/private_server_flow_test.dart ``` +### Run Linux VPN connect/disconnect smoke test + +```bash +flutter test integration_test/vpn/linux_connect_smoke_test.dart -d linux --dart-define=DISABLE_SYSTEM_TRAY=true --dart-define=ENABLE_IP_CHECK=true +``` + # Auto-Updater Integration The app supports automatic updates on macOS and Windows, using the [auto_updater](https://pub.dev/packages/auto_updater) package, which is a Flutter-friendly wrapper around the Sparkle update framework. diff --git a/integration_test/utils/widget_wait_utils.dart b/integration_test/utils/widget_wait_utils.dart new file mode 100644 index 0000000000..e645955b90 --- /dev/null +++ b/integration_test/utils/widget_wait_utils.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; + +class WidgetWaitUtils { + const WidgetWaitUtils._(); + + static Future waitForFinder( + WidgetTester tester, + Finder finder, { + required Duration timeout, + String? reason, + }) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 200)); + if (finder.evaluate().isNotEmpty) { + return; + } + } + fail(reason ?? 'Timed out waiting for expected widget'); + } + + static Future waitForAnyFinder( + WidgetTester tester, + List finders, { + required Duration timeout, + String? reason, + }) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 200)); + for (final finder in finders) { + if (finder.evaluate().isNotEmpty) { + return; + } + } + } + fail(reason ?? 'Timed out waiting for any expected widget'); + } + + static Future waitForFinderToDisappear( + WidgetTester tester, + Finder finder, { + required Duration timeout, + String? reason, + }) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 200)); + if (finder.evaluate().isEmpty) { + return; + } + } + fail(reason ?? 'Timed out waiting for widget to disappear'); + } +} diff --git a/integration_test/vpn/linux_connect_smoke_test.dart b/integration_test/vpn/linux_connect_smoke_test.dart new file mode 100644 index 0000000000..d1dc911d83 --- /dev/null +++ b/integration_test/vpn/linux_connect_smoke_test.dart @@ -0,0 +1,347 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:lantern/core/common/app_eum.dart'; +import 'package:lantern/main.dart' as app; + +import '../utils/widget_wait_utils.dart'; + +const _vpnStateKeyPrefixes = [ + 'vpn.switch.', + 'vpn.status.', +]; + +const _observableStates = [ + VPNStatus.connected, + VPNStatus.disconnected, + VPNStatus.connecting, + VPNStatus.disconnecting, + VPNStatus.missingPermission, + VPNStatus.error, +]; + +const _vpnStateLabels = { + VPNStatus.connected: 'Connected', + VPNStatus.disconnected: 'Disconnected', + VPNStatus.connecting: 'Connecting', + VPNStatus.disconnecting: 'Disconnecting', + VPNStatus.missingPermission: 'MissingPermission', + VPNStatus.error: 'Error', +}; + +const _stableStates = [ + VPNStatus.connected, + VPNStatus.disconnected, + VPNStatus.missingPermission, + VPNStatus.error, +]; + +const _enableIpCheck = + bool.fromEnvironment('ENABLE_IP_CHECK', defaultValue: false); + +const _ipCheckEndpoint = 'https://api64.ipify.org'; + +Future _fetchPublicIpOnce() async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 6); + try { + final request = await client.getUrl(Uri.parse(_ipCheckEndpoint)); + final response = await request.close().timeout(const Duration(seconds: 6)); + if (response.statusCode != HttpStatus.ok) { + return null; + } + + final body = + await response.transform(const SystemEncoding().decoder).join(); + final ip = body.trim(); + if (ip.isNotEmpty && InternetAddress.tryParse(ip) != null) { + return ip; + } + } catch (_) { + return null; + } finally { + client.close(force: true); + } + return null; +} + +Future _fetchPublicIpWithRetry({ + required Duration timeout, + required String reason, +}) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + final ip = await _fetchPublicIpOnce(); + if (ip != null && ip.isNotEmpty) { + debugPrint('IP check: fetched public IP for $reason'); + return ip; + } + await Future.delayed(const Duration(seconds: 2)); + } + fail('Failed to fetch public IP: $reason'); +} + +Future _assertPublicIpChangesFromBaseline(String baselineIp) async { + final deadline = DateTime.now().add(const Duration(seconds: 60)); + while (DateTime.now().isBefore(deadline)) { + final current = await _fetchPublicIpOnce(); + if (current != null && current.isNotEmpty && current != baselineIp) { + debugPrint('IP check: detected public IP change after connect'); + return; + } + await Future.delayed(const Duration(seconds: 3)); + } + fail('Public IP did not change after VPN connected (baseline: $baselineIp)'); +} + +class _VpnStateFinders { + VPNStatus? current() { + for (final state in _observableStates) { + for (final prefix in _vpnStateKeyPrefixes) { + if (find.byKey(Key('$prefix${state.name}')).evaluate().isNotEmpty) { + return state; + } + } + } + + for (final entry in _vpnStateLabels.entries) { + if (find.text(entry.value).evaluate().isNotEmpty) { + return entry.key; + } + } + + return null; + } + + Future tryWaitFor( + WidgetTester tester, { + required List expected, + required Duration timeout, + }) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 200)); + final state = current(); + if (expected.contains(state)) { + return state; + } + } + return null; + } + + Future waitFor( + WidgetTester tester, { + required List expected, + required Duration timeout, + String? reason, + }) async { + final state = await tryWaitFor( + tester, + expected: expected, + timeout: timeout, + ); + if (state != null) { + return state; + } + + final debugKeys = tester.allWidgets + .map((w) => w.key) + .whereType() + .map((k) => k.toString()) + .where((k) => k.contains('vpn.') || k.contains('onboarding.')) + .toSet() + .toList() + ..sort(); + fail( + '${reason ?? 'Timed out waiting for VPN state'}. Last observed: ${current()?.name ?? 'unknown'}. ' + 'Visible keyed widgets: $debugKeys', + ); + } +} + +Future _waitForVpnToggleWithOnboardingHandling( + WidgetTester tester, { + required Finder vpnToggle, + required Finder onboardingScreen, + required Finder onboardingSkip, + required Finder onboardingPrimary, + required Duration timeout, +}) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + if (onboardingScreen.evaluate().isNotEmpty) { + if (onboardingSkip.evaluate().isNotEmpty) { + await tester.tap(onboardingSkip); + } else if (onboardingPrimary.evaluate().isNotEmpty) { + await tester.tap(onboardingPrimary); + } + await tester.pump(const Duration(milliseconds: 400)); + continue; + } + + if (vpnToggle.hitTestable().evaluate().isNotEmpty) { + return; + } + + await tester.pump(const Duration(milliseconds: 300)); + } + fail('VPN toggle not visible'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Linux VPN connect/disconnect smoke', (tester) async { + app.main(); + + final homeScreen = find.byKey(const Key('home.screen')); + final onboardingScreen = find.byKey(const Key('onboarding.screen')); + final onboardingSkip = find.byKey(const Key('onboarding.skip')); + final onboardingPrimary = find.byKey(const Key('onboarding.primary')); + final vpnToggle = find.byKey(const Key('vpn.toggle')); + final vpnStateFinders = _VpnStateFinders(); + String? baselinePublicIp; + + await WidgetWaitUtils.waitForAnyFinder( + tester, + [homeScreen, onboardingScreen], + timeout: const Duration(seconds: 90), + reason: 'Home or onboarding did not appear after launch', + ); + + await WidgetWaitUtils.waitForFinder( + tester, + homeScreen, + timeout: const Duration(seconds: 45), + reason: 'Home screen did not load', + ); + + await _waitForVpnToggleWithOnboardingHandling( + tester, + vpnToggle: vpnToggle, + onboardingScreen: onboardingScreen, + onboardingSkip: onboardingSkip, + onboardingPrimary: onboardingPrimary, + timeout: const Duration(seconds: 30), + ); + + await WidgetWaitUtils.waitForFinderToDisappear( + tester, + onboardingScreen, + timeout: const Duration(seconds: 20), + reason: 'Onboarding screen remained visible', + ); + + await WidgetWaitUtils.waitForFinder( + tester, + homeScreen, + timeout: const Duration(seconds: 20), + reason: 'Home screen was not visible after onboarding flow', + ); + + await WidgetWaitUtils.waitForFinder( + tester, + vpnToggle, + timeout: const Duration(seconds: 20), + reason: 'VPN toggle was not visible on home screen', + ); + + var vpnState = await vpnStateFinders.tryWaitFor( + tester, + expected: _observableStates, + timeout: const Duration(seconds: 20), + ); + if (vpnState == null) { + await _waitForVpnToggleWithOnboardingHandling( + tester, + vpnToggle: vpnToggle, + onboardingScreen: onboardingScreen, + onboardingSkip: onboardingSkip, + onboardingPrimary: onboardingPrimary, + timeout: const Duration(seconds: 15), + ); + + // Recover from startup race where UI state keys are briefly unavailable. + await tester.tap(vpnToggle); + await tester.pump(const Duration(milliseconds: 200)); + + vpnState = await vpnStateFinders.waitFor( + tester, + expected: _observableStates, + timeout: const Duration(seconds: 45), + reason: 'Initial VPN state did not resolve after recovery toggle', + ); + } + + if (vpnState == VPNStatus.connecting || + vpnState == VPNStatus.disconnecting) { + vpnState = await vpnStateFinders.waitFor( + tester, + expected: _stableStates, + timeout: const Duration(seconds: 45), + reason: 'VPN did not settle from transitional startup state', + ); + } + + if (vpnState == VPNStatus.error) { + fail('VPN reported error before connect/disconnect smoke'); + } + if (vpnState == VPNStatus.missingPermission) { + fail('VPN reported missing permission before connect/disconnect smoke'); + } + + if (vpnState == VPNStatus.connected) { + await tester.tap(vpnToggle); + await tester.pump(const Duration(milliseconds: 200)); + + await vpnStateFinders.waitFor( + tester, + expected: const [VPNStatus.disconnected], + timeout: const Duration(seconds: 45), + reason: 'Failed to reach disconnected state before connect test', + ); + } + + if (_enableIpCheck) { + debugPrint('IP check: enabled; fetching baseline before connect'); + baselinePublicIp = await _fetchPublicIpWithRetry( + timeout: const Duration(seconds: 40), + reason: 'before connect', + ); + } + + await tester.tap(vpnToggle); + await tester.pump(const Duration(milliseconds: 200)); + + await vpnStateFinders.waitFor( + tester, + expected: const [VPNStatus.connected], + timeout: const Duration(seconds: 45), + reason: 'VPN did not reach connected state within 45 seconds', + ); + + if (_enableIpCheck && baselinePublicIp != null) { + debugPrint('IP check: waiting for IP change after connect'); + await Future.delayed(const Duration(seconds: 3)); + await _assertPublicIpChangesFromBaseline(baselinePublicIp); + debugPrint('IP check: passed'); + } + + await WidgetWaitUtils.waitForFinder( + tester, + vpnToggle, + timeout: const Duration(seconds: 15), + reason: 'VPN toggle not available for disconnect', + ); + await tester.tap(vpnToggle); + await tester.pump(const Duration(milliseconds: 200)); + + await vpnStateFinders.waitFor( + tester, + expected: const [VPNStatus.disconnected], + timeout: const Duration(seconds: 45), + reason: 'VPN did not return to disconnected state within 45 seconds', + ); + }); +} diff --git a/lib/core/common/app_build_info.dart b/lib/core/common/app_build_info.dart index 1a6f690f07..819c676c17 100644 --- a/lib/core/common/app_build_info.dart +++ b/lib/core/common/app_build_info.dart @@ -10,6 +10,11 @@ class AppBuildInfo { 'VERSION', defaultValue: '', ); + + static const bool disableSystemTray = bool.fromEnvironment( + 'DISABLE_SYSTEM_TRAY', + defaultValue: false, + ); } ///Always use values from app build info this will ensure that the version and build number are same diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 811d16978f..079cde62b9 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -89,8 +89,12 @@ class _HomeState extends ConsumerState { textTheme = Theme.of(context).textTheme; ref.read(appEventProvider); return Scaffold( + key: const Key('home.screen'), appBar: AppBar( - title: LanternLogo(isPro: isUserPro,color: context.textPrimary,), + title: LanternLogo( + isPro: isUserPro, + color: context.textPrimary, + ), bottom: PreferredSize( preferredSize: Size.fromHeight(0), child: DividerSpace(padding: EdgeInsets.zero), @@ -151,9 +155,9 @@ class _HomeState extends ConsumerState { if (serverType == ServerLocationType.privateServer) InfoRow(text: 'private_server_usage_message'.i18n) else if (PlatformUtils.isIOS) - const SizedBox.shrink() + const SizedBox.shrink() else - const DataUsage(), + const DataUsage(), }, SizedBox(height: 8), _buildSetting(ref), @@ -263,12 +267,14 @@ class _HomeState extends ConsumerState { SizedBox(height: 24), Text( 'help_improve_lantern'.i18n, - style: textTheme!.headlineSmall!.copyWith(color: context.textPrimary), + style: + textTheme!.headlineSmall!.copyWith(color: context.textPrimary), ), SizedBox(height: defaultSize), Text( 'share_anonymous_usage_data'.i18n, - style: textTheme!.bodyMedium!.copyWith(color: context.textSecondary), + style: + textTheme!.bodyMedium!.copyWith(color: context.textSecondary), ), SizedBox(height: defaultSize), Text( @@ -280,7 +286,8 @@ class _HomeState extends ConsumerState { SizedBox(height: defaultSize), Text( 'you_can_change_anytime'.i18n, - style: textTheme!.bodyMedium!.copyWith(color: context.textSecondary), + style: + textTheme!.bodyMedium!.copyWith(color: context.textSecondary), ), ], ), diff --git a/lib/features/onboarding/onboarding.dart b/lib/features/onboarding/onboarding.dart index 49bb8a9de8..5cecc3c7e3 100644 --- a/lib/features/onboarding/onboarding.dart +++ b/lib/features/onboarding/onboarding.dart @@ -43,10 +43,11 @@ class _OnboardingState extends ConsumerState { } return Scaffold( + key: const Key('onboarding.screen'), appBar: AppBar( leading: const SizedBox.shrink(), backgroundColor: context.bgElevated, - title: LanternLogo(color: context.textPrimary), + title: LanternLogo(color: context.textPrimary), bottom: PreferredSize( preferredSize: Size.fromHeight(0), child: DividerSpace(padding: EdgeInsets.zero), @@ -110,6 +111,7 @@ class _OnboardingState extends ConsumerState { ), ), PrimaryButton( + key: const Key('onboarding.primary'), label: pageIndex.value == 0 ? 'get_started'.i18n : 'continue'.i18n, isTaller: true, @@ -128,6 +130,7 @@ class _OnboardingState extends ConsumerState { if (pageIndex.value == 0) ...{ SizedBox(height: 12.0), AppTextButton( + key: const Key('onboarding.skip'), label: 'skip_connect_now'.i18n, textColor: context.textPrimary, onPressed: () { @@ -165,7 +168,10 @@ class _OnboardingState extends ConsumerState { AppTile( icon: Padding( padding: const EdgeInsets.only(top: 5.0), - child: AppImage(path: AppImagePaths.smartRouteMode,useThemeColor: false,), + child: AppImage( + path: AppImagePaths.smartRouteMode, + useThemeColor: false, + ), ), label: '', titleAlignment: ListTileTitleAlignment.top, @@ -186,7 +192,8 @@ class _OnboardingState extends ConsumerState { AppTile( icon: Padding( padding: const EdgeInsets.only(top: 5.0), - child: AppImage(path: AppImagePaths.advanceProtocol,useThemeColor: false), + child: AppImage( + path: AppImagePaths.advanceProtocol, useThemeColor: false), ), label: '', titleAlignment: ListTileTitleAlignment.top, @@ -207,7 +214,8 @@ class _OnboardingState extends ConsumerState { AppTile( icon: Padding( padding: const EdgeInsets.only(top: 5.0), - child: AppImage(path: AppImagePaths.privateServerIntro,useThemeColor: false), + child: AppImage( + path: AppImagePaths.privateServerIntro, useThemeColor: false), ), label: '', titleAlignment: ListTileTitleAlignment.top, @@ -228,7 +236,8 @@ class _OnboardingState extends ConsumerState { AppTile( icon: Padding( padding: const EdgeInsets.only(top: 5.0), - child: AppImage(path: AppImagePaths.nonProfit,useThemeColor: false), + child: + AppImage(path: AppImagePaths.nonProfit, useThemeColor: false), ), label: '', titleAlignment: ListTileTitleAlignment.top, @@ -362,8 +371,8 @@ class RouteModeContainer extends StatelessWidget { ), child: Text( tags(), - style: - textTheme.labelMedium!.copyWith(color: context.statusInfoText), + style: textTheme.labelMedium! + .copyWith(color: context.statusInfoText), )) ], ), @@ -371,7 +380,8 @@ class RouteModeContainer extends StatelessWidget { Padding( padding: const EdgeInsets.only(left: 38), child: Text(description(), - style: textTheme.bodyMedium!.copyWith(color: context.textSecondary)), + style: textTheme.bodyMedium! + .copyWith(color: context.textSecondary)), ) ], ), diff --git a/lib/features/system_tray/provider/system_tray_notifier.dart b/lib/features/system_tray/provider/system_tray_notifier.dart index 8073dc6d80..4293717fe7 100644 --- a/lib/features/system_tray/provider/system_tray_notifier.dart +++ b/lib/features/system_tray/provider/system_tray_notifier.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:lantern/core/common/app_build_info.dart'; import 'package:lantern/core/models/available_servers.dart'; import 'package:lantern/core/models/entity/app_setting_entity.dart'; import 'package:lantern/core/models/entity/server_location_entity.dart'; @@ -26,6 +28,7 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { List _locations = []; RoutingMode _currentRoutingMode = RoutingMode.full; ServerLocationEntity? _serverLocation; + bool _trayAvailable = true; bool get isConnected => _currentStatus == VPNStatus.connected; @@ -35,17 +38,33 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { @override Future build() async { - if (!PlatformUtils.isDesktop) return; + if (!PlatformUtils.isDesktop || AppBuildInfo.disableSystemTray) { + return; + } _currentStatus = ref.read(vpnProvider); _initializeState(); _setupListeners(); _setupTrayManager(); - await updateTrayMenu(); + if (_trayAvailable) { + await updateTrayMenu(); + } } void _setupTrayManager() { - trayManager.addListener(this); - ref.onDispose(() => trayManager.removeListener(this)); + try { + trayManager.addListener(this); + ref.onDispose(() => trayManager.removeListener(this)); + } on MissingPluginException catch (e) { + _trayAvailable = false; + appLogger.warning( + 'System tray plugin unavailable on ${Platform.operatingSystem}: $e', + ); + } on PlatformException catch (e) { + _trayAvailable = false; + appLogger.warning( + 'System tray initialization failed on ${Platform.operatingSystem}: $e', + ); + } } void _initializeState() { @@ -201,7 +220,10 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { if (loc.serverType.toServerLocationType == ServerLocationType.auto) { /// For auto location, we use the autoLocation info which contains the actual connected server details - final auto_ = loc.autoLocation!; + final auto_ = loc.autoLocation; + if (auto_ == null) { + return ''; + } countryCode = auto_.countryCode; displayName = auto_.displayName; } else { @@ -220,6 +242,10 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { } Future updateTrayMenu() async { + if (!_trayAvailable) { + return; + } + final locationDisplay = _currentLocationDisplay; final menu = Menu( @@ -342,10 +368,20 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { ], ); - await trayManager.setContextMenu(menu); - trayManager.setIcon(_trayIconPath(isConnected), - isTemplate: Platform.isMacOS); - trayManager.setToolTip('app_name'.i18n); + try { + await trayManager.setContextMenu(menu); + await trayManager.setIcon( + _trayIconPath(isConnected), + isTemplate: Platform.isMacOS, + ); + await trayManager.setToolTip('app_name'.i18n); + } on MissingPluginException catch (e) { + _trayAvailable = false; + appLogger.warning('System tray plugin unavailable: $e'); + } on PlatformException catch (e) { + _trayAvailable = false; + appLogger.warning('System tray update failed: $e'); + } } String _trayIconPath(bool connected) { @@ -357,6 +393,10 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { return connected ? AppImagePaths.lanternDarkConnected : AppImagePaths.lanternDarkDisconnected; + } else if (Platform.isLinux) { + return connected + ? AppImagePaths.lanternDarkConnected + : AppImagePaths.lanternDarkDisconnected; } return connected ? AppImagePaths.lanternConnected @@ -366,6 +406,9 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { /// Tray Event Handlers @override Future onTrayIconMouseDown() async { + if (!_trayAvailable) { + return; + } if (Platform.isMacOS) { await trayManager.popUpContextMenu(); } else { @@ -375,6 +418,9 @@ class SystemTrayNotifier extends _$SystemTrayNotifier with TrayListener { @override Future onTrayIconRightMouseDown() async { + if (!_trayAvailable) { + return; + } await trayManager.popUpContextMenu(); } } diff --git a/lib/features/system_tray/system_tray_wrapper.dart b/lib/features/system_tray/system_tray_wrapper.dart index a4d726188e..2b7171bc8d 100644 --- a/lib/features/system_tray/system_tray_wrapper.dart +++ b/lib/features/system_tray/system_tray_wrapper.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/common/app_build_info.dart'; import 'package:lantern/features/system_tray/provider/system_tray_notifier.dart'; class SystemTrayWrapper extends ConsumerWidget { @@ -12,6 +13,9 @@ class SystemTrayWrapper extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + if (AppBuildInfo.disableSystemTray) { + return child; + } ref.watch(systemTrayProvider); return child; } diff --git a/lib/features/vpn/provider/vpn_notifier.dart b/lib/features/vpn/provider/vpn_notifier.dart index c2a0c60552..c6304f00ee 100644 --- a/lib/features/vpn/provider/vpn_notifier.dart +++ b/lib/features/vpn/provider/vpn_notifier.dart @@ -43,7 +43,10 @@ class VpnNotifier extends _$VpnNotifier { ref.read(appSettingProvider.notifier).setSuccessfulConnection(true); /// Fetch auto server location after a delay to ensure VPN is fully connected - Future.delayed(Duration(seconds: 1), () { + Future.delayed(const Duration(seconds: 1), () { + if (!ref.mounted) { + return; + } ref .read(serverLocationProvider.notifier) .ifNeededGetAutoServerLocation(); diff --git a/lib/features/vpn/vpn_status.dart b/lib/features/vpn/vpn_status.dart index f759984e12..e664f3790c 100644 --- a/lib/features/vpn/vpn_status.dart +++ b/lib/features/vpn/vpn_status.dart @@ -15,6 +15,7 @@ class VpnStatus extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final vpnStatus = ref.watch(vpnProvider); + final statusValue = vpnStatus.name.capitalize; final textTheme = Theme.of(context).textTheme; MacOSExtensionState systemExtensionStatus = MacOSExtensionState(SystemExtensionStatus.notInstalled); @@ -23,10 +24,10 @@ class VpnStatus extends HookConsumerWidget { } return SettingTile( + key: Key('vpn.status.${vpnStatus.name}'), label: 'vpn_status'.i18n, - value: vpnStatus.name.capitalize, + value: statusValue, icon: AppImagePaths.glob, - onTap: isExtensionNeeded(systemExtensionStatus) ? () { appRouter.push(const MacOSExtensionDialog()); @@ -44,10 +45,11 @@ class VpnStatus extends HookConsumerWidget { if (isExtensionNeeded(systemExtensionStatus)) Text( 'network_extension_required'.i18n, - style: textTheme.titleMedium!.copyWith(color: context.textPrimary), + style: + textTheme.titleMedium!.copyWith(color: context.textPrimary), ) else - Text(vpnStatus.name.capitalize, + Text(statusValue, style: textTheme.titleMedium! .copyWith(color: getStatusColor(vpnStatus, context))), if (vpnStatus == VPNStatus.connecting) @@ -55,8 +57,8 @@ class VpnStatus extends HookConsumerWidget { animatedTexts: [ TyperAnimatedText( '... ', - textStyle: - textTheme.titleMedium!.copyWith(color: context.textPrimary), + textStyle: textTheme.titleMedium! + .copyWith(color: context.textPrimary), ), TyperAnimatedText('...', textStyle: textTheme.titleMedium! diff --git a/lib/features/vpn/vpn_switch.dart b/lib/features/vpn/vpn_switch.dart index fc8803617a..059c00813e 100644 --- a/lib/features/vpn/vpn_switch.dart +++ b/lib/features/vpn/vpn_switch.dart @@ -27,6 +27,7 @@ class VPNSwitch extends HookConsumerWidget { final vpnStatus = ref.watch(vpnProvider); final isVPNOn = (vpnStatus == VPNStatus.connected); return CustomAnimatedToggleSwitch( + key: Key('vpn.switch.${vpnStatus.name}'), current: isVPNOn, allowUnlistedValues: false, values: [false, true], @@ -59,6 +60,7 @@ class VPNSwitch extends HookConsumerWidget { ); } return GestureDetector( + key: const Key('vpn.toggle'), onTap: () { appLogger.info('VPN Switch tapped'); onVPNStateChange(ref, context); diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index 0e9978bca5..f595a3c329 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -38,6 +38,7 @@ export 'dart:ffi'; // For FFI export 'package:ffi/src/utf8.dart'; const String _libName = 'liblantern'; +const Set _ffiOkResults = {'ok', 'true'}; /// Communicates with the native library via FFI. /// @@ -591,11 +592,11 @@ class LanternFFIService implements LanternCoreService { ) .cast() .toDartString(); - if (result.isNotEmpty) { + if (result.isNotEmpty && !_ffiOkResults.contains(result)) { return left(Failure(error: result, localizedErrorMessage: result)); } appLogger.debug('startVPN result: $result'); - return right(result); + return right(result.isEmpty ? 'ok' : result); } catch (e) { appLogger.error('Error starting VPN: $e'); return Left(e.toFailure()); @@ -693,11 +694,11 @@ class LanternFFIService implements LanternCoreService { } final result = _ffiService.stopVPN().cast().toDartString(); - if (result.isNotEmpty) { - return left(Failure(error: result, localizedErrorMessage: '')); + if (result.isNotEmpty && !_ffiOkResults.contains(result)) { + return left(Failure(error: result, localizedErrorMessage: result)); } appLogger.debug('stopVPN result: $result'); - return right(result); + return right(result.isEmpty ? 'ok' : result); } catch (e) { appLogger.error('Error stopping VPN: $e'); return Left(e.toFailure()); diff --git a/linux/packaging/nfpm.yaml b/linux/packaging/nfpm.yaml index f00e4d244a..d2c41e0102 100644 --- a/linux/packaging/nfpm.yaml +++ b/linux/packaging/nfpm.yaml @@ -18,7 +18,7 @@ contents: # lanternd service binary - src: "${LANTERND_SRC}" - dst: /usr/sbin + dst: /usr/sbin/lanternd expand: true # systemd unit From 75839bfdf9687e574923e376ffd42bc2294f3017 Mon Sep 17 00:00:00 2001 From: atavism Date: Thu, 26 Feb 2026 10:17:18 -0800 Subject: [PATCH 4/5] fix unbounded ffi and stats --- lib/features/home/provider/app_event_notifier.dart | 2 +- lib/lantern/lantern_ffi_service.dart | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/features/home/provider/app_event_notifier.dart b/lib/features/home/provider/app_event_notifier.dart index c17f5151fd..98d4b0b41d 100644 --- a/lib/features/home/provider/app_event_notifier.dart +++ b/lib/features/home/provider/app_event_notifier.dart @@ -59,7 +59,7 @@ class UnboundedStats { /// Provider that tracks unbounded connection stats (active + total). final unboundedStatsProvider = Provider((ref) { - ref.watch(unboundedConnectionProvider); + ref.watch(unboundedStatsListenerProvider); return _UnboundedStatsAccumulator.stats; }); diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index 483e12a761..eb495bfb17 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'dart:ffi'; import 'dart:io'; import 'dart:isolate'; -import 'dart:ui' show PlatformDispatcher; import 'package:ffi/ffi.dart'; import 'package:flutter/foundation.dart'; @@ -165,7 +164,7 @@ class LanternFFIService implements LanternCoreService { String env = await _radianceEnv(); try { final appSetting = sl().getAppSetting(); - if (appSetting != null) { + if (appSetting != null) { consent = appSetting.telemetryConsent ? 1 : 0; } } catch (_) { @@ -176,7 +175,8 @@ class LanternFFIService implements LanternCoreService { final dataDir = await AppStorageUtils.getAppDirectory(); final logDir = await AppStorageUtils.getAppLogDirectory(); - appLogger.info("Radiance configuration - env: $env, dataDir: ${dataDir.path}, logDir: $logDir, telemetryConsent: $consent"); + appLogger.info( + "Radiance configuration - env: $env, dataDir: ${dataDir.path}, logDir: $logDir, telemetryConsent: $consent"); final dataDirPtr = dataDir.path.toCharPtr; final logDirPtr = logDir.toCharPtr; @@ -1425,10 +1425,9 @@ class LanternFFIService implements LanternCoreService { @override Future> setUnboundedEnabled(bool enabled) async { try { - final result = _ffiService - .setUnboundedEnabled(enabled ? 1 : 0) - .cast() - .toDartString(); + final resultPtr = _ffiService.setUnboundedEnabled(enabled ? 1 : 0); + final result = resultPtr.cast().toDartString(); + _ffiService.freeCString(resultPtr); checkAPIError(result); return right(unit); } catch (e, st) { From 3da1f8bcd57f18658964d04e0e1d89b4b697595d Mon Sep 17 00:00:00 2001 From: atavism Date: Thu, 26 Feb 2026 10:17:22 -0800 Subject: [PATCH 5/5] update unbounded ui and localization --- assets/locales/en.po | 35 ++++ lib/features/setting/setting.dart | 2 +- lib/features/unbounded/unbounded.dart | 154 +++++++++++------- .../unbounded/unbounded_settings.dart | 10 +- 4 files changed, 134 insertions(+), 67 deletions(-) diff --git a/assets/locales/en.po b/assets/locales/en.po index be99d4d412..1d48998424 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -1487,6 +1487,41 @@ msgstr "Dark" msgid "system" msgstr "System" +msgid "unbounded_settings" +msgstr "Unbounded Settings" + +msgid "auto_enable_unbounded" +msgstr "Auto-enable Unbounded" + +msgid "auto_enable_unbounded_description" +msgstr "Turn on automatically when Lantern is open" + +msgid "hide_unbounded" +msgstr "Hide Unbounded" + +msgid "hide_unbounded_description" +msgstr "Removes Unbounded from the UI" + +msgid "unbounded_welcome_title" +msgstr "Welcome to Unbounded" + +msgid "unbounded_welcome_body_primary" +msgstr "Unbounded lets you share a small amount of your internet bandwidth to help people in censored countries access the open web." + +msgid "unbounded_welcome_body_secondary" +msgstr "Your connection stays secure and private. You control when sharing is active." + +msgid "help_others_bypass_censorship_securely" +msgstr "Help others bypass censorship by securely sharing your connection." + +msgid "status_label" +msgstr "Status" + +msgid "people_helping_now" +msgstr "People you are helping right now:" + +msgid "total_people_helped" +msgstr "Total people helped to date:" diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index 3df65394f2..d5d8b97538 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -239,7 +239,7 @@ class _SettingState extends ConsumerState { AppCard( padding: EdgeInsets.zero, child: AppTile( - label: 'Unbounded Settings', + label: 'unbounded_settings'.i18n, icon: AppImagePaths.lanternLogoRounded, iconUseThemeColor: false, onPressed: () { diff --git a/lib/features/unbounded/unbounded.dart b/lib/features/unbounded/unbounded.dart index b3b615d58a..c9fafeadea 100644 --- a/lib/features/unbounded/unbounded.dart +++ b/lib/features/unbounded/unbounded.dart @@ -1,6 +1,9 @@ +import 'dart:convert'; + import 'package:auto_route/annotations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/widgets/switch_button.dart'; @@ -74,13 +77,13 @@ class UnboundedScreen extends HookConsumerWidget { ), const SizedBox(height: 16), Text( - 'Welcome to Unbounded', + 'unbounded_welcome_title'.i18n, style: textTheme.headlineSmall, textAlign: TextAlign.center, ), const SizedBox(height: 12), Text( - 'Unbounded lets you share a small amount of your internet bandwidth to help people in censored countries access the open web.', + 'unbounded_welcome_body_primary'.i18n, style: textTheme.bodyMedium!.copyWith( color: context.textSecondary, ), @@ -88,7 +91,7 @@ class UnboundedScreen extends HookConsumerWidget { ), const SizedBox(height: 8), Text( - 'Your connection stays secure and private. You control when sharing is active.', + 'unbounded_welcome_body_secondary'.i18n, style: textTheme.bodyMedium!.copyWith( color: context.textSecondary, ), @@ -104,7 +107,7 @@ class UnboundedScreen extends HookConsumerWidget { }, ), AppTextButton( - label: 'Got It', + label: 'got_it'.i18n, onPressed: () { appRouter.maybePop(); }, @@ -137,7 +140,7 @@ class _InfoBanner extends StatelessWidget { const SizedBox(width: 8), Expanded( child: Text( - 'Help others bypass censorship by securely sharing your connection.', + 'help_others_bypass_censorship_securely'.i18n, style: textTheme.bodySmall!.copyWith( color: context.textSecondary, ), @@ -175,15 +178,16 @@ class _StatusSection extends StatelessWidget { children: [ ListTile( dense: true, - leading: Icon(Icons.language, color: context.textSecondary, size: 22), + leading: + Icon(Icons.language, color: context.textSecondary, size: 22), title: Row( children: [ Text( - 'Status: ', + '${'status_label'.i18n}: ', style: textTheme.titleSmall, ), Text( - enabled ? 'Enabled' : 'Disabled', + enabled ? 'enabled'.i18n : 'disabled'.i18n, style: textTheme.titleSmall!.copyWith(color: statusColor), ), ], @@ -196,10 +200,12 @@ class _StatusSection extends StatelessWidget { DividerSpace(), ListTile( dense: true, - leading: Icon(Icons.person_outline, color: context.textSecondary, size: 22), + leading: Icon(Icons.person_outline, + color: context.textSecondary, size: 22), title: Text( - 'People you are helping right now:', - style: textTheme.bodySmall!.copyWith(color: context.textSecondary), + 'people_helping_now'.i18n, + style: + textTheme.bodySmall!.copyWith(color: context.textSecondary), ), trailing: Text( '$helpingNow', @@ -209,10 +215,12 @@ class _StatusSection extends StatelessWidget { DividerSpace(), ListTile( dense: true, - leading: Icon(Icons.people_outline, color: context.textSecondary, size: 22), + leading: Icon(Icons.people_outline, + color: context.textSecondary, size: 22), title: Text( - 'Total people helped to date:', - style: textTheme.bodySmall!.copyWith(color: context.textSecondary), + 'total_people_helped'.i18n, + style: + textTheme.bodySmall!.copyWith(color: context.textSecondary), ), trailing: Text( '$totalHelped', @@ -241,13 +249,14 @@ class _AutoEnableRow extends StatelessWidget { return AppCard( padding: EdgeInsets.zero, child: ListTile( - leading: Icon(Icons.settings_outlined, color: context.textSecondary, size: 22), + leading: Icon(Icons.settings_outlined, + color: context.textSecondary, size: 22), title: Text( - 'Auto-enable Unbounded', + 'auto_enable_unbounded'.i18n, style: textTheme.titleSmall, ), subtitle: Text( - 'Turn on automatically when Lantern is open', + 'auto_enable_unbounded_description'.i18n, style: textTheme.bodySmall!.copyWith(color: context.textTertiary), ), trailing: Checkbox( @@ -273,13 +282,26 @@ class _GlobeView extends ConsumerStatefulWidget { class _GlobeViewState extends ConsumerState<_GlobeView> { InAppWebViewController? _controller; bool _isLoading = true; + late final Future _globeHtmlContent; + + @override + void initState() { + super.initState(); + _globeHtmlContent = rootBundle.loadString('assets/unbounded/globe.html'); + } void _handleConnectionEvent(UnboundedConnectionEvent event) { if (_controller == null) return; + final payload = jsonEncode({ + 'type': 'connectionEvent', + 'state': event.state, + 'workerIdx': event.workerIdx, + 'addr': event.addr, + }); + _controller?.evaluateJavascript( - source: - "window.unboundedGlobe.handleMessage({type:'connectionEvent',state:${event.state},workerIdx:${event.workerIdx},addr:'${event.addr}'});", + source: 'window.unboundedGlobe.handleMessage($payload);', ); } @@ -291,55 +313,65 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { }); }); - return Stack( - children: [ - InAppWebView( - initialData: InAppWebViewInitialData( - data: _globeHtml, - baseUrl: WebUri('https://unpkg.com'), - mimeType: 'text/html', - encoding: 'utf-8', - ), - initialSettings: InAppWebViewSettings( - javaScriptEnabled: true, - transparentBackground: true, - hardwareAcceleration: true, - mediaPlaybackRequiresUserGesture: false, - supportZoom: false, - disableHorizontalScroll: true, - disableVerticalScroll: true, - allowUniversalAccessFromFileURLs: true, - allowFileAccessFromFileURLs: true, - ), - onWebViewCreated: (controller) { - _controller = controller; - }, - onLoadStop: (controller, url) { - setState(() => _isLoading = false); - _sendTheme(); - }, - onConsoleMessage: (controller, consoleMessage) { - debugPrint('Globe JS: ${consoleMessage.message}'); - }, - onReceivedError: (controller, request, error) { - debugPrint('Globe load error: ${error.description} for ${request.url}'); - }, - ), - if (_isLoading) - const Center( - child: CircularProgressIndicator( - color: Color(0xFF00BCD4), + return FutureBuilder( + future: _globeHtmlContent, + builder: (context, snapshot) { + final html = snapshot.data ?? _globeHtml; + return Stack( + children: [ + InAppWebView( + initialData: InAppWebViewInitialData( + data: html, + baseUrl: WebUri('https://unpkg.com'), + mimeType: 'text/html', + encoding: 'utf-8', + ), + initialSettings: InAppWebViewSettings( + javaScriptEnabled: true, + transparentBackground: true, + hardwareAcceleration: true, + mediaPlaybackRequiresUserGesture: false, + supportZoom: false, + disableHorizontalScroll: true, + disableVerticalScroll: true, + allowUniversalAccessFromFileURLs: false, + allowFileAccessFromFileURLs: false, + ), + onWebViewCreated: (controller) { + _controller = controller; + }, + onLoadStop: (controller, url) { + setState(() => _isLoading = false); + _sendTheme(); + }, + onConsoleMessage: (controller, consoleMessage) { + debugPrint('Globe JS: ${consoleMessage.message}'); + }, + onReceivedError: (controller, request, error) { + debugPrint( + 'Globe load error: ${error.description} for ${request.url}'); + }, ), - ), - ], + if (_isLoading) + const Center( + child: CircularProgressIndicator( + color: Color(0xFF00BCD4), + ), + ), + ], + ); + }, ); } void _sendTheme() { final theme = widget.isDark ? 'dark' : 'light'; + final payload = jsonEncode({ + 'type': 'setTheme', + 'theme': theme, + }); _controller?.evaluateJavascript( - source: - "window.unboundedGlobe.handleMessage({type:'setTheme',theme:'$theme'});", + source: 'window.unboundedGlobe.handleMessage($payload);', ); } diff --git a/lib/features/unbounded/unbounded_settings.dart b/lib/features/unbounded/unbounded_settings.dart index 0e11b4ef64..ba31d4f060 100644 --- a/lib/features/unbounded/unbounded_settings.dart +++ b/lib/features/unbounded/unbounded_settings.dart @@ -16,16 +16,16 @@ class UnboundedSettingsScreen extends HookConsumerWidget { final textTheme = Theme.of(context).textTheme; return BaseScreen( - title: 'Unbounded Settings', + title: 'unbounded_settings'.i18n, body: Column( children: [ const SizedBox(height: defaultSize), AppCard( padding: EdgeInsets.zero, child: AppTile( - label: 'Auto-enable Unbounded', + label: 'auto_enable_unbounded'.i18n, subtitle: Text( - 'Turn on automatically when Lantern is open', + 'auto_enable_unbounded_description'.i18n, style: textTheme.labelMedium!.copyWith( color: context.textTertiary, ), @@ -45,9 +45,9 @@ class UnboundedSettingsScreen extends HookConsumerWidget { AppCard( padding: EdgeInsets.zero, child: AppTile( - label: 'Hide Unbounded', + label: 'hide_unbounded'.i18n, subtitle: Text( - 'Removes Unbounded from the UI', + 'hide_unbounded_description'.i18n, style: textTheme.labelMedium!.copyWith( color: context.textTertiary, ),