diff --git a/sharing/linux/README.md b/sharing/linux/README.md new file mode 100644 index 00000000..9adc6da3 --- /dev/null +++ b/sharing/linux/README.md @@ -0,0 +1,22 @@ +# QuickShare for Linux + +## Native application logs + +The QuickShare GUI writes its native Abseil diagnostics to: + +```text +$XDG_STATE_HOME/quickshare/logs/quickshare.log +``` + +If `XDG_STATE_HOME` is not set to an absolute path, the directory defaults to +`$HOME/.local/state/quickshare/logs`. + +Records are stored exactly as Abseil emits them. The active log rotates at +10 MiB, with `quickshare.log.1` through `quickshare.log.4` retaining the four +previous files. The directory and its files are accessible only to the current +user. + +After file logging initializes, native Abseil diagnostics are not duplicated +to stderr. If the file cannot be initialized or written, diagnostics fall back +to stderr. Qt and QML messages keep their existing behavior and are not written +to these files. diff --git a/sharing/linux/app/BUILD b/sharing/linux/app/BUILD index 0a47d398..f32dc4e4 100644 --- a/sharing/linux/app/BUILD +++ b/sharing/linux/app/BUILD @@ -1,4 +1,5 @@ load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") load("@rules_qt//:qt.bzl", "qt_cc_binary", "qt_cc_library", "qt_resource_via_qrc") load("@rules_shell//shell:sh_binary.bzl", "sh_binary") @@ -67,6 +68,38 @@ qt_cc_library( ], ) +cc_library( + name = "native_file_logging", + srcs = ["native_file_logging.cc"], + hdrs = ["native_file_logging.h"], + deps = [ + "//sharing/linux:linux_sharing_platform", + "@com_google_absl//absl/base:log_severity", + "@com_google_absl//absl/log:globals", + "@com_google_absl//absl/log:initialize", + "@com_google_absl//absl/log:log_entry", + "@com_google_absl//absl/log:log_sink", + "@com_google_absl//absl/log:log_sink_registry", + "@com_google_absl//absl/strings:string_view", + ], +) + +cc_test( + name = "native_file_logging_test", + size = "small", + srcs = ["native_file_logging_test.cc"], + deps = [ + ":native_file_logging", + "//sharing/linux:linux_sharing_platform", + "@com_google_absl//absl/log", + "@com_google_absl//absl/log:globals", + "@com_google_absl//absl/log:initialize", + "@com_google_absl//absl/log:log_sink", + "@com_google_absl//absl/log:log_sink_registry", + "@com_google_googletest//:gtest_main", + ], +) + qt_cc_binary( name = "app", srcs = ["main.cc"], @@ -76,6 +109,7 @@ qt_cc_binary( deps = [ ":app_resources", ":backend", + ":native_file_logging", "@rules_qt//:qt_core", "@rules_qt//:qt_gui", "@rules_qt//:qt_hdrs", diff --git a/sharing/linux/app/main.cc b/sharing/linux/app/main.cc index 9ecf395a..572ffaba 100644 --- a/sharing/linux/app/main.cc +++ b/sharing/linux/app/main.cc @@ -18,6 +18,7 @@ #include #include "backend.h" #include "qobject.h" +#include "sharing/linux/app/native_file_logging.h" namespace { @@ -113,6 +114,8 @@ void InstallTerminationCleanup(QApplication& app) { } // namespace int main(int argc, char* argv[]) { + std::unique_ptr native_logging = + nearby::sharing::linux::NativeFileLogging::Initialize(); QApplication app(argc, argv); app.setApplicationName(QStringLiteral("QuickShare")); app.setDesktopFileName(QStringLiteral("quickshare")); diff --git a/sharing/linux/app/native_file_logging.cc b/sharing/linux/app/native_file_logging.cc new file mode 100644 index 00000000..c2a0a4ed --- /dev/null +++ b/sharing/linux/app/native_file_logging.cc @@ -0,0 +1,213 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "sharing/linux/app/native_file_logging.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "absl/log/globals.h" +#include "absl/log/initialize.h" +#include "absl/log/log_sink_registry.h" +#include "absl/strings/string_view.h" +#include "sharing/linux/platform/platform_util.h" + +namespace nearby::sharing::linux { +namespace { + +constexpr char kLogFileName[] = "quickshare.log"; +constexpr char kLockFileName[] = ".quickshare.log.lock"; + +bool WriteAll(int fd, const char* data, size_t size) { + while (size > 0) { + const ssize_t written = write(fd, data, size); + if (written < 0) { + if (errno == EINTR) { + continue; + } + return false; + } + data += written; + size -= static_cast(written); + } + return true; +} + +void WriteToStderr(absl::string_view message) { + static_cast(WriteAll(STDERR_FILENO, message.data(), message.size())); +} + +} // namespace + +std::unique_ptr NativeFileLogSink::Create( + std::filesystem::path log_directory, size_t max_file_size, int file_count) { + if (max_file_size == 0 || file_count < 1) { + return nullptr; + } + + std::error_code error; + std::filesystem::create_directories(log_directory, error); + if (error) { + return nullptr; + } + std::filesystem::permissions(log_directory, std::filesystem::perms::owner_all, + std::filesystem::perm_options::replace, error); + if (error) { + return nullptr; + } + + const std::filesystem::path lock_path = log_directory / kLockFileName; + const int lock_fd = + open(lock_path.c_str(), O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0600); + if (lock_fd < 0) { + return nullptr; + } + if (fchmod(lock_fd, 0600) != 0) { + close(lock_fd); + return nullptr; + } + + const std::filesystem::path log_path = log_directory / kLogFileName; + const int log_fd = + open(log_path.c_str(), + O_CREAT | O_WRONLY | O_APPEND | O_CLOEXEC | O_NOFOLLOW, 0600); + if (log_fd < 0 || fchmod(log_fd, 0600) != 0) { + if (log_fd >= 0) { + close(log_fd); + } + close(lock_fd); + return nullptr; + } + close(log_fd); + + return std::unique_ptr(new NativeFileLogSink( + std::move(log_directory), lock_fd, max_file_size, file_count)); +} + +NativeFileLogSink::NativeFileLogSink(std::filesystem::path log_directory, + int lock_fd, size_t max_file_size, + int file_count) + : log_path_(std::move(log_directory) / kLogFileName), + lock_fd_(lock_fd), + max_file_size_(max_file_size), + file_count_(file_count) {} + +NativeFileLogSink::~NativeFileLogSink() { + close(lock_fd_); +} + +void NativeFileLogSink::Send(const absl::LogEntry& entry) { + const absl::string_view message = + entry.text_message_with_prefix_and_newline(); + const bool flush = entry.log_severity() >= absl::LogSeverity::kError; + + std::lock_guard lock(mutex_); + if (flock(lock_fd_, LOCK_EX) != 0) { + WriteFallback(message.data(), message.size()); + return; + } + + const bool success = RotateIfNeeded(message.size()) && + WriteRecord(message.data(), message.size(), flush); + static_cast(flock(lock_fd_, LOCK_UN)); + if (!success) { + WriteFallback(message.data(), message.size()); + } +} + +bool NativeFileLogSink::RotateIfNeeded(size_t incoming_size) { + struct stat status{}; + if (stat(log_path_.c_str(), &status) != 0) { + return errno == ENOENT; + } + const size_t current_size = + status.st_size > 0 ? static_cast(status.st_size) : 0; + if (current_size == 0 || (incoming_size <= max_file_size_ && + current_size <= max_file_size_ - incoming_size)) { + return true; + } + + for (int index = file_count_ - 1; index >= 1; --index) { + const std::filesystem::path destination = + log_path_.string() + "." + std::to_string(index); + const std::filesystem::path source = + index == 1 ? log_path_ + : std::filesystem::path(log_path_.string() + "." + + std::to_string(index - 1)); + if (rename(source.c_str(), destination.c_str()) != 0 && errno != ENOENT) { + return false; + } + } + return true; +} + +bool NativeFileLogSink::WriteRecord(const char* data, size_t size, bool flush) { + const int fd = + open(log_path_.c_str(), + O_CREAT | O_WRONLY | O_APPEND | O_CLOEXEC | O_NOFOLLOW, 0600); + if (fd < 0) { + return false; + } + + bool success = fchmod(fd, 0600) == 0 && WriteAll(fd, data, size); + if (success && flush) { + success = fsync(fd) == 0; + } + if (close(fd) != 0) { + success = false; + } + return success; +} + +void NativeFileLogSink::WriteFallback(const char* data, size_t size) { + WriteToStderr(absl::string_view(data, size)); +} + +std::unique_ptr NativeFileLogging::Initialize() { + std::unique_ptr sink = + NativeFileLogSink::Create(internal::GetQuickShareLogPath().ToString()); + absl::InitializeLog(); + if (sink == nullptr) { + WriteToStderr( + "QuickShare: unable to initialize file logging; using " + "stderr.\n"); + return nullptr; + } + + absl::AddLogSink(sink.get()); + const absl::LogSeverityAtLeast previous_threshold = absl::StderrThreshold(); + absl::SetStderrThreshold(absl::LogSeverityAtLeast::kInfinity); + return std::unique_ptr( + new NativeFileLogging(std::move(sink), previous_threshold)); +} + +NativeFileLogging::NativeFileLogging( + std::unique_ptr sink, + absl::LogSeverityAtLeast previous_stderr_threshold) + : sink_(std::move(sink)), + previous_stderr_threshold_(previous_stderr_threshold) {} + +NativeFileLogging::~NativeFileLogging() { + absl::SetStderrThreshold(previous_stderr_threshold_); + absl::RemoveLogSink(sink_.get()); +} + +} // namespace nearby::sharing::linux diff --git a/sharing/linux/app/native_file_logging.h b/sharing/linux/app/native_file_logging.h new file mode 100644 index 00000000..3095b90b --- /dev/null +++ b/sharing/linux/app/native_file_logging.h @@ -0,0 +1,82 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef SHARING_LINUX_APP_NATIVE_FILE_LOGGING_H_ +#define SHARING_LINUX_APP_NATIVE_FILE_LOGGING_H_ + +#include +#include +#include +#include + +#include "absl/base/log_severity.h" +#include "absl/log/log_entry.h" +#include "absl/log/log_sink.h" + +namespace nearby::sharing::linux { + +class NativeFileLogSink final : public absl::LogSink { + public: + static constexpr size_t kDefaultMaxFileSize = 10 * 1024 * 1024; + static constexpr int kDefaultFileCount = 5; + + static std::unique_ptr Create( + std::filesystem::path log_directory, + size_t max_file_size = kDefaultMaxFileSize, + int file_count = kDefaultFileCount); + + NativeFileLogSink(const NativeFileLogSink&) = delete; + NativeFileLogSink& operator=(const NativeFileLogSink&) = delete; + ~NativeFileLogSink() override; + + void Send(const absl::LogEntry& entry) override; + + const std::filesystem::path& log_path() const { return log_path_; } + + private: + NativeFileLogSink(std::filesystem::path log_directory, int lock_fd, + size_t max_file_size, int file_count); + + bool RotateIfNeeded(size_t incoming_size); + bool WriteRecord(const char* data, size_t size, bool flush); + void WriteFallback(const char* data, size_t size); + + std::filesystem::path log_path_; + int lock_fd_; + size_t max_file_size_; + int file_count_; + std::mutex mutex_; +}; + +// Owns the process-wide Abseil sink registration. Initialize() must be called +// exactly once, before any application components start logging. +class NativeFileLogging final { + public: + static std::unique_ptr Initialize(); + + NativeFileLogging(const NativeFileLogging&) = delete; + NativeFileLogging& operator=(const NativeFileLogging&) = delete; + ~NativeFileLogging(); + + private: + NativeFileLogging(std::unique_ptr sink, + absl::LogSeverityAtLeast previous_stderr_threshold); + + std::unique_ptr sink_; + absl::LogSeverityAtLeast previous_stderr_threshold_; +}; + +} // namespace nearby::sharing::linux + +#endif // SHARING_LINUX_APP_NATIVE_FILE_LOGGING_H_ diff --git a/sharing/linux/app/native_file_logging_test.cc b/sharing/linux/app/native_file_logging_test.cc new file mode 100644 index 00000000..b306345f --- /dev/null +++ b/sharing/linux/app/native_file_logging_test.cc @@ -0,0 +1,265 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "sharing/linux/app/native_file_logging.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "absl/log/globals.h" +#include "absl/log/initialize.h" +#include "absl/log/log.h" +#include "absl/log/log_sink.h" +#include "absl/log/log_sink_registry.h" +#include "gtest/gtest.h" +#include "sharing/linux/platform/platform_util.h" + +namespace nearby::sharing::linux { +namespace { + +class TemporaryDirectory { + public: + TemporaryDirectory() { + std::string path = + (std::filesystem::temp_directory_path() / + ("quickshare-logging-test-" + std::to_string(getpid()) + "-XXXXXX")) + .string(); + path.push_back('\0'); + char* created = mkdtemp(path.data()); + EXPECT_NE(created, nullptr); + if (created != nullptr) { + path_ = created; + } + } + + ~TemporaryDirectory() { + std::error_code error; + std::filesystem::remove_all(path_, error); + } + + const std::filesystem::path& path() const { return path_; } + + private: + std::filesystem::path path_; +}; + +class ScopedEnvironmentVariable { + public: + ScopedEnvironmentVariable(const char* name, std::optional value) + : name_(name) { + const char* old_value = std::getenv(name); + if (old_value != nullptr) { + old_value_ = old_value; + } + if (value.has_value()) { + setenv(name, value->c_str(), 1); + } else { + unsetenv(name); + } + } + + ~ScopedEnvironmentVariable() { + if (old_value_.has_value()) { + setenv(name_.c_str(), old_value_->c_str(), 1); + } else { + unsetenv(name_.c_str()); + } + } + + private: + std::string name_; + std::optional old_value_; +}; + +class CaptureSink final : public absl::LogSink { + public: + void Send(const absl::LogEntry& entry) override { + std::lock_guard lock(mutex_); + records_.emplace_back(entry.text_message_with_prefix_and_newline()); + } + + std::vector records() { + std::lock_guard lock(mutex_); + return records_; + } + + private: + std::mutex mutex_; + std::vector records_; +}; + +void EnsureAbslLoggingInitialized() { + static const bool initialized = [] { + absl::InitializeLog(); + return true; + }(); + static_cast(initialized); +} + +std::string ReadFile(const std::filesystem::path& path) { + std::ifstream stream(path, std::ios::binary); + std::ostringstream contents; + contents << stream.rdbuf(); + return contents.str(); +} + +int PermissionBits(const std::filesystem::path& path) { + struct stat status{}; + EXPECT_EQ(stat(path.c_str(), &status), 0); + return status.st_mode & 0777; +} + +TEST(NativeFileLogSinkTest, PreservesOriginalAbseilRecord) { + EnsureAbslLoggingInitialized(); + TemporaryDirectory temporary_directory; + auto sink = NativeFileLogSink::Create(temporary_directory.path()); + ASSERT_NE(sink, nullptr); + CaptureSink capture; + absl::ScopedStderrThreshold suppress_stderr( + absl::LogSeverityAtLeast::kInfinity); + absl::AddLogSink(sink.get()); + absl::AddLogSink(&capture); + + LOG(INFO) << "native-record-sentinel"; + + absl::RemoveLogSink(&capture); + absl::RemoveLogSink(sink.get()); + const std::vector records = capture.records(); + ASSERT_EQ(records.size(), size_t{1}); + EXPECT_EQ(ReadFile(sink->log_path()), records.front()); +} + +TEST(NativeFileLogSinkTest, RotatesAndRetainsFiveFiles) { + EnsureAbslLoggingInitialized(); + TemporaryDirectory temporary_directory; + auto sink = NativeFileLogSink::Create(temporary_directory.path(), 256, 5); + ASSERT_NE(sink, nullptr); + absl::ScopedStderrThreshold suppress_stderr( + absl::LogSeverityAtLeast::kInfinity); + absl::AddLogSink(sink.get()); + + for (int index = 0; index < 8; ++index) { + LOG(INFO) << "rotation-record-" << index << "-" << std::string(180, 'x'); + } + + absl::RemoveLogSink(sink.get()); + EXPECT_TRUE(std::filesystem::exists(sink->log_path())); + for (int index = 1; index <= 4; ++index) { + EXPECT_TRUE(std::filesystem::exists(sink->log_path().string() + "." + + std::to_string(index))); + } + EXPECT_FALSE(std::filesystem::exists(sink->log_path().string() + ".5")); +} + +TEST(NativeFileLogSinkTest, SerializesConcurrentRecords) { + EnsureAbslLoggingInitialized(); + TemporaryDirectory temporary_directory; + auto sink = NativeFileLogSink::Create(temporary_directory.path()); + ASSERT_NE(sink, nullptr); + absl::ScopedStderrThreshold suppress_stderr( + absl::LogSeverityAtLeast::kInfinity); + absl::AddLogSink(sink.get()); + + std::vector threads; + for (int thread = 0; thread < 4; ++thread) { + threads.emplace_back([thread] { + for (int record = 0; record < 20; ++record) { + LOG(INFO) << "concurrent-record-" << thread << "-" << record; + } + }); + } + for (std::thread& thread : threads) { + thread.join(); + } + + absl::RemoveLogSink(sink.get()); + const std::string contents = ReadFile(sink->log_path()); + size_t record_count = 0; + size_t position = 0; + while ((position = contents.find("concurrent-record-", position)) != + std::string::npos) { + ++record_count; + position += 18; + } + EXPECT_EQ(record_count, size_t{80}); +} + +TEST(NativeFileLogSinkTest, UsesPrivatePermissions) { + TemporaryDirectory temporary_directory; + const std::filesystem::path log_directory = + temporary_directory.path() / "nested" / "logs"; + auto sink = NativeFileLogSink::Create(log_directory); + ASSERT_NE(sink, nullptr); + + EXPECT_EQ(PermissionBits(log_directory), 0700); + EXPECT_EQ(PermissionBits(sink->log_path()), 0600); + EXPECT_EQ(PermissionBits(log_directory / ".quickshare.log.lock"), 0600); +} + +TEST(NativeFileLogSinkTest, RejectsUnavailableDirectory) { + TemporaryDirectory temporary_directory; + const std::filesystem::path regular_file = + temporary_directory.path() / "not-a-directory"; + { + std::ofstream stream(regular_file); + stream << "content"; + } + + EXPECT_EQ(NativeFileLogSink::Create(regular_file), nullptr); +} + +TEST(LogPathTest, UsesAbsoluteXdgStateHome) { + TemporaryDirectory temporary_directory; + ScopedEnvironmentVariable state_home("XDG_STATE_HOME", + temporary_directory.path().string()); + + EXPECT_EQ(internal::GetQuickShareLogPath().ToString(), + (temporary_directory.path() / "quickshare" / "logs").string()); +} + +TEST(LogPathTest, FallsBackToHomeForRelativeXdgStateHome) { + TemporaryDirectory temporary_directory; + ScopedEnvironmentVariable home("HOME", temporary_directory.path().string()); + ScopedEnvironmentVariable state_home("XDG_STATE_HOME", "relative/path"); + + EXPECT_EQ( + internal::GetQuickShareLogPath().ToString(), + (temporary_directory.path() / ".local" / "state" / "quickshare" / "logs") + .string()); +} + +TEST(LogPathTest, FallsBackToHomeWhenXdgStateHomeIsMissing) { + TemporaryDirectory temporary_directory; + ScopedEnvironmentVariable home("HOME", temporary_directory.path().string()); + ScopedEnvironmentVariable state_home("XDG_STATE_HOME", std::nullopt); + + EXPECT_EQ( + internal::GetQuickShareLogPath().ToString(), + (temporary_directory.path() / ".local" / "state" / "quickshare" / "logs") + .string()); +} + +} // namespace +} // namespace nearby::sharing::linux diff --git a/sharing/linux/platform/linux_platform_components.cc b/sharing/linux/platform/linux_platform_components.cc index 7ff9d117..114747f3 100644 --- a/sharing/linux/platform/linux_platform_components.cc +++ b/sharing/linux/platform/linux_platform_components.cc @@ -182,7 +182,7 @@ class LinuxDeviceInfo final : public nearby::api::DeviceInfo { return BuildPathFromBase("/tmp", {"Google Nearby"}); } FilePath GetLogPath() const override { - return GetLocalAppDataPath(FilePath("logs")); + return GetQuickShareLogPath(); } bool IsScreenLocked() const override { return false; } void RegisterScreenLockedListener( diff --git a/sharing/linux/platform/platform_util.cc b/sharing/linux/platform/platform_util.cc index ec059720..9a0c481b 100644 --- a/sharing/linux/platform/platform_util.cc +++ b/sharing/linux/platform/platform_util.cc @@ -50,6 +50,19 @@ FilePath BuildPathFromBase(const std::string& base, return FilePath(path.string()); } +FilePath GetQuickShareLogPath() { + const char* state_home = std::getenv("XDG_STATE_HOME"); + std::filesystem::path base_path; + if (state_home != nullptr && *state_home != '\0' && + std::filesystem::path(state_home).is_absolute()) { + base_path = state_home; + } else { + base_path = + std::filesystem::path(GetHomeDirectory()) / ".local" / "state"; + } + return FilePath((base_path / "quickshare" / "logs").string()); +} + std::optional GetLanguageCode() { const char* lang = std::getenv("LANG"); if (lang == nullptr || *lang == '\0') { diff --git a/sharing/linux/platform/platform_util.h b/sharing/linux/platform/platform_util.h index be9a2a51..ee548f4d 100644 --- a/sharing/linux/platform/platform_util.h +++ b/sharing/linux/platform/platform_util.h @@ -29,6 +29,7 @@ std::string GetEnvOrDefault(const char* key, std::string fallback); std::string GetHomeDirectory(); FilePath BuildPathFromBase(const std::string& base, std::initializer_list components); +FilePath GetQuickShareLogPath(); std::optional GetLanguageCode(); bool HasNonLoopbackInterface();