Skip to content

Commit f0cf155

Browse files
authoredMar 28, 2025··
feat: add stubbed file sync UI (#116)
Closes #66 Relates to #63 The UI differs a fair bit from the wireframes & figma designs in the interest of being able to use the stock SwiftUI Table view. The biggest difference is that a modal is used to insert new file syncs, as opposed to creating them inline. This was done as it's a lot harder to do that within a SwiftUI table. This design is also consistent with tables used in Apple's own settings pages, and the HTTP header table in app settings. https://github.com/user-attachments/assets/7c3d98b9-36c4-430b-ac6f-7064b6b8dc31 The UI is mostly non-functional, it still needs to be wired up over gRPC, including conversions from Mutagen data types. As a result, the file sync button on the menu will not appear unless the file sync feature flag is enabled in settings. Right now, the workspace dropdown menu is populated from the online agents (any row with a coloured dot on the menubar menu) There's no tests for this since ViewInspector still does not support Tables.
1 parent d95289b commit f0cf155

19 files changed

+415
-44
lines changed
 

Diff for: ‎Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift

+8-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ struct DesktopApp: App {
2323
.environmentObject(appDelegate.state)
2424
}
2525
.windowResizability(.contentSize)
26+
Window("Coder File Sync", id: Windows.fileSync.rawValue) {
27+
FileSyncConfig<CoderVPNService, MutagenDaemon>()
28+
.environmentObject(appDelegate.state)
29+
.environmentObject(appDelegate.fileSyncDaemon)
30+
.environmentObject(appDelegate.vpn)
31+
}
2632
}
2733
}
2834

@@ -61,9 +67,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6167
await self.state.handleTokenExpiry()
6268
}
6369
}, content: {
64-
VPNMenu<CoderVPNService>().frame(width: 256)
70+
VPNMenu<CoderVPNService, MutagenDaemon>().frame(width: 256)
6571
.environmentObject(self.vpn)
6672
.environmentObject(self.state)
73+
.environmentObject(self.fileSyncDaemon)
6774
}
6875
))
6976
// Subscribe to system VPN updates
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import VPNLib
2+
3+
@MainActor
4+
final class PreviewFileSync: FileSyncDaemon {
5+
var sessionState: [VPNLib.FileSyncSession] = []
6+
7+
var state: DaemonState = .running
8+
9+
init() {}
10+
11+
func refreshSessions() async {}
12+
13+
func start() async throws(DaemonError) {
14+
state = .running
15+
}
16+
17+
func stop() async {
18+
state = .stopped
19+
}
20+
21+
func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {}
22+
23+
func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
24+
}

Diff for: ‎Coder-Desktop/Coder-Desktop/VPN/MenuState.swift

+5-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22
import SwiftUI
33
import VPNLib
44

5-
struct Agent: Identifiable, Equatable, Comparable {
5+
struct Agent: Identifiable, Equatable, Comparable, Hashable {
66
let id: UUID
77
let name: String
88
let status: AgentStatus
@@ -135,6 +135,10 @@ struct VPNMenuState {
135135
return items.sorted()
136136
}
137137

138+
var onlineAgents: [Agent] {
139+
agents.map(\.value).filter { $0.primaryHost != nil }
140+
}
141+
138142
mutating func clear() {
139143
agents.removeAll()
140144
workspaces.removeAll()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import SwiftUI
2+
import VPNLib
3+
4+
struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
5+
@EnvironmentObject var vpn: VPN
6+
@EnvironmentObject var fileSync: FS
7+
8+
@State private var selection: FileSyncSession.ID?
9+
@State private var addingNewSession: Bool = false
10+
@State private var editingSession: FileSyncSession?
11+
12+
@State private var loading: Bool = false
13+
@State private var deleteError: DaemonError?
14+
15+
var body: some View {
16+
Group {
17+
Table(fileSync.sessionState, selection: $selection) {
18+
TableColumn("Local Path") {
19+
Text($0.alphaPath).help($0.alphaPath)
20+
}.width(min: 200, ideal: 240)
21+
TableColumn("Workspace", value: \.agentHost)
22+
.width(min: 100, ideal: 120)
23+
TableColumn("Remote Path", value: \.betaPath)
24+
.width(min: 100, ideal: 120)
25+
TableColumn("Status") { $0.status.body }
26+
.width(min: 80, ideal: 100)
27+
TableColumn("Size") { item in
28+
Text(item.size)
29+
}
30+
.width(min: 60, ideal: 80)
31+
}
32+
.contextMenu(forSelectionType: FileSyncSession.ID.self, menu: { _ in },
33+
primaryAction: { selectedSessions in
34+
if let session = selectedSessions.first {
35+
editingSession = fileSync.sessionState.first(where: { $0.id == session })
36+
}
37+
})
38+
.frame(minWidth: 400, minHeight: 200)
39+
.padding(.bottom, 25)
40+
.overlay(alignment: .bottom) {
41+
VStack(alignment: .leading, spacing: 0) {
42+
Divider()
43+
HStack(spacing: 0) {
44+
Button {
45+
addingNewSession = true
46+
} label: {
47+
Image(systemName: "plus")
48+
.frame(width: 24, height: 24)
49+
}.disabled(vpn.menuState.agents.isEmpty)
50+
Divider()
51+
Button {
52+
Task {
53+
loading = true
54+
defer { loading = false }
55+
do throws(DaemonError) {
56+
try await fileSync.deleteSessions(ids: [selection!])
57+
} catch {
58+
deleteError = error
59+
}
60+
await fileSync.refreshSessions()
61+
selection = nil
62+
}
63+
} label: {
64+
Image(systemName: "minus").frame(width: 24, height: 24)
65+
}.disabled(selection == nil)
66+
if let selection {
67+
if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) {
68+
Divider()
69+
Button {
70+
// TODO: Pause & Unpause
71+
} label: {
72+
switch selectedSession.status {
73+
case .paused:
74+
Image(systemName: "play").frame(width: 24, height: 24)
75+
default:
76+
Image(systemName: "pause").frame(width: 24, height: 24)
77+
}
78+
}
79+
}
80+
}
81+
}
82+
.buttonStyle(.borderless)
83+
}
84+
.background(.primary.opacity(0.04))
85+
.fixedSize(horizontal: false, vertical: true)
86+
}
87+
}.sheet(isPresented: $addingNewSession) {
88+
FileSyncSessionModal<VPN, FS>()
89+
.frame(width: 700)
90+
}.sheet(item: $editingSession) { session in
91+
FileSyncSessionModal<VPN, FS>(existingSession: session)
92+
.frame(width: 700)
93+
}.alert("Error", isPresented: Binding(
94+
get: { deleteError != nil },
95+
set: { isPresented in
96+
if !isPresented {
97+
deleteError = nil
98+
}
99+
}
100+
)) {} message: {
101+
Text(deleteError?.description ?? "An unknown error occurred.")
102+
}.task {
103+
while !Task.isCancelled {
104+
await fileSync.refreshSessions()
105+
try? await Task.sleep(for: .seconds(2))
106+
}
107+
}.disabled(loading)
108+
}
109+
}
110+
111+
#if DEBUG
112+
#Preview {
113+
FileSyncConfig<PreviewVPN, PreviewFileSync>()
114+
.environmentObject(AppState(persistent: false))
115+
.environmentObject(PreviewVPN())
116+
.environmentObject(PreviewFileSync())
117+
}
118+
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import SwiftUI
2+
import VPNLib
3+
4+
struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
5+
var existingSession: FileSyncSession?
6+
@Environment(\.dismiss) private var dismiss
7+
@EnvironmentObject private var vpn: VPN
8+
@EnvironmentObject private var fileSync: FS
9+
10+
@State private var localPath: String = ""
11+
@State private var workspace: Agent?
12+
@State private var remotePath: String = ""
13+
14+
@State private var loading: Bool = false
15+
@State private var createError: DaemonError?
16+
17+
var body: some View {
18+
let agents = vpn.menuState.onlineAgents
19+
VStack(spacing: 0) {
20+
Form {
21+
Section {
22+
HStack(spacing: 5) {
23+
TextField("Local Path", text: $localPath)
24+
Spacer()
25+
Button {
26+
let panel = NSOpenPanel()
27+
panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser
28+
panel.allowsMultipleSelection = false
29+
panel.canChooseDirectories = true
30+
panel.canChooseFiles = false
31+
if panel.runModal() == .OK {
32+
localPath = panel.url?.path(percentEncoded: false) ?? "<none>"
33+
}
34+
} label: {
35+
Image(systemName: "folder")
36+
}
37+
}
38+
}
39+
Section {
40+
Picker("Workspace", selection: $workspace) {
41+
ForEach(agents, id: \.id) { agent in
42+
Text(agent.primaryHost!).tag(agent)
43+
}
44+
// HACK: Silence error logs for no-selection.
45+
Divider().tag(nil as Agent?)
46+
}
47+
}
48+
Section {
49+
TextField("Remote Path", text: $remotePath)
50+
}
51+
}.formStyle(.grouped).scrollDisabled(true).padding(.horizontal)
52+
Divider()
53+
HStack {
54+
Spacer()
55+
Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
56+
Button(existingSession == nil ? "Add" : "Save") { Task { await submit() }}
57+
.keyboardShortcut(.defaultAction)
58+
}.padding(20)
59+
}.onAppear {
60+
if let existingSession {
61+
localPath = existingSession.alphaPath
62+
workspace = agents.first { $0.primaryHost == existingSession.agentHost }
63+
remotePath = existingSession.betaPath
64+
} else {
65+
// Set the picker to the first agent by default
66+
workspace = agents.first
67+
}
68+
}.disabled(loading)
69+
.alert("Error", isPresented: Binding(
70+
get: { createError != nil },
71+
set: { if $0 { createError = nil } }
72+
)) {} message: {
73+
Text(createError?.description ?? "An unknown error occurred.")
74+
}
75+
}
76+
77+
func submit() async {
78+
createError = nil
79+
guard let workspace else {
80+
return
81+
}
82+
loading = true
83+
defer { loading = false }
84+
do throws(DaemonError) {
85+
if let existingSession {
86+
// TODO: Support selecting & deleting multiple sessions at once
87+
try await fileSync.deleteSessions(ids: [existingSession.id])
88+
}
89+
try await fileSync.createSession(
90+
localPath: localPath,
91+
agentHost: workspace.primaryHost!,
92+
remotePath: remotePath
93+
)
94+
} catch {
95+
createError = error
96+
return
97+
}
98+
dismiss()
99+
}
100+
}

Diff for: ‎Coder-Desktop/Coder-Desktop/Views/LoginForm.swift

+2-4
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,8 @@ struct LoginForm: View {
4848
loginError = nil
4949
}
5050
}
51-
)) {
52-
Button("OK", role: .cancel) {}.keyboardShortcut(.defaultAction)
53-
} message: {
54-
Text(loginError?.description ?? "")
51+
)) {} message: {
52+
Text(loginError?.description ?? "An unknown error occurred.")
5553
}.disabled(loading)
5654
.frame(width: 550)
5755
.fixedSize()

Diff for: ‎Coder-Desktop/Coder-Desktop/Views/Settings/LiteralHeadersSection.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ struct LiteralHeadersSection<VPN: VPNService>: View {
1515
Toggle(isOn: $state.useLiteralHeaders) {
1616
Text("HTTP Headers")
1717
Text("When enabled, these headers will be included on all outgoing HTTP requests.")
18-
if vpn.state != .disabled { Text("Cannot be modified while Coder Connect is enabled.") }
18+
if !vpn.state.canBeStarted { Text("Cannot be modified while Coder Connect is enabled.") }
1919
}
2020
.controlSize(.large)
2121

@@ -65,7 +65,7 @@ struct LiteralHeadersSection<VPN: VPNService>: View {
6565
LiteralHeaderModal(existingHeader: header)
6666
}.onTapGesture {
6767
selectedHeader = nil
68-
}.disabled(vpn.state != .disabled)
68+
}.disabled(!vpn.state.canBeStarted)
6969
.onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector
7070
}
7171
}

Diff for: ‎Coder-Desktop/Coder-Desktop/Views/StatusDot.swift

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import SwiftUI
2+
3+
struct StatusDot: View {
4+
let color: Color
5+
6+
var body: some View {
7+
ZStack {
8+
Circle()
9+
.fill(color.opacity(0.4))
10+
.frame(width: 12, height: 12)
11+
Circle()
12+
.fill(color.opacity(1.0))
13+
.frame(width: 7, height: 7)
14+
}
15+
}
16+
}

Diff for: ‎Coder-Desktop/Coder-Desktop/Views/VPNMenu.swift renamed to ‎Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift

+23-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import SwiftUI
2+
import VPNLib
23

3-
struct VPNMenu<VPN: VPNService>: View {
4+
struct VPNMenu<VPN: VPNService, FS: FileSyncDaemon>: View {
45
@EnvironmentObject var vpn: VPN
6+
@EnvironmentObject var fileSync: FS
57
@EnvironmentObject var state: AppState
68
@Environment(\.openSettings) private var openSettings
79
@Environment(\.openWindow) private var openWindow
@@ -60,6 +62,24 @@ struct VPNMenu<VPN: VPNService>: View {
6062
}.buttonStyle(.plain)
6163
TrayDivider()
6264
}
65+
if vpn.state == .connected {
66+
Button {
67+
openWindow(id: .fileSync)
68+
} label: {
69+
ButtonRowView {
70+
HStack {
71+
// TODO: A future PR will provide users a way to recover from a daemon failure without
72+
// needing to restart the app
73+
if case .failed = fileSync.state, sessionsHaveError(fileSync.sessionState) {
74+
Image(systemName: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90")
75+
.frame(width: 12, height: 12).help("One or more sync sessions have errors")
76+
}
77+
Text("File sync")
78+
}
79+
}
80+
}.buttonStyle(.plain)
81+
TrayDivider()
82+
}
6383
if vpn.state == .failed(.systemExtensionError(.needsUserApproval)) {
6484
Button {
6585
openSystemExtensionSettings()
@@ -119,8 +139,9 @@ func openSystemExtensionSettings() {
119139
appState.login(baseAccessURL: URL(string: "http://127.0.0.1:8080")!, sessionToken: "")
120140
// appState.clearSession()
121141

122-
return VPNMenu<PreviewVPN>().frame(width: 256)
142+
return VPNMenu<PreviewVPN, PreviewFileSync>().frame(width: 256)
123143
.environmentObject(PreviewVPN())
124144
.environmentObject(appState)
145+
.environmentObject(PreviewFileSync())
125146
}
126147
#endif

Diff for: ‎Coder-Desktop/Coder-Desktop/Views/VPNMenuItem.swift renamed to ‎Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift

+1-8
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,7 @@ struct MenuItemView: View {
7070
HStack(spacing: 0) {
7171
Link(destination: wsURL) {
7272
HStack(spacing: Theme.Size.trayPadding) {
73-
ZStack {
74-
Circle()
75-
.fill(item.status.color.opacity(0.4))
76-
.frame(width: 12, height: 12)
77-
Circle()
78-
.fill(item.status.color.opacity(1.0))
79-
.frame(width: 7, height: 7)
80-
}
73+
StatusDot(color: item.status.color)
8174
Text(itemName).lineLimit(1).truncationMode(.tail)
8275
Spacer()
8376
}.padding(.horizontal, Theme.Size.trayPadding)

Diff for: ‎Coder-Desktop/Coder-Desktop/Windows.swift

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import SwiftUI
33
// Window IDs
44
enum Windows: String {
55
case login
6+
case fileSync
67
}
78

89
extension OpenWindowAction {

Diff for: ‎Coder-Desktop/Coder-DesktopTests/Util.swift

+24
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Combine
33
import NetworkExtension
44
import SwiftUI
55
import ViewInspector
6+
import VPNLib
67

78
@MainActor
89
class MockVPNService: VPNService, ObservableObject {
@@ -26,4 +27,27 @@ class MockVPNService: VPNService, ObservableObject {
2627
var startWhenReady: Bool = false
2728
}
2829

30+
@MainActor
31+
class MockFileSyncDaemon: FileSyncDaemon {
32+
var sessionState: [VPNLib.FileSyncSession] = []
33+
34+
func refreshSessions() async {}
35+
36+
func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
37+
38+
var state: VPNLib.DaemonState = .running
39+
40+
func start() async throws(VPNLib.DaemonError) {
41+
return
42+
}
43+
44+
func stop() async {}
45+
46+
func listSessions() async throws -> [VPNLib.FileSyncSession] {
47+
[]
48+
}
49+
50+
func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {}
51+
}
52+
2953
extension Inspection: @unchecked Sendable, @retroactive InspectionEmissary {}

Diff for: ‎Coder-Desktop/Coder-DesktopTests/VPNMenuTests.swift

+5-3
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@ import ViewInspector
77
@Suite(.timeLimit(.minutes(1)))
88
struct VPNMenuTests {
99
let vpn: MockVPNService
10+
let fsd: MockFileSyncDaemon
1011
let state: AppState
11-
let sut: VPNMenu<MockVPNService>
12+
let sut: VPNMenu<MockVPNService, MockFileSyncDaemon>
1213
let view: any View
1314

1415
init() {
1516
vpn = MockVPNService()
1617
state = AppState(persistent: false)
17-
sut = VPNMenu<MockVPNService>()
18-
view = sut.environmentObject(vpn).environmentObject(state)
18+
sut = VPNMenu<MockVPNService, MockFileSyncDaemon>()
19+
fsd = MockFileSyncDaemon()
20+
view = sut.environmentObject(vpn).environmentObject(state).environmentObject(fsd)
1921
}
2022

2123
@Test

Diff for: ‎Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift

+20-23
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,12 @@ import SwiftUI
99
@MainActor
1010
public protocol FileSyncDaemon: ObservableObject {
1111
var state: DaemonState { get }
12+
var sessionState: [FileSyncSession] { get }
1213
func start() async throws(DaemonError)
1314
func stop() async
14-
func listSessions() async throws -> [FileSyncSession]
15-
func createSession(with: FileSyncSession) async throws
16-
}
17-
18-
public struct FileSyncSession {
19-
public let id: String
20-
public let name: String
21-
public let localPath: URL
22-
public let workspace: String
23-
public let agent: String
24-
public let remotePath: URL
15+
func refreshSessions() async
16+
func createSession(localPath: String, agentHost: String, remotePath: String) async throws(DaemonError)
17+
func deleteSessions(ids: [String]) async throws(DaemonError)
2518
}
2619

2720
@MainActor
@@ -41,6 +34,8 @@ public class MutagenDaemon: FileSyncDaemon {
4134
}
4235
}
4336

37+
@Published public var sessionState: [FileSyncSession] = []
38+
4439
private var mutagenProcess: Subprocess?
4540
private let mutagenPath: URL!
4641
private let mutagenDataDirectory: URL
@@ -79,7 +74,7 @@ public class MutagenDaemon: FileSyncDaemon {
7974
state = .failed(error)
8075
return
8176
}
82-
await stopIfNoSessions()
77+
await refreshSessions()
8378
}
8479
}
8580

@@ -227,6 +222,7 @@ public class MutagenDaemon: FileSyncDaemon {
227222
let process = Subprocess([mutagenPath.path, "daemon", "run"])
228223
process.environment = [
229224
"MUTAGEN_DATA_DIRECTORY": mutagenDataDirectory.path,
225+
"MUTAGEN_SSH_PATH": "/usr/bin",
230226
]
231227
logger.info("setting mutagen data directory: \(self.mutagenDataDirectory.path, privacy: .public)")
232228
return process
@@ -256,27 +252,28 @@ public class MutagenDaemon: FileSyncDaemon {
256252
}
257253
}
258254

259-
public func listSessions() async throws -> [FileSyncSession] {
260-
guard case .running = state else {
261-
return []
262-
}
255+
public func refreshSessions() async {
256+
guard case .running = state else { return }
263257
// TODO: Implement
264-
return []
265258
}
266259

267-
public func createSession(with _: FileSyncSession) async throws {
260+
public func createSession(
261+
localPath _: String,
262+
agentHost _: String,
263+
remotePath _: String
264+
) async throws(DaemonError) {
268265
if case .stopped = state {
269266
do throws(DaemonError) {
270267
try await start()
271268
} catch {
272269
state = .failed(error)
273-
return
270+
throw error
274271
}
275272
}
276-
// TODO: Add Session
273+
// TODO: Add session
277274
}
278275

279-
public func deleteSession() async throws {
276+
public func deleteSessions(ids _: [String]) async throws(DaemonError) {
280277
// TODO: Delete session
281278
await stopIfNoSessions()
282279
}
@@ -346,7 +343,7 @@ public enum DaemonError: Error {
346343
case terminatedUnexpectedly
347344
case grpcFailure(Error)
348345

349-
var description: String {
346+
public var description: String {
350347
switch self {
351348
case let .daemonStartFailure(error):
352349
"Daemon start failure: \(error)"
@@ -361,5 +358,5 @@ public enum DaemonError: Error {
361358
}
362359
}
363360

364-
var localizedDescription: String { description }
361+
public var localizedDescription: String { description }
365362
}

Diff for: ‎Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import SwiftUI
2+
3+
public struct FileSyncSession: Identifiable {
4+
public let id: String
5+
public let alphaPath: String
6+
public let agentHost: String
7+
public let betaPath: String
8+
public let status: FileSyncStatus
9+
public let size: String
10+
}
11+
12+
public enum FileSyncStatus {
13+
case unknown
14+
case error(String)
15+
case ok
16+
case paused
17+
case needsAttention(String)
18+
case working(String)
19+
20+
public var color: Color {
21+
switch self {
22+
case .ok:
23+
.white
24+
case .paused:
25+
.secondary
26+
case .unknown:
27+
.red
28+
case .error:
29+
.red
30+
case .needsAttention:
31+
.orange
32+
case .working:
33+
.white
34+
}
35+
}
36+
37+
public var description: String {
38+
switch self {
39+
case .unknown:
40+
"Unknown"
41+
case let .error(msg):
42+
msg
43+
case .ok:
44+
"Watching"
45+
case .paused:
46+
"Paused"
47+
case let .needsAttention(msg):
48+
msg
49+
case let .working(msg):
50+
msg
51+
}
52+
}
53+
54+
public var body: some View {
55+
Text(description).foregroundColor(color)
56+
}
57+
}
58+
59+
public func sessionsHaveError(_ sessions: [FileSyncSession]) -> Bool {
60+
for session in sessions {
61+
if case .error = session.status {
62+
return true
63+
}
64+
}
65+
return false
66+
}

0 commit comments

Comments
 (0)
Please sign in to comment.