Show SSH heartbeat status on blank browser tabs
Propagate workspace remote state + heartbeat into browser panels, add heartbeat emission in remote session controller, extend docker bootstrap regression for heartbeat continuity after blank browser open, and scrub workspace/surface env vars in tagged reload launches.
This commit is contained in:
parent
803912a9e3
commit
0d3d71661d
5 changed files with 279 additions and 9 deletions
|
|
@ -10,6 +10,13 @@ struct BrowserProxyEndpoint: Equatable {
|
|||
let port: Int
|
||||
}
|
||||
|
||||
struct BrowserRemoteWorkspaceStatus: Equatable {
|
||||
let target: String
|
||||
let connectionState: WorkspaceRemoteConnectionState
|
||||
let heartbeatCount: Int
|
||||
let lastHeartbeatAt: Date?
|
||||
}
|
||||
|
||||
enum BrowserSearchEngine: String, CaseIterable, Identifiable {
|
||||
case google
|
||||
case duckduckgo
|
||||
|
|
@ -1305,6 +1312,9 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
/// Published loading state
|
||||
@Published private(set) var isLoading: Bool = false
|
||||
|
||||
/// Snapshot of remote SSH connection status for this panel's workspace.
|
||||
@Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus?
|
||||
|
||||
/// Published download state for browser downloads (navigation + context menu).
|
||||
@Published private(set) var isDownloading: Bool = false
|
||||
|
||||
|
|
@ -1513,6 +1523,11 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
applyRemoteProxyConfigurationIfAvailable()
|
||||
}
|
||||
|
||||
func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) {
|
||||
guard remoteWorkspaceStatus != status else { return }
|
||||
remoteWorkspaceStatus = status
|
||||
}
|
||||
|
||||
private func applyRemoteProxyConfigurationIfAvailable() {
|
||||
guard #available(macOS 14.0, *) else { return }
|
||||
|
||||
|
|
|
|||
|
|
@ -729,7 +729,15 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
})
|
||||
} else {
|
||||
ZStack(alignment: .topLeading) {
|
||||
Color(nsColor: browserChromeBackgroundColor)
|
||||
if let status = panel.remoteWorkspaceStatus {
|
||||
remoteWorkspaceBlankStateIndicator(status)
|
||||
.padding(.top, 12)
|
||||
.padding(.leading, 12)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onRequestPanelFocus()
|
||||
|
|
@ -742,6 +750,80 @@ struct BrowserPanelView: View {
|
|||
.zIndex(0)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func remoteWorkspaceBlankStateIndicator(_ status: BrowserRemoteWorkspaceStatus) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: remoteStatusSymbolName(status.connectionState))
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(remoteStatusColor(status.connectionState))
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("SSH \(status.target)")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Color.primary.opacity(0.92))
|
||||
.lineLimit(1)
|
||||
Text(remoteStatusDetailText(status))
|
||||
.font(.system(size: 10, weight: .regular))
|
||||
.foregroundStyle(Color.primary.opacity(0.72))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.85))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.stroke(Color.primary.opacity(0.12), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func remoteStatusSymbolName(_ state: WorkspaceRemoteConnectionState) -> String {
|
||||
switch state {
|
||||
case .connected:
|
||||
return "network"
|
||||
case .connecting:
|
||||
return "arrow.triangle.2.circlepath"
|
||||
case .error:
|
||||
return "exclamationmark.triangle.fill"
|
||||
case .disconnected:
|
||||
return "network.slash"
|
||||
}
|
||||
}
|
||||
|
||||
private func remoteStatusColor(_ state: WorkspaceRemoteConnectionState) -> Color {
|
||||
switch state {
|
||||
case .connected:
|
||||
return .green
|
||||
case .connecting:
|
||||
return .orange
|
||||
case .error:
|
||||
return .red
|
||||
case .disconnected:
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func remoteStatusDetailText(_ status: BrowserRemoteWorkspaceStatus) -> String {
|
||||
switch status.connectionState {
|
||||
case .connected:
|
||||
guard let lastHeartbeat = status.lastHeartbeatAt else {
|
||||
return "Connected, waiting for heartbeat"
|
||||
}
|
||||
let ageSeconds = max(0, Int(Date().timeIntervalSince(lastHeartbeat)))
|
||||
return "Connected, heartbeat #\(status.heartbeatCount) \(ageSeconds)s ago"
|
||||
case .connecting:
|
||||
return "Connecting..."
|
||||
case .error:
|
||||
return "Connection error"
|
||||
case .disconnected:
|
||||
return "Disconnected"
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerFocusFlashAnimation() {
|
||||
focusFlashAnimationGeneration &+= 1
|
||||
let generation = focusFlashAnimationGeneration
|
||||
|
|
|
|||
|
|
@ -1948,6 +1948,10 @@ private final class WorkspaceRemoteSessionController {
|
|||
private var daemonRemotePath: String?
|
||||
private var reconnectRetryCount = 0
|
||||
private var reconnectWorkItem: DispatchWorkItem?
|
||||
private var heartbeatWorkItem: DispatchWorkItem?
|
||||
private var heartbeatCount: Int = 0
|
||||
|
||||
private static let heartbeatInterval: TimeInterval = 3.0
|
||||
|
||||
init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, controllerID: UUID) {
|
||||
self.workspace = workspace
|
||||
|
|
@ -1979,6 +1983,7 @@ private final class WorkspaceRemoteSessionController {
|
|||
reconnectWorkItem?.cancel()
|
||||
reconnectWorkItem = nil
|
||||
reconnectRetryCount = 0
|
||||
stopHeartbeatLocked(reset: true)
|
||||
|
||||
proxyLease?.release()
|
||||
proxyLease = nil
|
||||
|
|
@ -1994,6 +1999,7 @@ private final class WorkspaceRemoteSessionController {
|
|||
guard !isStopping else { return }
|
||||
|
||||
reconnectWorkItem = nil
|
||||
stopHeartbeatLocked(reset: true)
|
||||
let connectDetail: String
|
||||
let bootstrapDetail: String
|
||||
if reconnectRetryCount > 0 {
|
||||
|
|
@ -2072,7 +2078,10 @@ private final class WorkspaceRemoteSessionController {
|
|||
reconnectWorkItem?.cancel()
|
||||
reconnectWorkItem = nil
|
||||
reconnectRetryCount = 0
|
||||
guard proxyEndpoint != endpoint else { return }
|
||||
guard proxyEndpoint != endpoint else {
|
||||
startHeartbeatLocked()
|
||||
return
|
||||
}
|
||||
proxyEndpoint = endpoint
|
||||
publishProxyEndpoint(endpoint)
|
||||
publishPortsSnapshotLocked()
|
||||
|
|
@ -2080,8 +2089,10 @@ private final class WorkspaceRemoteSessionController {
|
|||
.connected,
|
||||
detail: "Connected to \(configuration.displayTarget) via shared local proxy \(endpoint.host):\(endpoint.port)"
|
||||
)
|
||||
startHeartbeatLocked()
|
||||
case .error(let detail):
|
||||
proxyEndpoint = nil
|
||||
stopHeartbeatLocked(reset: false)
|
||||
publishProxyEndpoint(nil)
|
||||
publishPortsSnapshotLocked()
|
||||
publishState(.error, detail: "Remote proxy to \(configuration.displayTarget) unavailable: \(detail)")
|
||||
|
|
@ -2183,6 +2194,55 @@ private final class WorkspaceRemoteSessionController {
|
|||
}
|
||||
}
|
||||
|
||||
private func startHeartbeatLocked() {
|
||||
guard !isStopping else { return }
|
||||
guard daemonReady else { return }
|
||||
guard proxyLease != nil else { return }
|
||||
guard heartbeatWorkItem == nil else { return }
|
||||
|
||||
heartbeatCount += 1
|
||||
publishHeartbeat(count: heartbeatCount, at: Date())
|
||||
scheduleNextHeartbeatLocked()
|
||||
}
|
||||
|
||||
private func scheduleNextHeartbeatLocked() {
|
||||
guard !isStopping else { return }
|
||||
guard daemonReady else { return }
|
||||
guard proxyLease != nil else { return }
|
||||
|
||||
heartbeatWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
self.heartbeatWorkItem = nil
|
||||
guard !self.isStopping else { return }
|
||||
guard self.daemonReady else { return }
|
||||
guard self.proxyLease != nil else { return }
|
||||
self.heartbeatCount += 1
|
||||
self.publishHeartbeat(count: self.heartbeatCount, at: Date())
|
||||
self.scheduleNextHeartbeatLocked()
|
||||
}
|
||||
heartbeatWorkItem = workItem
|
||||
queue.asyncAfter(deadline: .now() + Self.heartbeatInterval, execute: workItem)
|
||||
}
|
||||
|
||||
private func stopHeartbeatLocked(reset: Bool) {
|
||||
heartbeatWorkItem?.cancel()
|
||||
heartbeatWorkItem = nil
|
||||
if reset {
|
||||
heartbeatCount = 0
|
||||
publishHeartbeat(count: 0, at: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func publishHeartbeat(count: Int, at date: Date?) {
|
||||
let controllerID = self.controllerID
|
||||
DispatchQueue.main.async { [weak workspace] in
|
||||
guard let workspace else { return }
|
||||
guard workspace.activeRemoteSessionControllerID == controllerID else { return }
|
||||
workspace.applyRemoteHeartbeatUpdate(count: count, lastSeenAt: date)
|
||||
}
|
||||
}
|
||||
|
||||
private func sshCommonArguments(batchMode: Bool) -> [String] {
|
||||
let effectiveSSHOptions: [String] = {
|
||||
if batchMode {
|
||||
|
|
@ -3160,6 +3220,8 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
@Published var remoteForwardedPorts: [Int] = []
|
||||
@Published var remotePortConflicts: [Int] = []
|
||||
@Published var remoteProxyEndpoint: BrowserProxyEndpoint?
|
||||
@Published var remoteHeartbeatCount: Int = 0
|
||||
@Published var remoteLastHeartbeatAt: Date?
|
||||
@Published var listeningPorts: [Int] = []
|
||||
var surfaceTTYNames: [UUID: String] = [:]
|
||||
private var remoteSessionController: WorkspaceRemoteSessionController?
|
||||
|
|
@ -3170,6 +3232,11 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
private static let remoteErrorStatusKey = "remote.error"
|
||||
private static let remotePortConflictStatusKey = "remote.port_conflicts"
|
||||
private static let remoteHeartbeatDateFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:]
|
||||
|
||||
private static func isProxyOnlyRemoteError(_ detail: String) -> Bool {
|
||||
|
|
@ -3478,6 +3545,25 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
panelSubscriptions[browserPanel.id] = subscription
|
||||
}
|
||||
|
||||
private func browserRemoteWorkspaceStatusSnapshot() -> BrowserRemoteWorkspaceStatus? {
|
||||
guard let target = remoteDisplayTarget else { return nil }
|
||||
return BrowserRemoteWorkspaceStatus(
|
||||
target: target,
|
||||
connectionState: remoteConnectionState,
|
||||
heartbeatCount: remoteHeartbeatCount,
|
||||
lastHeartbeatAt: remoteLastHeartbeatAt
|
||||
)
|
||||
}
|
||||
|
||||
private func applyBrowserRemoteWorkspaceStatusToPanels() {
|
||||
let snapshot = browserRemoteWorkspaceStatusSnapshot()
|
||||
for panel in panels.values {
|
||||
guard let browserPanel = panel as? BrowserPanel else { continue }
|
||||
browserPanel.setRemoteWorkspaceStatus(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Panel Access
|
||||
|
||||
func panel(for surfaceId: TabID) -> (any Panel)? {
|
||||
|
|
@ -3906,6 +3992,14 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
func remoteStatusPayload() -> [String: Any] {
|
||||
let heartbeatAgeSeconds: Any = {
|
||||
guard let last = remoteLastHeartbeatAt else { return NSNull() }
|
||||
return max(0, Date().timeIntervalSince(last))
|
||||
}()
|
||||
let heartbeatTimestamp: Any = {
|
||||
guard let last = remoteLastHeartbeatAt else { return NSNull() }
|
||||
return Self.remoteHeartbeatDateFormatter.string(from: last)
|
||||
}()
|
||||
var payload: [String: Any] = [
|
||||
"enabled": remoteConfiguration != nil,
|
||||
"state": remoteConnectionState.rawValue,
|
||||
|
|
@ -3915,6 +4009,11 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
"forwarded_ports": remoteForwardedPorts,
|
||||
"conflicted_ports": remotePortConflicts,
|
||||
"detail": remoteConnectionDetail ?? NSNull(),
|
||||
"heartbeat": [
|
||||
"count": remoteHeartbeatCount,
|
||||
"last_seen_at": heartbeatTimestamp,
|
||||
"age_seconds": heartbeatAgeSeconds,
|
||||
],
|
||||
]
|
||||
if let endpoint = remoteProxyEndpoint {
|
||||
payload["proxy"] = [
|
||||
|
|
@ -3965,6 +4064,8 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
remoteForwardedPorts = []
|
||||
remotePortConflicts = []
|
||||
remoteProxyEndpoint = nil
|
||||
remoteHeartbeatCount = 0
|
||||
remoteLastHeartbeatAt = nil
|
||||
remoteConnectionDetail = nil
|
||||
remoteDaemonStatus = WorkspaceRemoteDaemonStatus()
|
||||
statusEntries.removeValue(forKey: Self.remoteErrorStatusKey)
|
||||
|
|
@ -3979,13 +4080,16 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
remoteSessionController = nil
|
||||
previousController?.stop()
|
||||
applyRemoteProxyEndpointUpdate(nil)
|
||||
applyBrowserRemoteWorkspaceStatusToPanels()
|
||||
|
||||
guard autoConnect else {
|
||||
remoteConnectionState = .disconnected
|
||||
applyBrowserRemoteWorkspaceStatusToPanels()
|
||||
return
|
||||
}
|
||||
|
||||
remoteConnectionState = .connecting
|
||||
applyBrowserRemoteWorkspaceStatusToPanels()
|
||||
let controllerID = UUID()
|
||||
let controller = WorkspaceRemoteSessionController(
|
||||
workspace: self,
|
||||
|
|
@ -4011,6 +4115,8 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
remoteForwardedPorts = []
|
||||
remotePortConflicts = []
|
||||
remoteProxyEndpoint = nil
|
||||
remoteHeartbeatCount = 0
|
||||
remoteLastHeartbeatAt = nil
|
||||
remoteConnectionState = .disconnected
|
||||
remoteConnectionDetail = nil
|
||||
remoteDaemonStatus = WorkspaceRemoteDaemonStatus()
|
||||
|
|
@ -4023,6 +4129,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
remoteConfiguration = nil
|
||||
}
|
||||
applyRemoteProxyEndpointUpdate(nil)
|
||||
applyBrowserRemoteWorkspaceStatusToPanels()
|
||||
recomputeListeningPorts()
|
||||
}
|
||||
|
||||
|
|
@ -4037,6 +4144,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
) {
|
||||
remoteConnectionState = state
|
||||
remoteConnectionDetail = detail
|
||||
applyBrowserRemoteWorkspaceStatusToPanels()
|
||||
|
||||
let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if state == .error, let trimmedDetail, !trimmedDetail.isEmpty {
|
||||
|
|
@ -4080,6 +4188,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
fileprivate func applyRemoteDaemonStatusUpdate(_ status: WorkspaceRemoteDaemonStatus, target: String) {
|
||||
remoteDaemonStatus = status
|
||||
applyBrowserRemoteWorkspaceStatusToPanels()
|
||||
guard status.state == .error else {
|
||||
remoteLastDaemonErrorFingerprint = nil
|
||||
return
|
||||
|
|
@ -4101,6 +4210,13 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
guard let browserPanel = panel as? BrowserPanel else { continue }
|
||||
browserPanel.setRemoteProxyEndpoint(endpoint)
|
||||
}
|
||||
applyBrowserRemoteWorkspaceStatusToPanels()
|
||||
}
|
||||
|
||||
fileprivate func applyRemoteHeartbeatUpdate(count: Int, lastSeenAt: Date?) {
|
||||
remoteHeartbeatCount = max(0, count)
|
||||
remoteLastHeartbeatAt = lastSeenAt
|
||||
applyBrowserRemoteWorkspaceStatusToPanels()
|
||||
}
|
||||
|
||||
fileprivate func applyRemotePortsSnapshot(detected: [Int], forwarded: [Int], conflicts: [Int], target: String) {
|
||||
|
|
@ -4522,6 +4638,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
installBrowserPanelSubscription(browserPanel)
|
||||
browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot())
|
||||
|
||||
return browserPanel
|
||||
}
|
||||
|
|
@ -4580,6 +4697,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
installBrowserPanelSubscription(browserPanel)
|
||||
browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot())
|
||||
|
||||
return browserPanel
|
||||
}
|
||||
|
|
@ -5037,6 +5155,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
} else if let browserPanel = detached.panel as? BrowserPanel {
|
||||
browserPanel.updateWorkspaceId(id)
|
||||
browserPanel.setRemoteProxyEndpoint(remoteProxyEndpoint)
|
||||
browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot())
|
||||
installBrowserPanelSubscription(browserPanel)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -392,6 +392,8 @@ fi
|
|||
OPEN_CLEAN_ENV=(
|
||||
env
|
||||
-u CMUX_SOCKET_PATH
|
||||
-u CMUX_WORKSPACE_ID
|
||||
-u CMUX_SURFACE_ID
|
||||
-u CMUX_TAB_ID
|
||||
-u CMUX_PANEL_ID
|
||||
-u CMUXD_UNIX_PATH
|
||||
|
|
|
|||
|
|
@ -101,6 +101,29 @@ def _wait_for_remote_connected(client: cmux, workspace_id: str, timeout: float =
|
|||
raise cmuxError(f"Remote did not converge to connected/ready under slow login profile: {last_status}")
|
||||
|
||||
|
||||
def _heartbeat_count(status: dict) -> int:
|
||||
remote = status.get("remote") or {}
|
||||
heartbeat = remote.get("heartbeat") or {}
|
||||
raw = heartbeat.get("count")
|
||||
try:
|
||||
return int(raw or 0)
|
||||
except Exception: # noqa: BLE001
|
||||
return 0
|
||||
|
||||
|
||||
def _wait_for_heartbeat_advance(client: cmux, workspace_id: str, minimum_count: int, timeout: float = 20.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
last_status: dict = {}
|
||||
while time.time() < deadline:
|
||||
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
if _heartbeat_count(last_status) >= minimum_count:
|
||||
return last_status
|
||||
time.sleep(0.5)
|
||||
raise cmuxError(
|
||||
f"Remote heartbeat did not advance to >= {minimum_count} within {timeout:.1f}s: {last_status}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not _docker_available():
|
||||
print("SKIP: docker is not available")
|
||||
|
|
@ -181,6 +204,36 @@ chmod 0644 "$HOME/.profile"
|
|||
detail = str(remote.get("detail") or "").lower()
|
||||
_must("timed out" not in detail, f"remote detail should not report bootstrap timeout: {status}")
|
||||
|
||||
baseline_heartbeat = _heartbeat_count(status)
|
||||
status = _wait_for_heartbeat_advance(
|
||||
client,
|
||||
workspace_id,
|
||||
minimum_count=max(1, baseline_heartbeat + 1),
|
||||
timeout=15.0,
|
||||
)
|
||||
|
||||
opened = client._call("browser.open_split", {"workspace_id": workspace_id}) or {}
|
||||
browser_surface_id = str(opened.get("surface_id") or "")
|
||||
_must(bool(browser_surface_id), f"browser.open_split returned no surface_id: {opened}")
|
||||
|
||||
after_open_heartbeat = _heartbeat_count(status)
|
||||
status_after_blank_tab = _wait_for_heartbeat_advance(
|
||||
client,
|
||||
workspace_id,
|
||||
minimum_count=after_open_heartbeat + 2,
|
||||
timeout=20.0,
|
||||
)
|
||||
remote_after_blank_tab = status_after_blank_tab.get("remote") or {}
|
||||
_must(
|
||||
str(remote_after_blank_tab.get("state") or "") == "connected",
|
||||
f"remote should remain connected after blank browser open: {status_after_blank_tab}",
|
||||
)
|
||||
heartbeat_payload = remote_after_blank_tab.get("heartbeat") or {}
|
||||
_must(
|
||||
heartbeat_payload.get("last_seen_at") is not None,
|
||||
f"remote heartbeat should expose last_seen_at after bootstrap: {status_after_blank_tab}",
|
||||
)
|
||||
|
||||
try:
|
||||
client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
|
|
@ -203,4 +256,3 @@ chmod 0644 "$HOME/.profile"
|
|||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue