From 212eac56e0c8f60685c9f68bc46c6afbd1e1e6d6 Mon Sep 17 00:00:00 2001 From: huxc573 Date: Sat, 17 Oct 2026 14:53:38 +0800 Subject: [PATCH 1/3] ui: the batch of bugs from the first real walk-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop - 显示方式 and the right-click panel were the last children of the screen's column, so a full-size overlay claimed the rest of the height and both panes were squeezed to nothing. The overlays are drawn over the screen now, and the card hangs under its button instead of 112dp down. - 通告 is drawn over the window; as a column child it took a row of its own, so every notice pushed the screen up and left a band of empty surface. - A pane takes focus on click. The pointer observer ran after the rows had consumed their presses, so clicking a row never moved the focus and the panes could not be switched. - 收藏夹 is grouped by 本端 / 远端 and protocol, opens in the focused pane, can be deleted, and `+` adds an address rather than only the current view. - USB is real: `Adb` lists devices, `adb forward` maps the device's port onto this machine, and the ordinary handshake follows. The screen said 已留位 for four releases. - 地址 and 端口 are two fields; an empty port means the protocol default. `HOST:PORT` is a format, not a question to ask a person. - 取色: one list on both ends, first chip the mark's indigo called 默认, names under every chip, and the 回到品牌靛蓝 button — which set a third colour — is gone. Android - Back: panels first, then the screen, then 传输, then one folder up, and only then the activity. It used to leave the application from any folder. - 停止共享 read a plain getter, so the card never changed; it collects the controller now. - Breadcrumbs show every layer, each tappable, scrolling sideways and parked at the current folder. The old limit folded three of them into an ellipsis even with room to spare. - 分栏方向 compares the real width and height; it compared 380 against 560, two constants, so 自动 stacked the panes on a phone held sideways. - 紧凑 is one line with the size beside the name; it was 详细 with 8dp less. - ← stops at the exposed root: the phone registered no local roots, so the boundary was unknown and the pane walked from /storage/emulated/0 to an unreadable /. - 连接另一台设备 is a card on the screen with host and port. Connecting opens the remote pane on the first shared folder; with no peer the remote pane says so instead of showing this phone's disk; an unshared path lists what is shared. - 本机 is 本端 everywhere. The far end may be another computer, another disk, or a WebDAV or FTP mount later, and 本机 was already wrong on a phone. Root and Shizuku are still not implemented: both need a backend that speaks through `su` or Shizuku. Selecting them now probes and says so rather than pretending. --- CHANGELOG.md | 23 +++ .../io/github/cubsync/android/AndroidApp.kt | 15 +- .../io/github/cubsync/android/AndroidState.kt | 138 +++++++++++++++++- .../github/cubsync/android/ConnectScreen.kt | 34 ++++- .../github/cubsync/android/TransferScreen.kt | 137 +++++++++-------- .../cubsync/android/PhoneScreenshotTest.kt | 16 ++ .../io/github/cubsync/desktop/AppState.kt | 112 +++++++++++++- .../cubsync/desktop/ui/ConnectScreen.kt | 98 ++++++++----- .../cubsync/desktop/ui/SettingsScreen.kt | 45 +++--- .../io/github/cubsync/desktop/ui/Shell.kt | 70 +++++++-- .../cubsync/desktop/ui/TransferScreen.kt | 54 ++++--- .../io/github/cubsync/desktop/Preview.kt | 13 +- .../github/cubsync/desktop/ScreenshotTest.kt | 2 +- docs/releases/v0.3.13.md | 90 ++++++++++++ gradle.properties | 2 +- .../kotlin/io/github/cubsync/service/Adb.kt | 99 +++++++++++++ .../io/github/cubsync/service/AppSettings.kt | 39 +++-- .../io/github/cubsync/service/Browser.kt | 10 +- .../github/cubsync/service/BrowserSession.kt | 55 ++++++- .../io/github/cubsync/service/Sidebar.kt | 13 +- .../io/github/cubsync/service/SyncService.kt | 9 +- .../io/github/cubsync/service/UiSpec.kt | 24 ++- .../github/cubsync/service/AppSettingsTest.kt | 8 +- 23 files changed, 922 insertions(+), 184 deletions(-) create mode 100644 docs/releases/v0.3.13.md create mode 100644 service/src/main/kotlin/io/github/cubsync/service/Adb.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ce2929..3fdc1e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ with the pre-1.0 rules described in [docs/VERSIONING.md](docs/VERSIONING.md). ## [Unreleased] +## [0.3.13] - 2026-10-17 + +### Fixed + +- Desktop: 显示方式 / right-click overlays no longer eat the pane area; the card hangs under its + button and the five modes keep their marks. +- Desktop: notices are drawn over the window instead of taking a row of their own. +- Desktop: a pane takes focus when clicked, and 显示方式 acts on the focused pane. +- Desktop favourites: grouped by 本端 / 远端 and protocol, opened in the focused pane, deletable, + and `+` adds an address rather than only the current view. +- Desktop: USB works through `adb forward`; the address box is split into host and port with the + protocol's port as the default. +- Accent: one list on both ends, the first chip is the brand indigo called 默认; the broken + 「回到品牌靛蓝」 button is gone. +- Android: the system back button closes panels, leaves screens, returns to 传输, then goes up one + folder, and only then exits. +- Android: 停止共享 updates the card; breadcrumbs show every layer and scroll to the current one. +- Android: 分栏方向 uses the real screen size, so 自动 is two columns in landscape. +- Android: no root, ← stops at the exposed root instead of climbing to `/`. +- Android: 连接另一台设备 is a visible card with host and port; connecting opens the remote pane on + the first shared folder, and an unshared path lists what is shared instead of 读不了这个文件夹. +- 本机 → 本端 throughout: the far end may be another computer, another disk, or a WebDAV mount. + ## [0.3.12] - 2026-10-17 ### Fixed diff --git a/app/android/src/main/kotlin/io/github/cubsync/android/AndroidApp.kt b/app/android/src/main/kotlin/io/github/cubsync/android/AndroidApp.kt index afa0808..bb8abd3 100644 --- a/app/android/src/main/kotlin/io/github/cubsync/android/AndroidApp.kt +++ b/app/android/src/main/kotlin/io/github/cubsync/android/AndroidApp.kt @@ -1,5 +1,6 @@ package io.github.cubsync.android +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -55,6 +56,11 @@ import kotlinx.coroutines.delay fun CubAndroidApp(state: AndroidAppState) { val colors = CubTheme.colors val route = state.route + state.revision + + // Back undoes the last thing, and only leaves when there is nothing left to undo. + // Without this every screen exited the application, including a folder. + BackHandler(enabled = state.canGoBack) { state.handleBack() } Box(Modifier.fillMaxSize().background(colors.background)) { // The system bars sit over the app (MainActivity calls enableEdgeToEdge), so the @@ -341,11 +347,12 @@ private fun Drawer(state: AndroidAppState) { detail = item.path, leadingIcon = IconPaths.FOLDER, modifier = Modifier.padding(horizontal = 8.dp), - onClick = { - state.drawerOpen = false - state.go(Section.TRANSFER) - state.openPane(io.github.cubsync.service.Side.LEFT, item.path, item.backendId) + trailing = { + IconButton(IconPaths.CLOSE, size = 30, mark = 13) { + state.removeFavourite(item) + } }, + onClick = { state.openFavourite(item) }, ) } } diff --git a/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt b/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt index 7a5336a..834ba12 100644 --- a/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt +++ b/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt @@ -116,6 +116,8 @@ class AndroidAppState( var peers by mutableStateOf>(emptyList()) var peerError by mutableStateOf(null) var peerDraft by mutableStateOf("") + var peerHost by mutableStateOf("") + var peerPortText by mutableStateOf("") var connecting by mutableStateOf(false) var running by mutableStateOf(null) @@ -180,7 +182,11 @@ class AndroidAppState( usableRoot()?.let { service.settings.sharedRoots = listOf(it) } } rootDraft = service.settings.sharedRoots - rootDraft = service.settings.sharedRoots + + // Tell the backend what this phone can actually open. Without this the local + // backend exposes no roots, so 上一层 had no boundary to stop at and walked from + // /storage/emulated/0 up to / — three levels the process cannot read. + localRoots().forEach { service.browsePool.registerLocalRoot(it) } } fun close() { @@ -218,9 +224,20 @@ class AndroidAppState( * cannot race one the caller asked for. */ private fun openSomethingToLookAt() { - if (service.browsing.current(Side.LEFT).path.isNotBlank()) return - (rootDraft.firstOrNull() ?: usableRoot())?.let { - service.browsing.open(Side.LEFT, EndpointId.LOCAL, it) + val left = service.browsing.current(Side.LEFT) + if (left.path.isBlank() || left.backendId != EndpointId.LOCAL) { + (rootDraft.firstOrNull() ?: usableRoot())?.let { + service.browsing.open(Side.LEFT, EndpointId.LOCAL, it) + } + } + // The right pane is 远端. When a peer is connected it opens on that peer rather + // than on this phone's own disk, which is what made 远端栏 show "/" and then refuse + // a path from the other machine: it was still the local backend. + val right = service.browsing.current(Side.RIGHT) + if (right.backendId == EndpointId.LOCAL) { + peers.firstOrNull()?.let { peer -> + service.browsing.open(Side.RIGHT, peer.backendId, peer.roots.firstOrNull().orEmpty()) + } } } @@ -232,6 +249,52 @@ class AndroidAppState( route = Route.Tabs } + /** + * The system back button, in the order a person would undo things. + * + * Android's back used to leave the application from every screen, so a folder entered + * in 传输 closed CubSync instead of going back to the folder. Panels close, then the + * screen, then the section, then one folder up — and only when there is nothing left to + * undo does the system get the event and finish the activity. + */ + fun handleBack() { + when { + drawerOpen -> drawerOpen = false + moreSheet -> moreSheet = false + connectSheet -> connectSheet = false + sharedSheet -> sharedSheet = false + screenMenu -> screenMenu = false + paneMenu != null -> paneMenu = null + actionSheet != null -> actionSheet = null + route !is Route.Tabs -> route = Route.Tabs + tab != Section.TRANSFER -> go(Section.TRANSFER) + else -> { + val side = liveSettings.activeSide + service.browsing.up(side) + touch() + } + } + } + + /** Whether back has something to undo; when it does not, the application closes. */ + val canGoBack: Boolean + get() { + if (drawerOpen || moreSheet || connectSheet || sharedSheet || screenMenu || + paneMenu != null || actionSheet != null || route !is Route.Tabs || + tab != Section.TRANSFER + ) { + return true + } + val side = liveSettings.activeSide + val pane = service.browsing.current(side) + if (pane.path.isBlank()) return false + val roots = service.browsing.exposedRoots(pane.backendId) + val root = roots.filter { pane.path == it || pane.path.startsWith("$it/") } + .maxByOrNull { it.length } + // At the exposed root there is no parent to show, so back means "leave". + return root == null || pane.path != root + } + val selectedConfig: SyncConfig? get() = selectedConfigId?.let { id -> configs.firstOrNull { it.configId == id } } @@ -434,6 +497,20 @@ class AndroidAppState( fun drawer(): DrawerModel = service.drawer(serverState) + /** Opens a favourite in the focused pane, which is where the next tap would go. */ + fun openFavourite(favourite: Favorite) { + drawerOpen = false + go(Section.TRANSFER) + service.browsing.open(liveSettings.activeSide, favourite.backendId, favourite.path) + touch() + } + + fun removeFavourite(favourite: Favorite) { + service.settings.removeFavourite(favourite) + revision++ + notify("已取消收藏 ${favourite.path}", NoticeKind.INFO) + } + /** 收藏 a configuration — the star in the top bar of its screen. */ fun toggleConfigStar() { val config = selectedConfig ?: return @@ -612,6 +689,23 @@ class AndroidAppState( // ---------------------------------------------------------------------- peers + /** A host and a port typed apart; an empty port means the protocol default. */ + fun connectPeerAddress() { + val host = peerHost.trim() + if (host.isEmpty()) { + peerError = "先填地址" + return + } + val port = peerPortText.trim().let { + if (it.isEmpty()) io.github.cubsync.core.protocol.Protocol.DEFAULT_PORT else it.toIntOrNull() + } + if (port == null || port !in 1..65535) { + peerError = "端口要填 1–65535,留空就用默认 ${io.github.cubsync.core.protocol.Protocol.DEFAULT_PORT}" + return + } + connectPeer("$host:$port") + } + fun connectPeer(spec: String) { val text = spec.trim() if (text.isEmpty()) { @@ -631,7 +725,17 @@ class AndroidAppState( peers = peers.filterNot { it.backendId == session.backendId } + session service.settings.lastPeer = text revision++ - notify("已连接 ${session.nodeName}", NoticeKind.SUCCESS) + // A handshake that leaves both panes as they were is a handshake the person + // cannot see. The right pane becomes the peer and opens on the first folder + // it offers — the same thing the desktop does when it connects out. + val first = session.roots.firstOrNull() + service.browsing.open(Side.RIGHT, session.backendId, first.orEmpty()) + liveSettings.activeSide = Side.RIGHT + notify( + if (first == null) "已连接 ${session.nodeName},但对面没有共享文件夹" + else "已连接 ${session.nodeName} · ${first}", + if (first == null) NoticeKind.WARNING else NoticeKind.SUCCESS, + ) } catch (failure: Throwable) { peerError = failure.message ?: "连接失败" notify("连接失败:$peerError", NoticeKind.ERROR) @@ -700,16 +804,36 @@ class AndroidAppState( fun stopServer() { server.stop() + // The screen reads `serverState` off the controller, which is not Compose state; + // without this the button stayed 停止共享 and the address stayed on screen after + // the server had already stopped. + revision++ notify("已停止共享", NoticeKind.INFO) } fun setRunMode(mode: RunMode) { changeSettings { it.runMode = mode } - if (mode == RunMode.ROOT) { - notify("已切到 Root 模式:设备需要已获取 Root 权限,普通模式下这一档不会生效", NoticeKind.WARNING) + // Say what is true rather than what the label promises. The two privileged modes + // need a backend that speaks through `su` or Shizuku, and this build does not have + // one — so the setting is recorded, and the sentence beside it says so. + when (mode) { + RunMode.NORMAL -> notify("已切到普通模式", NoticeKind.INFO) + RunMode.ROOT -> notify( + if (suAvailable()) "已记录 Root 模式;本版引擎仍按应用权限读文件,su 只用于探测" + else "没有拿到 Root 权限:su 不可用或被拒绝", + NoticeKind.WARNING, + ) + RunMode.SHIZUKU -> notify("Shizuku 需要 Shizuku 应用与它的 API;本版还没有接入", NoticeKind.WARNING) } } + /** Whether `su` answers with uid 0. A probe, not a promise. */ + private fun suAvailable(): Boolean = runCatching { + val process = ProcessBuilder("su", "-c", "id").redirectErrorStream(true).start() + val output = process.inputStream.bufferedReader().readText() + process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS) && output.contains("uid=0") + }.getOrDefault(false) + // ------------------------------------------------------------ 传输通道 /** The cards 传输通道 offers, taken from the system rather than invented. */ diff --git a/app/android/src/main/kotlin/io/github/cubsync/android/ConnectScreen.kt b/app/android/src/main/kotlin/io/github/cubsync/android/ConnectScreen.kt index 7d0caba..1b119e5 100644 --- a/app/android/src/main/kotlin/io/github/cubsync/android/ConnectScreen.kt +++ b/app/android/src/main/kotlin/io/github/cubsync/android/ConnectScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -47,7 +48,9 @@ import io.github.cubsync.service.Words @Composable fun ConnectScreen(state: AndroidAppState) { val colors = CubTheme.colors - val server = state.serverState + // Collected, not read off the controller: `serverState` is a plain getter, so the card + // stayed 停止共享 after the server had already stopped. + val server by state.server.state.collectAsState() val settings = state.liveSettings var modeOpen by remember { mutableStateOf(false) } val copy = rememberClipboard() @@ -160,6 +163,35 @@ fun ConnectScreen(state: AndroidAppState) { } } + SectionLabel("连接另一台设备") + Panel(Modifier.fillMaxWidth().padding(horizontal = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Field( + value = state.peerHost, + onValueChange = { state.peerHost = it }, + modifier = Modifier.weight(2f), + placeholder = "地址,如 192.168.1.20", + onSubmit = { state.connectPeerAddress() }, + ) + Spacer(Modifier.width(6.dp)) + Field( + value = state.peerPortText, + onValueChange = { state.peerPortText = it }, + modifier = Modifier.weight(1f), + placeholder = "${settings.sharePort}", + onSubmit = { state.connectPeerAddress() }, + ) + } + Spacer(Modifier.height(8.dp)) + PrimaryButton(if (state.connecting) "连接中…" else "连接", enabled = !state.connecting) { + state.connectPeerAddress() + } + state.peerError?.let { + Text(it, style = CubType.rowDetail, color = colors.danger) + } + CardDesc("端口留空就用对面共享时显示的那个;连上后远端栏会打开它共享的第一个目录。") + } + // The prototype's 连接 screen stops after the two buttons and 最近. An // 账户与连接状态 card was added here; accounts are a thing the drawer already // lists, and a screen that says the same thing twice is a screen with one diff --git a/app/android/src/main/kotlin/io/github/cubsync/android/TransferScreen.kt b/app/android/src/main/kotlin/io/github/cubsync/android/TransferScreen.kt index c2bb1a8..39ce9cb 100644 --- a/app/android/src/main/kotlin/io/github/cubsync/android/TransferScreen.kt +++ b/app/android/src/main/kotlin/io/github/cubsync/android/TransferScreen.kt @@ -4,8 +4,12 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -54,6 +58,8 @@ import io.github.cubsync.service.Columns import io.github.cubsync.service.CompareMark import io.github.cubsync.service.Density import io.github.cubsync.service.DisplayMode +import io.github.cubsync.service.EndpointId +import io.github.cubsync.service.Section import io.github.cubsync.service.Format import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable @@ -101,6 +107,12 @@ fun TransferScreen(state: AndroidAppState) { else -> null } + // The real size of the screen, not a guess: 分栏方向 said 自动 while the code compared + // 380 against 560 — two numbers that never change, so a phone held sideways still + // stacked the panes. + BoxWithConstraints(Modifier.fillMaxSize()) { + val widthDp = maxWidth.value + val heightDp = maxHeight.value Column( Modifier .fillMaxSize() @@ -117,11 +129,7 @@ fun TransferScreen(state: AndroidAppState) { if (settings.displayBarOpen) DisplayBar(state) HorizontalDivider(color = colors.outline) - val paneDirection = resolveDirection( - settings.paneDirection, - 380f, - if (solo != null) 700f else 560f, - ) + val paneDirection = resolveDirection(settings.paneDirection, widthDp, heightDp) when { solo != null -> Pane(state, solo, if (solo == Side.LEFT) left else right, compare, Modifier.weight(1f)) @@ -143,6 +151,7 @@ fun TransferScreen(state: AndroidAppState) { // from the selection bar. The desktop keeps its two arrows; this end does not. ActionBar(state) } + } } // --------------------------------------------------------------------- sub row @@ -199,7 +208,8 @@ private fun SubRow(state: AndroidAppState, left: PaneState, right: PaneState, bu ) { CubIcon(viewIcon(prefs.displayMode), size = 16, tint = colors.text) Spacer(Modifier.width(7.dp)) - Text(prefs.displayMode.label, style = CubType.label, color = colors.text, maxLines = 1) + // The prototype's pill reads 显示方式:详细 — the words, not only the mode. + Text("显示方式 · ${prefs.displayMode.label}", style = CubType.label, color = colors.text, maxLines = 1) Spacer(Modifier.width(5.dp)) CubIcon( if (settings.displayBarOpen) IconPaths.CHEVRON_DOWN else IconPaths.CHEVRON_RIGHT, @@ -371,19 +381,31 @@ private fun Pane(state: AndroidAppState, side: Side, pane: PaneState, compare: B .background(if (active) colors.accent.copy(alpha = 0.05f) else Color.Transparent) // Tap anywhere in a pane to focus it: the sub-row's 显示方式 acts on the focused // pane, and a focus you cannot change by touching the pane is a focus that lies. - .pointerInput(side, active) { - awaitEachGesture { - awaitFirstDown(requireUnconsumed = false) - if (!active) { - settings.activeSide = side - state.touch() - } + // A clickable on the container rather than a pointer observer — the observer ran + // after the rows had consumed their presses, so rows never moved the focus. + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + if (settings.activeSide != side) { + settings.activeSide = side + state.touch() } }, ) { PaneHeader(state, side, pane, active) Box(Modifier.weight(1f).fillMaxWidth().itemScaleGestures(state)) { when { + // 远端 with no peer is not an empty folder: it is a pane waiting for a + // connection, and it used to show this phone's own disk instead — which is + // how a path from the other machine ended up being asked of the wrong side. + side == Side.RIGHT && pane.backendId == EndpointId.LOCAL && + pane.path.isBlank() && state.peers.isEmpty() -> + EmptyState( + "还没有连接远端", + "在「连接」里填对面显示的地址,或者让对面先连上这台机器", + action = { QuietButton("去连接") { state.go(Section.CONNECT) } }, + ) pane.error.isNotEmpty() -> EmptyState("读不了这个文件夹", pane.error) // 对比 draws the other side's rows as ghosts, so a pane that has nothing of // its own is not an empty folder — it is a folder where everything will be @@ -456,7 +478,11 @@ private fun PaneHeader(state: AndroidAppState, side: Side, pane: PaneState, acti ) } } else { - val crumbs = Browsing.crumbs(pane.path, maxSegments = if (expanded) 64 else if (lines > 1) 3 else 4) + // Every layer. The old limit folded three of them into one `⋯` even when + // the header had room, so what looked like "only the parent" was really + // "the parent and an ellipsis" — and a layer in the ellipsis cannot be + // tapped, which is half the navigation the design promises. + val crumbs = Browsing.crumbs(pane.path) Box( Modifier .weight(1f) @@ -469,58 +495,36 @@ private fun PaneHeader(state: AndroidAppState, side: Side, pane: PaneState, acti }, ) } - // A sideways drag scrolls the address instead of jumping: a swipe - // that lands on a layer was the way this used to navigate by accident. - .pointerInput(pane.path) { - detectHorizontalDragGestures { change, _ -> change.consume() } - } .padding(horizontal = 2.dp, vertical = 4.dp), ) { Column { - if (lines > 1 && crumbs.size > 1) { - // N-1 lines of ancestors, each one a way back up, then the - // folder itself in the reading colour. - for (crumb in crumbs.dropLast(1).takeLast(lines - 1)) { - Text( - tailPath(crumb.path), - style = CubType.meta, - color = colors.textDim, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .clickableCustom { state.openPane(side, crumb.path, pane.backendId) }, - ) + run { + // Scrolls rather than folds: a narrow two-pane header keeps + // every layer reachable, and a wide one shows them all. + val crumbScroll = rememberScrollState() + // Parked at the right end: the folder being shown is the last + // crumb, and a row that starts at the anchor cuts it off. + androidx.compose.runtime.LaunchedEffect(pane.path, crumbScroll.maxValue) { + crumbScroll.scrollTo(crumbScroll.maxValue) } - Text( - crumbs.last().label, - style = CubType.meta.copy(fontSize = 11.5.sp), - color = colors.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.clickableCustom { - state.openPane(side, crumbs.last().path, pane.backendId) - }, - ) - } else { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + Modifier + .fillMaxWidth() + .horizontalScroll(crumbScroll), + verticalAlignment = Alignment.CenterVertically, + ) { for ((index, crumb) in crumbs.withIndex()) { if (index > 0) Text(" › ", style = CubType.meta, color = colors.textFaint) Text( crumb.label, style = CubType.meta.copy(fontSize = 11.5.sp), - color = when { - crumb.collapsed -> colors.accent - index == crumbs.lastIndex -> colors.text - else -> colors.textDim - }, + color = if (index == crumbs.lastIndex) colors.text else colors.textDim, maxLines = 1, - overflow = TextOverflow.Ellipsis, + softWrap = false, modifier = Modifier .clip(RoundedCornerShape(4.dp)) .clickableCustom { - if (crumb.collapsed) expanded = true - else state.openPane(side, crumb.path, pane.backendId) + state.openPane(side, crumb.path, pane.backendId) } .padding(horizontal = 1.dp), ) @@ -622,21 +626,38 @@ private fun PaneList(state: AndroidAppState, side: Side, pane: PaneState, scale: ) } Spacer(Modifier.width((10 * scale).dp)) - Column(Modifier.weight(1f)) { + if (mode == DisplayMode.COMPACT) { + // 紧凑 is one line: the size sits beside the name instead of under it. + // Otherwise it was the same screen as 详细 with a 8dp difference, which + // is what "every mode looks the same" means. Text( Browsing.name(entry.relPath), style = CubType.body.copy(fontSize = fontSize), color = if (entry.isDirectory) colors.text else colors.textDim, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) - if (settings.showMetadata) { + if (settings.showMetadata && !entry.isDirectory) { + Text(Format.bytes(entry.size), style = CubType.meta, color = colors.textFaint, maxLines = 1) + } + } else { + Column(Modifier.weight(1f)) { Text( - if (entry.isDirectory) DASH else "${Format.bytes(entry.size)} · ${Format.stamp(entry.mtimeMs)}", - style = CubType.meta, - color = colors.textFaint, + Browsing.name(entry.relPath), + style = CubType.body.copy(fontSize = fontSize), + color = if (entry.isDirectory) colors.text else colors.textDim, maxLines = 1, + overflow = TextOverflow.Ellipsis, ) + if (settings.showMetadata) { + Text( + if (entry.isDirectory) DASH else "${Format.bytes(entry.size)} · ${Format.stamp(entry.mtimeMs)}", + style = CubType.meta, + color = colors.textFaint, + maxLines = 1, + ) + } } } } diff --git a/app/android/src/test/kotlin/io/github/cubsync/android/PhoneScreenshotTest.kt b/app/android/src/test/kotlin/io/github/cubsync/android/PhoneScreenshotTest.kt index e62bf9e..b026c01 100644 --- a/app/android/src/test/kotlin/io/github/cubsync/android/PhoneScreenshotTest.kt +++ b/app/android/src/test/kotlin/io/github/cubsync/android/PhoneScreenshotTest.kt @@ -116,6 +116,22 @@ class PhoneScreenshotTest { fixture.state.setDisplayMode(Side.RIGHT, io.github.cubsync.service.DisplayMode.CARD) shot("13-transfer-card", fixture) + // 传输 · 紧凑 / 缩略:五种显示方式必须长得不一样 + fixture.state.setDisplayMode(Side.LEFT, io.github.cubsync.service.DisplayMode.COMPACT) + fixture.state.setDisplayMode(Side.RIGHT, io.github.cubsync.service.DisplayMode.COMPACT) + shot("15-transfer-compact", fixture) + + fixture.state.setDisplayMode(Side.LEFT, io.github.cubsync.service.DisplayMode.THUMBNAIL) + fixture.state.setDisplayMode(Side.RIGHT, io.github.cubsync.service.DisplayMode.THUMBNAIL) + shot("16-transfer-thumb", fixture) + + // 副行「显示方式」展开条 + fixture.state.setDisplayMode(Side.LEFT, io.github.cubsync.service.DisplayMode.DETAIL) + fixture.state.setDisplayMode(Side.RIGHT, io.github.cubsync.service.DisplayMode.DETAIL) + fixture.state.liveSettings.displayBarOpen = true + shot("17-transfer-viewbar", fixture) + fixture.state.liveSettings.displayBarOpen = false + // 副行 ⋮ 的「更多」 fixture.state.setDisplayMode(Side.LEFT, io.github.cubsync.service.DisplayMode.DETAIL) fixture.state.setDisplayMode(Side.RIGHT, io.github.cubsync.service.DisplayMode.DETAIL) diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/AppState.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/AppState.kt index 6663e50..f24839b 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/AppState.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/AppState.kt @@ -37,6 +37,7 @@ import io.github.cubsync.service.Words import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext enum class NoticeKind { INFO, SUCCESS, WARNING, ERROR } @@ -112,9 +113,17 @@ class AppState( var peers by mutableStateOf>(emptyList()) var peerError by mutableStateOf(null) + + /** The address box's two halves, kept apart so a port is optional and a colon is not. */ + var peerHost by mutableStateOf("") + var peerPortText by mutableStateOf("") var peerDraft by mutableStateOf("") var connecting by mutableStateOf(false) + /** USB: what adb can see, and the last thing it said. */ + var adbDevices by mutableStateOf>(emptyList()) + var adbNote by mutableStateOf(null) + var running by mutableStateOf(null) var notice by mutableStateOf(null) @@ -163,6 +172,8 @@ class AppState( logs = if (next.size > LOG_LIMIT) next.takeLast(LOG_LIMIT) else next } peerDraft = service.settings.lastPeer + service.settings.lastPeer.substringBeforeLast(':').takeIf { it.isNotEmpty() }?.let { peerHost = it } + service.settings.lastPeer.substringAfterLast(':', "").toIntOrNull()?.let { peerPortText = it.toString() } // A server with nothing shared cannot be started, so the first run seeds the list // with the one folder every machine has. 添加文件夹 changes it afterwards; the // point is that 启动服务端 works before anybody has been taught where to click. @@ -387,11 +398,52 @@ class AppState( service.settings.toggleFavourite(favourite) settingsRevision++ notify( - if (service.settings.isFavourite(device, pane.path)) "已收藏 ${pane.path}" else "已取消收藏", + if (service.settings.isFavourite(pane.backendId, pane.path)) "已收藏 ${pane.path}" else "已取消收藏", NoticeKind.INFO, ) } + /** 收藏夹's `+`: a folder added by address, not only the one a pane happens to show. */ + var favouriteDraft by mutableStateOf(null) + + fun addFavourite(path: String) { + val clean = io.github.cubsync.service.PathTools.normalizeRoot(path.trim()) + if (clean.isEmpty()) return + val side = liveSettings.activeSide + val pane = service.browsing.current(side) + val backendId = if (pane.backendId == EndpointId.LOCAL) EndpointId.LOCAL else pane.backendId + val device = if (backendId == EndpointId.LOCAL) Words.LOCAL else peerFor(backendId)?.nodeName ?: Words.REMOTE + service.settings.toggleFavourite(Favorite(device, backendId, clean)) + // toggle means "add"; if it was already there it has just been removed, so put it + // back — pressing + on a folder that is already starred should not unstar it. + if (!service.settings.isFavourite(backendId, clean)) { + service.settings.toggleFavourite(Favorite(device, backendId, clean)) + } + settingsRevision++ + favouriteDraft = null + notify("已收藏 $clean", NoticeKind.SUCCESS) + } + + fun removeFavourite(favourite: Favorite) { + service.settings.removeFavourite(favourite) + settingsRevision++ + notify("已取消收藏 ${favourite.path}", NoticeKind.INFO) + } + + /** + * Opens a favourite in whichever pane is focused. + * + * It used to always open on the left, so a favourite of a folder on the phone landed in + * 本端栏 and failed. The focused pane is the one the next click would have acted on, + * which is where a person expects it to appear. + */ + fun openFavourite(favourite: Favorite) { + goTo(Section.TRANSFER) + val side = liveSettings.activeSide + service.browsing.open(side, favourite.backendId, favourite.path) + touchBrowsing() + } + fun applyPrefs(side: Side, scope: Scope, change: (io.github.cubsync.service.BrowsePrefs) -> io.github.cubsync.service.BrowsePrefs) { val prefs = service.browsing.prefsOf(side) service.browsing.applyPrefs(side, scope, change(prefs)) @@ -596,6 +648,64 @@ class AppState( // ---------------------------------------------------------------------- peers + /** Looks for devices on the other end of a cable. */ + fun refreshAdb() { + scope.launch(kotlinx.coroutines.Dispatchers.IO) { + val found = io.github.cubsync.service.Adb.devices() + withContext(kotlinx.coroutines.Dispatchers.Main) { + adbDevices = found + adbNote = when { + !io.github.cubsync.service.Adb.available() -> "没有找到 adb:装好 platform-tools 并加进 PATH" + found.isEmpty() -> "没有已连接的 USB 设备" + else -> null + } + } + } + } + + /** + * Connects over the cable: `adb forward` first, then the ordinary TCP handshake. + * + * The phone is already serving on its share port; the cable only changes the route, so + * after the forward this is the same code path as Wi-Fi and the rest of the screen does + * not need to know which one is in use. + */ + fun connectUsb(serial: String?, port: Int) { + val target = if (port <= 0) io.github.cubsync.core.protocol.Protocol.DEFAULT_PORT else port + connecting = true + peerError = null + scope.launch(kotlinx.coroutines.Dispatchers.IO) { + val failure = io.github.cubsync.service.Adb.forward(target, target, serial) + withContext(kotlinx.coroutines.Dispatchers.Main) { + connecting = false + if (failure != null) { + adbNote = failure + peerError = failure + } else { + adbNote = "已转发 tcp:$target → 设备 tcp:$target" + connectPeer("127.0.0.1:$target") + } + } + } + } + + /** The address box: a host, and a port that defaults to the protocol's. */ + fun connectPeerAddress() { + val host = peerHost.trim() + if (host.isEmpty()) { + peerError = "先填地址" + return + } + val port = peerPortText.trim().let { + if (it.isEmpty()) io.github.cubsync.core.protocol.Protocol.DEFAULT_PORT else it.toIntOrNull() + } + if (port == null || port !in 1..65535) { + peerError = "端口要填 1–65535,留空就用默认 ${io.github.cubsync.core.protocol.Protocol.DEFAULT_PORT}" + return + } + connectPeer("$host:$port") + } + fun connectPeer(spec: String) { val text = spec.trim() if (text.isEmpty()) { diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt index 6fb323d..bd28136 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt @@ -87,9 +87,13 @@ fun ConnectScreen(state: AppState, server: ServerState) { private fun LinkCard(state: AppState, modifier: Modifier) { val colors = CubTheme.colors var mode by remember { mutableStateOf(LinkMode.ADB) } - val devices = state.peers + val settings = state.liveSettings - Card(modifier, title = "连接方式", sub = "USB 需 adb 在 PATH") { + // Ask once, on the frame the card first appears, so the list is not empty until the + // person presses 刷新 for no reason. + androidx.compose.runtime.LaunchedEffect(Unit) { state.refreshAdb() } + + Card(modifier, title = "连接方式", sub = if (io.github.cubsync.service.Adb.available()) "adb 已就绪" else "USB 需要 adb 在 PATH") { Row( Modifier .clip(RoundedCornerShape(8.dp)) @@ -102,10 +106,7 @@ private fun LinkCard(state: AppState, modifier: Modifier) { Modifier .weight(1f) .background(if (active) colors.accent else Color.Transparent) - .clickable { - mode = option - state.notify(Channels.ADB_RESERVED, NoticeKind.INFO) - } + .clickable { mode = option } .padding(vertical = 7.dp), contentAlignment = Alignment.Center, ) { @@ -119,42 +120,65 @@ private fun LinkCard(state: AppState, modifier: Modifier) { } Spacer(Modifier.height(14.dp)) - FieldLabel("USB 设备") - Spacer(Modifier.height(6.dp)) - Box( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(colors.surface) - .border(1.dp, colors.outline, RoundedCornerShape(8.dp)) - .clickable { state.notify(Channels.ADB_RESERVED, NoticeKind.INFO) } - .padding(horizontal = 10.dp, vertical = 7.dp), - ) { + if (mode == LinkMode.ADB) { + Row(verticalAlignment = Alignment.CenterVertically) { + FieldLabel("USB 设备") + Spacer(Modifier.weight(1f)) + QuietButton("刷新") { state.refreshAdb() } + } + Spacer(Modifier.height(6.dp)) + val ready = state.adbDevices.filter { it.ready } + if (ready.isEmpty()) { + Text( + state.adbNote ?: "没有已连接的 USB 设备", + style = CubType.body, + color = colors.textFaint, + ) + } + for (device in ready) { + Row( + Modifier.fillMaxWidth().padding(vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(device.label, style = CubType.body, color = colors.text, modifier = Modifier.weight(1f)) + QuietButton(if (state.connecting) "连接中…" else "连接并协商") { + state.connectUsb(device.serial, settings.sharePort) + } + } + } + state.adbNote?.takeIf { ready.isNotEmpty() }?.let { + Spacer(Modifier.height(4.dp)) + Text(it, style = CubType.tiny, color = colors.textFaint) + } + Spacer(Modifier.height(8.dp)) Text( - if (devices.isEmpty()) "没有已连接的 USB 设备" else devices.joinToString(" · ") { it.nodeName }, - style = CubType.body, - color = if (devices.isEmpty()) colors.textFaint else colors.text, + "USB 走的是 adb 转发:把设备上的 ${settings.sharePort} 映射到本机同名端口,之后与局域网是同一条连接。", + style = CubType.tiny, + color = colors.textFaint, ) - } - - Spacer(Modifier.height(12.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - QuietButton("启动 ADB 转发 :5740", Modifier.weight(1f)) { - state.notify(Channels.ADB_RESERVED, NoticeKind.INFO) + } else { + FieldLabel("地址") + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Field( + value = state.peerHost, + onValueChange = { state.peerHost = it }, + modifier = Modifier.weight(2f), + placeholder = "192.168.1.20", + onSubmit = { state.connectPeerAddress() }, + ) + Field( + value = state.peerPortText, + onValueChange = { state.peerPortText = it }, + modifier = Modifier.weight(1f), + placeholder = "端口(默认 ${settings.sharePort})", + onSubmit = { state.connectPeerAddress() }, + ) } - PrimaryButton("连接并协商", Modifier.weight(1f)) { state.connectPeer(state.peerDraft) } + Spacer(Modifier.height(8.dp)) + PrimaryButton(if (state.connecting) "连接中…" else "连接并协商") { state.connectPeerAddress() } } - Spacer(Modifier.height(12.dp)) - FieldLabel("HOST:PORT") - Spacer(Modifier.height(6.dp)) - Field( - value = state.peerDraft, - onValueChange = { state.peerDraft = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = "192.168.1.20:43817", - onSubmit = { state.connectPeer(state.peerDraft) }, - ) state.peerError?.let { Spacer(Modifier.height(6.dp)) Text(it, style = CubType.tiny, color = colors.danger) diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/SettingsScreen.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/SettingsScreen.kt index 8e82f52..bbdff7f 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/SettingsScreen.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/SettingsScreen.kt @@ -192,30 +192,34 @@ private fun AppearancePanel(state: AppState) { ) } - SettingRow("取色", "强调色用在选中档、聚焦栏与进度上") { + SettingRow("取色", "强调色用在选中档、聚焦栏与进度上;第一个是默认") { Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { for (swatch in ACCENTS) { - Box( - Modifier - .size(24.dp) - .clip(CircleShape) - .background(parseHexColour(swatch)) - .border( - 2.dp, - if (settings.accent.equals(swatch, ignoreCase = true)) colors.text else Color.Transparent, - CircleShape, - ) - .clickable { state.changeSettings { it.accent = swatch } }, - ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + Modifier + .size(24.dp) + .clip(CircleShape) + .background(parseHexColour(swatch.hex)) + .border( + 2.dp, + if (settings.accent.equals(swatch.hex, ignoreCase = true)) colors.text else Color.Transparent, + CircleShape, + ) + .clickable { state.changeSettings { it.accent = swatch.hex } }, + ) + Spacer(Modifier.height(2.dp)) + // The phone's chips carry their names, so these do too — and the + // first one is called 默认 on both, because that is what it is. + Text(swatch.label, style = CubType.tiny, color = colors.textFaint) + } } - Spacer(Modifier.width(4.dp)) - QuietButton("回到品牌靛蓝") { state.changeSettings { it.accent = AppSettings.ACCENT_DEFAULT } } } } Spacer(Modifier.height(6.dp)) Text( - "品牌主色 ${AppSettings.ACCENT_DEFAULT},辅色 #1FA8A0。换成别的颜色是允许的,但标志和界面会开始不像一套。", + "默认 ${AppSettings.ACCENT_DEFAULT} 是标志的靛蓝;辅色 ${io.github.cubsync.service.Brand.ACCENT}。换成别的颜色是允许的,但标志和界面会开始不像一套。", style = CubType.meta, color = colors.textFaint, ) @@ -256,8 +260,13 @@ private fun AboutPanel(state: AppState) { } } -/** The accents offered. The first is the brand's; the rest are there so it can be changed. */ -private val ACCENTS = listOf("#3A5CCC", "#1FA8A0", "#7A5AF8", "#D9480F", "#2E9E5B") +/** + * The accents offered — the phone's list, verbatim. + * + * Two lists meant two first chips: the desktop began with `#3A5CCC` and called it 靛蓝, the + * phone began with `#4C6EF5`, and 回到品牌靛蓝 set a third. One list, one name, one colour. + */ +private val ACCENTS = io.github.cubsync.service.Accent.SWATCHES /** * 设置's `.chead .acts`. diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Shell.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Shell.kt index 067cc73..45e5fce 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Shell.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/Shell.kt @@ -71,6 +71,7 @@ fun CubApp(state: AppState, chrome: WindowChrome) { val server by state.server.state.collectAsState() var railFolded by remember { mutableStateOf(false) } + Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize().background(CubTheme.colors.surface)) { TitleBar(state, server, chrome) Row(Modifier.fillMaxWidth().weight(1f)) { @@ -90,7 +91,41 @@ fun CubApp(state: AppState, chrome: WindowChrome) { } } StatusBar(state, server) - NoticeBar(state) + } + // Drawn over the window rather than under the status bar. As a child of the column it + // took a row of its own, so every notice pushed the whole screen up and left a band of + // empty surface below it — the window resized itself to say one sentence. + NoticeBar(state) + + // 收藏夹's `+`: the folder a pane is showing is not always the one worth keeping, so + // adding a favourite takes an address rather than only the current view. + if (state.favouriteDraft != null) { + var text by remember(state.favouriteDraft) { mutableStateOf(state.favouriteDraft.orEmpty()) } + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)) + .clickable { state.favouriteDraft = null }, + contentAlignment = Alignment.Center, + ) { + FloatingCard(width = 380) { + FloatingHeader("添加收藏", onClose = { state.favouriteDraft = null }) + Block("地址") { + Field( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = "收藏在聚焦的那一栏里:本端路径或远端路径", + style = CubType.meta, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PrimaryButton("收藏") { state.addFavourite(text) } + QuietButton("取消") { state.favouriteDraft = null } + } + } + } + } } } @@ -233,36 +268,44 @@ private fun Rail(state: AppState, server: ServerState, folded: Boolean, onFold: verticalAlignment = Alignment.CenterVertically, ) { Text(Words.FAVOURITES, style = CubType.railLabel, color = colors.textFaint, modifier = Modifier.weight(1f)) - IconButton(IconPaths.PLUS, size = 28, mark = 13) { state.toggleFavourite(Side.LEFT) } + IconButton(IconPaths.PLUS, size = 28, mark = 13) { state.favouriteDraft = "" } } val favourites = state.service.settings.favourites Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { - for (group in favourites.groupBy { it.device }.toSortedMap()) { + // Grouped by end, not by insertion order: 本端 first, then one heading per + // peer and protocol. A list that mixes a folder on this disk with a folder + // on a phone is a list nobody can scan. + val groups = favourites.groupBy { it.group } + .toList() + .sortedWith(compareBy({ if (it.first == Words.LOCAL) 0 else 1 }, { it.first })) + for ((heading, items) in groups) { Text( - group.key, + heading, style = CubType.tiny, color = colors.textFaint, modifier = Modifier.padding(start = 10.dp, top = 6.dp, bottom = 3.dp), ) - for (favourite in group.value) { + for (favourite in items) { Row( Modifier .fillMaxWidth() + .padding(end = 4.dp) .clip(RoundedCornerShape(6.dp)) - .clickable { - state.goTo(Section.TRANSFER) - state.openPane(Side.LEFT, favourite.path, favourite.backendId) - } - .padding(start = 20.dp, end = 10.dp, top = 5.dp, bottom = 5.dp), + .clickable { state.openFavourite(favourite) } + .padding(start = 20.dp, end = 2.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( - favourite.path, - style = CubType.monoSmall, + favourite.label.ifEmpty { favourite.path }, + style = CubType.body, color = colors.textDim, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) + IconButton(IconPaths.CLOSE, size = 22, mark = 11) { + state.removeFavourite(favourite) + } } } } @@ -458,9 +501,10 @@ private fun NoticeBar(state: AppState) { val notice = state.notice ?: return val colors = CubTheme.colors val tone = colourFor(notice, colors) - Box(Modifier.fillMaxWidth().padding(bottom = 46.dp), contentAlignment = Alignment.Center) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { Row( Modifier + .padding(bottom = 40.dp) .clip(RoundedCornerShape(9.dp)) .background(colors.text) .clickable { state.dismissNotice() } diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/TransferScreen.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/TransferScreen.kt index 7ca155d..36d28f6 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/TransferScreen.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/TransferScreen.kt @@ -1,6 +1,9 @@ package io.github.cubsync.desktop.ui import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -102,6 +105,7 @@ fun TransferScreen(state: AppState) { val direction = resolveDirection(settings.paneDirection, 1000f, if (revision >= 0) 600f else 600f) val sideBySide = direction == PaneDirection.SIDE_BY_SIDE + Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { // The prototype's transfer screen has no sub-row and no standing display bar: the // three verbs are on `.chead .acts`, and 显示方式 opens a drop-down from there. Both @@ -124,6 +128,10 @@ fun TransferScreen(state: AppState) { } TransferBar(state) + } + // Over the screen, not in it: as the last child of the column the overlay claimed + // the rest of the height, so opening 显示方式 or a right-click panel squeezed both + // panes to nothing and the files appeared to vanish. Overlays(state) } } @@ -188,11 +196,16 @@ private fun Pane(state: AppState, side: Side, pane: PaneState, compare: Boolean, Column( modifier .fillMaxHeight() - .background(if (active) colors.accent.copy(alpha = 0.04f) else Color.Transparent) + .background(if (active) colors.accent.copy(alpha = 0.07f) else Color.Transparent) // Pressing anywhere in a pane makes it the active one, which is what the - // heading's 显示方式 button says it acts on — and the observer is on Press - // rather than on a click, so it does not consume the row that was clicked. - .onPointerEvent(PointerEventType.Press) { + // heading's 显示方式 button says it acts on. A clickable on the container rather + // than a raw pointer observer: the observer ran after the rows had consumed + // their presses, so clicking a row never moved the focus and the two panes + // could not be switched at all. + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { if (!active) { settings.activeSide = side state.touchBrowsing() @@ -275,12 +288,16 @@ private fun PaneHeader(state: AppState, side: Side, pane: PaneState, active: Boo private fun Breadcrumbs(state: AppState, side: Side, pane: PaneState) { val colors = CubTheme.colors if (pane.path.isEmpty()) return - val crumbs = Browsing.crumbs(pane.path, maxSegments = 5) - var expanded by remember(pane.path) { mutableStateOf(false) } - val shown = if (expanded) Browsing.crumbs(pane.path, maxSegments = 64) else crumbs + // Every layer, always. A folded `⋯` that still hides three of them reads as "the + // address has one parent", which is what it looked like when the default limit was + // five segments; a row that runs out of width scrolls instead. + val shown = Browsing.crumbs(pane.path) Row( - Modifier.fillMaxWidth().padding(start = 10.dp, end = 8.dp, bottom = 5.dp), + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(start = 10.dp, end = 8.dp, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically, ) { for ((index, crumb) in shown.withIndex()) { @@ -288,18 +305,12 @@ private fun Breadcrumbs(state: AppState, side: Side, pane: PaneState) { Text( crumb.label, style = CubType.meta.copy(fontSize = 11.sp), - color = when { - crumb.collapsed -> colors.accent - index == shown.lastIndex -> colors.text - else -> colors.textDim - }, + color = if (index == shown.lastIndex) colors.text else colors.textDim, maxLines = 1, - overflow = TextOverflow.Ellipsis, + softWrap = false, modifier = Modifier .clip(RoundedCornerShape(4.dp)) - .clickable { - if (crumb.collapsed) expanded = true else state.openPane(side, crumb.path, pane.backendId) - } + .clickable { state.openPane(side, crumb.path, pane.backendId) } .padding(horizontal = 2.dp), ) } @@ -812,9 +823,10 @@ private fun Overlays(state: AppState) { state.displayMenu = false } if (displayMenu) { - // Anchored where the prototype anchors it: under the button that opened it, at - // the right end of the heading — which is where that button is. - Box(Modifier.fillMaxSize().padding(top = 112.dp, end = 22.dp), contentAlignment = Alignment.TopEnd) { + // Under the button that opened it: the heading's 显示方式 sits at the top-right + // of the screen, so the card hangs from the same corner. It used to be pushed + // 112dp down for no reason a reader could see. + Box(Modifier.fillMaxSize().padding(top = 6.dp, end = 14.dp), contentAlignment = Alignment.TopEnd) { DisplayModeMenuCard(state) } } @@ -864,7 +876,7 @@ private fun DisplayModeMenuCard(state: AppState) { CubIcon( viewIcon(mode), size = 16, - tint = if (active) colors.accent else colors.textDim, + tint = if (active) colors.accent else colors.text, ) Spacer(Modifier.width(10.dp)) Text( diff --git a/app/desktop/src/test/kotlin/io/github/cubsync/desktop/Preview.kt b/app/desktop/src/test/kotlin/io/github/cubsync/desktop/Preview.kt index abbc38e..3a01232 100644 --- a/app/desktop/src/test/kotlin/io/github/cubsync/desktop/Preview.kt +++ b/app/desktop/src/test/kotlin/io/github/cubsync/desktop/Preview.kt @@ -78,6 +78,7 @@ object Preview { val dark: Boolean, val jobDetail: Boolean = false, val view: DisplayMode? = null, + val displayMenu: Boolean = false, ) val shots: List = listOf( @@ -91,6 +92,9 @@ object Preview { Shot("07-transfer-light", Section.TRANSFER, dark = false), Shot("08-transfer-compare", Section.TRANSFER, dark = true, view = DisplayMode.COMPARE), Shot("09-transfer-card", Section.TRANSFER, dark = false, view = DisplayMode.CARD), + Shot("10-transfer-viewmenu", Section.TRANSFER, dark = true, displayMenu = true), + Shot("11-transfer-compact", Section.TRANSFER, dark = true, view = DisplayMode.COMPACT), + Shot("12-transfer-thumb", Section.TRANSFER, dark = true, view = DisplayMode.THUMBNAIL), ) /** @@ -100,7 +104,13 @@ object Preview { * started by [AppState] has finished by the time the frame is composed. A screenshot * test that sleeps to wait for its own data is one that fails on a slow machine. */ - fun state(section: Section, dark: Boolean, jobDetail: Boolean = false, view: DisplayMode? = null): AppState { + fun state( + section: Section, + dark: Boolean, + jobDetail: Boolean = false, + view: DisplayMode? = null, + displayMenu: Boolean = false, + ): AppState { val directory = Files.createTempDirectory("cubsync-preview-") val log = AppLog() val desktop = DesktopSettings.inStateDirectory(directory) @@ -140,6 +150,7 @@ object Preview { state.setDisplayMode(Side.RIGHT, view) } state.browsing.select(Side.LEFT, listOf("IMG_20261016_0731.jpg", "IMG_20261016_0730.jpg")) + state.displayMenu = displayMenu return state } diff --git a/app/desktop/src/test/kotlin/io/github/cubsync/desktop/ScreenshotTest.kt b/app/desktop/src/test/kotlin/io/github/cubsync/desktop/ScreenshotTest.kt index 2ef76db..7b6bda3 100644 --- a/app/desktop/src/test/kotlin/io/github/cubsync/desktop/ScreenshotTest.kt +++ b/app/desktop/src/test/kotlin/io/github/cubsync/desktop/ScreenshotTest.kt @@ -32,7 +32,7 @@ class ScreenshotTest { val written = ArrayList() for (shot in Preview.shots) { - val state = Preview.state(shot.section, shot.dark, shot.jobDetail, shot.view) + val state = Preview.state(shot.section, shot.dark, shot.jobDetail, shot.view, shot.displayMenu) try { val scene = ImageComposeScene(width = WIDTH, height = HEIGHT, density = Density(1f)) { CubTheme(dark = shot.dark) { CubApp(state, Preview.chrome) } diff --git a/docs/releases/v0.3.13.md b/docs/releases/v0.3.13.md new file mode 100644 index 0000000..d29cdd7 --- /dev/null +++ b/docs/releases/v0.3.13.md @@ -0,0 +1,90 @@ +# CubSync 0.3.13 + +## 下载 + +- **Windows**:`CubSync-0.3.13.msi`(安装)或 `cubsync-0.3.13-windows.zip`(解压即用) +- **Android**:`cubsync-0.3.13-android.apk` + +## 这一版修好的 + +**桌面** + +- 打开「显示方式」或右键菜单时,两栏的文件不再消失。此前弹层占掉了整个内容区,看起来像文件被清空了。 +- 「显示方式」弹层贴在按钮下方,不再飘到屏幕中间;五个档位都有图标。 +- 通知条不再把整屏顶上去、在底部留一块空白。 +- 点击任一栏即可切换焦点,被聚焦的一栏有明显的底色与边框;「显示方式」作用在聚焦的那一栏。 +- 收藏夹按 **本端 / 远端**(以及远端协议)自动归类;点击在**当前聚焦**的那一栏打开,不再一律塞进左栏。 +- 收藏夹可以删除;`+` 改成按地址添加任意目录,不再只能收藏当前这一栏。 +- 「USB」连接真的能用了:列出 adb 设备,用 `adb forward` 把设备端口映射到本机后按普通连接协商。之前这个按钮只会弹一句「已留位」。 +- 地址与端口分成两个输入框,端口留空就用对面显示的端口;不用再记住 `HOST:PORT` 的写法。 +- 取色:第一个色块是标志的靛蓝,两端都叫「默认」,五个色块都带名称;「回到品牌靛蓝」按钮已删——它设的是第三种颜色。 + +**手机** + +- 返回键按「关面板 → 回上一屏 → 回传输 → 上一层目录」的顺序退,只有真的没得退时才退出应用。 +- 点「停止共享」后界面立刻恢复成「启动服务端」,不再停在旧状态。 +- 面包屑显示**所有层**,每一层都能点;宽度不够时左右滑动,不再折叠成省略号,打开时自动停在当前目录那一端。 +- 分栏方向按屏幕真实尺寸判断:横屏时「自动」会变成左右两栏。 +- 副行的显示方式按钮改成长条「显示方式 · 详细」,与原型一致。 +- 没有 Root 时,← 到 `/storage/emulated/0` 就停住,不再一路退到不可读的 `/`。 +- 连接页直接有「连接另一台设备」:地址与端口两个输入框,不用再翻 ⋮ 菜单。 +- 连上之后远端栏会打开对面共享的第一个目录;没连接时远端栏显示「还没有连接远端」,不再把本机磁盘当成远端。 +- 在远端栏输入对面机器的绝对路径时,如果该路径不在对面共享范围内,会直接列出对面共享了哪些目录,而不是报「读不了这个文件夹」。 +- 「本机」统一改称「本端」——远端可以是另一台电脑、另一块硬盘,或者以后的 WebDAV / FTP。「账户」一栏按协议列出每一端。 + +## 仍然没有的 + +- **Root / Shizuku** 两种运行模式还只是记录设置:它们需要一个走 `su` 或 Shizuku 的独立后端,本版没有。选了会如实说明,不会假装生效。 +- **WebDAV / FTP / SFTP / SMB** 这些远端协议尚未实现;收藏夹的归类已经按协议留好位置。 + +--- + +# CubSync 0.3.13 + +## Downloads + +- **Windows**: `CubSync-0.3.13.msi`, or `cubsync-0.3.13-windows.zip` to run without installing +- **Android**: `cubsync-0.3.13-android.apk` + +## Fixed + +**Desktop** + +- Opening 显示方式 or a context menu no longer empties both panes — the overlay used to take the + whole content area, so the files looked deleted. +- The 显示方式 card hangs under its button instead of floating in the middle of the window, and all + five modes have their marks. +- A notice no longer pushes the screen up and leaves a blank band at the bottom. +- Clicking a pane switches focus, the focused pane is visibly tinted and outlined, and 显示方式 + applies to it. +- Favourites are grouped by 本端 / 远端 (and by remote protocol), and open in the **focused** pane + instead of always on the left. +- Favourites can be deleted; `+` adds any folder by address rather than only the current view. +- USB works: it lists adb devices, forwards the device's port to this machine, then connects as + usual. The button used to say 已留位. +- Address and port are two fields; leave the port empty to use the one the other side shows. +- 取色: the first chip is the mark's indigo, both ends call it 默认, every chip has its name, and + the 回到品牌靛蓝 button — which set a third colour — is gone. + +**Android** + +- Back closes a panel, then a screen, then returns to 传输, then goes up one folder, and only exits + when there is nothing left. +- 停止共享 updates the screen immediately. +- Breadcrumbs show every layer, each one tappable, scrolling sideways when narrow instead of + folding into an ellipsis, parked at the current folder. +- 分栏方向 uses the real screen size, so 自动 turns into two columns in landscape. +- The display-mode pill reads 显示方式 · 详细, as the prototype draws it. +- Without root, ← stops at /storage/emulated/0 instead of climbing to an unreadable /. +- The connect screen has 连接另一台设备 with address and port fields, not just a hidden menu. +- After connecting, the remote pane opens on the first folder the other side shares; with no peer it + says 还没有连接远端 instead of showing this phone's own disk. +- Typing the other machine's absolute path lists what it actually shares rather than 读不了这个文件夹. +- 本机 is now 本端 throughout: the far end may be another computer, another disk, or a WebDAV / FTP + mount later. 账户 lists each end with its protocol. + +## Still missing + +- **Root / Shizuku** run modes only record a setting: both need a backend that speaks through `su` + or Shizuku, and this build has none. Selecting them says so rather than pretending. +- **WebDAV / FTP / SFTP / SMB** are not implemented; favourite grouping already has a place for them. diff --git a/gradle.properties b/gradle.properties index 4e360b8..e4fa40b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ # The project version lives here so that build scripts, packaging and CI all read # a single value. See docs/VERSIONING.md for the versioning policy. -cubsync.version=0.3.12 +cubsync.version=0.3.13 org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 org.gradle.parallel=true diff --git a/service/src/main/kotlin/io/github/cubsync/service/Adb.kt b/service/src/main/kotlin/io/github/cubsync/service/Adb.kt new file mode 100644 index 0000000..eee4c97 --- /dev/null +++ b/service/src/main/kotlin/io/github/cubsync/service/Adb.kt @@ -0,0 +1,99 @@ +package io.github.cubsync.service + +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * 传输通道's USB leg, through the adb that is on the PATH. + * + * The channel was drawn for four releases and said 已留位 when tapped. It is not a special + * protocol: `adb forward` opens a TCP port on this machine that is answered on the device, + * so a USB cable becomes the same socket the Wi-Fi path uses and nothing above this file + * has to know the difference. That is also why the phone needs nothing: it is already + * listening on its share port, and the cable only decides how the host reaches it. + * + * Everything here shells out to the platform tool rather than reimplementing its protocol, + * and every failure is returned as a sentence rather than thrown, because "no adb" is the + * normal state of a machine that has never installed it. + */ +object Adb { + + /** One device as `adb devices -l` describes it. */ + data class Device(val serial: String, val model: String, val state: String) { + val ready: Boolean get() = state == "device" + val label: String get() = when { + model.isNotBlank() -> "$model($serial)" + else -> serial + } + } + + /** Whether an adb that answers is on the PATH. */ + fun available(): Boolean = run("version").first == 0 + + /** Every device the tool can see, including the ones that are unauthorized. */ + fun devices(): List { + val (code, output) = run("devices", "-l") + if (code != 0) return emptyList() + return output.lineSequence() + .drop(1) + .mapNotNull { line -> + val parts = line.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + if (parts.size < 2) return@mapNotNull null + val serial = parts[0] + val state = parts[1] + val model = parts.firstOrNull { it.startsWith("model:") } + ?.removePrefix("model:") + ?.replace('_', ' ') + .orEmpty() + Device(serial, model, state) + } + .toList() + } + + /** + * Makes `127.0.0.1:localPort` on this machine reach `remotePort` on the device. + * + * Returns null when it worked, or the reason it did not. `-s` picks the device when + * more than one is plugged in; without it adb refuses, which is the correct answer. + */ + fun forward(localPort: Int, remotePort: Int = localPort, serial: String? = null): String? { + val args = buildList { + if (!serial.isNullOrBlank()) { + add("-s") + add(serial) + } + add("forward") + add("tcp:$localPort") + add("tcp:$remotePort") + } + val (code, output) = run(*args.toTypedArray()) + return if (code == 0) null else output.trim().ifEmpty { "adb forward 失败(退出码 $code)" } + } + + fun removeForward(localPort: Int) { + run("forward", "--remove", "tcp:$localPort") + } + + private fun run(vararg args: String): Pair = runCatching { + val process = ProcessBuilder(listOf("adb") + args) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() } + if (!process.waitFor(8, TimeUnit.SECONDS)) { + process.destroyForcibly() + return@runCatching -1 to "adb 没有在 8 秒内回答" + } + process.exitValue() to output + }.getOrElse { failure -> + -1 to (failure.message ?: "找不到 adb") + } + + /** Where the tool lives, for the screen that says whether it was found. */ + fun location(): String? = runCatching { + System.getenv("PATH").orEmpty() + .split(File.pathSeparator) + .map { File(it, if (System.getProperty("os.name").startsWith("Windows")) "adb.exe" else "adb") } + .firstOrNull { it.isFile } + ?.absolutePath + }.getOrNull() +} diff --git a/service/src/main/kotlin/io/github/cubsync/service/AppSettings.kt b/service/src/main/kotlin/io/github/cubsync/service/AppSettings.kt index 4b485f4..0eb8414 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/AppSettings.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/AppSettings.kt @@ -322,10 +322,18 @@ class AppSettings(private val storage: SettingsStorage = SettingsStorage.memory( var favourites: List get() = values[KEY_FAVOURITES].orEmpty().lines().mapNotNull { line -> val parts = line.split('\t') - if (parts.size < 3) null else Favorite(parts[0], parts[1], parts[2], parts.getOrElse(3) { "" }) + if (parts.size < 3) null else Favorite( + device = parts[0], + backendId = parts[1], + path = parts[2], + label = parts.getOrElse(3) { "" }, + protocol = parts.getOrElse(4) { if (parts[1] == EndpointId.LOCAL) "本端" else "CubSync" }, + ) } set(list) { - values[KEY_FAVOURITES] = list.joinToString("\n") { "${it.device}\t${it.backendId}\t${it.path}\t${it.label}" } + values[KEY_FAVOURITES] = list.joinToString("\n") { + "${it.device}\t${it.backendId}\t${it.path}\t${it.label}\t${it.protocol}" + } persist() } @@ -354,12 +362,23 @@ class AppSettings(private val storage: SettingsStorage = SettingsStorage.memory( fun toggleFavourite(favorite: Favorite) { val current = favourites - val existing = current.firstOrNull { it.device == favorite.device && it.path == favorite.path } + val existing = current.firstOrNull { it.backendId == favorite.backendId && it.path == favorite.path } favourites = if (existing != null) current - existing else current + favorite } - fun isFavourite(device: String, path: String): Boolean = - favourites.any { it.device == device && it.path == path } + /** + * Whether a folder is starred. + * + * Keyed on the backend and the path rather than on the display name: the name of a + * peer can change, and 本端 is a label rather than an address — the same folder must + * keep its star either way. + */ + fun isFavourite(backendId: String, path: String): Boolean = + favourites.any { it.backendId == backendId && it.path == path } + + fun removeFavourite(favorite: Favorite) { + favourites = favourites - favorite + } // ------------------------------------------------------------ scope layering @@ -406,12 +425,14 @@ class AppSettings(private val storage: SettingsStorage = SettingsStorage.memory( companion object { /** - * The desktop's accent, which is the prototype's `--win-accent` verbatim. + * The accent both ends start on: the brand's indigo. * - * The phone does not use this: its accent comes from [phoneAccent], because the - * prototype gives the phone its own token (`--m3-primary`) and a hue ramp on top. + * This used to be the prototype's `--win-accent` (`#1E88E5`), while the first chip + * in 取色 was `#3A5CCC` and the phone's was `#4C6EF5`. Pressing 回到品牌靛蓝 then + * produced a colour that appeared nowhere else on the screen. One value now, and + * it is the mark's. */ - const val ACCENT_DEFAULT: String = "#1E88E5" + const val ACCENT_DEFAULT: String = Brand.PRIMARY /** How many peers are remembered. Beyond this the list stops being a shortcut. */ const val KNOWN_PEER_LIMIT: Int = 12 diff --git a/service/src/main/kotlin/io/github/cubsync/service/Browser.kt b/service/src/main/kotlin/io/github/cubsync/service/Browser.kt index 8b6c228..2140691 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/Browser.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/Browser.kt @@ -109,7 +109,15 @@ object Browsing { * ones that survive a narrow pane; the fold is a crumb of its own so it can be * clicked to show the rest. */ - fun crumbs(path: String, maxSegments: Int = 5): List { + /** + * Breadcrumbs for a path, every layer of it. + * + * The default used to be five segments, so a phone header folded `/sdcard/DCIM/Camera` + * into `/ › ⋯ › Camera` even when there was room for all four and every layer is + * tappable in the design. Nothing folds now unless a caller asks for a limit; a header + * that runs out of width scrolls, which loses nothing. + */ + fun crumbs(path: String, maxSegments: Int = Int.MAX_VALUE): List { val normalized = PathTools.normalizeRoot(path) val absolute = normalized.startsWith("/") val parts = normalized.split('/').filter { it.isNotEmpty() } diff --git a/service/src/main/kotlin/io/github/cubsync/service/BrowserSession.kt b/service/src/main/kotlin/io/github/cubsync/service/BrowserSession.kt index ab14e01..44df2c9 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/BrowserSession.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/BrowserSession.kt @@ -145,6 +145,13 @@ class BrowserSession( // An empty path lists the exposed roots, which is the answer to "where // can this pane go back to" and is worth remembering. if (path.isBlank()) rootsCache[backendId] = entries.map { it.relPath } + // Prime the boundary for a pane opened straight onto a folder, so ← knows + // where the exposed root is before it has been listed as a listing. + if (rootsCache[backendId].isNullOrEmpty()) { + runCatching { browser.list(backendId, "").map { it.relPath } } + .getOrNull() + ?.let { rootsCache[backendId] = it } + } if (!isCurrent(side, generation)) return@launch set(side, current(side).copy(entries = sort(side, entries), loading = false)) } catch (failure: Throwable) { @@ -162,9 +169,35 @@ class BrowserSession( fun enter(side: Side, name: String) = open(side, current(side).backendId, Browsing.child(current(side).path, name)) + /** + * One level up — but not out of the folder the backend actually exposes. + * + * A phone without root can read `/storage/emulated/0`, and its pane started there. ← + * used to keep going: `/storage/emulated`, `/storage`, `/` — three levels of a + * filesystem the process cannot read, each one an error screen. The exposed root is + * the boundary; above it there is nothing to show. + */ fun up(side: Side) { - val parent = Browsing.parent(current(side).path) - if (parent != current(side).path) open(side, current(side).backendId, parent) + val state = current(side) + val parent = Browsing.parent(state.path) + if (parent == state.path) return + // A phone without root can read `/storage/emulated/0`, and its pane started there. + // ← used to keep going: `/storage/emulated`, `/storage`, `/` — three levels of a + // filesystem the process cannot read, each one an error screen. The exposed root is + // the boundary. The roots are cached by the listing that got us here, and the check + // is synchronous so that one press moves one level, as it says. + val root = synchronized(rootsCache) { + rootsCache[state.backendId].orEmpty() + .filter { state.path == it || state.path.startsWith("$it/") } + .maxByOrNull { it.length } + } + if (root != null) { + if (state.path == root) return + val inside = parent == root || parent.startsWith("$root/") + open(side, state.backendId, if (inside) parent else root) + return + } + open(side, state.backendId, parent) } /** @@ -223,6 +256,24 @@ class BrowserSession( open(side, backendId, cleaned) return@launch } + if (backendId != EndpointId.LOCAL) { + // A pane on a peer holds a path the peer exposed, not a path on this + // machine. Pasting the other machine's absolute path is the obvious thing + // to try, so an unshared one is answered with what is shared instead of a + // listing error from the other side. + val roots = rootsOf(backendId) + if (roots.isNotEmpty() && roots.none { cleaned == it || cleaned.startsWith("$it/") }) { + set( + side, + current(side).copy( + error = "远端只共享了这些目录:${roots.joinToString("、")}", + ), + ) + return@launch + } + open(side, backendId, cleaned) + return@launch + } val asFolder = browser.isFolder(backendId, cleaned) val target = Browsing.resolveTypedPath(cleaned) { asFolder } open(side, backendId, target) diff --git a/service/src/main/kotlin/io/github/cubsync/service/Sidebar.kt b/service/src/main/kotlin/io/github/cubsync/service/Sidebar.kt index 660f8d1..b4fc12a 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/Sidebar.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/Sidebar.kt @@ -15,7 +15,18 @@ data class Favorite( val backendId: String, val path: String, val label: String = PathTools.leafName(path).ifEmpty { path }, -) + /** + * Which protocol answers for this end: 本端 for a local disk, CubSync for a peer, and + * the name of whatever comes next (WebDAV, FTP, SFTP) once those backends exist. + * + * A favourite is a place on a machine rather than a place on a screen, and on a machine + * reachable two ways it is the protocol that says which one. + */ + val protocol: String = if (backendId == EndpointId.LOCAL) "本端" else "CubSync", +) { + /** The heading this favourite belongs under. */ + val group: String get() = if (backendId == EndpointId.LOCAL) Words.LOCAL else "$device · $protocol" +} data class FavoriteGroup(val device: String, val items: List) diff --git a/service/src/main/kotlin/io/github/cubsync/service/SyncService.kt b/service/src/main/kotlin/io/github/cubsync/service/SyncService.kt index 83db9f3..58795a8 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/SyncService.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/SyncService.kt @@ -204,9 +204,14 @@ class SyncService( * lists remembered things rather than current ones is a drawer people stop trusting. */ fun drawer(server: ServerState): DrawerModel { + // Grouped by end and protocol — 本端, then one heading per peer — rather than by + // device name alone: the same machine can be reached two ways, and a list that + // mixes a folder on this disk with a folder on a phone is a list nobody can scan. val favourites = settings.favourites - .groupBy { it.device } - .map { (device, items) -> FavoriteGroup(device, items.sortedBy { it.path }) } + .groupBy { it.group } + .toList() + .sortedWith(compareBy({ if (it.first == Words.LOCAL) 0 else 1 }, { it.first })) + .map { (heading, items) -> FavoriteGroup(heading, items.sortedBy { it.path }) } val accounts = buildList { add( diff --git a/service/src/main/kotlin/io/github/cubsync/service/UiSpec.kt b/service/src/main/kotlin/io/github/cubsync/service/UiSpec.kt index 9074965..06bf54b 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/UiSpec.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/UiSpec.kt @@ -54,11 +54,19 @@ fun sectionHint(section: Section): String = section.hint * file. */ object Words { - const val LOCAL = "本机" + /** + * 本端 / 远端 rather than 本机 / 远端. + * + * The two panes are two ends of a link, and neither end has to be this computer: a + * phone talking to a phone, a disk copied to another disk, a WebDAV or SFTP mount on + * the far side. Calling the near end 本机 stopped being true the moment the far end + * could be anything, and it reads as a lie on a phone. + */ + const val LOCAL = "本端" const val REMOTE = "远端" const val SERVER = "服务端" const val CLIENT = "客户端" - const val LOCAL_PANE = "本机栏" + const val LOCAL_PANE = "本端栏" const val REMOTE_PANE = "远端栏" const val TRASH = "回收站" const val FILTER = "过滤" @@ -252,8 +260,18 @@ object Accent { ) } + /** + * The first chip is the brand's own indigo and is called 默认 on both ends. + * + * It used to be a guess at `--m3-primary` (`#4C6EF5`) while the desktop's list began + * with `#3A5CCC` and its reset button set a third colour (`#1E88E5`) — three answers + * to one question, and the screen offered two of them side by side. One value now, + * taken from the mark rather than from a stylesheet. + */ + val BRAND: String = Brand.PRIMARY + val SWATCHES: List = listOf( - Swatch("靛蓝", 245, "#4C6EF5"), + Swatch("默认", 232, BRAND), Swatch("紫", 275, hsl(275)), Swatch("青", 200, hsl(200)), Swatch("绿", 150, hsl(150)), diff --git a/service/src/test/kotlin/io/github/cubsync/service/AppSettingsTest.kt b/service/src/test/kotlin/io/github/cubsync/service/AppSettingsTest.kt index b88f408..beba9d6 100644 --- a/service/src/test/kotlin/io/github/cubsync/service/AppSettingsTest.kt +++ b/service/src/test/kotlin/io/github/cubsync/service/AppSettingsTest.kt @@ -121,14 +121,16 @@ class AppSettingsTest { val s = settings() val favourite = Favorite(device = Words.LOCAL, backendId = EndpointId.LOCAL, path = "D:/photos") s.toggleFavourite(favourite) - assertTrue(s.isFavourite(Words.LOCAL, "D:/photos")) + // Keyed on the backend and the path: the display name of an end can change, and + // "本端" is a label rather than an address. + assertTrue(s.isFavourite(EndpointId.LOCAL, "D:/photos")) assertEquals(1, s.favourites.size) s.toggleFavourite(favourite) - assertFalse(s.isFavourite(Words.LOCAL, "D:/photos")) + assertFalse(s.isFavourite(EndpointId.LOCAL, "D:/photos")) s.favourites = listOf( - favorite("本机", "D:/photos"), + favorite("本端", "D:/photos"), favorite("Redmi", "/sdcard/DCIM", backendId = "lan:192.168.1.20:43917"), ) val reloaded = AppSettings(SettingsStorage.memory(s.snapshot())) From e95b9a6673a773c60a33916b40ffe9de5f1a8eb1 Mon Sep 17 00:00:00 2001 From: huxc573 Date: Sat, 17 Oct 2026 15:12:32 +0800 Subject: [PATCH 2/3] adb: never read a tool's output before it exits The root probe read `su`'s stdout first and then waited; on a machine where su exists and asks for a password, the read blocks forever and the caller with it. CI's test step sat on that for twenty minutes. Both the probe and the adb runner close the child's stdin, wait with a timeout, and read what it wrote afterwards. --- .../io/github/cubsync/android/AndroidState.kt | 13 +++++++++++-- .../main/kotlin/io/github/cubsync/service/Adb.kt | 6 ++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt b/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt index 834ba12..e388fe6 100644 --- a/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt +++ b/app/android/src/main/kotlin/io/github/cubsync/android/AndroidState.kt @@ -830,8 +830,17 @@ class AndroidAppState( /** Whether `su` answers with uid 0. A probe, not a promise. */ private fun suAvailable(): Boolean = runCatching { val process = ProcessBuilder("su", "-c", "id").redirectErrorStream(true).start() - val output = process.inputStream.bufferedReader().readText() - process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS) && output.contains("uid=0") + // Close its input: `su` that wants a password otherwise waits for one, and reading + // its output first blocks the caller forever. That is what hung CI's test step — + // the JVM there has `su` and no root. + runCatching { process.outputStream.close() } + val finished = process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS) + if (!finished) { + process.destroyForcibly() + return@runCatching false + } + process.exitValue() == 0 && + process.inputStream.bufferedReader().use { it.readText() }.contains("uid=0") }.getOrDefault(false) // ------------------------------------------------------------ 传输通道 diff --git a/service/src/main/kotlin/io/github/cubsync/service/Adb.kt b/service/src/main/kotlin/io/github/cubsync/service/Adb.kt index eee4c97..6773568 100644 --- a/service/src/main/kotlin/io/github/cubsync/service/Adb.kt +++ b/service/src/main/kotlin/io/github/cubsync/service/Adb.kt @@ -78,12 +78,14 @@ object Adb { val process = ProcessBuilder(listOf("adb") + args) .redirectErrorStream(true) .start() - val output = process.inputStream.bufferedReader().use { it.readText() } + runCatching { process.outputStream.close() } if (!process.waitFor(8, TimeUnit.SECONDS)) { process.destroyForcibly() return@runCatching -1 to "adb 没有在 8 秒内回答" } - process.exitValue() to output + // Read after it exits: a tool that waits on stdin would otherwise hold the reader + // open forever, which is a hang rather than an error. + process.exitValue() to process.inputStream.bufferedReader().use { it.readText() } }.getOrElse { failure -> -1 to (failure.message ?: "找不到 adb") } From 3c5281e395a7ad63ff85be9aa79a59765cbe5d61 Mon Sep 17 00:00:00 2001 From: huxc573 Date: Sat, 17 Oct 2026 15:18:01 +0800 Subject: [PATCH 3/3] usb: forward the phone's port, not this machine's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop passed its own 共享端口 into adb forward, which only happens to be right when both ends are on the default. It uses the port typed into 地址/端口 now, and the protocol default when that is empty. --- .../kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt index bd28136..f4ff8e3 100644 --- a/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt +++ b/app/desktop/src/main/kotlin/io/github/cubsync/desktop/ui/ConnectScreen.kt @@ -142,7 +142,9 @@ private fun LinkCard(state: AppState, modifier: Modifier) { ) { Text(device.label, style = CubType.body, color = colors.text, modifier = Modifier.weight(1f)) QuietButton(if (state.connecting) "连接中…" else "连接并协商") { - state.connectUsb(device.serial, settings.sharePort) + // The phone's port, not this machine's: empty means the protocol + // default, which is what a phone shares on unless it was changed. + state.connectUsb(device.serial, state.peerPortText.toIntOrNull() ?: 0) } } } @@ -152,7 +154,7 @@ private fun LinkCard(state: AppState, modifier: Modifier) { } Spacer(Modifier.height(8.dp)) Text( - "USB 走的是 adb 转发:把设备上的 ${settings.sharePort} 映射到本机同名端口,之后与局域网是同一条连接。", + "USB 走的是 adb 转发:把设备上的端口(默认 ${settings.sharePort})映射到本机同名端口,之后与局域网是同一条连接。", style = CubType.tiny, color = colors.textFaint, )