diff --git a/backend/logs.go b/backend/logs.go new file mode 100644 index 0000000..8fb9465 --- /dev/null +++ b/backend/logs.go @@ -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) +} diff --git a/backend/logs_test.go b/backend/logs_test.go new file mode 100644 index 0000000..957ba14 --- /dev/null +++ b/backend/logs_test.go @@ -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 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 ' '", 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) + } + } +} diff --git a/backend/mobile_api.go b/backend/mobile_api.go index 519cfe6..49b7530 100644 --- a/backend/mobile_api.go +++ b/backend/mobile_api.go @@ -137,6 +137,7 @@ func (m *MobileAPI) StartServer(dataDir string) int { return 0 } globalClient = c + serverStartedAt = time.Now() return c.Port() } @@ -148,6 +149,7 @@ func (m *MobileAPI) StopServer() { } globalClient.Stop() globalClient = nil + serverStartedAt = time.Time{} } func (m *MobileAPI) GetServerPort() int { diff --git a/backend/patches/0002-in-process-log-access.patch b/backend/patches/0002-in-process-log-access.patch new file mode 100644 index 0000000..5a40c04 --- /dev/null +++ b/backend/patches/0002-in-process-log-access.patch @@ -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 " " ++// 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 ++} diff --git a/backend/patches/README.md b/backend/patches/README.md index b3dfa17..940426f 100644 --- a/backend/patches/README.md +++ b/backend/patches/README.md @@ -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). diff --git a/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/GoServerBridgeModule.kt b/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/GoServerBridgeModule.kt index 226c222..34c3f65 100644 --- a/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/GoServerBridgeModule.kt +++ b/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/GoServerBridgeModule.kt @@ -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) diff --git a/mobile-app/ios/syncup/GoBridgeWrapper.h b/mobile-app/ios/syncup/GoBridgeWrapper.h index f8faaf7..f4df34f 100644 --- a/mobile-app/ios/syncup/GoBridgeWrapper.h +++ b/mobile-app/ios/syncup/GoBridgeWrapper.h @@ -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 diff --git a/mobile-app/ios/syncup/GoBridgeWrapper.mm b/mobile-app/ios/syncup/GoBridgeWrapper.mm index 9b2a5a5..a7c3b1e 100644 --- a/mobile-app/ios/syncup/GoBridgeWrapper.mm +++ b/mobile-app/ios/syncup/GoBridgeWrapper.mm @@ -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; @@ -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"; diff --git a/mobile-app/ios/syncup/GoServerBridge.h b/mobile-app/ios/syncup/GoServerBridge.h index c785c3d..8b621a2 100644 --- a/mobile-app/ios/syncup/GoServerBridge.h +++ b/mobile-app/ios/syncup/GoServerBridge.h @@ -71,6 +71,8 @@ class GoServerBridgeImpl : public facebook::react::NativeGoServerBridgeCxxSpec void; readonly exportConfig: (asyncStorageJson: string) => string; readonly importConfig: (password: string) => string; + readonly getSystemLog: (since: string, limit: number) => string; + readonly writeSystemLog: (dstPath: string) => string; } export default TurboModuleRegistry.getEnforcing('GoServerBridge'); diff --git a/mobile-app/src/api/syncthing.ts b/mobile-app/src/api/syncthing.ts index 672fd11..8e581ff 100644 --- a/mobile-app/src/api/syncthing.ts +++ b/mobile-app/src/api/syncthing.ts @@ -8,7 +8,6 @@ import type { FolderErrorsResponse, IgnoresResponse, Options, - SystemLogResponse, SystemStatus, SystemVersion, Completion, @@ -110,23 +109,6 @@ export class SyncthingClient { return this.request('/rest/system/restart', { method: 'POST' }); } - systemLog(opts?: { since?: string; limit?: number }) { - const params = new URLSearchParams(); - if (opts?.since) params.set('since', opts.since); - if (opts?.limit != null) params.set('limit', String(opts.limit)); - const query = params.toString(); - return this.request( - `/rest/system/log${query ? `?${query}` : ''}`, - ); - } - - systemLogTxtEndpoint(): { url: string; headers: Record } { - return { - url: `${this.baseUrl}/rest/system/log.txt`, - headers: { 'X-API-Key': this.apiKey }, - }; - } - config() { return this.request('/rest/config'); } diff --git a/mobile-app/src/screens/LogsModal.tsx b/mobile-app/src/screens/LogsModal.tsx index fb0828b..6ea7685 100644 --- a/mobile-app/src/screens/LogsModal.tsx +++ b/mobile-app/src/screens/LogsModal.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { - ActivityIndicator, Alert, Modal, Platform, @@ -16,11 +15,12 @@ import * as Sharing from 'expo-sharing'; import ReactNativeBlobUtil from 'react-native-blob-util'; import { Focusable } from '../components/Focusable'; import { FlashList } from '@shopify/flash-list'; -import { useSyncthingClient } from '../daemon/SyncthingContext'; +import GoBridge from '../GoServerBridgeJSI'; import type { SystemLogMessage } from '../api/types'; import { SafeAreaView } from 'react-native-safe-area-context'; import { colors } from '../components/ui'; import { useKeyboardHeight } from '../hooks/useKeyboardHeight'; +import { parseLogFileResult, parseSystemLog } from '../utils/daemonLog'; import { formatLogHeader, logFileName } from '../utils/logExport'; interface Props { @@ -35,7 +35,6 @@ const LOG_BODY_TMP = 'syncup-logbody.tmp'; // inverted FlashList. FlatList was janky on Android at a few hundred rows. export function LogsModal({ visible, onClose }: Props) { - const client = useSyncthingClient(); const keyboardHeight = useKeyboardHeight(); const { height: winHeight } = useWindowDimensions(); const sheetHeight = Math.max(320, (winHeight - keyboardHeight) * 0.92); @@ -44,18 +43,17 @@ export function LogsModal({ visible, onClose }: Props) { const [messages, setMessages] = useState([]); const [error, setError] = useState(null); const [paused, setPaused] = useState(false); - const [loading, setLoading] = useState(false); const [exporting, setExporting] = useState(false); const lastSeenRef = useRef(''); - const fetchLogs = useCallback(async () => { + const fetchLogs = useCallback(() => { try { const since = lastSeenRef.current; - const res = await client.systemLog( - since ? { since } : { limit: INITIAL_TAIL }, + const fresh = parseSystemLog( + GoBridge.getSystemLog(since, since ? 0 : INITIAL_TAIL), ); - const fresh = res.messages ?? []; + setError(null); if (fresh.length === 0) return; lastSeenRef.current = fresh[fresh.length - 1].when; setMessages(prev => { @@ -67,7 +65,7 @@ export function LogsModal({ visible, onClose }: Props) { } catch (e) { setError(e instanceof Error ? e.message : String(e)); } - }, [client]); + }, []); useEffect(() => { if (!visible) { @@ -77,8 +75,7 @@ export function LogsModal({ visible, onClose }: Props) { setPaused(false); return; } - setLoading(true); - fetchLogs().finally(() => setLoading(false)); + fetchLogs(); }, [visible, fetchLogs]); useEffect(() => { @@ -104,21 +101,8 @@ export function LogsModal({ visible, onClose }: Props) { try { if (!dir) throw new Error('No cache directory available'); await deleteStaleExports(); - const { url, headers } = client.systemLogTxtEndpoint(); - const [download, version, status] = await Promise.all([ - FileSystem.downloadAsync(url, bodyUri, { - headers, - sessionType: FileSystem.FileSystemSessionType.FOREGROUND, - }), - client.systemVersion().catch(() => null), - client.systemStatus().catch(() => null), - ]); - if (download.status !== 200) { - throw new Error(`Log download failed: HTTP ${download.status}`); - } - const info = await FileSystem.getInfoAsync(bodyUri); - const logBytes = info.exists ? (info.size ?? 0) : 0; - if (logBytes === 0) { + const file = parseLogFileResult(GoBridge.writeSystemLog(stripScheme(bodyUri))); + if (file.bytes === 0) { Alert.alert('Nothing to export', 'The daemon log is empty.'); return; } @@ -128,11 +112,14 @@ export function LogsModal({ visible, onClose }: Props) { await FileSystem.writeAsStringAsync( uri, formatLogHeader({ - version, - status, - platform: `${Platform.OS} ${String(Platform.Version)}`, exportedAt, - logBytes, + platform: `${Platform.OS} ${String(Platform.Version)}`, + syncthingVersion: file.version, + build: `${file.os}/${file.arch}`, + deviceId: file.deviceId, + uptimeSec: file.uptimeSec, + goroutines: file.goroutines, + logBytes: file.bytes, }), ); await ReactNativeBlobUtil.fs.appendFile( @@ -159,7 +146,7 @@ export function LogsModal({ visible, onClose }: Props) { await FileSystem.deleteAsync(bodyUri, { idempotent: true }).catch(() => {}); setExporting(false); } - }, [client, exporting]); + }, [exporting]); const renderItem = useCallback( ({ item }: { item: SystemLogMessage }) => ( @@ -226,11 +213,7 @@ export function LogsModal({ visible, onClose }: Props) { {error && {error}} - {loading && messages.length === 0 ? ( - - - - ) : messages.length === 0 ? ( + {messages.length === 0 ? ( No log messages yet. diff --git a/mobile-app/src/utils/__tests__/daemonLog.test.ts b/mobile-app/src/utils/__tests__/daemonLog.test.ts new file mode 100644 index 0000000..d75805a --- /dev/null +++ b/mobile-app/src/utils/__tests__/daemonLog.test.ts @@ -0,0 +1,89 @@ +import { parseLogFileResult, parseSystemLog } from '../daemonLog'; +import { formatLogHeader } from '../logExport'; + +describe('parseSystemLog', () => { + it('returns the messages array', () => { + const lines = parseSystemLog( + '{"messages":[{"when":"2026-10-04T16:09:10Z","message":"Exiting","level":"INF"}]}', + ); + expect(lines).toHaveLength(1); + expect(lines[0].message).toBe('Exiting'); + expect(lines[0].level).toBe('INF'); + }); + + it('treats a missing or null messages field as empty', () => { + expect(parseSystemLog('{}')).toEqual([]); + expect(parseSystemLog('{"messages":null}')).toEqual([]); + expect(parseSystemLog('{"messages":[]}')).toEqual([]); + }); + + it('throws on a native error payload', () => { + expect(() => parseSystemLog('{"error":"bridge not initialized"}')).toThrow( + 'bridge not initialized', + ); + }); +}); + +describe('parseLogFileResult', () => { + it('fills in defaults for fields the daemon could not supply', () => { + // daemon never started: no device id, no uptime + const res = parseLogFileResult( + '{"path":"/tmp/x.txt","bytes":12,"version":"v1.30.0","os":"android","arch":"arm64","deviceId":"","uptimeSec":0,"goroutines":7}', + ); + expect(res).toEqual({ + path: '/tmp/x.txt', + bytes: 12, + version: 'v1.30.0', + os: 'android', + arch: 'arm64', + deviceId: '', + uptimeSec: 0, + goroutines: 7, + }); + }); + + it('throws on error or missing path', () => { + expect(() => parseLogFileResult('{"error":"destination path is empty"}')).toThrow( + 'destination path is empty', + ); + expect(() => parseLogFileResult('{"bytes":3}')).toThrow('no path'); + }); +}); + +describe('formatLogHeader', () => { + it('omits lines for facts the daemon could not supply', () => { + const header = formatLogHeader({ + exportedAt: '2026-10-05T10:00:00.000Z', + platform: 'android 34', + syncthingVersion: 'v1.30.0', + build: 'android/arm64', + deviceId: '', + uptimeSec: 0, + goroutines: 7, + logBytes: 2048, + }); + expect(header).toBe( + [ + '# SyncUp daemon log', + 'exported: 2026-10-05T10:00:00.000Z', + 'platform: android 34', + 'syncthing: v1.30.0', + 'build: android/arm64', + 'goroutines: 7', + 'log size: 2.0 KB', + '', + '', + ].join('\n'), + ); + }); + + it('includes device id and uptime for a running daemon', () => { + const header = formatLogHeader({ + exportedAt: '2026-10-05T10:00:00.000Z', + deviceId: 'OM2M7ZF-AIHEN4B', + uptimeSec: 61, + }); + expect(header).toContain('device id: OM2M7ZF-AIHEN4B\n'); + expect(header).toContain('uptime: 61s\n'); + }); +}); diff --git a/mobile-app/src/utils/daemonLog.ts b/mobile-app/src/utils/daemonLog.ts new file mode 100644 index 0000000..cc7c38f --- /dev/null +++ b/mobile-app/src/utils/daemonLog.ts @@ -0,0 +1,39 @@ +import type { SystemLogMessage } from '../api/types'; + +// Decoders for the native log bridge (GoBridge.getSystemLog / writeSystemLog). + +export interface LogFileResult { + path: string; + bytes: number; + version: string; + os: string; + arch: string; + deviceId: string; + uptimeSec: number; + goroutines: number; +} + +export function parseSystemLog(json: string): SystemLogMessage[] { + const parsed = JSON.parse(json) as { + messages?: SystemLogMessage[] | null; + error?: string; + }; + if (parsed.error) throw new Error(parsed.error); + return parsed.messages ?? []; +} + +export function parseLogFileResult(json: string): LogFileResult { + const parsed = JSON.parse(json) as Partial & { error?: string }; + if (parsed.error) throw new Error(parsed.error); + if (!parsed.path) throw new Error('log export returned no path'); + return { + path: parsed.path, + bytes: parsed.bytes ?? 0, + version: parsed.version ?? '', + os: parsed.os ?? '', + arch: parsed.arch ?? '', + deviceId: parsed.deviceId ?? '', + uptimeSec: parsed.uptimeSec ?? 0, + goroutines: parsed.goroutines ?? 0, + }; +} diff --git a/mobile-app/src/utils/logExport.ts b/mobile-app/src/utils/logExport.ts index 958e378..f699e95 100644 --- a/mobile-app/src/utils/logExport.ts +++ b/mobile-app/src/utils/logExport.ts @@ -1,10 +1,11 @@ -import type { SystemStatus, SystemVersion } from '../api/types'; - export interface LogFileMeta { - version?: SystemVersion | null; - status?: SystemStatus | null; - platform?: string | null; exportedAt: string; + platform?: string | null; + syncthingVersion?: string | null; + build?: string | null; + deviceId?: string | null; + uptimeSec?: number | null; + goroutines?: number | null; logBytes?: number | null; } @@ -30,11 +31,11 @@ export function formatLogHeader(meta: LogFileMeta): string { '# SyncUp daemon log', `exported: ${meta.exportedAt}`, meta.platform ? `platform: ${meta.platform}` : null, - meta.version ? `syncthing: ${meta.version.longVersion}` : null, - meta.version ? `build: ${meta.version.os}/${meta.version.arch}` : null, - meta.status ? `device id: ${meta.status.myID}` : null, - meta.status ? `uptime: ${meta.status.uptime}s` : null, - meta.status ? `goroutines: ${meta.status.goroutines}` : null, + meta.syncthingVersion ? `syncthing: ${meta.syncthingVersion}` : null, + meta.build ? `build: ${meta.build}` : null, + meta.deviceId ? `device id: ${meta.deviceId}` : null, + meta.uptimeSec ? `uptime: ${meta.uptimeSec}s` : null, + meta.goroutines ? `goroutines: ${meta.goroutines}` : null, meta.logBytes != null ? `log size: ${formatBytes(meta.logBytes)}` : null, '', ]