Compare commits
1 Commits
v1.7.9-alp
...
v1.7.10-al
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8ab06dd47 |
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.9-alpha"
|
||||
version = "1.7.10-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.9-alpha"
|
||||
version = "1.7.10-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -244,6 +244,32 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Run a command as root, but *outside* the archipelago service's
|
||||
/// restricted mount namespace.
|
||||
///
|
||||
/// archipelago.service uses `ProtectSystem=strict`, which makes `/opt`
|
||||
/// and `/usr` read-only inside the service — and sudo inherits the
|
||||
/// namespace, so `sudo mv /opt/archipelago/...` fails with EROFS even
|
||||
/// though sudo itself is root. `systemd-run --wait` spawns a transient
|
||||
/// service unit that inherits systemd's default protections (i.e. none
|
||||
/// of ours), escaping the namespace.
|
||||
async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> {
|
||||
let mut full: Vec<&str> = vec![
|
||||
"systemd-run",
|
||||
"--wait",
|
||||
"--quiet",
|
||||
"--collect",
|
||||
"--pipe",
|
||||
"--",
|
||||
];
|
||||
full.extend_from_slice(args);
|
||||
tokio::process::Command::new("sudo")
|
||||
.args(&full)
|
||||
.status()
|
||||
.await
|
||||
.context("sudo systemd-run spawn failed")
|
||||
}
|
||||
|
||||
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
|
||||
pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
let staging_dir = data_dir.join("update-staging");
|
||||
@@ -277,31 +303,25 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
|
||||
match name.as_str() {
|
||||
"archipelago" => {
|
||||
// We're running FROM /usr/local/bin/archipelago right now,
|
||||
// so we can't rewrite it in place — `install` / `cp` would
|
||||
// hit ETXTBSY on the busy executable. Use `mv` instead:
|
||||
// rename() is atomic and doesn't modify the existing file,
|
||||
// it just re-points the path at a new inode. The currently
|
||||
// running process keeps executing off the old inode; new
|
||||
// invocations (i.e. after the post-apply systemctl
|
||||
// restart) pick up the new binary.
|
||||
// Two namespace gotchas this block works around:
|
||||
// 1. We're running FROM /usr/local/bin/archipelago, so
|
||||
// `install`/`cp` (O_TRUNC + write) fail with ETXTBSY.
|
||||
// Use `mv`, which is atomic rename() and tolerates a
|
||||
// busy destination.
|
||||
// 2. archipelago.service sets ProtectSystem=strict, so
|
||||
// even `sudo mv` into /usr/local/bin/ fails EROFS —
|
||||
// sudo inherits the service's mount namespace. Route
|
||||
// the rename through systemd-run so it runs in a
|
||||
// transient unit with default protections.
|
||||
let staged = src.to_string_lossy().to_string();
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chmod", "0755", &staged])
|
||||
.status()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chown", "root:root", &staged])
|
||||
.status()
|
||||
.await;
|
||||
let status = tokio::process::Command::new("sudo")
|
||||
.args(["mv", &staged, "/usr/local/bin/archipelago"])
|
||||
.status()
|
||||
let _ = host_sudo(&["chmod", "0755", &staged]).await;
|
||||
let _ = host_sudo(&["chown", "root:root", &staged]).await;
|
||||
let status = host_sudo(&["mv", &staged, "/usr/local/bin/archipelago"])
|
||||
.await
|
||||
.with_context(|| format!("Failed to spawn mv for {}", name))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!(
|
||||
"sudo mv failed for {} (exit {:?})",
|
||||
"mv into /usr/local/bin failed for {} (exit {:?})",
|
||||
name,
|
||||
status.code()
|
||||
);
|
||||
@@ -320,78 +340,66 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
let web_ui = "/opt/archipelago/web-ui";
|
||||
let backup_path = "/opt/archipelago/web-ui.bak";
|
||||
|
||||
let mk = tokio::process::Command::new("sudo")
|
||||
.args(["mkdir", "-p", &staging_new])
|
||||
.status()
|
||||
// All sudo calls that touch /opt/archipelago go through
|
||||
// host_sudo so they see a normal root mount namespace.
|
||||
let mk = host_sudo(&["mkdir", "-p", &staging_new])
|
||||
.await
|
||||
.context("Failed to create frontend staging dir")?;
|
||||
if !mk.success() {
|
||||
anyhow::bail!("mkdir {} failed", staging_new);
|
||||
}
|
||||
let extract = tokio::process::Command::new("sudo")
|
||||
.args(["tar", "-xzf", &src.to_string_lossy(), "-C", &staging_new])
|
||||
.status()
|
||||
.await
|
||||
.with_context(|| format!("Failed to extract {}", name))?;
|
||||
let extract = host_sudo(&[
|
||||
"tar",
|
||||
"-xzf",
|
||||
&src.to_string_lossy(),
|
||||
"-C",
|
||||
&staging_new,
|
||||
])
|
||||
.await
|
||||
.with_context(|| format!("Failed to extract {}", name))?;
|
||||
if !extract.success() {
|
||||
// Best-effort cleanup of the partial extraction.
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["rm", "-rf", &staging_new])
|
||||
.status()
|
||||
.await;
|
||||
let _ = host_sudo(&["rm", "-rf", &staging_new]).await;
|
||||
anyhow::bail!("tar extraction failed for {}", name);
|
||||
}
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chown", "-R", "archipelago:archipelago", &staging_new])
|
||||
.status()
|
||||
.await;
|
||||
let _ = host_sudo(&[
|
||||
"chown",
|
||||
"-R",
|
||||
"archipelago:archipelago",
|
||||
&staging_new,
|
||||
])
|
||||
.await;
|
||||
|
||||
// Swap: mv current web-ui aside, then mv new into place.
|
||||
if Path::new(web_ui).exists() {
|
||||
let mv_old = tokio::process::Command::new("sudo")
|
||||
.args(["mv", web_ui, &staging_old])
|
||||
.status()
|
||||
let mv_old = host_sudo(&["mv", web_ui, &staging_old])
|
||||
.await
|
||||
.context("Failed to rotate old web-ui")?;
|
||||
if !mv_old.success() {
|
||||
anyhow::bail!("failed to move old web-ui aside");
|
||||
}
|
||||
}
|
||||
let mv_new = tokio::process::Command::new("sudo")
|
||||
.args(["mv", &staging_new, web_ui])
|
||||
.status()
|
||||
let mv_new = host_sudo(&["mv", &staging_new, web_ui])
|
||||
.await
|
||||
.context("Failed to swap new web-ui into place")?;
|
||||
if !mv_new.success() {
|
||||
// Roll back the rename so nginx keeps serving.
|
||||
if Path::new(&staging_old).exists() {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["mv", &staging_old, web_ui])
|
||||
.status()
|
||||
.await;
|
||||
let _ = host_sudo(&["mv", &staging_old, web_ui]).await;
|
||||
}
|
||||
anyhow::bail!("failed to move new web-ui into place");
|
||||
}
|
||||
|
||||
// Rotate previous rollback aside (best-effort) and install
|
||||
// this apply's old copy as the new rollback.
|
||||
// Rotate previous rollback aside and install this apply's
|
||||
// old copy as the new rollback.
|
||||
if Path::new(&staging_old).exists() {
|
||||
if Path::new(backup_path).exists() {
|
||||
// Tag the previous backup with its own ts so it
|
||||
// doesn't collide; best-effort cleanup.
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mv",
|
||||
backup_path,
|
||||
&format!("{}.{}", backup_path, ts),
|
||||
])
|
||||
.status()
|
||||
.await;
|
||||
}
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["mv", &staging_old, backup_path])
|
||||
.status()
|
||||
let _ = host_sudo(&[
|
||||
"mv",
|
||||
backup_path,
|
||||
&format!("{}.{}", backup_path, ts),
|
||||
])
|
||||
.await;
|
||||
}
|
||||
let _ = host_sudo(&["mv", &staging_old, backup_path]).await;
|
||||
}
|
||||
info!(name = %name, "Frontend archive extracted to /opt/archipelago/web-ui");
|
||||
}
|
||||
@@ -422,10 +430,10 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
// starting the new process — it would deadlock otherwise.
|
||||
tokio::spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["systemctl", "--no-block", "restart", "archipelago"])
|
||||
.status()
|
||||
.await;
|
||||
// systemctl talks to PID 1 over D-Bus — doesn't need the host
|
||||
// mount namespace, but routing through host_sudo keeps the
|
||||
// apply flow's sudo calls uniform.
|
||||
let _ = host_sudo(&["systemctl", "--no-block", "restart", "archipelago"]).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -317,26 +317,35 @@ const torConnected = computed(() => {
|
||||
})
|
||||
const vpnStatus = ref({ connected: false, provider: '' })
|
||||
const vpnConnected = computed(() => vpnStatus.value.connected || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running))
|
||||
const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null)
|
||||
const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
|
||||
const fipsDotClass = computed(() => {
|
||||
const s = fipsStatus.value
|
||||
if (!s || !s.installed) return 'bg-white/40'
|
||||
if (s.service_active) return 'bg-green-400'
|
||||
return 'bg-white/40'
|
||||
if (!s.service_active) return 'bg-white/40'
|
||||
// Active but no anchor = degraded, not fully green
|
||||
if (s.anchor_connected === false) return 'bg-orange-400'
|
||||
return 'bg-green-400'
|
||||
})
|
||||
const fipsTextClass = computed(() => {
|
||||
const s = fipsStatus.value
|
||||
if (!s || !s.installed) return 'text-white/40'
|
||||
if (s.service_active) return 'text-green-400'
|
||||
return 'text-white/40'
|
||||
if (!s.service_active) return 'text-white/40'
|
||||
if (s.anchor_connected === false) return 'text-orange-400'
|
||||
return 'text-green-400'
|
||||
})
|
||||
const fipsStatusLabel = computed(() => {
|
||||
const s = fipsStatus.value
|
||||
if (!s) return '…'
|
||||
if (!s.installed) return 'Not installed'
|
||||
if (s.service_active) return 'Active'
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
return 'Inactive'
|
||||
if (!s.service_active) {
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
return 'Inactive'
|
||||
}
|
||||
// Service is active — reflect anchor reachability in the label so the
|
||||
// Home and Server rows flip in sync with the FIPS card.
|
||||
if (s.anchor_connected === false) return 'No anchor'
|
||||
const peers = s.authenticated_peer_count ?? 0
|
||||
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
|
||||
})
|
||||
const bitcoinSyncDisplay = computed(() => {
|
||||
if (!systemStats.bitcoinAvailable) return 'Not running'
|
||||
|
||||
@@ -420,25 +420,31 @@ const networkData = ref({
|
||||
})
|
||||
|
||||
// FIPS status row for the Local Network card. Full FIPS card lives below.
|
||||
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null)
|
||||
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
|
||||
const fipsRowLabel = computed(() => {
|
||||
const s = fipsSummary.value
|
||||
if (!s) return '…'
|
||||
if (!s.installed) return 'Not installed'
|
||||
// Service-active wins even on legacy nodes with no seed-derived key.
|
||||
if (s.service_active) return 'Active'
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
return 'Inactive'
|
||||
if (!s.service_active) {
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
return 'Inactive'
|
||||
}
|
||||
// Service is active — reflect anchor reachability so the row flips in
|
||||
// sync with the full FIPS card below.
|
||||
if (s.anchor_connected === false) return 'No anchor'
|
||||
const peers = s.authenticated_peer_count ?? 0
|
||||
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
|
||||
})
|
||||
const fipsRowTextClass = computed(() => {
|
||||
const s = fipsSummary.value
|
||||
if (!s || !s.installed) return 'text-white/40'
|
||||
if (s.service_active) return 'text-green-400'
|
||||
return 'text-white/60'
|
||||
if (!s.service_active) return 'text-white/60'
|
||||
if (s.anchor_connected === false) return 'text-orange-400'
|
||||
return 'text-green-400'
|
||||
})
|
||||
async function loadFipsSummary() {
|
||||
try {
|
||||
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean }>({ method: 'fips.status' })
|
||||
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({ method: 'fips.status' })
|
||||
} catch { /* backend too old */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -68,8 +68,13 @@
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
|
||||
<img v-if="identity.profile?.picture" :src="displayableUrl(identity.profile.picture)" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
|
||||
<div v-if="!identity.profile?.picture" class="w-full h-full flex items-center justify-center" :class="{
|
||||
<img
|
||||
v-if="identity.profile?.picture && !listPictureFailed[identity.id]"
|
||||
:src="displayableUrl(identity.profile.picture)"
|
||||
class="w-full h-full object-cover"
|
||||
@error="() => { listPictureFailed[identity.id] = true }"
|
||||
/>
|
||||
<div v-if="!identity.profile?.picture || listPictureFailed[identity.id]" class="w-full h-full flex items-center justify-center" :class="{
|
||||
'bg-blue-500/20': identity.purpose === 'personal',
|
||||
'bg-orange-500/20': identity.purpose === 'business',
|
||||
'bg-purple-500/20': identity.purpose === 'anonymous',
|
||||
@@ -302,8 +307,14 @@
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
|
||||
<img v-if="profileForm.picture" :src="displayableUrl(profileForm.picture)" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
v-if="profileForm.picture && !editorPictureFailed"
|
||||
:src="displayableUrl(profileForm.picture)"
|
||||
class="w-full h-full object-cover"
|
||||
@error="editorPictureFailed = true"
|
||||
@load="editorPictureFailed = false"
|
||||
/>
|
||||
<div v-if="!profileForm.picture || editorPictureFailed" class="w-full h-full flex items-center justify-center">
|
||||
<span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,7 +379,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { safeClipboardWrite } from './utils'
|
||||
@@ -409,6 +420,18 @@ const profilePublishing = ref(false)
|
||||
const avatarUploading = ref(false)
|
||||
const bannerUploading = ref(false)
|
||||
|
||||
// Track image load failures so the UI can fall back to the initial/
|
||||
// identicon placeholder instead of showing a blank square. Pasted URLs
|
||||
// that 404 (or point at an onion the local browser can't reach) were
|
||||
// previously silently hidden by a display:none handler that left the
|
||||
// fallback unrendered.
|
||||
const editorPictureFailed = ref(false)
|
||||
const listPictureFailed = reactive<Record<string, boolean>>({})
|
||||
|
||||
// Reset the failure flag when the URL changes so a freshly pasted URL
|
||||
// gets re-tried (the watcher fires once the form reacts).
|
||||
watch(() => profileForm.value.picture, () => { editorPictureFailed.value = false })
|
||||
|
||||
// The backend returns onion-based public URLs for uploaded profile
|
||||
// pictures (so they're fetchable by external Nostr clients), but the
|
||||
// local browser session isn't Tor-routed and can't resolve .onion hosts.
|
||||
@@ -423,10 +446,12 @@ function displayableUrl(url: string | null | undefined): string {
|
||||
return url
|
||||
}
|
||||
|
||||
// Upload to the node's blob store and drop the returned public URL into
|
||||
// the profile field. The /api/blob endpoint marks these blobs public, so
|
||||
// the URL served back (`public_url`, onion-rooted when Tor is up) is
|
||||
// reachable by external Nostr clients fetching kind:0 metadata.
|
||||
// Upload to the node's blob store and drop a URL into the profile field.
|
||||
// For small images (≤64KB) we inline the bytes as a data URL so external
|
||||
// Nostr clients can render the picture without needing to reach a tor
|
||||
// onion. Larger uploads fall back to the onion-rooted public_url.
|
||||
const INLINE_MAX = 64 * 1024
|
||||
|
||||
async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
||||
const input = ev.target as HTMLInputElement
|
||||
const file = input?.files?.[0]
|
||||
@@ -436,6 +461,14 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
||||
profileError.value = ''
|
||||
try {
|
||||
const buf = await file.arrayBuffer()
|
||||
// Inline small images as a data URL — universally fetchable by any
|
||||
// Nostr client and bypasses the "only reachable over Tor" limitation.
|
||||
if (buf.byteLength <= INLINE_MAX) {
|
||||
const mime = file.type || 'image/png'
|
||||
const b64 = btoa(Array.from(new Uint8Array(buf), (b) => String.fromCharCode(b)).join(''))
|
||||
profileForm.value[field] = `data:${mime};base64,${b64}`
|
||||
return
|
||||
}
|
||||
const resp = await fetch('/api/blob', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
@@ -451,6 +484,11 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
||||
const url = public_url || self_test_url
|
||||
if (!url) throw new Error('blob API returned no URL')
|
||||
profileForm.value[field] = url
|
||||
// Heads-up for large uploads: onion URLs only render on Tor-routed
|
||||
// clients. Not an error, but worth telling the user.
|
||||
if (url.includes('.onion/')) {
|
||||
profileError.value = 'Large image stored on this node. Pasting a public https://… URL is recommended for Nostr visibility.'
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
profileError.value = e instanceof Error ? e.message : `${field} upload failed`
|
||||
} finally {
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
{
|
||||
"version": "1.7.9-alpha",
|
||||
"version": "1.7.10-alpha",
|
||||
"release_date": "2026-04-20",
|
||||
"changelog": [
|
||||
"OTA verification release — nothing new to see. Click Install Update, grab a coffee, and watch the sidebar flip to 1.7.9-alpha on its own. If this one works end to end, the pipeline is solid and future updates will flow the same way."
|
||||
"Install Update actually applies now. The installer had to write into system folders that the backend service was sandboxed out of — every earlier 'Failed to apply update' was a layer of that onion. Fixed by running the file swaps in a separate system context.",
|
||||
"FIPS status on the Home and Server pages now reflects whether the public anchor is reachable. You'll see 'Active · N peers' (green) when healthy or 'No anchor' (orange) when the network is blocking the bootstrap — same signal as the full FIPS card.",
|
||||
"Pasting an https://… URL into the profile picture or banner now previews correctly. Before, if the URL failed to load, the UI would silently blank out instead of showing your initial as a placeholder.",
|
||||
"Uploaded profile pictures under 64 KB are now embedded directly in your Nostr profile (as a data URL), so any Nostr client can see them — not just ones routing over Tor. Larger uploads keep the onion URL for now, with a hint to paste a public URL for wider visibility."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.8-alpha",
|
||||
"new_version": "1.7.9-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.9-alpha/archipelago",
|
||||
"sha256": "1ec7383de8e6b5caa67ec93311db7b5695e1831730fbd40ce56a5aa5aa301629",
|
||||
"size_bytes": 40378536
|
||||
"current_version": "1.7.9-alpha",
|
||||
"new_version": "1.7.10-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.10-alpha/archipelago",
|
||||
"sha256": "4a77c704b5c1ac0b424ccfc7ed123c50e2708764ac2b4916af534e80382aa6f8",
|
||||
"size_bytes": 40379696
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.9-alpha.tar.gz",
|
||||
"current_version": "1.7.8-alpha",
|
||||
"new_version": "1.7.9-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.9-alpha/archipelago-frontend-1.7.9-alpha.tar.gz",
|
||||
"sha256": "4fb796643cc9dc8469078ca3392f7cc5541071f6849979922b3259e5f20172e9",
|
||||
"size_bytes": 76984615
|
||||
"name": "archipelago-frontend-1.7.10-alpha.tar.gz",
|
||||
"current_version": "1.7.9-alpha",
|
||||
"new_version": "1.7.10-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.10-alpha/archipelago-frontend-1.7.10-alpha.tar.gz",
|
||||
"sha256": "0644a43611309031efbb9b235a3602f0828f709fcaec0047543d96e1cbd54f58",
|
||||
"size_bytes": 76983846
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
BIN
releases/v1.7.10-alpha/archipelago
Executable file
BIN
releases/v1.7.10-alpha/archipelago
Executable file
Binary file not shown.
BIN
releases/v1.7.10-alpha/archipelago-frontend-1.7.10-alpha.tar.gz
Normal file
BIN
releases/v1.7.10-alpha/archipelago-frontend-1.7.10-alpha.tar.gz
Normal file
Binary file not shown.
Reference in New Issue
Block a user