Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ jobs:
use_oidc: true
- name: Installation script contracts
run: bash tests/scripts.test.sh --installation-only
- name: Device sharing form contracts
run: node --test tests/device-sharing.test.mjs
- name: Integration fixture contracts
run: go -C tests/integration test -race ./...
- name: Reproduce bundled core
Expand Down
12 changes: 10 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@

Notable changes to Syncshell for Omarchy are documented here.

## 0.2.4 - 2026-10-19

- render external adapter text literally and escape desktop notifications and
Waybar tooltips
- distinguish confirmed and unconfirmed shares in the device-sharing form
- preserve sharing selections during device-state refreshes
- clean up QML variable scope, property access, and lint diagnostics

## 0.2.3 - 2026-10-19

- qualify Omarchy palette references for compatibility with Omarchy 4.0.5 and
Qt 6.12
- qualify Omarchy palette references for compatibility with Omarchy 4.0.5 and Qt
6.12
- fix nested submenu dismissal and preserve unsaved folder-sharing selections
during background refreshes

Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,11 @@ See [CHANGELOG.md](CHANGELOG.md) for details.
| Release | Date | What changed |
| ------- | ---------- | ------------------------------------------------------- |
| Future | TBD | mature alpha DMS, II, Caelestia, and Waybar adapters |
| 0.2.3 | 2026-10-19 | support Omarchy 4.0.5 and Qt 6.12 palette references |
| | | fix submenu dismissal and preserve sharing selections |
| 0.2.4 | 2026-10-19 | protect adapter messages and tooltips from markup |
| | | distinguish confirmed and unconfirmed device shares |
| | | preserve sharing drafts and clean up QML diagnostics |
| 0.2.3 | 2026-10-19 | support Omarchy 4.0.5 and Qt 6.12 palette references |
| | | fix submenu dismissal and preserve sharing selections |
| 0.2.2 | 2026-10-06 | secure pending-device dismissal confirmations |
| 0.2.1 | 2026-10-05 | render pending device names as plain text in adapters |
| 0.2.0 | 2026-10-05 | improve multi-folder navigation, search, and filtering |
Expand Down
Binary file modified bin/aarch64/syncshell-core
Binary file not shown.
Binary file modified bin/x86_64/syncshell-core
Binary file not shown.
2 changes: 1 addition & 1 deletion core/cmd/syncshell-core/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
"github.com/omarchy-QOL/syncshell/core/internal/waybarconfig"
)

var buildVersion = "0.2.2"
var buildVersion = "0.2.4"

type options struct {
session.Config
Expand Down
3 changes: 2 additions & 1 deletion core/internal/session/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ const (
)

var eventTypes = []string{
"ConfigSaved", "DeviceConnected", "DeviceDisconnected", "DevicePaused",
"ConfigSaved", "ClusterConfigReceived", "FolderCompletion",
"DeviceConnected", "DeviceDisconnected", "DevicePaused",
"DeviceResumed", "DownloadProgress",
"FolderErrors", "FolderPaused", "FolderResumed", "FolderScanProgress",
"FolderSummary", "ItemFinished", "ItemStarted", "LocalIndexUpdated",
Expand Down
2 changes: 1 addition & 1 deletion core/internal/session/normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func normalizeFolder(
) (Folder, int) {
devices := make([]FolderDevice, 0, min(len(folder.Devices), maxFolderDevices))
for _, device := range folder.Devices[:min(len(folder.Devices), maxFolderDevices)] {
devices = append(devices, FolderDevice{ID: boundedIdentifier(device.DeviceID)})
devices = append(devices, FolderDevice{ID: boundedIdentifier(device.DeviceID), RemoteState: "unknown"})
}
sort.Slice(devices, func(i, j int) bool { return devices[i].ID < devices[j].ID })
currentErrors := append([]syncthing.FolderError(nil), errorsResponse.Errors...)
Expand Down
1 change: 1 addition & 0 deletions core/internal/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ func (s *Session) loadAuthenticated(
truncation.NearbyDevices = max(0,
len(nearbyDeviceIDs(discovery, devices, status.MyID))-maxNearbyDevices)
truncation.FolderErrors = omittedFolderErrors
s.loadRemoteSharing(ctx, normalizedFolders, devices, connections, status.MyID)
return hydratedState{
identity: Identity{DeviceID: boundedIdentifier(status.MyID),
Version: boundedLabel(version.Version)},
Expand Down
2 changes: 1 addition & 1 deletion core/internal/session/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@ func TestConfigureSupportsConcurrentUpdates(t *testing.T) {
}
}

func newTestSession(t *testing.T, api *testAPI, active, expectedID string) *Session {
func newTestSession(t *testing.T, api http.Handler, active, expectedID string) *Session {
t.Helper()
server := httptest.NewServer(api)
t.Cleanup(server.Close)
Expand Down
62 changes: 62 additions & 0 deletions core/internal/session/sharing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package session

import (
"context"
"sync"
"time"

"github.com/omarchy-QOL/syncshell/core/internal/syncthing"
)

// Optional peer metadata must not hold up the local folder snapshot indefinitely.
func (s *Session) loadRemoteSharing(ctx context.Context, folders []Folder,
devices []syncthing.Device, connections syncthing.Connections, localID string,
) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
connected := make(map[string]bool, len(devices))
for _, device := range devices {
connected[device.DeviceID] = !device.Paused && connections.Connections[device.DeviceID].Connected
}
type pair struct {
folderID string
member *FolderDevice
}
jobs := make(chan pair)
var workers sync.WaitGroup
for i := 0; i < 4; i++ {
workers.Add(1)
go func() {
defer workers.Done()
for job := range jobs {
completion, err := s.client.FolderCompletion(ctx, job.folderID, job.member.ID)
if err != nil {
continue
}
switch completion.RemoteState {
case "valid", "paused", "notSharing":
job.member.RemoteState = completion.RemoteState
}
}
}()
}
enqueue:
for _, folder := range folders {
if folder.Paused {
continue
}
for i := range folder.Devices {
member := &folder.Devices[i]
if member.ID == localID || !connected[member.ID] {
continue
}
select {
case jobs <- pair{folder.ID, member}:
case <-ctx.Done():
break enqueue
}
}
}
close(jobs)
workers.Wait()
}
155 changes: 155 additions & 0 deletions core/internal/session/sharing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package session

import (
"context"
"encoding/json"
"net/http"
"slices"
"sync/atomic"
"testing"
"time"

"github.com/omarchy-QOL/syncshell/core/internal/syncthing"
)

func TestRemoteSharingStatesHydrateAndRecover(t *testing.T) {
api := &testAPI{}
var fail atomic.Bool
var accepted atomic.Bool
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/rest/config/folders" {
folders := []syncthing.Folder{}
for _, id := range []string{"valid", "paused", "notSharing", "unknown", "unexpected"} {
folders = append(folders, syncthing.Folder{ID: id,
Devices: []syncthing.FolderDevice{syncthing.NewFolderDevice("REMOTE")}})
}
_ = json.NewEncoder(w).Encode(folders)
return
}
if r.URL.Path == "/rest/db/completion" {
if r.Method != http.MethodGet || r.URL.Query().Get("device") != "REMOTE" ||
r.Header.Get("X-API-Key") != sessionTestKey {
t.Error("completion must be an authenticated per-device GET")
}
if fail.Load() {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
return
}
state := r.URL.Query().Get("folder")
if accepted.Load() {
state = "valid"
}
_ = json.NewEncoder(w).Encode(syncthing.FolderCompletion{RemoteState: state})
return
}
api.ServeHTTP(w, r)
})
s := newTestSession(t, handler, "inactive", "LOCAL-ID")
check := func(allUnknown, allValid bool) {
t.Helper()
snapshot, err := s.Refresh(context.Background())
if err != nil || !snapshot.State.Connection.Fresh {
t.Fatalf("optional completion failed the local snapshot: %v", err)
}
for _, folder := range snapshot.State.Folders {
want := folder.ID
if allUnknown || want == "unexpected" {
want = "unknown"
}
if allValid {
want = "valid"
}
if got := folder.Devices[0].RemoteState; got != want {
t.Errorf("%s remote state = %s, want %s", folder.ID, got, want)
}
}
}
check(false, false)
fail.Store(true)
check(true, false)
fail.Store(false)
accepted.Store(true)
check(false, true)
}

func TestRemoteSharingSkipsUnavailablePeersAndBoundsConcurrency(t *testing.T) {
var requests, running, maximum atomic.Int32
api := &testAPI{}
s := newTestSession(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/rest/db/completion" {
api.ServeHTTP(w, r)
return
}
requests.Add(1)
current := running.Add(1)
defer running.Add(-1)
for current > maximum.Load() {
previous := maximum.Load()
if current <= previous || maximum.CompareAndSwap(previous, current) {
break
}
}
if r.URL.Query().Get("device") != "REMOTE" || r.URL.Query().Get("folder") == "local-paused" {
t.Error("queried a local, paused or disconnected target")
}
time.Sleep(time.Millisecond)
writeSessionJSON(w, `{"remoteState":"valid"}`)
}), "inactive", "LOCAL-ID")
devices := []syncthing.Device{{DeviceID: "LOCAL-ID"}, {DeviceID: "REMOTE"},
{DeviceID: "OFFLINE"}, {DeviceID: "PAUSED", Paused: true}}
connections := syncthing.Connections{Connections: map[string]syncthing.Connection{
"LOCAL-ID": {Connected: true}, "REMOTE": {Connected: true}, "PAUSED": {Connected: true},
}}
folders := make([]Folder, 21)
for i := range folders {
folders[i].ID = "folder"
for _, device := range devices {
folders[i].Devices = append(folders[i].Devices,
FolderDevice{ID: device.DeviceID, RemoteState: "unknown"})
}
}
folders[20].ID, folders[20].Paused = "local-paused", true
s.loadRemoteSharing(context.Background(), folders, devices, connections, "LOCAL-ID")
if requests.Load() != 20 || maximum.Load() > 4 {
t.Fatalf("requests=%d concurrent=%d", requests.Load(), maximum.Load())
}
for _, folder := range folders {
for _, member := range folder.Devices {
want := "unknown"
if member.ID == "REMOTE" && !folder.Paused {
want = "valid"
}
if member.RemoteState != want {
t.Errorf("%s/%s state=%s want=%s", folder.ID, member.ID, member.RemoteState, want)
}
}
}
}

func TestRemoteSharingCancellationLeavesUnknown(t *testing.T) {
api := &testAPI{}
s := newTestSession(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/rest/db/completion" {
<-r.Context().Done()
return
}
api.ServeHTTP(w, r)
}), "inactive", "LOCAL-ID")
folders := []Folder{{ID: "folder", Devices: []FolderDevice{{ID: "REMOTE", RemoteState: "unknown"}}}}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
started := time.Now()
s.loadRemoteSharing(ctx, folders, []syncthing.Device{{DeviceID: "REMOTE"}},
syncthing.Connections{Connections: map[string]syncthing.Connection{"REMOTE": {Connected: true}}}, "LOCAL-ID")
if time.Since(started) > time.Second || folders[0].Devices[0].RemoteState != "unknown" {
t.Fatal("cancelled peer lookup blocked or falsely confirmed sharing")
}
}

func TestSharingEventsRefreshRemoteState(t *testing.T) {
for _, kind := range []string{"ClusterConfigReceived", "FolderCompletion"} {
if !slices.Contains(eventTypes, kind) || !requiresHydration([]syncthing.Event{{Type: kind}}) {
t.Errorf("%s does not refresh sharing state", kind)
}
}
}
3 changes: 2 additions & 1 deletion core/internal/session/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ type Device struct {

// FolderDevice is one normalized folder-sharing relationship.
type FolderDevice struct {
ID string `json:"id"`
ID string `json:"id"`
RemoteState string `json:"remoteState"`
}

// FolderStatus is normalized authoritative folder state.
Expand Down
8 changes: 8 additions & 0 deletions core/internal/syncthing/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,14 @@ func (c *Client) FolderStatus(ctx context.Context, folderID string) (FolderStatu
return response, err
}

// FolderCompletion reads remote sharing state for one folder-device pair.
func (c *Client) FolderCompletion(ctx context.Context, folderID, deviceID string) (FolderCompletion, error) {
var response FolderCompletion
query := url.Values{"folder": {folderID}, "device": {deviceID}}
err := c.request(ctx, http.MethodGet, "/rest/db/completion?"+query.Encode(), nil, true, &response)
return response, err
}

// Rescan requests one verified folder scan.
func (c *Client) Rescan(ctx context.Context, folderID string) (RescanDisposition, error) {
path := "/rest/db/scan"
Expand Down
24 changes: 24 additions & 0 deletions core/internal/syncthing/completion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package syncthing

import (
"context"
"net/http"
"net/http/httptest"
"testing"
)

func TestFolderCompletionUsesExactPair(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/rest/db/completion" ||
r.URL.Query().Get("folder") != "folder &+?" || r.URL.Query().Get("device") != "REMOTE" ||
r.Header.Get("X-API-Key") != testAPIKey {
t.Error("completion query did not preserve the authenticated folder-device pair")
}
writeJSON(w, `{"remoteState":"notSharing","completion":0}`)
}))
defer server.Close()
result, err := testClient(t, server.URL, "", false).FolderCompletion(context.Background(), "folder &+?", "REMOTE")
if err != nil || result.RemoteState != "notSharing" {
t.Fatalf("completion = %#v, %v", result, err)
}
}
5 changes: 5 additions & 0 deletions core/internal/syncthing/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ type FolderStatus struct {
GlobalBytes int64 `json:"globalBytes"`
}

// FolderCompletion contains the remote's current folder-sharing state.
type FolderCompletion struct {
RemoteState string `json:"remoteState"`
}

// Connections is the configured connection response.
type Connections struct {
Connections map[string]Connection `json:"connections"`
Expand Down
1 change: 1 addition & 0 deletions core/internal/waybarconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ func installConfig(text, root string) (string, error) {
"on-click-right": quickshell + " ipc call syncshell refresh",
"on-click-middle": quickshell + " ipc call syncshell openWebUi",
"tooltip": true,
"escape": true,
}
encoded, err := json.MarshalIndent(values, " ", " ")
if err != nil {
Expand Down
Loading
Loading