@bakapiano/ccsm 0.17.11 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/devices.js +215 -0
- package/lib/tunnel.js +253 -0
- package/package.json +1 -1
- package/public/css/layout.css +7 -0
- package/public/css/responsive.css +123 -3
- package/public/css/terminals.css +15 -1
- package/public/css/wco.css +14 -13
- package/public/css/widgets.css +276 -2
- package/public/js/api.js +43 -2
- package/public/js/backend.js +66 -10
- package/public/js/components/App.js +38 -2
- package/public/js/components/HealthOverlay.js +12 -0
- package/public/js/components/MobileNavFab.js +29 -0
- package/public/js/components/PendingApprovalOverlay.js +86 -0
- package/public/js/components/Sidebar.js +13 -4
- package/public/js/components/TerminalView.js +19 -3
- package/public/js/icons.js +24 -0
- package/public/js/main.js +94 -11
- package/public/js/pages/RemotePage.js +446 -0
- package/public/js/state.js +10 -0
- package/scripts/dev.js +11 -0
- package/server.js +214 -8
package/public/css/wco.css
CHANGED
|
@@ -124,7 +124,8 @@ body.is-app:not(.is-wco) .session-pane {
|
|
|
124
124
|
title-bar border. Give a little breathing room. Sessions has its
|
|
125
125
|
own full-bleed terminal pane; About has its own hero header. */
|
|
126
126
|
body.is-app:not(.is-wco) [data-panel="configure"],
|
|
127
|
-
body.is-app:not(.is-wco) [data-panel="launch"]
|
|
127
|
+
body.is-app:not(.is-wco) [data-panel="launch"],
|
|
128
|
+
body.is-app:not(.is-wco) [data-panel="remote"] {
|
|
128
129
|
padding-top: var(--s-4);
|
|
129
130
|
}
|
|
130
131
|
/* Sidebar nav rows (New Session / Settings) also need a top gap in
|
|
@@ -149,22 +150,22 @@ body.is-wco .page-title-bar {
|
|
|
149
150
|
}
|
|
150
151
|
body.is-wco .page-title-bar,
|
|
151
152
|
body.is-wco .sidebar-top {
|
|
152
|
-
/*
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
height: calc(
|
|
160
|
-
min-height: calc(
|
|
161
|
-
max-height: calc(
|
|
153
|
+
/* --titlebar-h is set from JS reading
|
|
154
|
+
navigator.windowControlsOverlay.getTitlebarAreaRect().height — the
|
|
155
|
+
OS-reported strip the overlay reserves for us. CSS env() and a 32px
|
|
156
|
+
baseline are layered fallbacks when the JS API is unavailable. No
|
|
157
|
+
min-floor on the JS path: the OS knows its own caption height best,
|
|
158
|
+
and over-padding (the previous max(40px, …)) made the chrome look
|
|
159
|
+
visibly chunkier than the rest of the window. */
|
|
160
|
+
height: calc(var(--titlebar-h, env(titlebar-area-height, 32px)) * var(--anti-zoom, 1));
|
|
161
|
+
min-height: calc(var(--titlebar-h, env(titlebar-area-height, 32px)) * var(--anti-zoom, 1));
|
|
162
|
+
max-height: calc(var(--titlebar-h, env(titlebar-area-height, 32px)) * var(--anti-zoom, 1));
|
|
162
163
|
}
|
|
163
164
|
body.is-wco .sidebar-brand,
|
|
164
165
|
body.is-wco .sidebar-brand-button,
|
|
165
166
|
body.is-wco .collapse-toggle {
|
|
166
|
-
height:
|
|
167
|
-
min-height:
|
|
167
|
+
height: var(--titlebar-h, env(titlebar-area-height, 32px));
|
|
168
|
+
min-height: var(--titlebar-h, env(titlebar-area-height, 32px));
|
|
168
169
|
}
|
|
169
170
|
/* terminals.css uses the .tab-panel's gap (s-4) plus a -s-4 margin-top on
|
|
170
171
|
.session-tabs to close that gap, so the tab strip visually flushes
|
package/public/css/widgets.css
CHANGED
|
@@ -749,8 +749,15 @@
|
|
|
749
749
|
display: flex;
|
|
750
750
|
flex-direction: column;
|
|
751
751
|
gap: var(--s-4);
|
|
752
|
-
|
|
753
|
-
|
|
752
|
+
/* Negative right margin = -var(--s-4) cancels .main's padding-right
|
|
753
|
+
so the scroll container — and therefore the scrollbar — reach the
|
|
754
|
+
full window edge. Padding-right = var(--s-4) preserves the same
|
|
755
|
+
visual gap between content and the right edge that was there
|
|
756
|
+
before. Top padding bumped to var(--s-3) so the first section title
|
|
757
|
+
has visible breathing room from the page-title-bar separator above
|
|
758
|
+
it (4px was almost flush). Negative margin-top stays in sync. */
|
|
759
|
+
padding: var(--s-4) var(--s-4) var(--s-4) 4px;
|
|
760
|
+
margin: -4px calc(-1 * var(--s-4)) 0 -4px;
|
|
754
761
|
}
|
|
755
762
|
/* In a flex column container, items default to flex-shrink:1 which
|
|
756
763
|
causes the cards to compress instead of pushing the scroll container
|
|
@@ -1626,3 +1633,270 @@
|
|
|
1626
1633
|
background: var(--bg);
|
|
1627
1634
|
border: 1px solid var(--border);
|
|
1628
1635
|
}
|
|
1636
|
+
|
|
1637
|
+
/* ── Remote page ──────────────────────────────────────────────────
|
|
1638
|
+
Uses the existing .settings-scroll + Section + .config-grid + .field
|
|
1639
|
+
+ .chip system from ConfigurePage. Only adds the bits that don't
|
|
1640
|
+
already exist: the inline status line per provider, the token-row
|
|
1641
|
+
input + action cluster, the URL row, the CLI log block, and the
|
|
1642
|
+
bulleted security list. */
|
|
1643
|
+
|
|
1644
|
+
.remote-status-line {
|
|
1645
|
+
display: flex;
|
|
1646
|
+
align-items: center;
|
|
1647
|
+
flex-wrap: wrap;
|
|
1648
|
+
gap: var(--s-2);
|
|
1649
|
+
font-size: 12.5px;
|
|
1650
|
+
color: var(--ink-mid);
|
|
1651
|
+
}
|
|
1652
|
+
.remote-status-line .small-mono { font-size: 11px; }
|
|
1653
|
+
.remote-status-line .warn { color: #b86a2a; font-weight: 500; }
|
|
1654
|
+
.remote-status-line .muted { color: var(--ink-muted); }
|
|
1655
|
+
.remote-status-line code {
|
|
1656
|
+
font-family: var(--mono);
|
|
1657
|
+
font-size: 11.5px;
|
|
1658
|
+
background: var(--bg);
|
|
1659
|
+
padding: 1px 5px;
|
|
1660
|
+
border-radius: 3px;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
.remote-token-row {
|
|
1664
|
+
display: flex;
|
|
1665
|
+
gap: var(--s-2);
|
|
1666
|
+
align-items: center;
|
|
1667
|
+
flex-wrap: wrap;
|
|
1668
|
+
}
|
|
1669
|
+
.remote-token-input {
|
|
1670
|
+
flex: 1;
|
|
1671
|
+
min-width: 240px;
|
|
1672
|
+
font-family: var(--mono);
|
|
1673
|
+
font-size: 12.5px;
|
|
1674
|
+
padding: 7px 11px;
|
|
1675
|
+
border: 1px solid var(--border-strong);
|
|
1676
|
+
border-radius: var(--r-sm);
|
|
1677
|
+
background: var(--bg-elev);
|
|
1678
|
+
color: var(--ink);
|
|
1679
|
+
}
|
|
1680
|
+
.remote-token-input:focus {
|
|
1681
|
+
outline: none;
|
|
1682
|
+
border-color: var(--ink);
|
|
1683
|
+
box-shadow: 0 0 0 1px var(--ink);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
.remote-url-line {
|
|
1687
|
+
display: flex;
|
|
1688
|
+
gap: var(--s-2);
|
|
1689
|
+
align-items: center;
|
|
1690
|
+
flex-wrap: wrap;
|
|
1691
|
+
}
|
|
1692
|
+
.remote-url-value {
|
|
1693
|
+
flex: 1;
|
|
1694
|
+
min-width: 0;
|
|
1695
|
+
font-family: var(--mono);
|
|
1696
|
+
font-size: 12px;
|
|
1697
|
+
color: var(--ink);
|
|
1698
|
+
background: var(--bg);
|
|
1699
|
+
padding: 6px 10px;
|
|
1700
|
+
border-radius: 4px;
|
|
1701
|
+
border: 1px solid var(--border);
|
|
1702
|
+
overflow: hidden;
|
|
1703
|
+
text-overflow: ellipsis;
|
|
1704
|
+
white-space: nowrap;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
.remote-log {
|
|
1708
|
+
font-size: 11.5px;
|
|
1709
|
+
color: var(--ink-mid);
|
|
1710
|
+
}
|
|
1711
|
+
.remote-log summary { cursor: pointer; user-select: none; }
|
|
1712
|
+
.remote-log pre {
|
|
1713
|
+
margin-top: var(--s-2);
|
|
1714
|
+
padding: var(--s-3);
|
|
1715
|
+
background: var(--ink);
|
|
1716
|
+
color: var(--bg-elev);
|
|
1717
|
+
border-radius: var(--r-sm);
|
|
1718
|
+
font-family: var(--mono);
|
|
1719
|
+
font-size: 11px;
|
|
1720
|
+
line-height: 1.5;
|
|
1721
|
+
max-height: 220px;
|
|
1722
|
+
overflow: auto;
|
|
1723
|
+
white-space: pre-wrap;
|
|
1724
|
+
word-break: break-all;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
.remote-empty {
|
|
1728
|
+
margin: 0;
|
|
1729
|
+
font-size: 12.5px;
|
|
1730
|
+
color: var(--ink-muted);
|
|
1731
|
+
padding: var(--s-3);
|
|
1732
|
+
background: var(--bg);
|
|
1733
|
+
border-radius: var(--r-sm);
|
|
1734
|
+
text-align: center;
|
|
1735
|
+
}
|
|
1736
|
+
.remote-devices {
|
|
1737
|
+
display: flex;
|
|
1738
|
+
flex-direction: column;
|
|
1739
|
+
gap: var(--s-4);
|
|
1740
|
+
}
|
|
1741
|
+
.remote-devices-group {
|
|
1742
|
+
display: flex;
|
|
1743
|
+
flex-direction: column;
|
|
1744
|
+
gap: 6px;
|
|
1745
|
+
}
|
|
1746
|
+
.remote-devices-group-head {
|
|
1747
|
+
display: flex;
|
|
1748
|
+
align-items: baseline;
|
|
1749
|
+
gap: var(--s-2);
|
|
1750
|
+
margin-bottom: 2px;
|
|
1751
|
+
}
|
|
1752
|
+
.remote-devices-group-title {
|
|
1753
|
+
font-size: 11px;
|
|
1754
|
+
font-weight: 600;
|
|
1755
|
+
text-transform: uppercase;
|
|
1756
|
+
letter-spacing: 0.06em;
|
|
1757
|
+
color: var(--ink-mid);
|
|
1758
|
+
}
|
|
1759
|
+
.remote-devices-group-count {
|
|
1760
|
+
font-family: var(--mono);
|
|
1761
|
+
font-size: 11px;
|
|
1762
|
+
color: var(--ink-muted);
|
|
1763
|
+
background: var(--bg);
|
|
1764
|
+
padding: 1px 7px;
|
|
1765
|
+
border-radius: 999px;
|
|
1766
|
+
border: 1px solid var(--border);
|
|
1767
|
+
}
|
|
1768
|
+
.remote-devices-group-hint {
|
|
1769
|
+
font-size: 11px;
|
|
1770
|
+
font-style: italic;
|
|
1771
|
+
color: var(--ink-muted);
|
|
1772
|
+
}
|
|
1773
|
+
.remote-device {
|
|
1774
|
+
display: flex;
|
|
1775
|
+
align-items: center;
|
|
1776
|
+
gap: var(--s-3);
|
|
1777
|
+
padding: 10px 12px;
|
|
1778
|
+
background: var(--bg-elev);
|
|
1779
|
+
border: 1px solid var(--border);
|
|
1780
|
+
border-radius: var(--r-sm);
|
|
1781
|
+
}
|
|
1782
|
+
.remote-device.is-pending {
|
|
1783
|
+
border-color: #b86a2a;
|
|
1784
|
+
background: rgba(184, 106, 42, 0.04);
|
|
1785
|
+
}
|
|
1786
|
+
.remote-device.is-rejected {
|
|
1787
|
+
background: var(--bg);
|
|
1788
|
+
opacity: 0.8;
|
|
1789
|
+
}
|
|
1790
|
+
.remote-device-main {
|
|
1791
|
+
flex: 1;
|
|
1792
|
+
min-width: 0;
|
|
1793
|
+
}
|
|
1794
|
+
.remote-device-label {
|
|
1795
|
+
display: flex;
|
|
1796
|
+
align-items: center;
|
|
1797
|
+
gap: 6px;
|
|
1798
|
+
font-size: 13px;
|
|
1799
|
+
font-weight: 500;
|
|
1800
|
+
color: var(--ink);
|
|
1801
|
+
}
|
|
1802
|
+
.remote-device-label .icon-btn {
|
|
1803
|
+
background: transparent;
|
|
1804
|
+
border: 0;
|
|
1805
|
+
padding: 2px;
|
|
1806
|
+
cursor: pointer;
|
|
1807
|
+
color: var(--ink-muted);
|
|
1808
|
+
border-radius: 3px;
|
|
1809
|
+
display: inline-flex;
|
|
1810
|
+
align-items: center;
|
|
1811
|
+
}
|
|
1812
|
+
.remote-device-label .icon-btn:hover { color: var(--ink); background: var(--bg); }
|
|
1813
|
+
.remote-device-meta {
|
|
1814
|
+
font-size: 11.5px;
|
|
1815
|
+
color: var(--ink-mid);
|
|
1816
|
+
margin-top: 2px;
|
|
1817
|
+
display: flex;
|
|
1818
|
+
align-items: baseline;
|
|
1819
|
+
flex-wrap: wrap;
|
|
1820
|
+
gap: 4px;
|
|
1821
|
+
}
|
|
1822
|
+
.remote-device-meta .mono { font-family: var(--mono); font-size: 11px; }
|
|
1823
|
+
.remote-device-ua {
|
|
1824
|
+
font-family: var(--mono);
|
|
1825
|
+
font-size: 11px;
|
|
1826
|
+
color: var(--ink-muted);
|
|
1827
|
+
overflow: hidden;
|
|
1828
|
+
text-overflow: ellipsis;
|
|
1829
|
+
max-width: 380px;
|
|
1830
|
+
white-space: nowrap;
|
|
1831
|
+
}
|
|
1832
|
+
.remote-device-actions {
|
|
1833
|
+
display: flex;
|
|
1834
|
+
gap: 6px;
|
|
1835
|
+
flex-shrink: 0;
|
|
1836
|
+
}
|
|
1837
|
+
.remote-device-actions .action.small { padding: 4px 10px; font-size: 11.5px; }
|
|
1838
|
+
|
|
1839
|
+
/* Remote · "How access works" — three fact rows separated by hairlines,
|
|
1840
|
+
inset on the left by a 2px ink bar so the section reads as quiet
|
|
1841
|
+
reference material instead of an alert. */
|
|
1842
|
+
.remote-facts {
|
|
1843
|
+
margin: 0;
|
|
1844
|
+
padding: 0;
|
|
1845
|
+
display: flex;
|
|
1846
|
+
flex-direction: column;
|
|
1847
|
+
}
|
|
1848
|
+
.remote-fact {
|
|
1849
|
+
position: relative;
|
|
1850
|
+
padding: var(--s-3) var(--s-3) var(--s-3) var(--s-5);
|
|
1851
|
+
border-bottom: 1px solid var(--border);
|
|
1852
|
+
}
|
|
1853
|
+
.remote-fact:first-child { padding-top: var(--s-2); }
|
|
1854
|
+
.remote-fact:last-child { border-bottom: 0; padding-bottom: var(--s-2); }
|
|
1855
|
+
.remote-fact::before {
|
|
1856
|
+
content: "";
|
|
1857
|
+
position: absolute;
|
|
1858
|
+
left: 0;
|
|
1859
|
+
top: var(--s-3);
|
|
1860
|
+
bottom: var(--s-3);
|
|
1861
|
+
width: 2px;
|
|
1862
|
+
background: var(--ink);
|
|
1863
|
+
border-radius: 1px;
|
|
1864
|
+
opacity: 0.35;
|
|
1865
|
+
}
|
|
1866
|
+
.remote-fact dt {
|
|
1867
|
+
font-size: 12.5px;
|
|
1868
|
+
font-weight: 600;
|
|
1869
|
+
color: var(--ink);
|
|
1870
|
+
letter-spacing: -0.005em;
|
|
1871
|
+
margin-bottom: 4px;
|
|
1872
|
+
}
|
|
1873
|
+
.remote-fact dd {
|
|
1874
|
+
margin: 0;
|
|
1875
|
+
font-size: 12px;
|
|
1876
|
+
color: var(--ink-mid);
|
|
1877
|
+
line-height: 1.6;
|
|
1878
|
+
}
|
|
1879
|
+
.remote-fact dd code {
|
|
1880
|
+
font-family: var(--mono);
|
|
1881
|
+
font-size: 11px;
|
|
1882
|
+
background: var(--bg);
|
|
1883
|
+
padding: 1px 5px;
|
|
1884
|
+
border-radius: 3px;
|
|
1885
|
+
}
|
|
1886
|
+
.remote-fact dd strong {
|
|
1887
|
+
color: var(--ink);
|
|
1888
|
+
font-weight: 600;
|
|
1889
|
+
}
|
|
1890
|
+
.remote-fact dd em {
|
|
1891
|
+
font-style: italic;
|
|
1892
|
+
color: var(--ink);
|
|
1893
|
+
}
|
|
1894
|
+
.remote-fact-pill {
|
|
1895
|
+
display: inline-block;
|
|
1896
|
+
font-size: 11px;
|
|
1897
|
+
padding: 1px 7px;
|
|
1898
|
+
border-radius: 999px;
|
|
1899
|
+
background: rgba(184, 106, 42, 0.14);
|
|
1900
|
+
color: #8b4f1f;
|
|
1901
|
+
font-weight: 500;
|
|
1902
|
+
}
|
package/public/js/api.js
CHANGED
|
@@ -1,17 +1,58 @@
|
|
|
1
1
|
// Fetch wrapper + every loader. Loaders push into signals from ./state.js.
|
|
2
2
|
// Cross-origin (hosted frontend → local backend) flows through httpBase().
|
|
3
3
|
|
|
4
|
+
import { signal } from '@preact/signals';
|
|
4
5
|
import * as S from './state.js';
|
|
5
|
-
import { httpBase } from './backend.js';
|
|
6
|
+
import { httpBase, getToken, getDeviceId, isRemoteAccess } from './backend.js';
|
|
7
|
+
|
|
8
|
+
// Global pending-approval signal. Flipped to true whenever any /api
|
|
9
|
+
// call returns 403 {pending:true}; PendingApprovalOverlay watches this
|
|
10
|
+
// and shows the blocking screen. We also stash the server's record so
|
|
11
|
+
// the overlay can display "we recorded you at HH:MM" detail.
|
|
12
|
+
export const pendingDevice = signal(null);
|
|
6
13
|
|
|
7
14
|
export async function api(method, url, body) {
|
|
8
15
|
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
|
16
|
+
// When a remote token is configured (Remote page set it OR the page
|
|
17
|
+
// was loaded with ?token= and we stashed it in localStorage), attach
|
|
18
|
+
// it to every API call. The server middleware lets loopback Hosts
|
|
19
|
+
// through without the token; for tunnel-served pages this is the
|
|
20
|
+
// only way past the 401.
|
|
21
|
+
const tok = getToken();
|
|
22
|
+
if (tok) opts.headers['Authorization'] = `Bearer ${tok}`;
|
|
23
|
+
// Always send our device id when one exists in localStorage. The host
|
|
24
|
+
// browser at localhost doesn't strictly need it (loopback bypass),
|
|
25
|
+
// but harmless — the server simply records lastSeen for it. Required
|
|
26
|
+
// for any tunnel-served page to clear the device-approval gate.
|
|
27
|
+
const dev = getDeviceId();
|
|
28
|
+
if (dev) opts.headers['X-Device-Id'] = dev;
|
|
9
29
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
10
30
|
const r = await fetch(httpBase() + url, opts);
|
|
11
31
|
const text = await r.text();
|
|
12
32
|
let json;
|
|
13
33
|
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
|
|
14
|
-
if (!r.ok)
|
|
34
|
+
if (!r.ok) {
|
|
35
|
+
// Surface device-approval pending state. Only matters on remote
|
|
36
|
+
// tabs — host's loopback browser never gets a 401/403 from these
|
|
37
|
+
// checks.
|
|
38
|
+
if (isRemoteAccess()) {
|
|
39
|
+
if (r.status === 403 && json && (json.pending || json.rejected)) {
|
|
40
|
+
pendingDevice.value = { ...json, at: Date.now() };
|
|
41
|
+
} else if (r.status === 401) {
|
|
42
|
+
// Server doesn't recognise our device — either fresh page load
|
|
43
|
+
// (no /api/devices/me hit yet) or our record got deleted /
|
|
44
|
+
// pruned. Drop into the pending overlay; its /me poll will
|
|
45
|
+
// re-register us using the token we still have in localStorage,
|
|
46
|
+
// and the response sets pendingDevice to the correct state.
|
|
47
|
+
pendingDevice.value = { pending: true, at: Date.now() };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw new Error(json.error || `HTTP ${r.status}`);
|
|
51
|
+
}
|
|
52
|
+
// PendingApprovalOverlay clears pendingDevice itself based on the
|
|
53
|
+
// /api/devices/me body (which can return 200 with status:'pending'
|
|
54
|
+
// since that endpoint is gate-exempt). Doing an auto-clear here on
|
|
55
|
+
// any 2xx would race the overlay's poll and dismiss it prematurely.
|
|
15
56
|
return json;
|
|
16
57
|
}
|
|
17
58
|
|
package/public/js/backend.js
CHANGED
|
@@ -1,28 +1,84 @@
|
|
|
1
|
-
// One source of truth for "where is the ccsm backend reachable"
|
|
1
|
+
// One source of truth for "where is the ccsm backend reachable"
|
|
2
|
+
// and "what auth token (if any) do we attach to every request".
|
|
2
3
|
//
|
|
3
|
-
// localhost / 127.0.0.1
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// localhost / 127.0.0.1 same-origin (page IS the backend)
|
|
5
|
+
// bakapiano.github.io http://localhost:7777 (the hosted
|
|
6
|
+
// frontend talks to the user's local
|
|
7
|
+
// backend via CORS)
|
|
8
|
+
// anything else (tunnel domain) same-origin (the local backend is
|
|
9
|
+
// serving this frontend over the
|
|
10
|
+
// tunnel; API calls go to the same
|
|
11
|
+
// tunnel URL automatically)
|
|
6
12
|
//
|
|
7
13
|
// httpBase is used by fetch(); wsBase is used by WebSocket constructions.
|
|
8
14
|
// Keep both as functions rather than constants so the values reflect
|
|
9
15
|
// `location.*` at call time (matters for tests / route changes).
|
|
10
16
|
|
|
17
|
+
const HOSTED_HOST = 'bakapiano.github.io';
|
|
18
|
+
|
|
11
19
|
function isLocal() {
|
|
12
20
|
return location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
|
13
21
|
}
|
|
22
|
+
function isHosted() {
|
|
23
|
+
return location.hostname === HOSTED_HOST;
|
|
24
|
+
}
|
|
14
25
|
|
|
15
26
|
export function httpBase() {
|
|
16
|
-
|
|
27
|
+
if (isHosted()) return 'http://localhost:7777';
|
|
28
|
+
// Local OR tunnel-served — both same-origin.
|
|
29
|
+
return '';
|
|
17
30
|
}
|
|
18
31
|
|
|
19
32
|
export function wsBase() {
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
return 'ws://localhost:7777';
|
|
33
|
+
if (isHosted()) return 'ws://localhost:7777';
|
|
34
|
+
return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
export function isHostedFrontend() {
|
|
27
|
-
return
|
|
38
|
+
return isHosted();
|
|
39
|
+
}
|
|
40
|
+
// True when the page is being served via a remote tunnel — neither the
|
|
41
|
+
// host machine itself (localhost) nor the GH-Pages router. Used to gate
|
|
42
|
+
// off "wake backend" affordances that only work locally.
|
|
43
|
+
export function isRemoteAccess() {
|
|
44
|
+
return !isLocal() && !isHosted();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Remote-access bearer token ────────────────────────────────────
|
|
48
|
+
// Persisted in localStorage so it survives reloads on whatever device
|
|
49
|
+
// loaded the share URL. main.js captures a fresh token from `?token=`
|
|
50
|
+
// on first arrival and stashes it via setToken(), then strips the
|
|
51
|
+
// query string from the URL so the secret doesn't sit in the address
|
|
52
|
+
// bar / browser history.
|
|
53
|
+
const LS_KEY = 'ccsm.token';
|
|
54
|
+
|
|
55
|
+
export function getToken() {
|
|
56
|
+
try { return localStorage.getItem(LS_KEY) || null; } catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
export function setToken(t) {
|
|
59
|
+
try {
|
|
60
|
+
if (t) localStorage.setItem(LS_KEY, t);
|
|
61
|
+
else localStorage.removeItem(LS_KEY);
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Device id ─────────────────────────────────────────────────────
|
|
66
|
+
// Per-browser-profile UUID that identifies this device to the host
|
|
67
|
+
// machine for the approval flow. Generated once, persisted in
|
|
68
|
+
// localStorage, sent on every API call as X-Device-Id. The host pairs
|
|
69
|
+
// the id with the User-Agent the server records on first sight, so
|
|
70
|
+
// the approval UI can show "iPhone · Safari" instead of a raw uuid.
|
|
71
|
+
const LS_DEVICE = 'ccsm.deviceId';
|
|
72
|
+
|
|
73
|
+
export function getDeviceId() {
|
|
74
|
+
try {
|
|
75
|
+
let id = localStorage.getItem(LS_DEVICE);
|
|
76
|
+
if (!id) {
|
|
77
|
+
id = (crypto.randomUUID && crypto.randomUUID()) || (Math.random().toString(36).slice(2) + Date.now().toString(36));
|
|
78
|
+
localStorage.setItem(LS_DEVICE, id);
|
|
79
|
+
}
|
|
80
|
+
return id;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
28
84
|
}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { html } from '../html.js';
|
|
2
|
-
import { activeTab } from '../state.js';
|
|
2
|
+
import { activeTab, selectTab } from '../state.js';
|
|
3
|
+
import { useEffect } from 'preact/hooks';
|
|
4
|
+
import { isRemoteAccess } from '../backend.js';
|
|
5
|
+
import { PageTitleBar } from './PageTitleBar.js';
|
|
3
6
|
import { Sidebar } from './Sidebar.js';
|
|
4
7
|
import { Toast } from './Toast.js';
|
|
5
8
|
import { DialogHost } from './DialogHost.js';
|
|
6
9
|
import { HealthOverlay } from './HealthOverlay.js';
|
|
10
|
+
import { PendingApprovalOverlay } from './PendingApprovalOverlay.js';
|
|
11
|
+
import { MobileNavFab } from './MobileNavFab.js';
|
|
12
|
+
import { isMobile, mobileDrawerOpen } from '../state.js';
|
|
7
13
|
import { SessionsPage } from '../pages/SessionsPage.js';
|
|
8
14
|
import { LaunchPage } from '../pages/LaunchPage.js';
|
|
9
15
|
import { ConfigurePage } from '../pages/ConfigurePage.js';
|
|
16
|
+
import { RemotePage } from '../pages/RemotePage.js';
|
|
10
17
|
import { AboutPage } from '../pages/AboutPage.js';
|
|
11
18
|
|
|
12
19
|
function Panel({ name, children }) {
|
|
@@ -14,22 +21,51 @@ function Panel({ name, children }) {
|
|
|
14
21
|
return html`<section class="tab-panel" data-panel=${name} data-active=${active || null}>${children}</section>`;
|
|
15
22
|
}
|
|
16
23
|
|
|
24
|
+
// Static placeholder for #remote on tunnel-served pages. Remote / device
|
|
25
|
+
// / tunnel management is loopback-only — the server returns 403 on
|
|
26
|
+
// every relevant endpoint — so even if a user navigates here via URL
|
|
27
|
+
// hash we render a clear "host machine only" message instead of a
|
|
28
|
+
// broken RemotePage spamming the console.
|
|
29
|
+
function RemoteHostOnlyPanel() {
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
// Bounce back to whatever tab they were on before, after a brief
|
|
32
|
+
// moment so the message is readable.
|
|
33
|
+
const t = setTimeout(() => selectTab('sessions'), 2500);
|
|
34
|
+
return () => clearTimeout(t);
|
|
35
|
+
}, []);
|
|
36
|
+
return html`
|
|
37
|
+
<${PageTitleBar} title="Remote" />
|
|
38
|
+
<div class="settings-scroll">
|
|
39
|
+
<p class="remote-empty" style="margin-top:var(--s-6)">
|
|
40
|
+
Remote management is only available on the host machine.
|
|
41
|
+
Bouncing back to Sessions…
|
|
42
|
+
</p>
|
|
43
|
+
</div>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
17
46
|
export function App() {
|
|
18
47
|
const tab = activeTab.value;
|
|
48
|
+
const remoteLocked = tab === 'remote' && isRemoteAccess();
|
|
49
|
+
const mobile = isMobile.value;
|
|
50
|
+
const drawer = mobileDrawerOpen.value;
|
|
19
51
|
|
|
20
52
|
return html`
|
|
21
|
-
<div class
|
|
53
|
+
<div class=${`app${mobile ? ' is-mobile' : ''}${mobile && drawer ? ' drawer-open' : ''}`}>
|
|
22
54
|
<${Sidebar} />
|
|
23
55
|
<main class="main">
|
|
24
56
|
<div class="content">
|
|
25
57
|
${tab === 'sessions' ? html`<${Panel} name="sessions"><${SessionsPage} /></${Panel}>` : null}
|
|
26
58
|
${tab === 'launch' ? html`<${Panel} name="launch"><${LaunchPage} /></${Panel}>` : null}
|
|
27
59
|
${tab === 'configure' ? html`<${Panel} name="configure"><${ConfigurePage} /></${Panel}>` : null}
|
|
60
|
+
${tab === 'remote' && !remoteLocked ? html`<${Panel} name="remote"><${RemotePage} /></${Panel}>` : null}
|
|
61
|
+
${remoteLocked ? html`<${Panel} name="remote"><${RemoteHostOnlyPanel} /></${Panel}>` : null}
|
|
28
62
|
${tab === 'about' ? html`<${Panel} name="about"><${AboutPage} /></${Panel}>` : null}
|
|
29
63
|
</div>
|
|
30
64
|
</main>
|
|
31
65
|
<${Toast} />
|
|
32
66
|
<${DialogHost} />
|
|
33
67
|
<${HealthOverlay} />
|
|
68
|
+
<${PendingApprovalOverlay} />
|
|
69
|
+
<${MobileNavFab} />
|
|
34
70
|
</div>`;
|
|
35
71
|
}
|
|
@@ -21,6 +21,7 @@ import { useEffect } from 'preact/hooks';
|
|
|
21
21
|
import { serverHealth, hasBootedOnline } from '../state.js';
|
|
22
22
|
import { pollHealth, refreshAll } from '../api.js';
|
|
23
23
|
import { BrandMark } from '../icons.js';
|
|
24
|
+
import { isRemoteAccess } from '../backend.js';
|
|
24
25
|
|
|
25
26
|
const THRESHOLD = 3; // failures before we switch from "checking" to "not running"
|
|
26
27
|
const FAST_POLL_MS = 1500;
|
|
@@ -66,6 +67,17 @@ export function HealthOverlay() {
|
|
|
66
67
|
<p class="offline-copy">
|
|
67
68
|
${count === 0 ? 'Probing localhost:7777.' : `${count} attempt${count > 1 ? 's' : ''}. Hang tight.`}
|
|
68
69
|
</p>
|
|
70
|
+
` : isRemoteAccess() ? html`
|
|
71
|
+
<h1 class="offline-title">Host machine offline</h1>
|
|
72
|
+
<p class="offline-copy">
|
|
73
|
+
The ccsm backend you connected to over the tunnel isn't reachable.
|
|
74
|
+
Only the operator at the host machine can restart it — the tunnel
|
|
75
|
+
URL is dead until ccsm is running there again.
|
|
76
|
+
</p>
|
|
77
|
+
<p class="offline-copy" style="margin-top:8px;font-size:12px;color:var(--ink-muted)">
|
|
78
|
+
We'll keep polling and reconnect automatically as soon as the
|
|
79
|
+
backend comes back.
|
|
80
|
+
</p>
|
|
69
81
|
` : html`
|
|
70
82
|
<h1 class="offline-title">Backend not running</h1>
|
|
71
83
|
<p class="offline-copy">
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Phone-only navigation affordance.
|
|
2
|
+
//
|
|
3
|
+
// On viewports ≤ 640px the sidebar is hidden via CSS (.is-mobile body
|
|
4
|
+
// class). Instead, a circular floating button sits bottom-left; tapping
|
|
5
|
+
// it sets mobileDrawerOpen, which the sidebar reads to flip into a
|
|
6
|
+
// full-screen overlay. A backdrop captures taps outside the sidebar
|
|
7
|
+
// and dismisses.
|
|
8
|
+
//
|
|
9
|
+
// Visible only when isMobile signal is true — saves a render branch
|
|
10
|
+
// elsewhere.
|
|
11
|
+
|
|
12
|
+
import { html } from '../html.js';
|
|
13
|
+
import { isMobile, mobileDrawerOpen } from '../state.js';
|
|
14
|
+
import { IconSidebarToggle, IconClose } from '../icons.js';
|
|
15
|
+
|
|
16
|
+
export function MobileNavFab() {
|
|
17
|
+
if (!isMobile.value) return null;
|
|
18
|
+
const open = mobileDrawerOpen.value;
|
|
19
|
+
return html`
|
|
20
|
+
${open ? html`
|
|
21
|
+
<div class="mobile-nav-backdrop"
|
|
22
|
+
onClick=${() => { mobileDrawerOpen.value = false; }} />
|
|
23
|
+
` : null}
|
|
24
|
+
<button class=${`mobile-nav-fab${open ? ' is-open' : ''}`}
|
|
25
|
+
aria-label=${open ? 'close navigation' : 'open navigation'}
|
|
26
|
+
onClick=${() => { mobileDrawerOpen.value = !open; }}>
|
|
27
|
+
${open ? html`<${IconClose} />` : html`<${IconSidebarToggle} />`}
|
|
28
|
+
</button>`;
|
|
29
|
+
}
|