Skip to content

Commit f2770ed

Browse files
committed
Add remote session and stale-state foundation
1 parent e0d4ca9 commit f2770ed

23 files changed

Lines changed: 493 additions & 15 deletions

CMakeLists.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ qt_add_executable(BrickSuite
6363
src/network/BrickSuiteWebSocketClient.cpp
6464
src/network/BrickSuiteNetworkManager.h
6565
src/network/BrickSuiteNetworkManager.cpp
66+
src/network/RemoteSessionState.h
67+
src/network/RemoteSessionState.cpp
6668

6769
src/database/DatabaseManager.h
6870
src/database/DatabaseManager.cpp
@@ -1101,6 +1103,7 @@ if(BUILD_TESTING)
11011103
src/services/application/HostReadExecutor.h src/services/application/HostReadExecutor.cpp
11021104
src/services/application/HostReadProtocolService.h src/services/application/HostReadProtocolService.cpp
11031105
src/services/application/RemoteReadApplicationServices.h src/services/application/RemoteReadApplicationServices.cpp
1106+
src/network/RemoteSessionState.h src/network/RemoteSessionState.cpp
11041107
src/services/application/AsyncReadResult.h
11051108
src/services/application/dto/RemoteReadDtos.h
11061109
src/services/application/dto/RemoteReadJson.h src/services/application/dto/RemoteReadJson.cpp
@@ -1138,6 +1141,16 @@ if(BUILD_TESTING)
11381141
BRICKSUITE_VERSION="${PROJECT_VERSION}")
11391142
target_link_libraries(RemoteReadFoundationTest PRIVATE Qt6::Core Qt6::Sql)
11401143
add_test(NAME RemoteReadFoundation COMMAND RemoteReadFoundationTest)
1144+
1145+
qt_add_executable(RemoteSessionStateTest EXCLUDE_FROM_ALL
1146+
tests/RemoteSessionStateTest.cpp
1147+
src/network/RemoteSessionState.h
1148+
src/network/RemoteSessionState.cpp
1149+
src/app/WorkspaceContext.h
1150+
src/app/WorkspaceContext.cpp
1151+
)
1152+
target_link_libraries(RemoteSessionStateTest PRIVATE Qt6::Core)
1153+
add_test(NAME RemoteSessionState COMMAND RemoteSessionStateTest)
11411154
endif()
11421155

11431156
if(WIN32)

resources/help/bricksuite_server.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
<h1>BrickSuite Server</h1>
44
<p>BrickSuite Server lets another BrickSuite installation establish a secure connection to the computer that owns the authoritative workshop database. A connected Client can browse Host Workspaces, Storage used by Inventory, My Inventory, Inventory History, Builds, Missing Parts, Pulling, My Collection, Collection Details, and shared user Part Reference customizations. Remote shared data is read-only at this stage; changes still require using the Host computer.</p>
55
<p>The built-in Part Reference manifest remains local and is usable immediately; authenticated Host customizations are overlaid when available. Set, Minifig, and Part catalog presentation and image caches also remain local to the Client. Collection and Part Reference customization add, edit, archive, and remove operations are not available remotely.</p>
6-
<p>The Client chooses its Host Workspace independently. This does not change the Workspace selected in the Host computer's UI. The selection is remembered on the Client for that Host identity and validated again after reconnecting.</p>
6+
<p>The Client chooses its Host Workspace independently. This does not change the Workspace selected in the Host computer's UI. The selection is remembered using the trusted Host certificate identity and validated again after reconnecting.</p>
7+
<div class="note"><b>Temporary disconnections:</b> previously loaded Host data may remain visible and is marked as stale while the Host is unavailable. Host-dependent actions remain unavailable. After reconnecting to the same trusted Host, BrickSuite validates the remembered Workspace and refreshes Host-backed views automatically, even when the selected Workspace has not changed. Client-local catalogs, built-in Part Reference information, and cached images remain available during the outage.</div>
78
<div class="note"><b>Hybrid data:</b> operational Workspace, Storage, Inventory, History, Build requirements, allocations, Missing Parts, Pulling state, and Collection come from the Host. Catalog names, compositions, built-in Part Reference content, and images continue to use the Client device's local Rebrickable data and image cache. Unknown local catalog items remain visible using Host fallback text.</div>
89
<p>In <b>Builds</b>, a Client can view the Host Build list, immutable requirement snapshots, Host-calculated Missing Parts, and the current Pulling projection. Pulling is read-only: creating or editing Builds, allocating Inventory, recording pulls, reconciling pull lists, completing, cancelling, archiving, or disassembling a Build is not available remotely yet.</p>
910
<p>The Storage tab shows the selected Host Workspace's complete hierarchy with editing controls disabled. My Collection and Collection Details likewise show Host-owned records read-only. User Part Reference customizations come from the Host and overlay the Client's local built-in manifest; customization changes still require the Host computer.</p>

src/app/WorkspaceContext.cpp

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,25 @@ bool WorkspaceContext::hasCurrentWorkspace() const
3434
return m_currentWorkspaceId > 0;
3535
}
3636

37+
quint64 WorkspaceContext::generation() const
38+
{
39+
return m_generation;
40+
}
41+
3742
void WorkspaceContext::setCurrentWorkspaceId(int workspaceId)
3843
{
3944
if (m_currentWorkspaceId == workspaceId)
4045
return;
4146

4247
m_currentWorkspaceId = workspaceId;
4348

49+
++m_generation;
50+
4451
emit currentWorkspaceChanged(m_currentWorkspaceId);
52+
emit generationChanged(m_generation, m_currentWorkspaceId);
4553
}
4654

4755
void WorkspaceContext::clearCurrentWorkspace()
4856
{
4957
setCurrentWorkspaceId(0);
50-
}
58+
}

src/app/WorkspaceContext.h

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,16 @@ class WorkspaceContext : public QObject
3131

3232
int currentWorkspaceId() const;
3333
bool hasCurrentWorkspace() const;
34+
quint64 generation() const;
3435

3536
void setCurrentWorkspaceId(int workspaceId);
3637
void clearCurrentWorkspace();
3738

3839
signals:
3940
void currentWorkspaceChanged(int workspaceId);
41+
void generationChanged(quint64 generation, int workspaceId);
4042

4143
private:
4244
int m_currentWorkspaceId = 0;
43-
};
45+
quint64 m_generation = 0;
46+
};

src/network/BrickSuiteNetworkManager.cpp

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include "BrickSuiteAuthentication.h"
44
#include "BrickSuiteWebSocketClient.h"
55
#include "BrickSuiteWebSocketServer.h"
6+
#include "RemoteSessionState.h"
67
#include "../database/DatabaseManager.h"
78
#include "../services/application/HostReadProtocolService.h"
89
#include "../services/application/RemoteReadApplicationServices.h"
@@ -21,14 +22,20 @@ BrickSuiteNetworkManager::BrickSuiteNetworkManager(QObject* parent)
2122
, m_server(new BrickSuiteWebSocketServer(this))
2223
, m_client(new BrickSuiteWebSocketClient(this))
2324
{
24-
m_remoteReads = std::make_unique<RemoteReadApplicationServices>(*m_client, this);
25+
m_remoteSession = std::make_unique<RemoteSessionState>(this);
26+
m_remoteReads = std::make_unique<RemoteReadApplicationServices>(*m_client,
27+
m_remoteSession.get(), this);
2528
m_hostReads = std::make_unique<HostReadProtocolService>(
2629
DatabaseManager::instance().databasePath(), this);
2730
m_hostReads->registerOperations(m_server->operationDispatcher());
2831
connect(m_server, &BrickSuiteWebSocketServer::statusChanged,
2932
this, &BrickSuiteNetworkManager::statusChanged);
3033
connect(m_client, &BrickSuiteWebSocketClient::statusChanged,
3134
this, [this](const BrickSuiteConnectionStatus&) { emit statusChanged(); });
35+
connect(m_client, &BrickSuiteWebSocketClient::authenticatedSessionEstablished,
36+
m_remoteSession.get(), &RemoteSessionState::authenticated);
37+
connect(m_client, &BrickSuiteWebSocketClient::authenticatedSessionLost,
38+
m_remoteSession.get(), &RemoteSessionState::disconnected);
3239
}
3340

3441
BrickSuiteNetworkManager::~BrickSuiteNetworkManager() { stop(); }
@@ -99,6 +106,8 @@ BrickSuiteWebSocketServer* BrickSuiteNetworkManager::server() const { return m_s
99106
BrickSuiteWebSocketClient* BrickSuiteNetworkManager::client() const { return m_client; }
100107
RemoteReadApplicationServices* BrickSuiteNetworkManager::remoteReads() const
101108
{ return m_remoteReads.get(); }
109+
RemoteSessionState* BrickSuiteNetworkManager::remoteSession() const
110+
{ return m_remoteSession.get(); }
102111
BrickSuiteConnectionStatus BrickSuiteNetworkManager::connectionStatus() const { return m_client->status(); }
103112

104113
QString BrickSuiteNetworkManager::serverStatusText() const

src/network/BrickSuiteNetworkManager.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class BrickSuiteWebSocketClient;
99
class BrickSuiteWebSocketServer;
1010
class HostReadProtocolService;
1111
class RemoteReadApplicationServices;
12+
class RemoteSessionState;
1213

1314
class BrickSuiteNetworkManager : public QObject
1415
{
@@ -23,6 +24,7 @@ class BrickSuiteNetworkManager : public QObject
2324
BrickSuiteWebSocketServer* server() const;
2425
BrickSuiteWebSocketClient* client() const;
2526
RemoteReadApplicationServices* remoteReads() const;
27+
RemoteSessionState* remoteSession() const;
2628
BrickSuiteConnectionStatus connectionStatus() const;
2729
QString serverStatusText() const;
2830
QString generateOrRotateHostToken(QString* error = nullptr);
@@ -38,4 +40,5 @@ class BrickSuiteNetworkManager : public QObject
3840
QString m_serverError;
3941
std::unique_ptr<HostReadProtocolService> m_hostReads;
4042
std::unique_ptr<RemoteReadApplicationServices> m_remoteReads;
43+
std::unique_ptr<RemoteSessionState> m_remoteSession;
4144
};

src/network/BrickSuiteWebSocketClient.cpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ void BrickSuiteWebSocketClient::disconnectFromHost()
8888
m_explicitDisconnect = true;
8989
m_reconnectTimer.stop();
9090
failPending(QStringLiteral("TIMEOUT"), QStringLiteral("The Host connection closed."), true);
91+
if (m_authenticated) {
92+
m_authenticated = false;
93+
m_capabilities = {};
94+
emit authenticatedSessionLost();
95+
}
9196
m_socket.close(QWebSocketProtocol::CloseCodeNormal, QStringLiteral("Client disconnect."));
9297
setStatus(BrickSuiteConnectionState::Disconnected, QStringLiteral("Disconnected."));
9398
}
@@ -169,6 +174,11 @@ void BrickSuiteWebSocketClient::handleConnected()
169174
void BrickSuiteWebSocketClient::handleDisconnected()
170175
{
171176
failPending(QStringLiteral("TIMEOUT"), QStringLiteral("The Host connection was interrupted."), true);
177+
if (m_authenticated) {
178+
m_authenticated = false;
179+
m_capabilities = {};
180+
emit authenticatedSessionLost();
181+
}
172182
if (!m_explicitDisconnect && m_reconnectAutomatically
173183
&& m_status.state != BrickSuiteConnectionState::HostIdentityMismatch
174184
&& m_status.state != BrickSuiteConnectionState::AuthenticationFailed
@@ -271,13 +281,17 @@ void BrickSuiteWebSocketClient::handleResponse(const BrickSuiteProtocol::Message
271281
} else if (operation == QStringLiteral("system.capabilities")) {
272282
m_capabilities = message.payload;
273283
m_reconnectAttempt = 0;
284+
const QSslCertificate certificate = m_socket.sslConfiguration().peerCertificate();
285+
m_presentedFingerprint = BrickSuiteHostIdentity::fingerprint(certificate);
286+
m_authenticated = true;
274287
setStatus(BrickSuiteConnectionState::ConnectedAuthenticated,
275288
QStringLiteral("Connected and authenticated to BrickSuite %1 — protocol %2.%3")
276289
.arg(message.payload.value(QStringLiteral("brickSuiteVersion")).toString())
277290
.arg(message.payload.value(QStringLiteral("protocolMajor")).toInt())
278291
.arg(message.payload.value(QStringLiteral("protocolMinor")).toInt()));
279292
qInfo() << "BrickSuite Host secure connection and authentication completed in"
280293
<< m_connectTimer.elapsed() << "ms.";
294+
emit authenticatedSessionEstablished(m_presentedFingerprint);
281295
emit testConnectionCompleted(true, m_status.message);
282296
}
283297
}

src/network/BrickSuiteWebSocketClient.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ class BrickSuiteWebSocketClient : public QObject
4040
void requestCompleted(const QString& requestId, const QJsonObject& payload);
4141
void requestFailed(const QString& requestId, const BrickSuiteProtocol::Error& error);
4242
void testConnectionCompleted(bool success, const QString& message);
43+
void authenticatedSessionEstablished(const QString& verifiedFingerprint);
44+
void authenticatedSessionLost();
4345

4446
private:
4547
struct Pending {
@@ -77,6 +79,7 @@ class BrickSuiteWebSocketClient : public QObject
7779
bool m_explicitDisconnect = true;
7880
int m_reconnectAttempt = 0;
7981
QElapsedTimer m_connectTimer;
82+
bool m_authenticated = false;
8083
};
8184

8285
Q_DECLARE_METATYPE(BrickSuiteConnectionStatus)

src/network/RemoteSessionState.cpp

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#include "RemoteSessionState.h"
2+
3+
#include <QDebug>
4+
5+
namespace {
6+
QString normalizedFingerprint(const QString& value)
7+
{
8+
QString normalized;
9+
for (const QChar character : value) {
10+
const QChar upper = character.toUpper();
11+
if (character.isDigit() || (upper >= QLatin1Char('A') && upper <= QLatin1Char('F')))
12+
normalized.append(upper);
13+
}
14+
return normalized.size() == 64 ? normalized : QString();
15+
}
16+
}
17+
18+
RemoteSessionState::RemoteSessionState(QObject* parent) : QObject(parent) {}
19+
20+
QString RemoteSessionState::hostIdentity() const { return m_hostIdentity; }
21+
quint64 RemoteSessionState::sessionGeneration() const { return m_sessionGeneration; }
22+
quint64 RemoteSessionState::workspaceGeneration() const { return m_workspaceGeneration; }
23+
int RemoteSessionState::workspaceId() const { return m_workspaceId; }
24+
bool RemoteSessionState::isAuthenticated() const { return m_authenticated; }
25+
RemoteSessionState::DataState RemoteSessionState::dataState() const { return m_dataState; }
26+
27+
RemoteSessionState::Snapshot RemoteSessionState::snapshot() const
28+
{
29+
return {m_hostIdentity, m_sessionGeneration, m_workspaceGeneration, m_workspaceId};
30+
}
31+
32+
bool RemoteSessionState::accepts(const Snapshot& value) const
33+
{
34+
return m_authenticated && value.hostIdentity == m_hostIdentity
35+
&& value.sessionGeneration == m_sessionGeneration
36+
&& value.workspaceGeneration == m_workspaceGeneration
37+
&& value.workspaceId == m_workspaceId;
38+
}
39+
40+
void RemoteSessionState::authenticated(const QString& verifiedFingerprint)
41+
{
42+
const QString identity = normalizedFingerprint(verifiedFingerprint);
43+
if (identity.isEmpty()) return;
44+
45+
const bool hadSession = m_hadAuthenticatedSession;
46+
const bool sameHost = !m_hostIdentity.isEmpty() && identity == m_hostIdentity;
47+
const bool changedHost = !m_hostIdentity.isEmpty() && !sameHost;
48+
++m_sessionGeneration;
49+
m_authenticated = true;
50+
m_hadAuthenticatedSession = true;
51+
52+
if (changedHost) {
53+
m_hostIdentity = identity;
54+
advanceWorkspaceGeneration(0);
55+
setDataState(DataState::NeverLoaded);
56+
emit hostIdentityChanged();
57+
emit operationalStateMustClear();
58+
} else {
59+
m_hostIdentity = identity;
60+
}
61+
62+
qDebug().noquote() << "Remote session generation" << m_sessionGeneration
63+
<< (sameHost ? "restored for Host" : "established for Host")
64+
<< m_hostIdentity.left(12);
65+
66+
emit authenticatedSessionEstablished(sameHost);
67+
if (hadSession && sameHost) {
68+
emit sameHostSessionRestored();
69+
emit operationalStateShouldRefresh();
70+
}
71+
}
72+
73+
void RemoteSessionState::disconnected()
74+
{
75+
if (!m_authenticated) return;
76+
++m_sessionGeneration;
77+
m_authenticated = false;
78+
setDataState(m_hostIdentity.isEmpty() ? DataState::NeverLoaded
79+
: DataState::StaleDisconnected);
80+
qDebug() << "Remote session lost; generation" << m_sessionGeneration;
81+
emit authenticatedSessionLost();
82+
}
83+
84+
void RemoteSessionState::setWorkspaceId(int workspaceId)
85+
{
86+
workspaceId = qMax(0, workspaceId);
87+
if (workspaceId == m_workspaceId) return;
88+
advanceWorkspaceGeneration(workspaceId);
89+
}
90+
91+
void RemoteSessionState::markCurrent()
92+
{
93+
if (m_authenticated) setDataState(DataState::Current);
94+
}
95+
96+
void RemoteSessionState::setDataState(DataState state)
97+
{
98+
if (m_dataState == state) return;
99+
m_dataState = state;
100+
emit dataStateChanged(state);
101+
}
102+
103+
void RemoteSessionState::advanceWorkspaceGeneration(int workspaceId)
104+
{
105+
m_workspaceId = workspaceId;
106+
++m_workspaceGeneration;
107+
qDebug() << "Remote Workspace generation" << m_workspaceGeneration
108+
<< "Workspace" << m_workspaceId;
109+
emit workspaceGenerationChanged(m_workspaceGeneration, m_workspaceId);
110+
}

src/network/RemoteSessionState.h

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#pragma once
2+
3+
#include <QObject>
4+
#include <QString>
5+
6+
class RemoteSessionState : public QObject
7+
{
8+
Q_OBJECT
9+
public:
10+
enum class DataState { NeverLoaded, Current, StaleDisconnected };
11+
Q_ENUM(DataState)
12+
13+
struct Snapshot {
14+
QString hostIdentity;
15+
quint64 sessionGeneration = 0;
16+
quint64 workspaceGeneration = 0;
17+
int workspaceId = 0;
18+
};
19+
20+
explicit RemoteSessionState(QObject* parent = nullptr);
21+
22+
QString hostIdentity() const;
23+
quint64 sessionGeneration() const;
24+
quint64 workspaceGeneration() const;
25+
int workspaceId() const;
26+
bool isAuthenticated() const;
27+
DataState dataState() const;
28+
Snapshot snapshot() const;
29+
bool accepts(const Snapshot& snapshot) const;
30+
31+
public slots:
32+
void authenticated(const QString& verifiedFingerprint);
33+
void disconnected();
34+
void setWorkspaceId(int workspaceId);
35+
void markCurrent();
36+
37+
signals:
38+
void authenticatedSessionEstablished(bool sameHost);
39+
void sameHostSessionRestored();
40+
void authenticatedSessionLost();
41+
void hostIdentityChanged();
42+
void workspaceGenerationChanged(quint64 generation, int workspaceId);
43+
void dataStateChanged(RemoteSessionState::DataState state);
44+
void operationalStateMustClear();
45+
void operationalStateShouldRefresh();
46+
47+
private:
48+
void setDataState(DataState state);
49+
void advanceWorkspaceGeneration(int workspaceId);
50+
51+
QString m_hostIdentity;
52+
quint64 m_sessionGeneration = 0;
53+
quint64 m_workspaceGeneration = 0;
54+
int m_workspaceId = 0;
55+
bool m_authenticated = false;
56+
bool m_hadAuthenticatedSession = false;
57+
DataState m_dataState = DataState::NeverLoaded;
58+
};

0 commit comments

Comments
 (0)