Compare commits

..
Author SHA1 Message Date
Archipelago a2096a6148 Archipelago — open-source initial import 2026-08-12 10:55:49 +00:00
9 changed files with 187 additions and 10 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 17
versionName = "0.4.13"
versionCode = 18
versionName = "0.4.14"
vectorDrawables {
useSupportLibrary = true
@@ -239,7 +239,12 @@ private fun CameraQrPreview(onDecoded: (String) -> Unit) {
val preview = Preview.Builder().build().also {
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()
.setTargetResolution(android.util.Size(1280, 720))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
@@ -268,7 +273,14 @@ private fun CameraQrPreview(onDecoded: (String) -> Unit) {
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
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) {
@@ -284,7 +296,13 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna
0, 0, image.width, image.height,
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)
} catch (_: NotFoundException) {
// 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"
target="_blank"
rel="noopener noreferrer"
download
>
Download app
</a>
@@ -92,7 +93,9 @@
</div>
<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
v-if="pairQrDataUrl"
:src="pairQrDataUrl"
@@ -132,6 +135,7 @@
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as QRCode from 'qrcode'
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
import { companionIntroRequested } from '@/composables/useCompanionIntro'
import { useLoginTransitionStore } from '@/stores/loginTransition'
import { rpcClient } from '@/api/rpc-client'
@@ -202,11 +206,26 @@ function maybeShow() {
}, 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) => {
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, {
width: 112,
margin: 1,
width: 512,
margin: 2,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
@@ -247,9 +266,11 @@ async function showPairScreen() {
step.value = 'pair'
if (!pairQrDataUrl.value) {
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, {
width: 112,
margin: 1,
width: 512,
margin: 3,
errorCorrectionLevel: 'M',
color: {
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
}
+4
View File
@@ -150,6 +150,9 @@
</div>
</div>
<!-- Mobile companion app banner opens the download/pairing modal -->
<CompanionBanner />
<!-- Category Section Divider -->
<div class="flex items-center gap-3 mb-5">
<span class="discover-terminal-tag">all</span>
@@ -239,6 +242,7 @@ import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout
import { APP_STORE_SECTIONS } from './appStoreCategories'
import DiscoverHero from './discover/DiscoverHero.vue'
import FeaturedApps from './discover/FeaturedApps.vue'
import CompanionBanner from './discover/CompanionBanner.vue'
import AppGrid from './discover/AppGrid.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue'
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>
+3 -1
View File
@@ -37,7 +37,9 @@ export default defineConfig({
]
},
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,
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,mp4,webp}'],
globIgnores: [