fix: qualify mobile Cloud viewer and companion downloads
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 52
|
versionCode = 54
|
||||||
versionName = "0.5.32"
|
versionName = "0.5.34"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
@@ -142,6 +142,9 @@ tasks.matching {
|
|||||||
}.configureEach { dependsOn("buildRustArm64") }
|
}.configureEach { dependsOn("buildRustArm64") }
|
||||||
|
|
||||||
dependencies {
|
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")
|
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
|
||||||
implementation(composeBom)
|
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.
|
// before surfacing the error page: the mesh tunnel works from anywhere.
|
||||||
meshFallbackUrl: String? = null,
|
meshFallbackUrl: String? = null,
|
||||||
) {
|
) {
|
||||||
|
val fullscreen = rememberWebViewFullscreen()
|
||||||
|
val downloads = rememberWebViewDownloads()
|
||||||
var isLoading by remember { mutableStateOf(true) }
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
// First kiosk load (often over the FIPS mesh) gets the full branded
|
// First kiosk load (often over the FIPS mesh) gets the full branded
|
||||||
// loader; later navigations keep just the slim top progress bar.
|
// loader; later navigations keep just the slim top progress bar.
|
||||||
@@ -913,6 +915,7 @@ fun WebViewScreen(
|
|||||||
cookieManager.setAcceptThirdPartyCookies(this, true)
|
cookieManager.setAcceptThirdPartyCookies(this, true)
|
||||||
|
|
||||||
applyArchipelagoSettings()
|
applyArchipelagoSettings()
|
||||||
|
setDownloadListener(downloads)
|
||||||
settings.apply {
|
settings.apply {
|
||||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||||
// Let JS open windows without a synchronous user-gesture
|
// Let JS open windows without a synchronous user-gesture
|
||||||
@@ -1181,6 +1184,12 @@ fun WebViewScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
webChromeClient = object : WebChromeClient() {
|
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) {
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||||
loadProgress = newProgress
|
loadProgress = newProgress
|
||||||
}
|
}
|
||||||
@@ -1546,6 +1555,8 @@ private fun InAppBrowser(
|
|||||||
appName: String? = null,
|
appName: String? = null,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val fullscreen = rememberWebViewFullscreen()
|
||||||
|
val downloads = rememberWebViewDownloads()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
|
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
|
||||||
// kiosk's isSameNode; a mismatch here bounced app links to the browser.
|
// kiosk's isSameNode; a mismatch here bounced app links to the browser.
|
||||||
@@ -1643,6 +1654,7 @@ private fun InAppBrowser(
|
|||||||
|
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||||
applyArchipelagoSettings()
|
applyArchipelagoSettings()
|
||||||
|
setDownloadListener(downloads)
|
||||||
// Node apps (BTCPay invoices, LND, Portainer tokens) are
|
// Node apps (BTCPay invoices, LND, Portainer tokens) are
|
||||||
// served over plain HTTP too — same dead-clipboard trap.
|
// served over plain HTTP too — same dead-clipboard trap.
|
||||||
addClipboardBridge()
|
addClipboardBridge()
|
||||||
@@ -1662,6 +1674,12 @@ private fun InAppBrowser(
|
|||||||
)
|
)
|
||||||
|
|
||||||
webChromeClient = object : WebChromeClient() {
|
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) {
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||||
progress = newProgress
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
version; its regression passes. Latest notes UI built and deployed dev/yaya index
|
||||||
SHA256 `97aab07e67eccc1bb3d215534b537b83e1372bf5c36b505b6127a24a2b629e23`.
|
SHA256 `97aab07e67eccc1bb3d215534b537b83e1372bf5c36b505b6127a24a2b629e23`.
|
||||||
Phone background/reconnect acceptance question is pending, not passed.
|
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.
|
||||||
|
|||||||
@@ -12,19 +12,18 @@ whenever human acceptance is needed.
|
|||||||
|
|
||||||
## Current evidence
|
## Current evidence
|
||||||
|
|
||||||
- Latest alpha backend source: isolated suite 1,681 passed, zero failed,
|
- Alpha backend: isolated suite **1,681 passed**, zero failed, four explicit
|
||||||
four explicit ignores; separate container runtime suite 82 passed. Optimized
|
ignores; separate container runtime suite **82 passed**. Optimized binary
|
||||||
build including the Nostr security and File Browser changes is in progress.
|
SHA256 `a5a6cfc7dcab011963a6f18dc570446b6fa624180d206a108fab27504f4fd41d`
|
||||||
- Frontend: full suite 1,211 passed across 148 files; production UI and AIUI
|
is deployed on dev, yaya and Framework with private rollback backups.
|
||||||
builds passed. Real dev desktop/mobile upload fault injection passes, including
|
- Frontend: **1,222 passed across 150 files**; production build and real mobile/
|
||||||
interrupted JWT refresh and exact saved-file hashes.
|
desktop media/menu checks pass. Dev serves index SHA256
|
||||||
- Currently deployed backend on dev/yaya SHA256
|
`c18b24024a78789fe65c74c5ce27efe2125ae869016ab65e33c5a2680b543f17`.
|
||||||
`e218e40f5c16c3d0cc4dc06c0a378c14b56b9087ded5c515b8a48351ceea3bcf`.
|
Yaya/Framework still have the preceding qualified upload UI `67de835a…`.
|
||||||
It includes Nostr/File Browser fixes but predates the alpha version suffix.
|
- Companion **0.5.34/build54**: 12 native tests, clean build, v1/v2/v3 signatures
|
||||||
Private rollback backups exist.
|
and unchanged signer pass. Viewer and phone download are operator accepted.
|
||||||
- Latest deployed UI index SHA256 on dev/yaya
|
Dev APK SHA256 `ceb58a7dc5f1398fe84f30255ec9ed79834f5db5f8fbc03eac52e14186fab1a1`.
|
||||||
`67de835a25db59a483314eff583b809c3468c8529080bfa74c9962e44a6f54f9`.
|
Fleet publication remains part of the final release.
|
||||||
Both served byte checks pass; unrelated production containers stayed unchanged.
|
|
||||||
- NPM corrected gateway/client-IP integration: 23 Python checks and complete
|
- NPM corrected gateway/client-IP integration: 23 Python checks and complete
|
||||||
disposable real-image integration passed. Catalog generator now requires both
|
disposable real-image integration passed. Catalog generator now requires both
|
||||||
migration-backup and legacy-gateway capabilities; its generator/drift selection
|
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
|
- [ ] **NPM:** corrected private signature, dev/yaya selection and yaya override
|
||||||
retirement now PASS (2026-10-05). Remaining: full boot/OTA/ISO and
|
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
|
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
|
- [ ] **Shorty NPM:** shop certificate12/Force SSL are operator accepted and
|
||||||
independently verified; qualify and apply the manual-route migration. Preserve live
|
independently verified; qualify and apply the manual-route migration. Preserve live
|
||||||
management containment and current public app routing.
|
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.
|
No production spending or channel closure is authorized by this checklist.
|
||||||
- [ ] **Paid files:** finish buyer restart/outage and updated-seller persistence
|
- [ ] **Paid files:** finish buyer restart/outage and updated-seller persistence
|
||||||
acceptance; preserve atomic ownership and safe retries without repayment.
|
acceptance; preserve atomic ownership and safe retries without repayment.
|
||||||
- [ ] **Uploads:** prior physical companion flow is operator accepted. New
|
- [x] **Uploads:** real dev/yaya interrupted-network, offset recovery, lost
|
||||||
resumable-transfer requirement needs interrupted-network/background
|
replies, hashes, cancellation and compact origin-screen display pass.
|
||||||
recovery acceptance; viewport checks alone are not physical-phone proof.
|
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
|
- [ ] **File Browser credentials:** unique managed login, default-password
|
||||||
removal, account/file preservation and rollback pass real Podman fixtures
|
removal, account/file preservation and rollback pass real Podman fixtures
|
||||||
including actual Quadlet restart. Deploy/verify dev and yaya, rerun yaya
|
including actual Quadlet restart. Dev/yaya/Framework migration and Cloud
|
||||||
upload tests, qualify Framework's reported auth issue, and verify packaged
|
acceptance pass. Remaining: packaged OTA/ISO startup. Docker behavior is not inferred from Podman fixtures.
|
||||||
OTA/ISO startup. Docker behavior is not inferred from Podman fixtures.
|
|
||||||
- [ ] **Apps:** complete upgrade inventory matrix for installed/stopped/removed/
|
- [ ] **Apps:** complete upgrade inventory matrix for installed/stopped/removed/
|
||||||
restarting/legacy aliases; Immich/retired-app removal and unexpected-service
|
restarting/legacy aliases; Immich/retired-app removal and unexpected-service
|
||||||
identification; Portainer/Gitea migration from actual request namespace.
|
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
|
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.
|
qualifying mock backend compatibility with new Cloud uploads. No demo deployed yet.
|
||||||
No OTA/catalog/ISO has been published.
|
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",
|
"versionName": "0.5.34",
|
||||||
"versionCode": 52
|
"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>
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<div
|
||||||
|
role="button"
|
||||||
|
@keydown.enter.self.prevent="handleClick"
|
||||||
|
@keydown.space.self.prevent="handleClick"
|
||||||
class="cloud-file-item group"
|
class="cloud-file-item group"
|
||||||
data-controller-container
|
data-controller-container
|
||||||
data-controller-primary
|
data-controller-primary
|
||||||
@@ -49,48 +52,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<FileActionsMenu class="cloud-card-menu" :name="item.name" :download-href="item.isDir ? undefined : downloadHref"
|
||||||
<div class="cloud-file-item-actions" @click.stop>
|
can-share :can-delete="!item.isDir"
|
||||||
<button
|
@share="emit('share', item.path, item.name, item.isDir)" @delete="emit('delete', item.path)" />
|
||||||
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>
|
</div>
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import FileActionsMenu from './FileActionsMenu.vue'
|
||||||
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
||||||
import { useCloudStore } from '@/stores/cloud'
|
import { useCloudStore } from '@/stores/cloud'
|
||||||
import { useFileType, formatSize, formatDate } from '@/composables/useFileType'
|
import { useFileType, formatSize, formatDate } from '@/composables/useFileType'
|
||||||
@@ -135,3 +105,7 @@ function handleClick() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cloud-card-menu { margin-left: auto; flex: none; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<div
|
||||||
|
role="button"
|
||||||
|
@keydown.enter.self.prevent="handleClick"
|
||||||
|
@keydown.space.self.prevent="handleClick"
|
||||||
class="cloud-grid-card group"
|
class="cloud-grid-card group"
|
||||||
data-controller-container
|
data-controller-container
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -78,46 +81,16 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions overlay at top-left (visible on hover) -->
|
<FileActionsMenu class="cloud-card-menu" :name="item.name" :download-href="item.isDir ? undefined : downloadHref"
|
||||||
<div class="cloud-grid-card-actions" @click.stop>
|
can-share :can-delete="!item.isDir"
|
||||||
<button
|
@share="emit('share', item.path, item.name, item.isDir)" @delete="emit('delete', item.path)" />
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import FileActionsMenu from './FileActionsMenu.vue'
|
||||||
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
||||||
import { useCloudStore } from '@/stores/cloud'
|
import { useCloudStore } from '@/stores/cloud'
|
||||||
import { useFileType, formatSize, formatDate } from '@/composables/useFileType'
|
import { useFileType, formatSize, formatDate } from '@/composables/useFileType'
|
||||||
@@ -178,3 +151,8 @@ function handleClick() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cloud-card-menu { position: absolute; bottom: 8px; right: 8px; z-index: 4; }
|
||||||
|
.cloud-grid-card-info { padding-right: 60px; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
<div
|
<div
|
||||||
v-if="show"
|
v-if="show"
|
||||||
class="lightbox-backdrop"
|
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"
|
@click.self="close"
|
||||||
@keydown="onKeydown"
|
@keydown="onKeydown"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -12,17 +13,27 @@
|
|||||||
>
|
>
|
||||||
<!-- Top bar -->
|
<!-- Top bar -->
|
||||||
<div class="lightbox-topbar">
|
<div class="lightbox-topbar">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="lightbox-heading">
|
||||||
<span v-if="mediaItems.length > 1" class="text-sm text-white/50">
|
<span v-if="mediaItems.length > 1" class="lightbox-count">
|
||||||
{{ currentIndex + 1 }} / {{ mediaItems.length }}
|
{{ currentIndex + 1 }} / {{ mediaItems.length }}
|
||||||
</span>
|
</span>
|
||||||
<p class="text-sm text-white/80 truncate">{{ currentItem?.name }}</p>
|
<p class="lightbox-title" :title="currentItem?.name">{{ currentItem?.name }}</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
v-if="pipAvailable && currentItem && isVideoFile(currentItem)"
|
v-if="pipAvailable && currentItem && isVideoFile(currentItem)"
|
||||||
class="lightbox-btn"
|
class="lightbox-btn"
|
||||||
title="Picture-in-picture"
|
title="Picture-in-picture" aria-label="Picture-in-picture"
|
||||||
@click.stop="togglePip(videoEl)"
|
@click.stop="togglePip(videoEl)"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<rect x="12" y="12" width="7" height="5" rx="1" stroke-width="2" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -41,7 +52,7 @@
|
|||||||
<!-- Navigation arrows -->
|
<!-- Navigation arrows -->
|
||||||
<button
|
<button
|
||||||
v-if="mediaItems.length > 1"
|
v-if="mediaItems.length > 1"
|
||||||
class="lightbox-nav lightbox-nav-prev"
|
class="lightbox-nav lightbox-nav-prev" aria-label="Previous file"
|
||||||
@click.stop="prev"
|
@click.stop="prev"
|
||||||
>
|
>
|
||||||
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -50,7 +61,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="mediaItems.length > 1"
|
v-if="mediaItems.length > 1"
|
||||||
class="lightbox-nav lightbox-nav-next"
|
class="lightbox-nav lightbox-nav-next" aria-label="Next file"
|
||||||
@click.stop="next"
|
@click.stop="next"
|
||||||
>
|
>
|
||||||
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -67,21 +78,23 @@
|
|||||||
|
|
||||||
<!-- Image -->
|
<!-- Image -->
|
||||||
<img
|
<img
|
||||||
v-else-if="currentItem && currentUrl && isImageFile(currentItem)"
|
v-else-if="!mediaError && currentItem && currentUrl && isImageFile(currentItem)"
|
||||||
:src="currentUrl"
|
:src="currentUrl"
|
||||||
:alt="currentItem.name"
|
:alt="currentItem.name"
|
||||||
class="lightbox-media-img"
|
class="lightbox-media-img"
|
||||||
|
@dblclick="toggleFullscreen"
|
||||||
@error="onMediaError"
|
@error="onMediaError"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Video -->
|
<!-- Video -->
|
||||||
<video
|
<video
|
||||||
v-else-if="currentItem && currentUrl && isVideoFile(currentItem)"
|
v-else-if="!mediaError && currentItem && currentUrl && isVideoFile(currentItem)"
|
||||||
ref="videoEl"
|
ref="videoEl"
|
||||||
:src="currentUrl"
|
:src="currentUrl"
|
||||||
:key="currentUrl"
|
:key="currentUrl"
|
||||||
class="lightbox-media-video"
|
class="lightbox-media-video"
|
||||||
controls
|
controls
|
||||||
|
playsinline
|
||||||
autoplay
|
autoplay
|
||||||
@dblclick="toggleFullscreen"
|
@dblclick="toggleFullscreen"
|
||||||
@error="onMediaError"
|
@error="onMediaError"
|
||||||
@@ -91,7 +104,7 @@
|
|||||||
|
|
||||||
<!-- Audio -->
|
<!-- Audio -->
|
||||||
<div
|
<div
|
||||||
v-else-if="currentItem && currentUrl && isAudioFile(currentItem)"
|
v-else-if="!mediaError && currentItem && currentUrl && isAudioFile(currentItem)"
|
||||||
class="lightbox-audio-container"
|
class="lightbox-audio-container"
|
||||||
>
|
>
|
||||||
<div class="lightbox-audio-artwork">
|
<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">
|
<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" />
|
<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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,6 +142,7 @@ import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
|||||||
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
||||||
import { getFileCategory } from '@/composables/useFileType'
|
import { getFileCategory } from '@/composables/useFileType'
|
||||||
import { isPipSupported, togglePip } from '@/utils/pip'
|
import { isPipSupported, togglePip } from '@/utils/pip'
|
||||||
|
import FileActionsMenu from './FileActionsMenu.vue'
|
||||||
import { usePipSession } from '@/composables/usePipSession'
|
import { usePipSession } from '@/composables/usePipSession'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -136,10 +151,14 @@ const props = defineProps<{
|
|||||||
show: boolean
|
show: boolean
|
||||||
fetchBlobUrl: (path: string) => Promise<string>
|
fetchBlobUrl: (path: string) => Promise<string>
|
||||||
streamUrl?: (path: string) => Promise<string>
|
streamUrl?: (path: string) => Promise<string>
|
||||||
|
downloadUrl?: (path: string) => string
|
||||||
|
fileActions?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
|
share: [item: FileBrowserItem]
|
||||||
|
delete: [item: FileBrowserItem]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const currentIndex = ref(0)
|
const currentIndex = ref(0)
|
||||||
@@ -149,6 +168,12 @@ const currentUrl = ref<string | null>(null)
|
|||||||
const backdropEl = ref<HTMLElement | null>(null)
|
const backdropEl = ref<HTMLElement | null>(null)
|
||||||
const videoEl = ref<HTMLVideoElement | 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 pipAvailable = isPipSupported()
|
||||||
const pipSession = usePipSession()
|
const pipSession = usePipSession()
|
||||||
const pipHandoff = ref(false)
|
const pipHandoff = ref(false)
|
||||||
@@ -187,49 +212,36 @@ function isAudioFile(item: FileBrowserItem): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadMedia(item: FileBrowserItem) {
|
async function loadMedia(item: FileBrowserItem) {
|
||||||
|
const requestId = ++loadId
|
||||||
loading.value = true
|
loading.value = true
|
||||||
mediaError.value = false
|
mediaError.value = false
|
||||||
currentUrl.value = null
|
currentUrl.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cached = urlCache.get(item.path)
|
let url = urlCache.get(item.path)
|
||||||
if (cached) {
|
if (!url) {
|
||||||
currentUrl.value = cached
|
url = (isVideoFile(item) || isAudioFile(item)) && props.streamUrl
|
||||||
} else {
|
? await props.streamUrl(item.path) : await props.fetchBlobUrl(item.path)
|
||||||
// Use streaming URL for video/audio (avoids downloading entire file into blob)
|
if (disposed || requestId !== loadId) {
|
||||||
// Use blob URL for images (needed for rendering)
|
if (url.startsWith('blob:')) URL.revokeObjectURL(url)
|
||||||
const isStreamable = isVideoFile(item) || isAudioFile(item)
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
urlCache.set(item.path, url)
|
||||||
}
|
}
|
||||||
|
if (requestId === loadId) currentUrl.value = url
|
||||||
} catch {
|
} catch {
|
||||||
mediaError.value = true
|
if (requestId === loadId) mediaError.value = true
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (requestId === loadId) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function preloadAdjacent() {
|
function retryMedia() {
|
||||||
const items = mediaItems.value
|
const item = currentItem.value
|
||||||
if (items.length <= 1) return
|
if (!item) return
|
||||||
const prevIdx = (currentIndex.value - 1 + items.length) % items.length
|
const old = urlCache.get(item.path)
|
||||||
const nextIdx = (currentIndex.value + 1) % items.length
|
if (old?.startsWith('blob:')) URL.revokeObjectURL(old)
|
||||||
|
urlCache.delete(item.path)
|
||||||
for (const idx of [prevIdx, nextIdx]) {
|
void loadMedia(item)
|
||||||
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 prev() {
|
function prev() {
|
||||||
@@ -245,6 +257,7 @@ function next() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
|
if (document.fullscreenElement === backdropEl.value) void document.exitFullscreen?.().catch(() => {})
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,14 +293,37 @@ function onLeavePip() {
|
|||||||
pipSession.release()
|
pipSession.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFullscreen() {
|
type SafariVideo = HTMLVideoElement & { webkitEnterFullscreen?: () => void }
|
||||||
const el = videoEl.value
|
async function toggleFullscreen() {
|
||||||
if (!el) return
|
if (document.fullscreenElement === backdropEl.value) {
|
||||||
if (document.fullscreenElement) {
|
try { await document.exitFullscreen() } catch { /* Keep the exit control available. */ }
|
||||||
document.exitFullscreen()
|
return
|
||||||
} else {
|
|
||||||
el.requestFullscreen().catch(() => {})
|
|
||||||
}
|
}
|
||||||
|
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() {
|
function onMediaError() {
|
||||||
@@ -296,33 +332,47 @@ function onMediaError() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') { e.preventDefault(); close() }
|
if (e.key === 'Escape') {
|
||||||
else if (e.key === 'ArrowLeft') { e.preventDefault(); prev() }
|
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() }
|
else if (e.key === 'ArrowRight') { e.preventDefault(); next() }
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(currentItem, (item) => {
|
|
||||||
if (item) {
|
|
||||||
loadMedia(item)
|
|
||||||
preloadAdjacent()
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.show, async (visible) => {
|
watch(() => props.show, async (visible) => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
pipHandoff.value = false
|
pipHandoff.value = false
|
||||||
currentIndex.value = props.startIndex
|
expanded.value = false
|
||||||
const item = mediaItems.value[props.startIndex]
|
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||||
if (item) {
|
previousOverflow = document.body.style.overflow
|
||||||
await loadMedia(item)
|
document.body.style.overflow = 'hidden'
|
||||||
preloadAdjacent()
|
document.addEventListener('fullscreenchange', fullscreenChanged)
|
||||||
}
|
currentIndex.value = Math.max(0, Math.min(props.startIndex, mediaItems.value.length - 1))
|
||||||
await nextTick()
|
await nextTick()
|
||||||
backdropEl.value?.focus()
|
backdropEl.value?.focus()
|
||||||
|
} else {
|
||||||
|
++loadId
|
||||||
|
restorePage()
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch([currentItem, () => props.show], ([item, visible]) => {
|
||||||
|
if (visible && item) void loadMedia(item)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
disposed = true
|
||||||
|
++loadId
|
||||||
|
if (handoffTimer) clearTimeout(handoffTimer)
|
||||||
|
restorePage()
|
||||||
for (const url of urlCache.values()) {
|
for (const url of urlCache.values()) {
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
@@ -334,7 +384,10 @@ onUnmounted(() => {
|
|||||||
.lightbox-backdrop {
|
.lightbox-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 60;
|
z-index: 4000;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -352,14 +405,23 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
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%);
|
background: linear-gradient(to bottom, rgba(0,0,0,0.6) 0%, transparent 100%);
|
||||||
z-index: 10;
|
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 {
|
.lightbox-btn {
|
||||||
width: 2.5rem;
|
width: 44px;
|
||||||
height: 2.5rem;
|
height: 44px;
|
||||||
|
flex: 0 0 44px;
|
||||||
|
padding: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -410,7 +472,11 @@ onUnmounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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 {
|
.lightbox-media-img {
|
||||||
@@ -423,7 +489,8 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.lightbox-media-video {
|
.lightbox-media-video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: auto;
|
||||||
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: black;
|
background: black;
|
||||||
@@ -501,28 +568,33 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile */
|
/* Mobile controls have their own safe-area row, never covering video controls. */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px), (max-height: 500px) and (pointer: coarse) {
|
||||||
.lightbox-content { padding: 3rem 0; }
|
.lightbox-topbar { padding-bottom: 8px; }
|
||||||
.lightbox-nav { width: 2.5rem; height: 2.5rem; }
|
.lightbox-heading { flex: 1; }
|
||||||
.lightbox-nav-prev { left: 0.5rem; }
|
.lightbox-actions {
|
||||||
.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 {
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: auto;
|
bottom: calc(var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 12px);
|
||||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 1rem);
|
|
||||||
left: 50%;
|
left: 50%;
|
||||||
right: auto;
|
|
||||||
transform: translateX(-50%);
|
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>
|
</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 }) }
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -415,6 +415,10 @@
|
|||||||
:show="lightboxIndex !== null"
|
:show="lightboxIndex !== null"
|
||||||
:fetch-blob-url="cloudStore.fetchBlobUrl"
|
:fetch-blob-url="cloudStore.fetchBlobUrl"
|
||||||
:stream-url="cloudStore.streamUrl"
|
: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"
|
@close="lightboxIndex = null"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="actionError" role="alert" class="alert-error mt-3">{{ actionError }}</p>
|
||||||
|
|
||||||
<!-- Share Modal -->
|
<!-- Share Modal -->
|
||||||
<ShareModal
|
<ShareModal
|
||||||
v-if="shareTarget"
|
v-if="shareTarget"
|
||||||
@@ -148,6 +150,10 @@
|
|||||||
:show="lightboxIndex !== null"
|
:show="lightboxIndex !== null"
|
||||||
:fetch-blob-url="cloudStore.fetchBlobUrl"
|
:fetch-blob-url="cloudStore.fetchBlobUrl"
|
||||||
:stream-url="cloudStore.streamUrl"
|
: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"
|
@close="lightboxIndex = null"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,6 +320,7 @@ watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const actionError = ref<string | null>(null)
|
||||||
const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(null)
|
const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(null)
|
||||||
const lightboxIndex = ref<number | null>(null)
|
const lightboxIndex = ref<number | null>(null)
|
||||||
|
|
||||||
@@ -370,7 +377,9 @@ async function handleUpload(files: File[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(path: string) {
|
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) {
|
async function handlePlay(path: string, name: string) {
|
||||||
|
|||||||
@@ -71,15 +71,22 @@ echo "publish-companion-apk: zipalign + sign (v1+v2+v3)…" >&2
|
|||||||
"$SIGNED"
|
"$SIGNED"
|
||||||
|
|
||||||
# 4. Verify all three schemes (min-sdk 21 forces the v1 path to be exercised).
|
# 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
|
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
|
echo "publish-companion-apk: ERROR — $scheme NOT present after signing. Aborting." >&2
|
||||||
printf '%s\n' "$VERIFY" | grep -iE "scheme" >&2
|
printf '%s\n' "$VERIFY" | grep -iE "scheme" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
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.
|
# 5. Publish.
|
||||||
mkdir -p "$(dirname "$DEST")"
|
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)});
|
||||||
Reference in New Issue
Block a user