Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions backend/logs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package gobridge

import (
"bufio"
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"time"

"github.com/syncthing/syncthing/lib/build"
stlib "github.com/syncthing/syncthing/lib/syncthing"
)

// The app reads the daemon log through here rather than /rest/system/log so
// it still gets a log when the daemon never came up, or came up but the
// device drops loopback traffic (siddarthkay/syncup#73). Both cases are
// exactly when a user is asked for their log.

var serverStartedAt time.Time

// GetSystemLog returns {"messages":[{when,message,level}...]} for lines after
// since (RFC3339; empty means everything). limit > 0 keeps only the newest
// limit lines. Same shape as GET /rest/system/log.
func (m *MobileAPI) GetSystemLog(since string, limit int) string {
var t time.Time
if since != "" {
if parsed, err := time.Parse(time.RFC3339, since); err == nil {
t = parsed
}
}
b, err := stlib.SystemLogJSON(t, limit)
if err != nil {
return marshalErr(err)
}
return string(b)
}

type logFileJSON struct {
Path string `json:"path"`
Bytes int64 `json:"bytes"`
Version string `json:"version"`
OS string `json:"os"`
Arch string `json:"arch"`
DeviceID string `json:"deviceId"`
UptimeSec int64 `json:"uptimeSec"`
Goroutines int `json:"goroutines"`
}

// WriteSystemLog writes the whole log buffer to dstPath in the
// /rest/system/log.txt format and returns the file size plus the facts the
// app used to fetch from /rest/system/{version,status} for the export
// header. Returns {"error":...} on failure.
func (m *MobileAPI) WriteSystemLog(dstPath string) string {
if dstPath == "" {
return marshalErr(errors.New("destination path is empty"))
}
if err := os.MkdirAll(filepath.Dir(dstPath), 0o700); err != nil {
return marshalErr(err)
}
f, err := os.Create(dstPath)
if err != nil {
return marshalErr(err)
}
w := bufio.NewWriter(f)
if err := stlib.WriteSystemLogTxt(w, time.Time{}); err != nil {
f.Close()
os.Remove(dstPath)
return marshalErr(err)
}
if err := w.Flush(); err != nil {
f.Close()
os.Remove(dstPath)
return marshalErr(err)
}
if err := f.Close(); err != nil {
os.Remove(dstPath)
return marshalErr(err)
}
info, err := os.Stat(dstPath)
if err != nil {
return marshalErr(err)
}

res := logFileJSON{
Path: dstPath,
Bytes: info.Size(),
Version: build.LongVersion,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Goroutines: runtime.NumGoroutine(),
}
globalMu.Lock()
if globalClient != nil {
res.DeviceID = globalClient.DeviceID()
if !serverStartedAt.IsZero() {
res.UptimeSec = int64(time.Since(serverStartedAt).Seconds())
}
}
globalMu.Unlock()
b, _ := json.Marshal(res)
return string(b)
}
138 changes: 138 additions & 0 deletions backend/logs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package gobridge

import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
)

type systemLogJSON struct {
Messages []struct {
When time.Time `json:"when"`
Message string `json:"message"`
Level string `json:"level"`
} `json:"messages"`
}

func decodeSystemLog(t *testing.T, raw string) systemLogJSON {
t.Helper()
var out systemLogJSON
if err := json.Unmarshal([]byte(raw), &out); err != nil {
t.Fatalf("bad JSON %q: %v", raw, err)
}
return out
}

func TestGetSystemLog(t *testing.T) {
m := NewMobileAPI()
marker := "logs_test marker " + t.Name()
slog.Warn(marker)

all := decodeSystemLog(t, m.GetSystemLog("", 0))
var found bool
for _, l := range all.Messages {
if strings.Contains(l.Message, marker) {
found = true
if l.Level != "WRN" {
t.Errorf("level = %q, want WRN", l.Level)
}
}
}
if !found {
t.Fatalf("marker not in log: %q", all.Messages)
}

tail := decodeSystemLog(t, m.GetSystemLog("", 1))
if len(tail.Messages) != 1 {
t.Fatalf("limit=1 returned %d lines", len(tail.Messages))
}
if !strings.Contains(tail.Messages[0].Message, marker) {
t.Errorf("tail = %q, want the marker (newest line)", tail.Messages[0].Message)
}

future := time.Now().Add(time.Hour).Format(time.RFC3339)
none := decodeSystemLog(t, m.GetSystemLog(future, 0))
if len(none.Messages) != 0 {
t.Errorf("since=future returned %d lines", len(none.Messages))
}
// messages must be [] not null so JS callers can iterate without a guard
if raw := m.GetSystemLog(future, 0); !strings.Contains(raw, `"messages":[]`) {
t.Errorf("empty result = %s, want \"messages\":[]", raw)
}

// garbage since behaves like no since, as the REST endpoint does
garbage := decodeSystemLog(t, m.GetSystemLog("not-a-time", 0))
if len(garbage.Messages) == 0 {
t.Error("unparseable since dropped every line")
}
}

func TestWriteSystemLog(t *testing.T) {
m := NewMobileAPI()
marker := "logs_test marker " + t.Name()
slog.Error(marker)

dst := filepath.Join(t.TempDir(), "nested", "log.txt")
var res struct {
Path string `json:"path"`
Bytes int64 `json:"bytes"`
Version string `json:"version"`
OS string `json:"os"`
Arch string `json:"arch"`
Goroutines int `json:"goroutines"`
Error string `json:"error"`
}
raw := m.WriteSystemLog(dst)
if err := json.Unmarshal([]byte(raw), &res); err != nil {
t.Fatalf("bad JSON %q: %v", raw, err)
}
if res.Error != "" {
t.Fatalf("error = %q", res.Error)
}
if res.Path != dst {
t.Errorf("path = %q, want %q", res.Path, dst)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if int64(len(data)) != res.Bytes || res.Bytes == 0 {
t.Errorf("bytes = %d, file has %d", res.Bytes, len(data))
}
if !strings.Contains(string(data), "ERR "+marker) {
t.Errorf("file missing %q:\n%s", "ERR "+marker, data)
}
// each line is "<RFC3339> <LVL> <message>"; RFC3339 is the same stamp
// the JSON endpoint carries, so a reader can line them up
first := strings.SplitN(string(data), "\n", 2)[0]
fields := strings.SplitN(first, " ", 3)
if len(fields) != 3 {
t.Fatalf("line %q not '<when> <level> <message>'", first)
}
if _, err := time.Parse(time.RFC3339, fields[0]); err != nil {
t.Errorf("timestamp %q: %v", fields[0], err)
}
if res.OS == "" || res.Arch == "" || res.Version == "" || res.Goroutines == 0 {
t.Errorf("meta incomplete: %+v", res)
}
}

func TestWriteSystemLogErrors(t *testing.T) {
m := NewMobileAPI()
for _, dst := range []string{"", filepath.Join(t.TempDir(), "a-file-not-a-dir", "x", "log.txt")} {
if dst != "" {
// make the parent path a file so MkdirAll fails
if err := os.WriteFile(filepath.Dir(filepath.Dir(dst)), nil, 0o600); err != nil {
t.Fatal(err)
}
}
raw := m.WriteSystemLog(dst)
if !strings.Contains(raw, `"error"`) {
t.Errorf("WriteSystemLog(%q) = %s, want error", dst, raw)
}
}
}
2 changes: 2 additions & 0 deletions backend/mobile_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ func (m *MobileAPI) StartServer(dataDir string) int {
return 0
}
globalClient = c
serverStartedAt = time.Now()
return c.Port()
}

Expand All @@ -148,6 +149,7 @@ func (m *MobileAPI) StopServer() {
}
globalClient.Stop()
globalClient = nil
serverStartedAt = time.Time{}
}

func (m *MobileAPI) GetServerPort() int {
Expand Down
51 changes: 51 additions & 0 deletions backend/patches/0002-in-process-log-access.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
--- /dev/null
+++ b/lib/syncthing/mobilelog.go
@@ -0,0 +1,48 @@
+// Copyright (C) 2025 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package syncthing
+
+import (
+ "encoding/json"
+ "io"
+ "time"
+
+ "github.com/syncthing/syncthing/internal/slogutil"
+)
+
+// In-process access to the global log buffer for the mobile wrapper. The
+// wrapper lives in another module and so cannot import internal/slogutil
+// itself; it also cannot rely on /rest/system/log, because that goes over
+// loopback and a device firewall or VPN lockdown can drop loopback traffic
+// while the daemon itself is perfectly healthy (siddarthkay/syncup#73).
+// Output matches the REST endpoints byte for byte so clients need no second
+// parser.
+
+// SystemLogJSON is /rest/system/log: {"messages": [...]} for lines after
+// since. A positive limit keeps only the newest limit lines.
+func SystemLogJSON(since time.Time, limit int) ([]byte, error) {
+ lines := slogutil.GlobalRecorder.Since(since)
+ if limit > 0 && limit < len(lines) {
+ lines = lines[len(lines)-limit:]
+ }
+ if lines == nil {
+ lines = []slogutil.Line{}
+ }
+ return json.Marshal(map[string][]slogutil.Line{"messages": lines})
+}
+
+// WriteSystemLogTxt is /rest/system/log.txt: one "<RFC3339> <LVL> <message>"
+// line per entry after since.
+func WriteSystemLogTxt(w io.Writer, since time.Time) error {
+ format := slogutil.LineFormat{TimestampFormat: time.RFC3339, LevelString: true}
+ for _, line := range slogutil.GlobalRecorder.Since(since) {
+ if _, err := line.WriteTo(w, format); err != nil {
+ return err
+ }
+ }
+ return nil
+}
6 changes: 6 additions & 0 deletions backend/patches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,9 @@ Bumping the syncthing pin in `go.mod` means re-checking every patch here.
adds `?limit=N` to `/rest/system/log` so the live log view can ask for just
the tail of that buffer, and includes the level in `/rest/system/log.txt`
(the endpoint the app streams to a file when a user exports their log).
- `0002-in-process-log-access.patch` — adds `lib/syncthing/mobilelog.go`, which
exposes the global log recorder (an `internal/` package, so the wrapper can't
import it directly) in the same JSON and text shapes as `/rest/system/log`
and `/rest/system/log.txt`. The app's log view and export call this through
gomobile instead of HTTP, so they work when the daemon failed to start or
the device drops loopback traffic (siddarthkay/syncup#73).
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,24 @@ class GoServerBridgeModule(reactContext: ReactApplicationContext) :
}
}

override fun getSystemLog(since: String, limit: Double): String {
return try {
mobileAPI.getSystemLog(since, limit.toLong()) ?: "{\"messages\":[]}"
} catch (e: Exception) {
android.util.Log.e(NAME, "getSystemLog failed", e)
"{\"error\":\"${e.message}\"}"
}
}

override fun writeSystemLog(dstPath: String): String {
return try {
mobileAPI.writeSystemLog(dstPath) ?: "{\"error\":\"nil result\"}"
} catch (e: Exception) {
android.util.Log.e(NAME, "writeSystemLog failed", e)
"{\"error\":\"${e.message}\"}"
}
}

private fun jsonError(msg: String): String =
org.json.JSONObject().apply {
put("ok", false)
Expand Down
4 changes: 4 additions & 0 deletions mobile-app/ios/syncup/GoBridgeWrapper.h
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,8 @@

+ (NSString * _Nonnull)importConfig:(NSString * _Nonnull)password;

+ (NSString * _Nonnull)getSystemLog:(NSString * _Nonnull)since limit:(NSInteger)limit;

+ (NSString * _Nonnull)writeSystemLog:(NSString * _Nonnull)dstPath;

@end
22 changes: 22 additions & 0 deletions mobile-app/ios/syncup/GoBridgeWrapper.mm
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ - (void)registerExternalRoot:(NSString *)path;
- (void)unregisterExternalRoot:(NSString *)path;
- (NSString *)exportConfig:(NSString *)srcDataDir dstZipPath:(NSString *)dstZipPath extrasJSON:(NSString *)extrasJSON;
- (NSString *)importConfig:(NSString *)srcZipPath dstDataDir:(NSString *)dstDataDir password:(NSString *)password;
- (NSString *)getSystemLog:(NSString *)since limit:(long)limit;
- (NSString *)writeSystemLog:(NSString *)dstPath;
@end

static Class GobridgeMobileAPIClass;
Expand Down Expand Up @@ -657,6 +659,26 @@ + (NSString *)importConfig:(NSString *)password {
}
}

+ (NSString *)getSystemLog:(NSString *)since limit:(NSInteger)limit {
@try {
id api = [self api];
if (!api) return @"{\"error\":\"bridge not initialized\"}";
return [api getSystemLog:(since ?: @"") limit:(long)limit] ?: @"{\"messages\":[]}";
} @catch (NSException *exception) {
return @"{\"error\":\"exception\"}";
}
}

+ (NSString *)writeSystemLog:(NSString *)dstPath {
@try {
id api = [self api];
if (!api) return @"{\"error\":\"bridge not initialized\"}";
return [api writeSystemLog:(dstPath ?: @"")] ?: @"{\"error\":\"nil result\"}";
} @catch (NSException *exception) {
return @"{\"error\":\"exception\"}";
}
}

+ (NSString *)timestampString {
NSDateFormatter *df = [[NSDateFormatter alloc] init];
df.dateFormat = @"yyyyMMdd-HHmmss";
Expand Down
Loading
Loading