fix: qualify mobile Cloud viewer and companion downloads

This commit is contained in:
archipelago
2026-10-05 14:41:08 -04:00
parent daac47cac4
commit 5aa74d0513
22 changed files with 1173 additions and 197 deletions
+5 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 52
versionName = "0.5.32"
versionCode = 54
versionName = "0.5.34"
vectorDrawables {
useSupportLibrary = true
@@ -142,6 +142,9 @@ tasks.matching {
}.configureEach { dependsOn("buildRustArm64") }
dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
testImplementation("org.robolectric:robolectric:4.14.1")
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
implementation(composeBom)
@@ -0,0 +1,179 @@
package com.archipelago.app.ui.screens
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import android.webkit.CookieManager
import android.webkit.DownloadListener
import android.webkit.URLUtil
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.*
import okhttp3.Call
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.io.OutputStream
import java.util.concurrent.TimeUnit
internal data class WebDownload(val url: String, val userAgent: String, val cookies: String, val name: String, val mime: String)
/** Only the starting origin receives its WebView cookies, even across redirects. */
internal fun streamWebDownload(
download: WebDownload,
output: OutputStream,
client: OkHttpClient,
onCall: (Call) -> Unit = {},
checkCancelled: () -> Unit = {},
onProgress: (Long, Long) -> Unit = { _, _ -> },
): Long {
val transport = client.newBuilder().followRedirects(false).followSslRedirects(false).build()
val original = download.url.toHttpUrlOrNull() ?: throw IOException("Unsupported download link")
var url = original
var redirects = 0
while (true) {
checkCancelled()
if (url.username.isNotEmpty() || url.password.isNotEmpty()) throw IOException("Unsupported download link")
val request = Request.Builder().url(url).header("User-Agent", download.userAgent)
if (url.scheme == original.scheme && url.host == original.host && url.port == original.port && download.cookies.isNotBlank()) {
request.header("Cookie", download.cookies)
}
val call = transport.newCall(request.build())
onCall(call)
call.execute().use { response ->
if (response.code in listOf(301, 302, 303, 307, 308)) {
if (++redirects > 5) throw IOException("Too many download redirects")
val next = response.header("Location")?.let { url.resolve(it) } ?: throw IOException("Invalid download redirect")
if (url.isHttps && !next.isHttps) throw IOException("Insecure download redirect blocked")
url = next
} else {
if (response.code == 401 || response.code == 403) throw IOException("Sign in to the node again, then retry the download")
if (!response.isSuccessful) throw IOException("Download failed (HTTP ${response.code})")
if (response.header("Content-Type")?.substringBefore(';')?.trim()?.lowercase() == "text/html" &&
download.mime != "text/html" && !download.name.endsWith(".html", true) && !download.name.endsWith(".htm", true)) {
throw IOException("Sign in to the node again, then retry the download")
}
val body = response.body ?: throw IOException("The download was empty")
val total = body.contentLength()
var written = 0L
body.byteStream().use { input ->
val buffer = ByteArray(64 * 1024)
var lastUpdate = 0L
while (true) {
checkCancelled()
val count = input.read(buffer)
if (count == -1) break
output.write(buffer, 0, count)
written += count
val now = System.nanoTime()
if (now - lastUpdate > 100_000_000L) { onProgress(written, total); lastUpdate = now }
}
}
if (total >= 0 && written != total) throw IOException("Download interrupted; please retry")
onProgress(written, total)
return written
}
}
}
}
/** Uses the system Save dialog: no broad storage permission and no external browser login. */
@Composable
internal fun rememberWebViewDownloads(): DownloadListener {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var pending by remember { mutableStateOf<WebDownload?>(null) }
var active by remember { mutableStateOf<WebDownload?>(null) }
var progress by remember { mutableStateOf<Pair<Long, Long>>(0L to -1L) }
var failure by remember { mutableStateOf<String?>(null) }
var job by remember { mutableStateOf<Job?>(null) }
val currentCall = remember { java.util.concurrent.atomic.AtomicReference<Call?>(null) }
val client = remember {
OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
.connectTimeout(20, TimeUnit.SECONDS).readTimeout(60, TimeUnit.SECONDS).build()
}
fun cancel() { job?.cancel(); currentCall.getAndSet(null)?.cancel() }
DisposableEffect(Unit) { onDispose { currentCall.getAndSet(null)?.cancel() } }
val save = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val download = pending
pending = null
val uri = result.data?.data
if (result.resultCode != Activity.RESULT_OK || uri == null || download == null) return@rememberLauncherForActivityResult
job = scope.launch {
active = download
progress = 0L to -1L
var complete = false
try {
withContext(Dispatchers.IO) {
val task = currentCoroutineContext()
context.contentResolver.openOutputStream(uri, "w")?.use { output ->
streamWebDownload(download, output, client,
onCall = { call -> currentCall.set(call); if (!task.isActive) call.cancel() },
checkCancelled = { task.ensureActive() },
onProgress = { done, total -> scope.launch { progress = done to total } })
} ?: throw IOException("Unable to open the selected destination")
}
complete = true
Toast.makeText(context, "Download complete: ${download.name}", Toast.LENGTH_LONG).show()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
if (currentCoroutineContext().isActive) {
// Do not expose authenticated URLs or request headers in UI/logs.
failure = when {
error is javax.net.ssl.SSLException -> "The server certificate could not be verified."
error is IOException && error.message?.startsWith("Sign in") == true -> error.message
else -> "Download failed. Check your connection and available storage, then try again."
}
}
} finally {
currentCall.getAndSet(null)?.cancel()
if (!complete) withContext(NonCancellable + Dispatchers.IO) {
// This URI was newly created by ACTION_CREATE_DOCUMENT; never remove an existing user file.
runCatching { DocumentsContract.deleteDocument(context.contentResolver, uri) }
}
active = null
job = null
}
}
}
if (active != null) {
AlertDialog(onDismissRequest = {}, title = { Text("Downloading") }, text = {
Column {
Text(active!!.name)
if (progress.second > 0) LinearProgressIndicator(progress = (progress.first.toFloat() / progress.second).coerceIn(0f, 1f))
else LinearProgressIndicator()
}
}, confirmButton = {}, dismissButton = { TextButton(onClick = { cancel() }) { Text("Cancel") } })
}
failure?.let { message ->
AlertDialog(onDismissRequest = { failure = null }, title = { Text("Download unavailable") },
text = { Text(message) }, confirmButton = { TextButton(onClick = { failure = null }) { Text("OK") } })
}
return DownloadListener { url, userAgent, disposition, mimeType, _ ->
if (active != null || pending != null) {
Toast.makeText(context, "Finish or cancel the current download first", Toast.LENGTH_SHORT).show()
} else if (url.toHttpUrlOrNull() == null) {
failure = "This download link is not supported. Open the file from Cloud and try again."
} else {
val mime = mimeType?.substringBefore(';')?.takeIf { it.contains('/') } ?: "application/octet-stream"
val name = URLUtil.guessFileName(url, disposition, mime).replace(Regex("[\\\\/\\p{Cntrl}]"), "_").take(180).ifBlank { "download" }
pending = WebDownload(url, userAgent ?: "Archipelago Companion", CookieManager.getInstance().getCookie(url).orEmpty(), name, mime)
try {
save.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE); type = mime; putExtra(Intent.EXTRA_TITLE, name)
})
} catch (_: Exception) { pending = null; failure = "No file-saving app is available on this device." }
}
}
}
@@ -0,0 +1,99 @@
package com.archipelago.app.ui.screens
import android.content.Context
import android.content.ContextWrapper
import android.graphics.Color
import android.view.View
import android.view.ViewGroup
import android.webkit.WebChromeClient
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
private fun Context.fullscreenActivity(): ComponentActivity? = when (this) {
is ComponentActivity -> this
is ContextWrapper -> baseContext.takeIf { it !== this }?.fullscreenActivity()
else -> null
}
/** Hosts Chromium's custom fullscreen view without replacing or reloading its WebView. */
internal class WebViewFullscreen(private val activity: ComponentActivity?) {
private var overlay: FrameLayout? = null
private var callback: WebChromeClient.CustomViewCallback? = null
private var back: OnBackPressedCallback? = null
private var visibleBars = 0
private var originalBehavior = 0
fun show(view: View?, onHidden: WebChromeClient.CustomViewCallback?) {
val owner = activity
// A second enter must not detach the active video or strand its callback.
if (owner == null || owner.isFinishing || owner.isDestroyed || view == null ||
view.parent != null || overlay != null
) {
onHidden?.onCustomViewHidden()
return
}
val decor = owner.window.decorView as? ViewGroup
if (decor == null) { onHidden?.onCustomViewHidden(); return }
val controller = WindowCompat.getInsetsController(owner.window, decor)
val insets = ViewCompat.getRootWindowInsets(decor)
visibleBars = 0
if (insets?.isVisible(WindowInsetsCompat.Type.statusBars()) != false) {
visibleBars = visibleBars or WindowInsetsCompat.Type.statusBars()
}
if (insets?.isVisible(WindowInsetsCompat.Type.navigationBars()) != false) {
visibleBars = visibleBars or WindowInsetsCompat.Type.navigationBars()
}
originalBehavior = controller.systemBarsBehavior
val host = FrameLayout(owner).apply {
setBackgroundColor(Color.BLACK)
keepScreenOn = true
addView(view, FrameLayout.LayoutParams(-1, -1))
}
overlay = host
callback = onHidden
decor.addView(host, ViewGroup.LayoutParams(-1, -1))
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
back = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() = hide()
}.also { owner.onBackPressedDispatcher.addCallback(it) }
view.requestFocus()
}
fun hide() {
val host = overlay ?: return
// Clear first: Chromium may synchronously call onHideCustomView again.
overlay = null
val notify = callback
callback = null
back?.remove()
back = null
host.keepScreenOn = false
host.removeAllViews()
(host.parent as? ViewGroup)?.removeView(host)
activity?.let { owner ->
val controller = WindowCompat.getInsetsController(owner.window, owner.window.decorView)
controller.systemBarsBehavior = originalBehavior
controller.hide(WindowInsetsCompat.Type.systemBars())
if (visibleBars != 0) controller.show(visibleBars)
}
notify?.onCustomViewHidden()
}
}
@Composable
internal fun rememberWebViewFullscreen(): WebViewFullscreen {
val context = LocalContext.current
val fullscreen = remember(context) { WebViewFullscreen(context.fullscreenActivity()) }
DisposableEffect(fullscreen) { onDispose { fullscreen.hide() } }
return fullscreen
}
@@ -611,6 +611,8 @@ fun WebViewScreen(
// before surfacing the error page: the mesh tunnel works from anywhere.
meshFallbackUrl: String? = null,
) {
val fullscreen = rememberWebViewFullscreen()
val downloads = rememberWebViewDownloads()
var isLoading by remember { mutableStateOf(true) }
// First kiosk load (often over the FIPS mesh) gets the full branded
// loader; later navigations keep just the slim top progress bar.
@@ -913,6 +915,7 @@ fun WebViewScreen(
cookieManager.setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
setDownloadListener(downloads)
settings.apply {
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
// Let JS open windows without a synchronous user-gesture
@@ -1181,6 +1184,12 @@ fun WebViewScreen(
}
webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: android.view.View?, callback: CustomViewCallback?) {
fullscreen.show(view, callback)
}
override fun onHideCustomView() = fullscreen.hide()
override fun onProgressChanged(view: WebView?, newProgress: Int) {
loadProgress = newProgress
}
@@ -1546,6 +1555,8 @@ private fun InAppBrowser(
appName: String? = null,
onClose: () -> Unit,
) {
val fullscreen = rememberWebViewFullscreen()
val downloads = rememberWebViewDownloads()
val context = LocalContext.current
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
// kiosk's isSameNode; a mismatch here bounced app links to the browser.
@@ -1643,6 +1654,7 @@ private fun InAppBrowser(
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
setDownloadListener(downloads)
// Node apps (BTCPay invoices, LND, Portainer tokens) are
// served over plain HTTP too — same dead-clipboard trap.
addClipboardBridge()
@@ -1662,6 +1674,12 @@ private fun InAppBrowser(
)
webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: android.view.View?, callback: CustomViewCallback?) {
fullscreen.show(view, callback)
}
override fun onHideCustomView() = fullscreen.hide()
override fun onProgressChanged(view: WebView?, newProgress: Int) {
progress = newProgress
}
@@ -0,0 +1,88 @@
package com.archipelago.app.ui.screens
import okhttp3.OkHttpClient
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import org.junit.Assert.*
import org.junit.Test
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.concurrent.CancellationException
class WebViewDownloadsTest {
private fun spec(url: String) = WebDownload(url, "test-agent", "session=test-only", "file.bin", "application/octet-stream")
@Test fun authenticatedDownloadWritesExactBytesAndReportsCompletion() {
MockWebServer().use { server ->
val bytes = ByteArray(256 * 1024 + 13) { (it % 251).toByte() }
server.enqueue(MockResponse().setBody(Buffer().write(bytes)))
val out = ByteArrayOutputStream()
var progress = 0L to 0L
assertEquals(bytes.size.toLong(), streamWebDownload(spec(server.url("/file").toString()), out, OkHttpClient(), onProgress = { done, total -> progress = done to total }))
assertArrayEquals(bytes, out.toByteArray())
assertEquals(bytes.size.toLong() to bytes.size.toLong(), progress)
assertEquals("session=test-only", server.takeRequest().getHeader("Cookie"))
}
}
@Test fun sameOriginRedirectKeepsSessionButCrossOriginNeverReceivesIt() {
MockWebServer().use { first -> MockWebServer().use { second ->
second.enqueue(MockResponse().setBody("final"))
first.enqueue(MockResponse().setResponseCode(302).addHeader("Location", "/relative"))
first.enqueue(MockResponse().setResponseCode(307).addHeader("Location", second.url("/target")))
val out = ByteArrayOutputStream()
streamWebDownload(spec(first.url("/start").toString()), out, OkHttpClient())
assertEquals("final", out.toString())
assertEquals("session=test-only", first.takeRequest().getHeader("Cookie"))
assertEquals("session=test-only", first.takeRequest().getHeader("Cookie"))
assertNull(second.takeRequest().getHeader("Cookie"))
} }
}
@Test fun authenticationFailureDoesNotSaveErrorBody() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setResponseCode(401).setBody("login required"))
val out = ByteArrayOutputStream()
val error = assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/").toString()), out, OkHttpClient()) }
assertTrue(error.message!!.startsWith("Sign in"))
assertEquals(0, out.size())
}
}
@Test fun redirectsAreBoundedAndUnsafeSchemesAreRejected() {
MockWebServer().use { server ->
repeat(6) { server.enqueue(MockResponse().setResponseCode(302).addHeader("Location", "/loop")) }
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/loop").toString()), ByteArrayOutputStream(), OkHttpClient()) }
assertEquals(6, server.requestCount)
}
for (url in listOf("file:///etc/passwd", "data:text/plain,test", "blob:test")) {
assertThrows(IOException::class.java) { streamWebDownload(spec(url), ByteArrayOutputStream(), OkHttpClient()) }
}
}
@Test fun cancellationAndDestinationFailureAreNotReportedAsComplete() {
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody("bytes"))
assertThrows(CancellationException::class.java) { streamWebDownload(spec(server.url("/").toString()), ByteArrayOutputStream(), OkHttpClient(), checkCancelled = { throw CancellationException() }) }
assertEquals(0, server.requestCount)
var progressCalled = false
val out = object : java.io.OutputStream() { override fun write(b: Int) { throw IOException("disk full") } }
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/").toString()), out, OkHttpClient(), onProgress = { _, _ -> progressCalled = true }) }
assertFalse(progressCalled)
}
}
@Test fun tlsDowngradeAndLoginHtmlAreRejected() {
var requests = 0
val client = OkHttpClient.Builder().addInterceptor { chain ->
requests++
okhttp3.Response.Builder().request(chain.request()).protocol(okhttp3.Protocol.HTTP_1_1)
.code(302).message("redirect").header("Location", "http://example.test/file").body("".toResponseBody(null)).build()
}.build()
assertThrows(IOException::class.java) { streamWebDownload(spec("https://example.test/file"), ByteArrayOutputStream(), client) }
assertEquals(1, requests)
MockWebServer().use { server ->
server.enqueue(MockResponse().addHeader("Content-Type", "Text/HTML; charset=utf-8").setBody("<html>Sign in</html>"))
val out = ByteArrayOutputStream()
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/file").toString()), out, OkHttpClient()) }
assertEquals(0, out.size())
}
}
}
@@ -0,0 +1,71 @@
package com.archipelago.app.ui.screens
import android.view.View
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [28, 35])
class WebViewFullscreenTest {
@Test fun backExitsFullscreenWithoutFinishingActivityAndNotifiesOnce() {
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {
val activity = lifecycle.get()
val fullscreen = WebViewFullscreen(activity)
val video = View(activity)
var hidden = 0
fullscreen.show(video) { hidden++ }
assertNotNull(video.parent)
activity.onBackPressedDispatcher.onBackPressed()
assertNull(video.parent)
assertFalse(activity.isFinishing)
assertEquals(1, hidden)
fullscreen.hide()
assertEquals(1, hidden)
} finally { lifecycle.pause().stop().destroy() }
}
@Test fun duplicateRequestPreservesActiveViewAndCanReenterAfterExit() {
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {
val activity = lifecycle.get()
val fullscreen = WebViewFullscreen(activity)
val first = View(activity)
val second = View(activity)
var firstHidden = 0
var secondHidden = 0
fullscreen.show(first) { firstHidden++ }
fullscreen.show(second) { secondHidden++ }
assertNotNull(first.parent)
assertNull(second.parent)
assertEquals(0, firstHidden)
assertEquals(1, secondHidden)
fullscreen.hide()
fullscreen.show(second) { secondHidden++ }
assertNotNull(second.parent)
fullscreen.hide()
assertEquals(1, firstHidden)
assertEquals(2, secondHidden)
} finally { lifecycle.pause().stop().destroy() }
}
@Test fun rejectsOwnedViewWithoutReparentingAndHandlesUnavailableActivity() {
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {
val activity = lifecycle.get()
val video = View(activity)
val owner = FrameLayout(activity).apply { addView(video) }
var hidden = 0
WebViewFullscreen(activity).show(video) { hidden++ }
assertSame(owner, video.parent)
WebViewFullscreen(null).show(null) { hidden++ }
assertEquals(2, hidden)
} finally { lifecycle.pause().stop().destroy() }
}
}
+117
View File
@@ -1425,3 +1425,120 @@ Nostr additions. The checker now rejects stale descriptions/dates for an existin
version; its regression passes. Latest notes UI built and deployed dev/yaya index
SHA256 `97aab07e67eccc1bb3d215534b537b83e1372bf5c36b505b6127a24a2b629e23`.
Phone background/reconnect acceptance question is pending, not passed.
## Mobile Cloud media viewer — 2026-10-05 release addition
Operator screenshot shows the filename behind the companion status bar and a
cramped video viewer. Confirmed mobile CSS positioned every toolbar button at the
same coordinates; fullscreen was only attached to a video double-click, and the
viewer ignored the companion's `--safe-area-top/bottom` values. Legacy global
lightbox maximum dimensions also constrained the component unexpectedly.
The viewer now reserves separate safe-area-aware title, media and action rows on
phones, keeps each action at least44px without shrinking, and places previous/next
beside the action row rather than over the media. Videos retain their intrinsic
picture ratio with native playback controls. Photos and videos have a labeled
fullscreen action: standard fullscreen where supported, native Safari video
fallback, and an expandable in-page viewer when embedded browsers deny it.
Escape/exit and keyboard focus remain usable. Decode failures offer retry, and
late media requests cannot replace a newly selected file or leak their blob URL.
Validation:11component tests pass, including existing PiP handoff, fullscreen
success/denial/Safari fallback, decode retry, fetch ordering and focus restoration.
Real Chromium checks at320x568,390x844,844x390 and1440x900 pass safe-area/control
geometry, fullscreen and exit, generated video playback and close. Screenshots
were inspected. This does not claim physical companion/Safari acceptance.
Evidence: `/tmp/archy-190-lightbox-focused.log`,
`/tmp/archy-190-lightbox-browser.log`; repeatable browser fixture
`tests/lifecycle/media-lightbox-browser.cjs`.
The operator separately accepted both physical phone upload background/reconnect
and Framework Cloud folder/upload/open checks. Those upload manual gates are
closed; this newly reported media viewer gate is separate. The ongoing candidate
ISO and staged OTA predate this addition and must not be published as final;
regenerate final artifacts and hashes after this fix is qualified.
### Permanent file menus and companion fullscreen follow-up
The operator additionally reported that touch users cannot reach hover-only file
actions without opening the file. Grid and list cards now have a permanent44px
translucent ellipsis action, and owned-file lightboxes expose the same menu.
Share/download/delete are available without opening the underlying file; delete
requires a separate explicit confirmation. Unsupported actions are omitted for
non-owned callers. The menu stays within the viewport, participates in native
fullscreen, traps keyboard focus, dismisses on Escape/outside tap and restores
focus. Cloud-folder delete failures now remain visible instead of becoming an
unhandled rejected promise.
Sixteen focused component tests pass. Real Chromium grid/list touch checks pass
at320,390and1440px, covering permanent visibility, unclipped menus, share and
cancelled delete with no preview triggered. Lightbox action access also passes
inside fullscreen at all four prior viewport sizes. The actual deployed dev
Cloud screenshot and3840x2160video decode successfully (75.633seconds, no media
error); native Chromium fullscreen/exit/close pass. No operator files changed.
The Android companion has no existing `onShowCustomView` implementation. Added a
shared fullscreen host to both dashboard and app WebViews: retains the current
WebView, accepts Chromium's custom view, hides system bars with swipe escape,
handles Back and Chromium exit, restores prior bars, releases the view on screen
disposal and rejects duplicate/reparented requests. Kotlin compilation passes.
Companion version0.5.33/build53 is reserved for this change. Lifecycle tests,
clean signed APK build and physical companion acceptance remain required; the
web fallback is not evidence of native Android fullscreen acceptance.
Updated qualification: all **1,222 frontend tests / 150 files** pass, production
build passes, and the permanent menus are deployed on the dev box. Live browser
checks pass for real Cloud photo/video decode, card-menu access without preview,
cancelled deletion, and the viewer's action menu during fullscreen. No files were
deleted or shared by the tests. Android's three lifecycle tests pass (zero errors
or failures). The clean APK build initially failed because the expected signing
keystore was absent. Recovered the existing local key after matching its public
certificate exactly to the currently served APK; a clean packaging retry is in
progress. No replacement signing identity was generated, and no private signing
material is included in this change.
Companion packaging exposed an additional release-script defect: noisy successful
`apksigner` output caused `printf | grep -q` under `pipefail` to return141/SIGPIPE,
rejecting an APK whose v1/v2/v3 verification results were all true. The publisher
now uses input redirection for these checks and additionally pins the existing
companion certificate, protecting in-place update compatibility. Four executable
regressions exercise the actual verification block: large valid output, missing
signature schemes, wrong signer and verifier failure. All pass; the test is
included in `tests/release/run.sh`. A fresh canonical clean/package/sign run is
required after this script fix; no failed packaging attempt is marked published.
### Companion download regression — operator report after build53
The operator accepted the improved viewer, then reported Download did nothing,
confirmed companion-only. Real Chromium downloaded the exact Cloud screenshot
bytes (202,648 bytes; matching SHA256), so this is separate from the web menu.
Both companion WebViews lack a general DownloadListener. Added a shared native
Save dialog/download handler, with bounded streaming, existing WebView cookies,
progress, cancellation and deletion of the newly created incomplete destination
on failure. Redirects retain cookies only for the starting origin, HTTPS
downgrades are rejected, and authentication/login-page failures do not save an
error page as the user's file. TLS verification stays enabled. No broad storage
permission is introduced. Blob/data URLs currently report unsupported instead of
silently doing nothing; own Cloud files use authenticated HTTP(S) raw URLs.
Companion0.5.34/build54 is reserved for this repair. Compile, network regression
suite, canonical clean signing, dev deployment and a real phone download remain
required. Build53 must not be described as having working companion downloads.
The existing fullscreen suite also passed its Android28+35 matrix: six cases,
zero failures/errors. No release or APK fleet publication has occurred.
Download validation update: the sequential clean Android build and all12tests
pass (six network-download cases plus six fullscreen lifecycle cases across
Android28/35). Tests cover exact authenticated bytes/progress, same-origin versus
cross-origin redirects, bounded redirects, unsupported URLs, auth failure,
cancellation, destination failure, TLS downgrade refusal and login HTML rejection.
XML evidence was preserved before packaging in
`/tmp/archy-190-companion-download-test-results/`. The canonical clean0.5.34/build54
APK package passes v1/v2/v3 verification and the existing signing-certificate pin;
the APK contains the new download handler. Fleet publication remains held pending
physical save/open acceptance and the existing release gates.
2026-10-05 operator acceptance: companion0.5.34/build54 phone download check
(save/open/cancel) accepted: “works, we can proceed”. Viewer and physical upload
acceptance retained. Close this manual gate; other release/security/Angor gates
remain open. No fleet OTA, ISO or public demo publication inferred.
+63 -21
View File
@@ -12,19 +12,18 @@ whenever human acceptance is needed.
## Current evidence
- Latest alpha backend source: isolated suite 1,681 passed, zero failed,
four explicit ignores; separate container runtime suite 82 passed. Optimized
build including the Nostr security and File Browser changes is in progress.
- Frontend: full suite 1,211 passed across 148 files; production UI and AIUI
builds passed. Real dev desktop/mobile upload fault injection passes, including
interrupted JWT refresh and exact saved-file hashes.
- Currently deployed backend on dev/yaya SHA256
`e218e40f5c16c3d0cc4dc06c0a378c14b56b9087ded5c515b8a48351ceea3bcf`.
It includes Nostr/File Browser fixes but predates the alpha version suffix.
Private rollback backups exist.
- Latest deployed UI index SHA256 on dev/yaya
`67de835a25db59a483314eff583b809c3468c8529080bfa74c9962e44a6f54f9`.
Both served byte checks pass; unrelated production containers stayed unchanged.
- Alpha backend: isolated suite **1,681 passed**, zero failed, four explicit
ignores; separate container runtime suite **82 passed**. Optimized binary
SHA256 `a5a6cfc7dcab011963a6f18dc570446b6fa624180d206a108fab27504f4fd41d`
is deployed on dev, yaya and Framework with private rollback backups.
- Frontend: **1,222 passed across 150 files**; production build and real mobile/
desktop media/menu checks pass. Dev serves index SHA256
`c18b24024a78789fe65c74c5ce27efe2125ae869016ab65e33c5a2680b543f17`.
Yaya/Framework still have the preceding qualified upload UI `67de835a…`.
- Companion **0.5.34/build54**: 12 native tests, clean build, v1/v2/v3 signatures
and unchanged signer pass. Viewer and phone download are operator accepted.
Dev APK SHA256 `ceb58a7dc5f1398fe84f30255ec9ed79834f5db5f8fbc03eac52e14186fab1a1`.
Fleet publication remains part of the final release.
- NPM corrected gateway/client-IP integration: 23 Python checks and complete
disposable real-image integration passed. Catalog generator now requires both
migration-backup and legacy-gateway capabilities; its generator/drift selection
@@ -41,9 +40,10 @@ whenever human acceptance is needed.
- [ ] **NPM:** corrected private signature, dev/yaya selection and yaya override
retirement now PASS (2026-10-05). Remaining: full boot/OTA/ISO and
staging-CA issuance/renewal and full legacy-backend migration/rollback
full legacy-backend migration/rollback
acceptance; retain the completed
fresh/nested disposable and actual yaya state-preservation checks.
fresh/nested disposable, public staging issuance/forced renewal and actual
yaya state-preservation checks.
- [ ] **Shorty NPM:** shop certificate12/Force SSL are operator accepted and
independently verified; qualify and apply the manual-route migration. Preserve live
management containment and current public app routing.
@@ -56,14 +56,14 @@ whenever human acceptance is needed.
No production spending or channel closure is authorized by this checklist.
- [ ] **Paid files:** finish buyer restart/outage and updated-seller persistence
acceptance; preserve atomic ownership and safe retries without repayment.
- [ ] **Uploads:** prior physical companion flow is operator accepted. New
resumable-transfer requirement needs interrupted-network/background
recovery acceptance; viewport checks alone are not physical-phone proof.
- [x] **Uploads:** real dev/yaya interrupted-network, offset recovery, lost
replies, hashes, cancellation and compact origin-screen display pass.
Physical companion background/reconnect and Framework Cloud flow are
operator accepted. Final packaged-artifact checks remain below.
- [ ] **File Browser credentials:** unique managed login, default-password
removal, account/file preservation and rollback pass real Podman fixtures
including actual Quadlet restart. Deploy/verify dev and yaya, rerun yaya
upload tests, qualify Framework's reported auth issue, and verify packaged
OTA/ISO startup. Docker behavior is not inferred from Podman fixtures.
including actual Quadlet restart. Dev/yaya/Framework migration and Cloud
acceptance pass. Remaining: packaged OTA/ISO startup. Docker behavior is not inferred from Podman fixtures.
- [ ] **Apps:** complete upgrade inventory matrix for installed/stopped/removed/
restarting/legacy aliases; Immich/retired-app removal and unexpected-service
identification; Portainer/Gitea migration from actual request namespace.
@@ -517,3 +517,45 @@ reported1.8.8. Existing Docker Compose demo deployment located read-only; do not
confuse it with Yaya's v4v stack. Update after release, preserving rollback and
qualifying mock backend compatibility with new Cloud uploads. No demo deployed yet.
No OTA/catalog/ISO has been published.
## 2026-10-05 additional mobile media and file-action qualification
Operator accepted both physical phone upload recovery and Framework Cloud
folder/upload/open checks. These manual gates are closed.
A subsequent Cloud screenshot and touch-action report introduced new release
requirements: safe-area-aware photo/video viewing, separated touch controls,
fullscreen/exit, permanent translucent file-card actions, and the same actions
inside the viewer. Source and component tests are in the regression ledger.
The complete updated frontend suite passes: **1,222 tests in 150 files**.
Production frontend build passes. Chromium checks cover phone portrait,
landscape and desktop geometry, native fullscreen, action-menu access while
fullscreen, video decoding/playback, no accidental preview from a menu tap,
and cancellation before deletion. The deployed dev box reads the operator's
actual screenshot and 4K video without changing either file.
The new Android fullscreen callback implementation compiles and its three
Robolectric lifecycle tests pass with zero failures/errors. It still requires a
clean APK, signature verification and physical companion acceptance; compilation
and browser fullscreen do not establish that acceptance. Version 0.5.33/build53
is reserved for the companion update. No native fullscreen APK published yet.
Final OTA/frontend/ISO checksums and source attribution must be regenerated after
these additions. Candidate ISO build215 is superseded for publication purposes.
Previously recorded NPM fleet migration, exact signed OTA/rollback and ISO install
qualification, full-chain/Angor scope, mirror parity and public-demo requirements
remain open unless separately closed by direct evidence. No prior publication
hold is waived by the mobile test results.
### Companion download qualification update
Operator accepted the viewer but reported companion-only download failure.
Browser download of the same file matches its exact bytes. Added native saving
through Android's system file picker with authentication, streamed progress,
cancellation and error cleanup. The clean download/fullscreen suite passes all
12cases. Canonical clean companion0.5.34/build54 packaging passes v1/v2/v3 and
existing-signer verification. Dev serving is verified byte-for-byte with SHA256
`ceb58a7dc5f1398fe84f30255ec9ed79834f5db5f8fbc03eac52e14186fab1a1`.
The operator reports “works, we can proceed” after the phone save/open/cancel
check. This physical companion download gate is **accepted (2026-10-05)**. The APK is staged for fleet OTA/ISO and official/demo downloads;
only the dev-box test download is updated now. No fleet/public release is claimed.
Binary file not shown.
@@ -1,4 +1,4 @@
{
"versionName": "0.5.32",
"versionCode": 52
"versionName": "0.5.34",
"versionCode": 54
}
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import MediaLightbox from '../cloud/MediaLightbox.vue'
import type { FileBrowserItem } from '../../api/filebrowser-client'
const items = ['first.jpg', 'second.jpg', 'clip.mp4'].map(name => ({ name, path: '/' + name, isDir: false }) as FileBrowserItem)
const mounted: ReturnType<typeof mount>[] = []
async function viewer(extra = {}) {
const w = mount(MediaLightbox, { props: { items, startIndex: 0, show: true, fetchBlobUrl: vi.fn(async (p: string) => 'blob:' + p), ...extra }, attachTo: document.body })
mounted.push(w); await flushPromises(); return w
}
const button = (name: string) => document.querySelector(`[aria-label="${name}"]`) as HTMLButtonElement
beforeEach(() => { URL.revokeObjectURL = vi.fn() })
afterEach(() => { mounted.forEach(w => w.unmount()); mounted.length = 0; document.body.innerHTML = ''; vi.restoreAllMocks() })
describe('mobile media viewer', () => {
it('requests fullscreen on the whole viewer for photos and exits it', async () => {
await viewer()
const backdrop = document.querySelector('.lightbox-backdrop') as HTMLElement
const request = vi.fn(async () => {})
backdrop.requestFullscreen = request
button('Fullscreen').click(); await flushPromises()
expect(request).toHaveBeenCalledOnce()
expect(button('Exit fullscreen')).toBeTruthy()
Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: backdrop })
document.exitFullscreen = vi.fn(async () => {})
button('Exit fullscreen').click(); await flushPromises()
expect(document.exitFullscreen).toHaveBeenCalledOnce()
Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null })
})
it('offers an exit when an embedded browser denies native fullscreen', async () => {
await viewer()
const backdrop = document.querySelector('.lightbox-backdrop') as HTMLElement
backdrop.requestFullscreen = vi.fn(async () => { throw new Error('Denied') })
button('Fullscreen').click(); await flushPromises()
expect(backdrop.classList.contains('lightbox-expanded')).toBe(true)
button('Exit fullscreen').click(); await flushPromises()
expect(backdrop.classList.contains('lightbox-expanded')).toBe(false)
})
it('uses the Safari video fullscreen fallback', async () => {
await viewer({ startIndex: 2 })
const video = document.querySelector('video')! as HTMLVideoElement & { webkitEnterFullscreen: () => void }
video.webkitEnterFullscreen = vi.fn()
button('Fullscreen').click(); await flushPromises()
expect(video.webkitEnterFullscreen).toHaveBeenCalledOnce()
expect(video.hasAttribute('playsinline')).toBe(true)
})
it('shows decode errors instead of leaving a broken video and can retry', async () => {
const streamUrl = vi.fn(async () => 'https://example.test/clip.mp4')
await viewer({ startIndex: 2, streamUrl })
document.querySelector('video')!.dispatchEvent(new Event('error')); await flushPromises()
expect(document.querySelector('video')).toBeNull()
expect(document.querySelector('[role="alert"]')?.textContent).toContain('Unable to load')
;(document.querySelector('.lightbox-retry') as HTMLButtonElement).click(); await flushPromises()
expect(streamUrl).toHaveBeenCalledTimes(2)
expect(document.querySelector('video')).toBeTruthy()
})
it('ignores a late fetch from the previous photo and revokes its blob', async () => {
let resolveFirst!: (url: string) => void
const fetchBlobUrl = vi.fn((path: string) => path === '/first.jpg' ? new Promise<string>(resolve => { resolveFirst = resolve }) : Promise.resolve('blob:second'))
await viewer({ fetchBlobUrl })
button('Next file').click(); await flushPromises()
resolveFirst('blob:stale'); await flushPromises()
expect(document.querySelector('img')?.getAttribute('src')).toBe('blob:second')
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:stale')
})
it('restores focus and page scrolling after closing', async () => {
const origin = document.createElement('button'); document.body.append(origin); origin.focus()
document.body.style.overflow = 'auto'
const w = await viewer()
expect(document.body.style.overflow).toBe('hidden')
w.unmount(); mounted.splice(mounted.indexOf(w), 1)
expect(document.activeElement).toBe(origin)
expect(document.body.style.overflow).toBe('auto')
})
})
@@ -0,0 +1,92 @@
<template>
<span class="file-actions" @click.stop @keydown.stop>
<button ref="trigger" type="button" class="file-actions-trigger" :aria-label="`Actions for ${name}`"
title="File actions" aria-haspopup="dialog" :aria-expanded="open" @click="toggle">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/>
</svg>
</button>
<Teleport v-if="open" :to="portalTarget">
<div class="file-actions-overlay" @click.self.stop="close" @keydown.stop="onKeydown">
<section ref="panel" class="file-actions-panel" role="dialog" aria-modal="true" :aria-label="`File actions for ${name}`"
:style="position" @click.stop>
<p class="file-actions-name" :title="name">{{ name }}</p>
<template v-if="!confirmDelete">
<button v-if="canShare" type="button" @click="choose('share')">Share with peers</button>
<a v-if="downloadHref" :href="downloadHref" :download="name" @click="close">Download</a>
<button v-if="canDelete" type="button" class="file-actions-danger" @click="confirmDelete = true">Delete…</button>
<button type="button" @click="close">Close menu</button>
</template>
<template v-else>
<p class="file-actions-warning">Permanently delete this file?</p>
<button type="button" @click="confirmDelete = false">Keep file</button>
<button type="button" class="file-actions-danger" @click="choose('delete')">Delete file</button>
</template>
</section>
</div>
</Teleport>
</span>
</template>
<script setup lang="ts">
import { ref, nextTick, watch, onUnmounted } from 'vue'
defineProps<{ name: string; downloadHref?: string; canShare?: boolean; canDelete?: boolean }>()
const emit = defineEmits<{ share: []; delete: [] }>()
const trigger = ref<HTMLButtonElement | null>(null)
const panel = ref<HTMLElement | null>(null)
const open = ref(false)
const confirmDelete = ref(false)
const portalTarget = ref<Element | string>('body')
const position = ref<Record<string, string>>({})
function close() { open.value = false; confirmDelete.value = false; trigger.value?.focus() }
async function toggle() {
if (open.value) { close(); return }
portalTarget.value = document.fullscreenElement ?? 'body'
const r = trigger.value!.getBoundingClientRect()
const width = Math.min(248, window.innerWidth - 24)
position.value = { width: `${width}px`, left: `${Math.max(12, Math.min(r.right - width, window.innerWidth - width - 12))}px`,
...(r.top > window.innerHeight / 2 ? { bottom: `${window.innerHeight - r.top + 8}px` } : { top: `${r.bottom + 8}px` }) }
open.value = true
await nextTick()
const height = panel.value?.getBoundingClientRect().height ?? 0
const top = Math.max(12, Math.min(r.top > height + 20 ? r.top - height - 8 : r.bottom + 8, window.innerHeight - height - 12))
position.value = { width: `${width}px`, left: position.value.left!, top: `${top}px` }
panel.value?.querySelector<HTMLElement>('button, a')?.focus()
}
function choose(action: 'share' | 'delete') { close(); if (action === 'share') emit('share'); else emit('delete') }
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); close() }
if (event.key !== 'Tab') return
const controls = Array.from(panel.value?.querySelectorAll<HTMLElement>('button, a') ?? [])
const current = controls.indexOf(document.activeElement as HTMLElement)
event.preventDefault()
controls[(current + (event.shiftKey ? -1 : 1) + controls.length) % controls.length]?.focus()
}
watch(confirmDelete, async () => { await nextTick(); panel.value?.querySelector<HTMLElement>('button, a')?.focus() })
// A viewport change invalidates the anchor; dismiss rather than strand actions offscreen.
watch(open, visible => {
window[visible ? 'addEventListener' : 'removeEventListener']('resize', close)
})
onUnmounted(() => window.removeEventListener('resize', close))
</script>
<style scoped>
.file-actions { display: inline-flex; flex: none; }
.file-actions-trigger {
width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center;
border-radius: 50%; color: #fff; background: rgba(16, 20, 24, .5);
border: 1px solid rgba(255,255,255,.16); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
cursor: pointer; padding: 0; flex: none;
}
.file-actions-trigger:hover { background: rgba(50,55,60,.7); }
.file-actions-trigger:focus-visible, .file-actions-panel :is(button,a):focus-visible { outline: 2px solid white; outline-offset: 2px; }
.file-actions-overlay { position: fixed; inset: 0; z-index: 5000; background: rgba(0,0,0,.12); }
.file-actions-panel {
position: fixed; padding: 8px; border-radius: 16px; border: 1px solid rgba(255,255,255,.16);
background: rgba(20,24,28,.94); color: #fff; backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 40px rgba(0,0,0,.4); max-height: calc(100dvh - 100px); overflow-y: auto;
}
.file-actions-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #bbb; padding: 8px 12px; }
.file-actions-panel :is(button,a) { display: flex; align-items: center; width: 100%; min-height: 44px; padding: 10px 12px; text-align: left; border-radius: 8px; font-size: 14px; cursor: pointer; text-decoration: none; }
.file-actions-panel :is(button,a):hover { background: rgba(255,255,255,.08); }
.file-actions-panel .file-actions-danger { color: #ffaaa3; }
.file-actions-warning { padding: 8px 12px; font-size: 14px; }
</style>
+13 -39
View File
@@ -1,5 +1,8 @@
<template>
<button
<div
role="button"
@keydown.enter.self.prevent="handleClick"
@keydown.space.self.prevent="handleClick"
class="cloud-file-item group"
data-controller-container
data-controller-primary
@@ -49,48 +52,15 @@
</div>
</div>
<!-- Actions -->
<div class="cloud-file-item-actions" @click.stop>
<button
class="cloud-file-action-btn cloud-file-action-share"
title="Share with peers"
@click.stop="$emit('share', item.path, item.name, item.isDir)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
<a
v-if="!item.isDir"
:href="downloadHref"
download
class="cloud-file-action-btn"
title="Download"
@click.stop
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</a>
<button
v-if="!item.isDir"
class="cloud-file-action-btn cloud-file-action-delete"
title="Delete"
@click.stop="$emit('delete', item.path)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<svg v-if="item.isDir" class="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
<FileActionsMenu class="cloud-card-menu" :name="item.name" :download-href="item.isDir ? undefined : downloadHref"
can-share :can-delete="!item.isDir"
@share="emit('share', item.path, item.name, item.isDir)" @delete="emit('delete', item.path)" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import FileActionsMenu from './FileActionsMenu.vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { useCloudStore } from '@/stores/cloud'
import { useFileType, formatSize, formatDate } from '@/composables/useFileType'
@@ -135,3 +105,7 @@ function handleClick() {
}
}
</script>
<style scoped>
.cloud-card-menu { margin-left: auto; flex: none; }
</style>
+14 -36
View File
@@ -1,5 +1,8 @@
<template>
<button
<div
role="button"
@keydown.enter.self.prevent="handleClick"
@keydown.space.self.prevent="handleClick"
class="cloud-grid-card group"
data-controller-container
tabindex="0"
@@ -78,46 +81,16 @@
</span>
</div>
<!-- Actions overlay at top-left (visible on hover) -->
<div class="cloud-grid-card-actions" @click.stop>
<button
class="cloud-file-action-btn cloud-file-action-share"
title="Share with peers"
@click.stop="emit('share', item.path, item.name, item.isDir)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
<a
v-if="!item.isDir"
:href="downloadHref"
download
class="cloud-file-action-btn"
title="Download"
@click.stop
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</a>
<button
v-if="!item.isDir"
class="cloud-file-action-btn cloud-file-action-delete"
title="Delete"
@click.stop="emit('delete', item.path)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<FileActionsMenu class="cloud-card-menu" :name="item.name" :download-href="item.isDir ? undefined : downloadHref"
can-share :can-delete="!item.isDir"
@share="emit('share', item.path, item.name, item.isDir)" @delete="emit('delete', item.path)" />
</div>
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import FileActionsMenu from './FileActionsMenu.vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { useCloudStore } from '@/stores/cloud'
import { useFileType, formatSize, formatDate } from '@/composables/useFileType'
@@ -178,3 +151,8 @@ function handleClick() {
}
}
</script>
<style scoped>
.cloud-card-menu { position: absolute; bottom: 8px; right: 8px; z-index: 4; }
.cloud-grid-card-info { padding-right: 60px; }
</style>
+165 -93
View File
@@ -4,7 +4,8 @@
<div
v-if="show"
class="lightbox-backdrop"
:class="{ 'lightbox-pip-handoff': pipHandoff }"
:class="{ 'lightbox-pip-handoff': pipHandoff, 'lightbox-expanded': expanded }"
role="dialog" aria-modal="true" aria-label="Media viewer"
@click.self="close"
@keydown="onKeydown"
tabindex="0"
@@ -12,17 +13,27 @@
>
<!-- Top bar -->
<div class="lightbox-topbar">
<div class="flex items-center gap-3 min-w-0">
<span v-if="mediaItems.length > 1" class="text-sm text-white/50">
<div class="lightbox-heading">
<span v-if="mediaItems.length > 1" class="lightbox-count">
{{ currentIndex + 1 }} / {{ mediaItems.length }}
</span>
<p class="text-sm text-white/80 truncate">{{ currentItem?.name }}</p>
<p class="lightbox-title" :title="currentItem?.name">{{ currentItem?.name }}</p>
</div>
<div class="flex items-center gap-1">
<FileActionsMenu v-if="currentItem && (fileActions || downloadUrl)" :name="currentItem.name"
:download-href="downloadUrl?.(currentItem.path)" :can-share="fileActions" :can-delete="fileActions"
@share="emit('share', currentItem!); close()" @delete="emit('delete', currentItem!); close()" />
<div class="lightbox-actions">
<button class="lightbox-btn" :aria-label="expanded ? 'Exit fullscreen' : 'Fullscreen'"
:title="expanded ? 'Exit fullscreen' : 'Fullscreen'" @click.stop="toggleFullscreen">
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path v-if="!expanded" d="M8 3H3v5m13-5h5v5M3 16v5h5m13-5v5h-5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path v-else d="M3 8h5V3m8 0v5h5M8 21v-5H3m13 5v-5h5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button
v-if="pipAvailable && currentItem && isVideoFile(currentItem)"
class="lightbox-btn"
title="Picture-in-picture"
title="Picture-in-picture" aria-label="Picture-in-picture"
@click.stop="togglePip(videoEl)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -30,7 +41,7 @@
<rect x="12" y="12" width="7" height="5" rx="1" stroke-width="2" />
</svg>
</button>
<button class="lightbox-btn" @click="close">
<button class="lightbox-btn" aria-label="Close viewer" title="Close" @click="close">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -41,7 +52,7 @@
<!-- Navigation arrows -->
<button
v-if="mediaItems.length > 1"
class="lightbox-nav lightbox-nav-prev"
class="lightbox-nav lightbox-nav-prev" aria-label="Previous file"
@click.stop="prev"
>
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -50,7 +61,7 @@
</button>
<button
v-if="mediaItems.length > 1"
class="lightbox-nav lightbox-nav-next"
class="lightbox-nav lightbox-nav-next" aria-label="Next file"
@click.stop="next"
>
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -67,21 +78,23 @@
<!-- Image -->
<img
v-else-if="currentItem && currentUrl && isImageFile(currentItem)"
v-else-if="!mediaError && currentItem && currentUrl && isImageFile(currentItem)"
:src="currentUrl"
:alt="currentItem.name"
class="lightbox-media-img"
@dblclick="toggleFullscreen"
@error="onMediaError"
/>
<!-- Video -->
<video
v-else-if="currentItem && currentUrl && isVideoFile(currentItem)"
v-else-if="!mediaError && currentItem && currentUrl && isVideoFile(currentItem)"
ref="videoEl"
:src="currentUrl"
:key="currentUrl"
class="lightbox-media-video"
controls
playsinline
autoplay
@dblclick="toggleFullscreen"
@error="onMediaError"
@@ -91,7 +104,7 @@
<!-- Audio -->
<div
v-else-if="currentItem && currentUrl && isAudioFile(currentItem)"
v-else-if="!mediaError && currentItem && currentUrl && isAudioFile(currentItem)"
class="lightbox-audio-container"
>
<div class="lightbox-audio-artwork">
@@ -115,7 +128,8 @@
<svg class="w-12 h-12 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p class="text-white/40 text-sm">Failed to load media</p>
<p class="text-white/70 text-sm" role="alert">Unable to load this file.</p>
<button class="lightbox-retry" @click="retryMedia">Try again</button>
</div>
</div>
</div>
@@ -128,6 +142,7 @@ import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { getFileCategory } from '@/composables/useFileType'
import { isPipSupported, togglePip } from '@/utils/pip'
import FileActionsMenu from './FileActionsMenu.vue'
import { usePipSession } from '@/composables/usePipSession'
const props = defineProps<{
@@ -136,10 +151,14 @@ const props = defineProps<{
show: boolean
fetchBlobUrl: (path: string) => Promise<string>
streamUrl?: (path: string) => Promise<string>
downloadUrl?: (path: string) => string
fileActions?: boolean
}>()
const emit = defineEmits<{
close: []
share: [item: FileBrowserItem]
delete: [item: FileBrowserItem]
}>()
const currentIndex = ref(0)
@@ -149,6 +168,12 @@ const currentUrl = ref<string | null>(null)
const backdropEl = ref<HTMLElement | null>(null)
const videoEl = ref<HTMLVideoElement | null>(null)
const expanded = ref(false)
let loadId = 0
let disposed = false
let previousFocus: HTMLElement | null = null
let previousOverflow: string | null = null
const pipAvailable = isPipSupported()
const pipSession = usePipSession()
const pipHandoff = ref(false)
@@ -187,49 +212,36 @@ function isAudioFile(item: FileBrowserItem): boolean {
}
async function loadMedia(item: FileBrowserItem) {
const requestId = ++loadId
loading.value = true
mediaError.value = false
currentUrl.value = null
try {
const cached = urlCache.get(item.path)
if (cached) {
currentUrl.value = cached
} else {
// Use streaming URL for video/audio (avoids downloading entire file into blob)
// Use blob URL for images (needed for rendering)
const isStreamable = isVideoFile(item) || isAudioFile(item)
if (isStreamable && props.streamUrl) {
const url = await props.streamUrl(item.path)
urlCache.set(item.path, url)
currentUrl.value = url
} else {
const url = await props.fetchBlobUrl(item.path)
urlCache.set(item.path, url)
currentUrl.value = url
let url = urlCache.get(item.path)
if (!url) {
url = (isVideoFile(item) || isAudioFile(item)) && props.streamUrl
? await props.streamUrl(item.path) : await props.fetchBlobUrl(item.path)
if (disposed || requestId !== loadId) {
if (url.startsWith('blob:')) URL.revokeObjectURL(url)
return
}
urlCache.set(item.path, url)
}
if (requestId === loadId) currentUrl.value = url
} catch {
mediaError.value = true
if (requestId === loadId) mediaError.value = true
} finally {
loading.value = false
if (requestId === loadId) loading.value = false
}
}
function preloadAdjacent() {
const items = mediaItems.value
if (items.length <= 1) return
const prevIdx = (currentIndex.value - 1 + items.length) % items.length
const nextIdx = (currentIndex.value + 1) % items.length
for (const idx of [prevIdx, nextIdx]) {
const item = items[idx]
if (item && !urlCache.has(item.path) && isImageFile(item)) {
props.fetchBlobUrl(item.path).then(url => {
urlCache.set(item.path, url)
}).catch(() => {})
}
}
function retryMedia() {
const item = currentItem.value
if (!item) return
const old = urlCache.get(item.path)
if (old?.startsWith('blob:')) URL.revokeObjectURL(old)
urlCache.delete(item.path)
void loadMedia(item)
}
function prev() {
@@ -245,6 +257,7 @@ function next() {
}
function close() {
if (document.fullscreenElement === backdropEl.value) void document.exitFullscreen?.().catch(() => {})
emit('close')
}
@@ -280,14 +293,37 @@ function onLeavePip() {
pipSession.release()
}
function toggleFullscreen() {
const el = videoEl.value
if (!el) return
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
el.requestFullscreen().catch(() => {})
type SafariVideo = HTMLVideoElement & { webkitEnterFullscreen?: () => void }
async function toggleFullscreen() {
if (document.fullscreenElement === backdropEl.value) {
try { await document.exitFullscreen() } catch { /* Keep the exit control available. */ }
return
}
if (expanded.value) { expanded.value = false; return }
const el = backdropEl.value
if (!el) return
try {
if (el.requestFullscreen) {
await el.requestFullscreen()
expanded.value = true
return
}
const video = videoEl.value as SafariVideo | null
if (video?.webkitEnterFullscreen) { video.webkitEnterFullscreen(); return }
} catch { /* Embedded browsers may deny native fullscreen; expand in-place. */ }
expanded.value = true
}
function fullscreenChanged() {
expanded.value = document.fullscreenElement === backdropEl.value
}
function restorePage() {
if (previousOverflow !== null) document.body.style.overflow = previousOverflow
previousOverflow = null
previousFocus?.focus()
previousFocus = null
document.removeEventListener('fullscreenchange', fullscreenChanged)
}
function onMediaError() {
@@ -296,33 +332,47 @@ function onMediaError() {
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { e.preventDefault(); close() }
else if (e.key === 'ArrowLeft') { e.preventDefault(); prev() }
else if (e.key === 'ArrowRight') { e.preventDefault(); next() }
}
watch(currentItem, (item) => {
if (item) {
loadMedia(item)
preloadAdjacent()
if (e.key === 'Escape') {
e.preventDefault()
if (expanded.value) void toggleFullscreen()
else close()
} else if (e.key === 'Tab') {
const controls = Array.from(backdropEl.value?.querySelectorAll<HTMLElement>('button:not(:disabled), video[controls], audio[controls]') ?? [])
const first = controls[0], last = controls[controls.length - 1]
if (e.shiftKey && (document.activeElement === first || document.activeElement === backdropEl.value)) { e.preventDefault(); last?.focus() }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus() }
} else if (!(e.target instanceof HTMLMediaElement)) {
if (e.key === 'ArrowLeft') { e.preventDefault(); prev() }
else if (e.key === 'ArrowRight') { e.preventDefault(); next() }
}
})
}
watch(() => props.show, async (visible) => {
if (visible) {
pipHandoff.value = false
currentIndex.value = props.startIndex
const item = mediaItems.value[props.startIndex]
if (item) {
await loadMedia(item)
preloadAdjacent()
}
expanded.value = false
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
document.addEventListener('fullscreenchange', fullscreenChanged)
currentIndex.value = Math.max(0, Math.min(props.startIndex, mediaItems.value.length - 1))
await nextTick()
backdropEl.value?.focus()
} else {
++loadId
restorePage()
}
}, { immediate: true })
watch([currentItem, () => props.show], ([item, visible]) => {
if (visible && item) void loadMedia(item)
}, { immediate: true })
onUnmounted(() => {
disposed = true
++loadId
if (handoffTimer) clearTimeout(handoffTimer)
restorePage()
for (const url of urlCache.values()) {
URL.revokeObjectURL(url)
}
@@ -334,7 +384,10 @@ onUnmounted(() => {
.lightbox-backdrop {
position: fixed;
inset: 0;
z-index: 60;
z-index: 4000;
height: 100dvh;
overflow: hidden;
overscroll-behavior: contain;
display: flex;
align-items: center;
justify-content: center;
@@ -352,14 +405,23 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
gap: 1rem;
padding: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 12px) max(16px, env(safe-area-inset-right, 0px)) 12px max(16px, env(safe-area-inset-left, 0px));
background: linear-gradient(to bottom, rgba(0,0,0,0.6) 0%, transparent 100%);
z-index: 10;
}
.lightbox-heading { flex: 1; display: flex; align-items: center; gap: 12px; min-width: 0; }
.lightbox-title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: #eee; font-size: 14px; }
.lightbox-count { flex: none; font-size: 12px; color: #aaa; font-variant-numeric: tabular-nums; }
.lightbox-actions { display: flex; flex: none; gap: 8px; }
.lightbox-btn:focus-visible, .lightbox-nav:focus-visible, .lightbox-retry:focus-visible { outline: 2px solid white; outline-offset: 3px; }
.lightbox-retry { padding: 12px 20px; border-radius: 24px; background: #292929; color: white; }
.lightbox-btn {
width: 2.5rem;
height: 2.5rem;
width: 44px;
height: 44px;
flex: 0 0 44px;
padding: 0;
border-radius: 50%;
display: flex;
align-items: center;
@@ -410,7 +472,11 @@ onUnmounted(() => {
justify-content: center;
width: 100%;
height: 100%;
padding: 3.5rem 1rem;
max-width: none;
max-height: none;
min-height: 0;
box-sizing: border-box;
padding: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 76px) 76px calc(var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 16px);
}
.lightbox-media-img {
@@ -423,7 +489,8 @@ onUnmounted(() => {
.lightbox-media-video {
width: 100%;
height: 100%;
height: auto;
max-height: 100%;
object-fit: contain;
border-radius: 0;
background: black;
@@ -501,28 +568,33 @@ onUnmounted(() => {
}
}
/* Mobile */
@media (max-width: 768px) {
.lightbox-content { padding: 3rem 0; }
.lightbox-nav { width: 2.5rem; height: 2.5rem; }
.lightbox-nav-prev { left: 0.5rem; }
.lightbox-nav-next { right: 0.5rem; }
.lightbox-audio-artwork { width: 8rem; height: 8rem; }
.lightbox-media-video { border-radius: 0; }
/* The close button used to sit in the top bar, which lands under the
status bar / notch safe area on most phones and is awkward to reach.
Detach it from the top bar and pin it bottom-center, under the media,
for mobile only — desktop keeps it in the top bar. */
.lightbox-topbar { padding-right: 1rem; }
.lightbox-topbar .lightbox-btn {
/* Mobile controls have their own safe-area row, never covering video controls. */
@media (max-width: 768px), (max-height: 500px) and (pointer: coarse) {
.lightbox-topbar { padding-bottom: 8px; }
.lightbox-heading { flex: 1; }
.lightbox-actions {
position: fixed;
top: auto;
bottom: calc(env(safe-area-inset-bottom, 0px) + 1rem);
bottom: calc(var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 12px);
left: 50%;
right: auto;
transform: translateX(-50%);
z-index: 20;
gap: 8px;
}
.lightbox-content {
padding: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 48px) max(8px, env(safe-area-inset-right, 0px)) calc(var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 72px) max(8px, env(safe-area-inset-left, 0px));
}
.lightbox-nav {
top: auto;
bottom: calc(var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 12px);
transform: none;
width: 44px; height: 44px; padding: 0;
}
.lightbox-nav-prev { left: max(12px, env(safe-area-inset-left, 0px)); }
.lightbox-nav-next { right: max(12px, env(safe-area-inset-right, 0px)); }
.lightbox-audio-artwork { width: 8rem; height: 8rem; }
}
.lightbox-expanded .lightbox-content { padding-top: var(--safe-area-top, env(safe-area-inset-top, 0px)); }
.lightbox-expanded .lightbox-heading { visibility: hidden; }
@media (prefers-reduced-motion: reduce) {
.lightbox-fade-enter-active, .lightbox-fade-leave-active { transition: none; }
}
</style>
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import FileActionsMenu from '../FileActionsMenu.vue'
import FileCard from '../FileCard.vue'
import FileCardGrid from '../FileCardGrid.vue'
vi.mock('@/stores/cloud', () => ({ useCloudStore: () => ({ downloadUrl: (p: string) => '/download' + p }) }))
vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ playing: { value: false }, currentSrc: { value: '' } }) }))
const wrappers: ReturnType<typeof mount>[] = []
function render(component: any, props: any) { const w = mount(component, { props, attachTo: document.body }); wrappers.push(w); return w }
afterEach(() => { wrappers.forEach(w => w.unmount()); wrappers.length = 0; document.body.innerHTML = '' })
async function openMenu() { (document.querySelector('.file-actions-trigger') as HTMLButtonElement).click(); await flushPromises() }
function action(text: string) { return Array.from(document.querySelectorAll('.file-actions-panel button')).find(e => e.textContent === text) as HTMLButtonElement }
describe('visible file actions', () => {
for (const card of [FileCard, FileCardGrid]) {
it(`${card.__name} menu never opens a file and forwards share/delete correctly`, async () => {
const item = { name: 'photo.jpg', path: '/photo.jpg', extension: 'jpg', isDir: false, size: 123, modified: '2026-10-05' }
const w = render(card, { item }); await openMenu()
expect(w.emitted('preview')).toBeUndefined()
expect(document.querySelector('.file-actions-panel a')?.getAttribute('href')).toBe('/download/photo.jpg')
action('Share with peers').click(); await flushPromises()
expect(w.emitted('share')).toEqual([['/photo.jpg', 'photo.jpg', false]])
expect(w.emitted('preview')).toBeUndefined()
await openMenu(); action('Delete…').click(); await flushPromises()
expect(w.emitted('delete')).toBeUndefined()
action('Keep file').click(); await flushPromises()
expect(w.emitted('delete')).toBeUndefined()
action('Delete…').click(); await flushPromises(); action('Delete file').click(); await flushPromises()
expect(w.emitted('delete')).toEqual([['/photo.jpg']])
expect(w.emitted('preview')).toBeUndefined()
})
}
it('omits actions that the caller cannot offer', async () => {
render(FileActionsMenu, { name: 'Purchased image.jpg', downloadHref: 'blob:owned' }); await openMenu()
expect(action('Delete…')).toBeUndefined(); expect(action('Share with peers')).toBeUndefined()
expect(document.querySelector('.file-actions-panel a')).toBeTruthy()
})
it('dismisses on Escape and restores the trigger focus', async () => {
render(FileActionsMenu, { name: 'file', canShare: true }); await openMenu()
action('Share with peers').dispatchEvent(new KeyboardEvent('keydown', {key:'Escape', bubbles:true})); await flushPromises()
expect(document.querySelector('.file-actions-panel')).toBeNull()
expect(document.activeElement).toBe(document.querySelector('.file-actions-trigger'))
})
it('renders inside the fullscreen element so the menu remains reachable', async () => {
const host = document.createElement('div');document.body.appendChild(host)
Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: host })
try { render(FileActionsMenu, {name:'file', canShare:true}); await openMenu();expect(host.querySelector('.file-actions-panel')).toBeTruthy() }
finally { Object.defineProperty(document, 'fullscreenElement', { configurable:true,value:null }) }
})
})
+4
View File
@@ -415,6 +415,10 @@
:show="lightboxIndex !== null"
:fetch-blob-url="cloudStore.fetchBlobUrl"
:stream-url="cloudStore.streamUrl"
:download-url="cloudStore.downloadUrl"
file-actions
@share="item => handleShare(item.path, item.name, item.isDir)"
@delete="item => handleDelete(item.path)"
@close="lightboxIndex = null"
/>
+10 -1
View File
@@ -130,6 +130,8 @@
/>
</div>
<p v-if="actionError" role="alert" class="alert-error mt-3">{{ actionError }}</p>
<!-- Share Modal -->
<ShareModal
v-if="shareTarget"
@@ -148,6 +150,10 @@
:show="lightboxIndex !== null"
:fetch-blob-url="cloudStore.fetchBlobUrl"
:stream-url="cloudStore.streamUrl"
:download-url="cloudStore.downloadUrl"
file-actions
@share="item => handleShare(item.path, item.name, item.isDir)"
@delete="item => handleDelete(item.path)"
@close="lightboxIndex = null"
/>
</div>
@@ -314,6 +320,7 @@ watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => {
}
}, { immediate: true })
const actionError = ref<string | null>(null)
const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(null)
const lightboxIndex = ref<number | null>(null)
@@ -370,7 +377,9 @@ async function handleUpload(files: File[]) {
}
async function handleDelete(path: string) {
await cloudStore.deleteItem(path)
actionError.value = null
try { await cloudStore.deleteItem(path) }
catch (error) { actionError.value = error instanceof Error ? error.message : 'Unable to delete this file. Please try again.' }
}
async function handlePlay(path: string, name: string) {
+10 -3
View File
@@ -71,15 +71,22 @@ echo "publish-companion-apk: zipalign + sign (v1+v2+v3)…" >&2
"$SIGNED"
# 4. Verify all three schemes (min-sdk 21 forces the v1 path to be exercised).
VERIFY="$("$APKSIGNER" verify -v --min-sdk-version 21 "$SIGNED" 2>&1)"
VERIFY="$("$APKSIGNER" verify -v --min-sdk-version 21 --print-certs "$SIGNED" 2>&1)"
for scheme in "v1 scheme" "v2 scheme" "v3 scheme"; do
if ! printf '%s\n' "$VERIFY" | grep -iq "$scheme.*: true"; then
if ! grep -iq "$scheme.*: true" <<< "$VERIFY"; then
echo "publish-companion-apk: ERROR — $scheme NOT present after signing. Aborting." >&2
printf '%s\n' "$VERIFY" | grep -iE "scheme" >&2
exit 1
fi
done
echo "publish-companion-apk: verified v1 + v2 + v3 signatures." >&2
# The existing shared companion identity must survive updates. Verifying a new
# certificate's mathematical validity alone does not establish upgrade compatibility.
EXPECTED_SIGNER_SHA256="d622e07e7f474246e8e2bcee828ca9bf6c6cdf9434c065c58fe89bb4eec2664d"
if ! grep -iq "Signer #1 certificate SHA-256 digest: $EXPECTED_SIGNER_SHA256" <<< "$VERIFY"; then
echo "publish-companion-apk: ERROR — signing identity differs from the existing companion. Aborting." >&2
exit 1
fi
echo "publish-companion-apk: verified v1 + v2 + v3 signatures and existing signer." >&2
# 5. Publish.
mkdir -p "$(dirname "$DEST")"
@@ -0,0 +1,41 @@
"""Run the publisher's actual verifier block, including noisy apksigner output."""
import os
import pathlib
import subprocess
import tempfile
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[2]
SCRIPT = (ROOT / 'scripts/publish-companion-apk.sh').read_text()
BLOCK = SCRIPT[SCRIPT.index('# 4. Verify'):SCRIPT.index('# 5. Publish')]
CERT = 'd622e07e7f474246e8e2bcee828ca9bf6c6cdf9434c065c58fe89bb4eec2664d'
GOOD = '\n'.join(f'Verified using {v} scheme (test): true' for v in ['v1', 'v2', 'v3']) + '\nSigner #1 certificate SHA-256 digest: ' + CERT
class VerificationTests(unittest.TestCase):
def check(self, output, code=0):
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp)
verifier = root / 'apksigner'
verifier.write_text('#!/bin/sh\ncat "$TEST_VERIFY_OUTPUT"\nexit "$TEST_VERIFY_CODE"\n')
verifier.chmod(0o700)
(root / 'output').write_text(output)
env = dict(os.environ, APKSIGNER=str(verifier), SIGNED=str(root / 'test.apk'),
TEST_VERIFY_OUTPUT=str(root / 'output'), TEST_VERIFY_CODE=str(code))
return subprocess.run(['bash', '-c', 'set -euo pipefail\n' + BLOCK], env=env, capture_output=True, text=True).returncode
def test_noisy_valid_apk_does_not_fail_from_grep_sigpipe(self):
# Reproduces pipefail+grep -q exit141 after an otherwise valid APK.
self.assertEqual(self.check(GOOD + '\n' + 'WARNING: compatible signature algorithm\n' * 4000), 0)
def test_all_three_schemes_are_required(self):
for version in ['v1', 'v2', 'v3']:
with self.subTest(version=version):
self.assertNotEqual(self.check(GOOD.replace(f'{version} scheme (test): true', f'{version} scheme (test): false')), 0)
def test_wrong_signing_identity_fails(self):
self.assertNotEqual(self.check(GOOD.replace(CERT, '0' * 64)), 0)
def test_verifier_failure_is_never_accepted(self):
self.assertNotEqual(self.check(GOOD, 1), 0)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,11 @@
// Local Vite browser regression; creates no real files and performs no network deletions.
const { chromium, expect } = require('../../neode-ui/node_modules/@playwright/test');
(async()=>{const source=await fetch((process.env.LIGHTBOX_TEST_URL||'http://127.0.0.1:5187')+'/src/stores/cloud.ts').then(r=>r.text());const piniaUrl=source.match(/from "([^"]*pinia\.js[^"]*)"/)[1];const b=await chromium.launch({headless:true});try{for(const width of [320,390,1440]){const p=await b.newPage({viewport:{width,height:844},hasTouch:true});
p.on('pageerror',e=>console.error('fixture error',e.message));
await p.route('**/actions-fixture',r=>r.fulfill({contentType:'text/html',body:`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><div id="fixture"></div><script type="module">
import {createApp,h} from '/node_modules/.vite/deps/vue.js';import {createPinia,setActivePinia} from '${piniaUrl}';import Card from '/src/components/cloud/FileCardGrid.vue';import Row from '/src/components/cloud/FileCard.vue';import {useCloudStore} from '/src/stores/cloud.ts';import '/src/style.css';
const pinia=createPinia();setActivePinia(pinia);const cloud=useCloudStore();cloud.downloadUrl=()=>'';window.previews=0;window.shares=0;window.deletes=0;const item={name:'A long video filename which must remain readable.mp4',path:'/clip.mp4',extension:'mp4',isDir:false,size:123,modified:'2026-10-05'};const props={item,onPreview:()=>window.previews++,onShare:()=>window.shares++,onDelete:()=>window.deletes++};createApp({render:()=>h('div',{style:'padding:16px'},[h('div',{class:'cloud-card-grid'},[h(Card,props),h(Card,props)]),h(Row,props)])}).use(pinia).mount('#fixture');</script>`}));
await p.goto((process.env.LIGHTBOX_TEST_URL||'http://127.0.0.1:5187')+'/actions-fixture');const buttons=p.locator('.file-actions-trigger');await expect(buttons).toHaveCount(3,{timeout:30000});
for(let i=0;i<3;i++){const trigger=buttons.nth(i);await expect(trigger).toBeVisible();const rect=await trigger.boundingBox();if(rect.width<44||rect.height<44||rect.x<0||rect.x+rect.width>width)throw Error('Invalid permanent action geometry');await trigger.tap();await expect(p.getByRole('dialog')).toBeVisible();const menu=await p.getByRole('dialog').boundingBox();if(menu.x<0||menu.x+menu.width>width||menu.y<0||menu.y+menu.height>844)throw Error('Menu clipped');await p.getByRole('button',{name:'Share with peers',exact:true}).tap();await expect.poll(()=>p.evaluate(()=>window.previews)).toBe(0);}
await buttons.first().tap();await p.getByRole('button',{name:'Delete…',exact:true}).tap();await expect.poll(()=>p.evaluate(()=>window.deletes)).toBe(0);await p.getByRole('button',{name:'Keep file',exact:true}).tap();await p.getByRole('button',{name:'Close menu',exact:true}).tap();
await p.screenshot({path:'/tmp/archy-file-actions-'+width+'.png'});console.log('PASS',width,'permanent touch menu in grid/list, no accidental open, visible unclipped menu, share, cancel delete');await p.close();}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
@@ -0,0 +1,48 @@
// Run against a local Vite dev server; no node credentials or user media involved.
const { chromium, expect } = require('../../neode-ui/node_modules/@playwright/test');
const base = process.env.LIGHTBOX_TEST_URL || 'http://127.0.0.1:5187';
(async () => {
const browser = await chromium.launch({ headless: true });
try {
for (const [width,height] of [[320,568],[390,844],[844,390],[1440,900]]) {
const context = await browser.newContext({viewport:{width,height},hasTouch:width<900});
const page = await context.newPage();
const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.route('**/lightbox-fixture',r=>r.fulfill({contentType:'text/html',body:`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><div id="fixture"></div><script type="module">
import {createApp,h,ref} from '/node_modules/.vite/deps/vue.js';
import Viewer from '/src/components/cloud/MediaLightbox.vue';
import '/src/style.css';
document.documentElement.style.setProperty('--safe-area-top','28px');
document.documentElement.style.setProperty('--safe-area-bottom','24px');
const svg='<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800"><rect width="1200" height="800" fill="#123b3b"/><circle cx="600" cy="400" r="200" fill="#79dbc8"/></svg>';
const url=URL.createObjectURL(new Blob([svg],{type:'image/svg+xml'}));
window.show=ref(true);window.start=ref(0);
const items=[{name:'A very long photo name that must truncate without shrinking any buttons.jpg',path:'/one.jpg'},{name:'second.jpg',path:'/two.jpg'},{name:'Landscape video with a long filename.webm',path:'/video.webm'}];
createApp({render:()=>h(Viewer,{items,startIndex:window.start.value,show:window.show.value,fileActions:true,downloadUrl:()=>url,fetchBlobUrl:async()=>url,streamUrl:async()=>window.testVideoUrl,onClose:()=>window.show.value=false})}).mount('#fixture');
</script>`}));
await page.goto(base+'/lightbox-fixture');
await expect(page.locator('.lightbox-media-img')).toBeVisible();
await page.waitForTimeout(200);
const boxes=await page.locator('.lightbox-btn,.lightbox-nav,.file-actions-trigger').evaluateAll(es=>es.map(e=>{const r=e.getBoundingClientRect();return {name:e.getAttribute('aria-label'),x:r.x,y:r.y,w:r.width,h:r.height}}));
for(const a of boxes){if(a.w<44||a.h<44||a.x<0||a.x+a.w>width+.1||a.y<28||a.y+a.h>height-24+.1)throw Error('Unsafe control '+JSON.stringify(a));}
for(let i=0;i<boxes.length;i++)for(let j=i+1;j<boxes.length;j++){const a=boxes[i],b=boxes[j];if(a.x<b.x+b.w&&a.x+a.w>b.x&&a.y<b.y+b.h&&a.y+a.h>b.y)throw Error('Overlapping controls');}
const title=await page.locator('.lightbox-title').boundingBox();if(title.y<28)throw Error('Title overlaps system bar');
await page.getByRole('button',{name:'Fullscreen',exact:true}).click();
await expect.poll(()=>page.evaluate(()=>!!document.fullscreenElement)).toBe(true);
await page.locator('.file-actions-trigger').click();await expect(page.getByRole('link',{name:'Download',exact:true})).toBeVisible();await page.getByRole('button',{name:'Close menu',exact:true}).click();
await page.getByRole('button',{name:'Exit fullscreen',exact:true}).click();
await expect.poll(()=>page.evaluate(()=>!!document.fullscreenElement)).toBe(false);
// A short local landscape video exercises native controls without fetching a user file.
await page.evaluate(async()=>{const c=document.createElement('canvas');c.width=640;c.height=360;const g=c.getContext('2d');g.fillStyle='#234';g.fillRect(0,0,640,360);const stream=c.captureStream(10);const r=new MediaRecorder(stream,{mimeType:'video/webm'});const chunks=[];r.ondataavailable=e=>chunks.push(e.data);const done=new Promise(resolve=>r.onstop=resolve);r.start();for(let i=0;i<12;i++){g.fillStyle=i%2?'#234':'#345';g.fillRect(0,0,640,360);await new Promise(resolve=>setTimeout(resolve,50));}r.stop();await done;stream.getTracks().forEach(t=>t.stop());window.testVideoUrl=URL.createObjectURL(new Blob(chunks,{type:'video/webm'}));});
await page.getByRole('button',{name:'Previous file',exact:true}).click();
const video=page.locator('video');await expect(video).toBeVisible();
await expect.poll(()=>video.evaluate(v=>v.readyState)).toBeGreaterThanOrEqual(2);await video.evaluate(v=>{v.loop=true;return v.play()});await expect.poll(()=>video.evaluate(v=>v.currentTime)).toBeGreaterThan(0);
const media=await video.boundingBox();if(media.height>height-110||media.width>width||await video.evaluate(v=>getComputedStyle(v).objectFit)!=='contain')throw Error('Video stretched or controls clipped '+JSON.stringify(media));
await page.screenshot({path:'/tmp/archy-lightbox-'+width+'x'+height+'.png'});
await page.getByRole('button',{name:'Close viewer',exact:true}).click();await expect(page.getByRole('dialog')).toHaveCount(0);
if(errors.length)throw Error(errors.join('\n'));
console.log('PASS',width,height,'safe areas, 44px nonoverlapping controls, image fullscreen/exit, real landscape video, close');
await context.close();
}
} finally {await browser.close()}
})().catch(e=>{console.error(e);process.exit(1)});