Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2096a6148 |
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 17
|
versionCode = 18
|
||||||
versionName = "0.4.13"
|
versionName = "0.4.14"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@@ -239,7 +239,12 @@ private fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
|||||||
val preview = Preview.Builder().build().also {
|
val preview = Preview.Builder().build().also {
|
||||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||||
}
|
}
|
||||||
|
// CameraX's analysis default is 640x480 — too few pixels per module
|
||||||
|
// to decode a modal-sized QR at arm's length. 1280x720 more than
|
||||||
|
// doubles the pixel density at negligible analysis cost.
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
val analysis = ImageAnalysis.Builder()
|
val analysis = ImageAnalysis.Builder()
|
||||||
|
.setTargetResolution(android.util.Size(1280, 720))
|
||||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
.build()
|
.build()
|
||||||
.also {
|
.also {
|
||||||
@@ -268,7 +273,14 @@ private fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
|||||||
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
|
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
|
||||||
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
|
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
|
||||||
private val reader = MultiFormatReader().apply {
|
private val reader = MultiFormatReader().apply {
|
||||||
setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)))
|
setHints(
|
||||||
|
mapOf(
|
||||||
|
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||||
|
// Screen-displayed QRs come with moiré, glare, and soft focus at
|
||||||
|
// close range — the exhaustive search is worth the milliseconds.
|
||||||
|
DecodeHintType.TRY_HARDER to true,
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun analyze(image: ImageProxy) {
|
override fun analyze(image: ImageProxy) {
|
||||||
@@ -284,7 +296,13 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna
|
|||||||
0, 0, image.width, image.height,
|
0, 0, image.width, image.height,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
val result = reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
|
val result = try {
|
||||||
|
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
|
||||||
|
} catch (_: NotFoundException) {
|
||||||
|
// Dark-themed pages can render light-on-dark QRs — retry inverted.
|
||||||
|
reader.reset()
|
||||||
|
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
|
||||||
|
}
|
||||||
onDecoded(result.text)
|
onDecoded(result.text)
|
||||||
} catch (_: NotFoundException) {
|
} catch (_: NotFoundException) {
|
||||||
// No QR in this frame — keep scanning.
|
// No QR in this frame — keep scanning.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
@@ -59,6 +59,7 @@
|
|||||||
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
|
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
download
|
||||||
>
|
>
|
||||||
Download app
|
Download app
|
||||||
</a>
|
</a>
|
||||||
@@ -92,7 +93,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center mb-4">
|
<div class="flex justify-center mb-4">
|
||||||
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
<!-- Bigger than the download QR: this one is scanned by the
|
||||||
|
companion app camera, and physical size drives decode. -->
|
||||||
|
<div class="w-[192px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||||
<img
|
<img
|
||||||
v-if="pairQrDataUrl"
|
v-if="pairQrDataUrl"
|
||||||
:src="pairQrDataUrl"
|
:src="pairQrDataUrl"
|
||||||
@@ -132,6 +135,7 @@
|
|||||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import * as QRCode from 'qrcode'
|
import * as QRCode from 'qrcode'
|
||||||
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
|
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
|
||||||
|
import { companionIntroRequested } from '@/composables/useCompanionIntro'
|
||||||
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
@@ -202,11 +206,26 @@ function maybeShow() {
|
|||||||
}, 250)
|
}, 250)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manual open (App Store banner etc.) — ignores the once-per-browser gate.
|
||||||
|
watch(companionIntroRequested, (requested) => {
|
||||||
|
if (!requested) return
|
||||||
|
companionIntroRequested.value = false
|
||||||
|
if (calmTicker) {
|
||||||
|
clearInterval(calmTicker)
|
||||||
|
calmTicker = null
|
||||||
|
}
|
||||||
|
step.value = 'download'
|
||||||
|
visible.value = true
|
||||||
|
})
|
||||||
|
|
||||||
watch(visible, async (isVisible) => {
|
watch(visible, async (isVisible) => {
|
||||||
if (!isVisible) return
|
if (!isVisible) return
|
||||||
|
// Generate large and let CSS scale down — at 112px source a ~45-module QR
|
||||||
|
// is 2.5px/module, which camera decoders (the companion app included)
|
||||||
|
// routinely fail on. 512px keeps every module crisp.
|
||||||
qrDataUrl.value = await QRCode.toDataURL(companionDownloadUrl, {
|
qrDataUrl.value = await QRCode.toDataURL(companionDownloadUrl, {
|
||||||
width: 112,
|
width: 512,
|
||||||
margin: 1,
|
margin: 2,
|
||||||
errorCorrectionLevel: 'M',
|
errorCorrectionLevel: 'M',
|
||||||
color: {
|
color: {
|
||||||
dark: '#111111',
|
dark: '#111111',
|
||||||
@@ -247,9 +266,11 @@ async function showPairScreen() {
|
|||||||
step.value = 'pair'
|
step.value = 'pair'
|
||||||
if (!pairQrDataUrl.value) {
|
if (!pairQrDataUrl.value) {
|
||||||
pairingUrl.value = await buildPairingUrl()
|
pairingUrl.value = await buildPairingUrl()
|
||||||
|
// Large source + a real quiet zone; this QR is scanned by the companion
|
||||||
|
// app's camera, so give it every advantage (see download QR note above).
|
||||||
pairQrDataUrl.value = await QRCode.toDataURL(pairingUrl.value, {
|
pairQrDataUrl.value = await QRCode.toDataURL(pairingUrl.value, {
|
||||||
width: 112,
|
width: 512,
|
||||||
margin: 1,
|
margin: 3,
|
||||||
errorCorrectionLevel: 'M',
|
errorCorrectionLevel: 'M',
|
||||||
color: {
|
color: {
|
||||||
dark: '#111111',
|
dark: '#111111',
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-view trigger for the Remote Companion intro/pairing modal
|
||||||
|
* (CompanionIntroOverlay, mounted once in Dashboard.vue). Views like the
|
||||||
|
* App Store banner call openCompanionIntro() to pop it on demand — this
|
||||||
|
* bypasses the once-per-browser auto-show gate.
|
||||||
|
*/
|
||||||
|
export const companionIntroRequested = ref(false)
|
||||||
|
|
||||||
|
export function openCompanionIntro(): void {
|
||||||
|
companionIntroRequested.value = true
|
||||||
|
}
|
||||||
@@ -150,6 +150,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile companion app banner — opens the download/pairing modal -->
|
||||||
|
<CompanionBanner />
|
||||||
|
|
||||||
<!-- Category Section Divider -->
|
<!-- Category Section Divider -->
|
||||||
<div class="flex items-center gap-3 mb-5">
|
<div class="flex items-center gap-3 mb-5">
|
||||||
<span class="discover-terminal-tag">all</span>
|
<span class="discover-terminal-tag">all</span>
|
||||||
@@ -239,6 +242,7 @@ import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout
|
|||||||
import { APP_STORE_SECTIONS } from './appStoreCategories'
|
import { APP_STORE_SECTIONS } from './appStoreCategories'
|
||||||
import DiscoverHero from './discover/DiscoverHero.vue'
|
import DiscoverHero from './discover/DiscoverHero.vue'
|
||||||
import FeaturedApps from './discover/FeaturedApps.vue'
|
import FeaturedApps from './discover/FeaturedApps.vue'
|
||||||
|
import CompanionBanner from './discover/CompanionBanner.vue'
|
||||||
import AppGrid from './discover/AppGrid.vue'
|
import AppGrid from './discover/AppGrid.vue'
|
||||||
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
||||||
import type { MarketplaceApp, FeaturedApp } from './discover/types'
|
import type { MarketplaceApp, FeaturedApp } from './discover/types'
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Companion app banner — same format as the featured app banner, with a
|
||||||
|
phone mockup rising out of the right edge. Clicking anywhere (or the
|
||||||
|
Install button) opens the Remote Companion download/pairing modal. -->
|
||||||
|
<div
|
||||||
|
class="featured-banner companion-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
||||||
|
@click="openCompanionIntro()"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/assets/img/bg-intro-4.webp"
|
||||||
|
alt=""
|
||||||
|
class="featured-banner-img"
|
||||||
|
@error="(e: Event) => ((e.target as HTMLImageElement).style.display = 'none')"
|
||||||
|
/>
|
||||||
|
<div class="featured-banner-overlay companion-banner-overlay">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<span class="discover-terminal-tag">companion</span>
|
||||||
|
<span class="text-white/50 text-sm font-mono">REMOTE CONTROL // IN YOUR POCKET</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">Your Node. In Your Pocket.</h2>
|
||||||
|
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">
|
||||||
|
The Archipelago Companion puts your node on your phone — remote control and typing,
|
||||||
|
your cloud files on the go, and one scan to connect. Sovereignty doesn't stay home.
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
@click.stop="openCompanionIntro()"
|
||||||
|
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
|
||||||
|
>Install</button>
|
||||||
|
<span class="text-white/40 text-sm">Archipelago Companion · Android</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="companion-phone-wrap" aria-hidden="true">
|
||||||
|
<div class="companion-phone">
|
||||||
|
<span class="companion-phone-lens"></span>
|
||||||
|
<img src="/assets/img/companion-phone-screen.webp" alt="" class="companion-phone-screen" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { openCompanionIntro } from '@/composables/useCompanionIntro'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.companion-banner {
|
||||||
|
border-color: rgba(251, 146, 60, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the copy clear of the phone mockup on wide screens */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.companion-banner-overlay {
|
||||||
|
padding-right: 320px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.companion-phone-wrap {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.companion-phone-wrap {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 2rem;
|
||||||
|
right: 3.5rem;
|
||||||
|
z-index: 2;
|
||||||
|
transform: rotate(6deg);
|
||||||
|
transition: transform 0.4s ease;
|
||||||
|
}
|
||||||
|
.companion-banner:hover .companion-phone-wrap {
|
||||||
|
transform: rotate(4deg) translateY(-8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.companion-phone {
|
||||||
|
position: relative;
|
||||||
|
width: 200px;
|
||||||
|
padding: 9px;
|
||||||
|
border-radius: 30px;
|
||||||
|
background: #0b0b12;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 60px rgba(0, 0, 0, 0.55),
|
||||||
|
0 0 46px rgba(251, 146, 60, 0.28),
|
||||||
|
inset 0 0 2px rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Punch-hole camera */
|
||||||
|
.companion-phone-lens {
|
||||||
|
position: absolute;
|
||||||
|
top: 17px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #000;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.companion-phone-screen {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen glare */
|
||||||
|
.companion-phone::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 9px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(115deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.04) 28%, transparent 45%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -37,7 +37,9 @@ export default defineConfig({
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
navigateFallbackDenylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//],
|
// /packages/ must bypass the SPA fallback — otherwise clicking the
|
||||||
|
// companion APK download link gets index.html instead of the file.
|
||||||
|
navigateFallbackDenylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//, /^\/packages\//],
|
||||||
cleanupOutdatedCaches: true,
|
cleanupOutdatedCaches: true,
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,mp4,webp}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,mp4,webp}'],
|
||||||
globIgnores: [
|
globIgnores: [
|
||||||
|
|||||||
Reference in New Issue
Block a user