Compare commits

...

3 Commits

Author SHA1 Message Date
3935104cb5 Fix xterm scroll jump and viewport desync during long output
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m24s
Build App / build-windows (push) Successful in 3m30s
Build App / build-linux (push) Successful in 4m47s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Auto-scroll viewport on new output when user is at bottom, debounce
scroll state updates to reduce re-renders, preserve scroll position
across resize reflows, and fix "Jump to Current" button by re-fitting
the terminal to clear viewport desync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 12:59:19 -07:00
b17c759bd6 Fix remaining repo.anhonesthost.net references in user-facing code
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 4m0s
Build App / build-linux (push) Successful in 4m35s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 14s
- help_commands.rs: fetch HOW-TO-USE.md from GitHub raw instead of Gitea
- DockerSettings.tsx: display GHCR image address in settings UI
- HOW-TO-USE.md: update registry description to ghcr.io

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:31:46 -07:00
bab1df1c57 Switch distribution from Gitea to GitHub/GHCR
All checks were successful
Build App / compute-version (push) Successful in 6s
Build Container / build-container (push) Successful in 1m44s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m30s
Build App / build-linux (push) Successful in 4m52s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Work VPN blocks repo.anhonesthost.net, breaking update checks and image
pulls. Move all user-facing distribution to GitHub (releases API) and
GHCR (container images) while keeping Gitea as the source of truth for
development and CI.

- CI: push container images to GHCR alongside Gitea registry
- App updates: switch releases API to api.github.com, filter by asset
  filename instead of tag suffix for unified releases
- Image updates: switch registry to ghcr.io with anonymous token auth
- Container pull: point REGISTRY_IMAGE to ghcr.io/shadowdao/triple-c-sandbox
- Rename GiteaRelease/GiteaAsset structs to GitHubRelease/GitHubAsset

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:55:41 -07:00
8 changed files with 119 additions and 47 deletions

View File

@@ -36,6 +36,13 @@ jobs:
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: shadowdao
password: ${{ secrets.GH_PAT }}
- name: Build and push container image - name: Build and push container image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -46,5 +53,7 @@ jobs:
tags: | tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
ghcr.io/shadowdao/triple-c-sandbox:latest
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max

View File

@@ -70,7 +70,7 @@ Choose an **Image Source**:
| Source | Description | When to Use | | Source | Description | When to Use |
|--------|-------------|-------------| |--------|-------------|-------------|
| **Registry** | Pulls the pre-built image from `repo.anhonesthost.net` | Fastest setup — recommended for most users | | **Registry** | Pulls the pre-built image from `ghcr.io` | Fastest setup — recommended for most users |
| **Local Build** | Builds the image locally from the embedded Dockerfile | If you can't reach the registry, or want a custom build | | **Local Build** | Builds the image locally from the embedded Dockerfile | If you can't reach the registry, or want a custom build |
| **Custom** | Use any Docker image you specify | Advanced — bring your own sandbox image | | **Custom** | Use any Docker image you specify | Advanced — bring your own sandbox image |

View File

@@ -2,7 +2,7 @@ use std::sync::OnceLock;
use tokio::sync::Mutex; use tokio::sync::Mutex;
const HELP_URL: &str = const HELP_URL: &str =
"https://repo.anhonesthost.net/cybercovellc/triple-c/raw/branch/main/HOW-TO-USE.md"; "https://raw.githubusercontent.com/shadowdao/triple-c/main/HOW-TO-USE.md";
const EMBEDDED_HELP: &str = include_str!("../../../../HOW-TO-USE.md"); const EMBEDDED_HELP: &str = include_str!("../../../../HOW-TO-USE.md");

View File

@@ -1,15 +1,20 @@
use serde::Deserialize;
use tauri::State; use tauri::State;
use crate::docker; use crate::docker;
use crate::models::{container_config, GiteaRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo}; use crate::models::{container_config, GitHubRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo};
use crate::AppState; use crate::AppState;
const RELEASES_URL: &str = const RELEASES_URL: &str =
"https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases"; "https://api.github.com/repos/shadowdao/triple-c/releases";
/// Gitea container-registry tag object (v2 manifest). /// GHCR container-registry API base (OCI distribution spec).
const REGISTRY_API_BASE: &str = const REGISTRY_API_BASE: &str =
"https://repo.anhonesthost.net/v2/cybercovellc/triple-c/triple-c-sandbox"; "https://ghcr.io/v2/shadowdao/triple-c-sandbox";
/// GHCR token endpoint for anonymous pull access.
const GHCR_TOKEN_URL: &str =
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
#[tauri::command] #[tauri::command]
pub fn get_app_version() -> String { pub fn get_app_version() -> String {
@@ -23,9 +28,10 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
.build() .build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?; .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let releases: Vec<GiteaRelease> = client let releases: Vec<GitHubRelease> = client
.get(RELEASES_URL) .get(RELEASES_URL)
.header("Accept", "application/json") .header("Accept", "application/json")
.header("User-Agent", "triple-c-updater")
.send() .send()
.await .await
.map_err(|e| format!("Failed to fetch releases: {}", e))? .map_err(|e| format!("Failed to fetch releases: {}", e))?
@@ -36,30 +42,27 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
let current_version = env!("CARGO_PKG_VERSION"); let current_version = env!("CARGO_PKG_VERSION");
let current_semver = parse_semver(current_version).unwrap_or((0, 0, 0)); let current_semver = parse_semver(current_version).unwrap_or((0, 0, 0));
// Determine platform suffix for tag filtering // Determine platform-specific asset extensions
let platform_suffix: &str = if cfg!(target_os = "windows") { let platform_extensions: &[&str] = if cfg!(target_os = "windows") {
"-win" &[".msi", ".exe"]
} else if cfg!(target_os = "macos") { } else if cfg!(target_os = "macos") {
"-mac" &[".dmg", ".app.tar.gz"]
} else { } else {
"" // Linux uses bare tags (no suffix) &[".AppImage", ".deb", ".rpm"]
}; };
// Filter releases by platform tag suffix // Filter releases that have at least one asset matching the current platform
let platform_releases: Vec<&GiteaRelease> = releases let platform_releases: Vec<&GitHubRelease> = releases
.iter() .iter()
.filter(|r| { .filter(|r| {
if platform_suffix.is_empty() { r.assets.iter().any(|a| {
// Linux: bare tag only (no -win, no -mac) platform_extensions.iter().any(|ext| a.name.ends_with(ext))
!r.tag_name.ends_with("-win") && !r.tag_name.ends_with("-mac") })
} else {
r.tag_name.ends_with(platform_suffix)
}
}) })
.collect(); .collect();
// Find the latest release with a higher semver version // Find the latest release with a higher semver version
let mut best: Option<(&GiteaRelease, (u32, u32, u32))> = None; let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
for release in &platform_releases { for release in &platform_releases {
if let Some(ver) = parse_semver_from_tag(&release.tag_name) { if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
if ver > current_semver { if ver > current_semver {
@@ -72,9 +75,13 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
match best { match best {
Some((release, _)) => { Some((release, _)) => {
// Only include assets matching the current platform
let assets = release let assets = release
.assets .assets
.iter() .iter()
.filter(|a| {
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
})
.map(|a| ReleaseAsset { .map(|a| ReleaseAsset {
name: a.name.clone(), name: a.name.clone(),
browser_download_url: a.browser_download_url.clone(), browser_download_url: a.browser_download_url.clone(),
@@ -82,7 +89,6 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
}) })
.collect(); .collect();
// Reconstruct version string from tag
let version = extract_version_from_tag(&release.tag_name) let version = extract_version_from_tag(&release.tag_name)
.unwrap_or_else(|| release.tag_name.clone()); .unwrap_or_else(|| release.tag_name.clone());
@@ -113,17 +119,13 @@ fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
} }
} }
/// Parse semver from a tag like "v0.2.5", "v0.2.5-win", "v0.2.5-mac" -> (0, 2, 5) /// Parse semver from a tag like "v0.2.5" -> (0, 2, 5)
fn parse_semver_from_tag(tag: &str) -> Option<(u32, u32, u32)> { fn parse_semver_from_tag(tag: &str) -> Option<(u32, u32, u32)> {
let clean = tag.trim_start_matches('v'); let clean = tag.trim_start_matches('v');
// Remove platform suffix
let clean = clean.strip_suffix("-win")
.or_else(|| clean.strip_suffix("-mac"))
.unwrap_or(clean);
parse_semver(clean) parse_semver(clean)
} }
/// Extract a clean version string from a tag like "v0.2.5-win" -> "0.2.5" /// Extract a clean version string from a tag like "v0.2.5" -> "0.2.5"
fn extract_version_from_tag(tag: &str) -> Option<String> { fn extract_version_from_tag(tag: &str) -> Option<String> {
let (major, minor, patch) = parse_semver_from_tag(tag)?; let (major, minor, patch) = parse_semver_from_tag(tag)?;
Some(format!("{}.{}.{}", major, minor, patch)) Some(format!("{}.{}.{}", major, minor, patch))
@@ -152,7 +154,7 @@ pub async fn check_image_update(
// 1. Get local image digest via Docker // 1. Get local image digest via Docker
let local_digest = docker::get_local_image_digest(&image_name).await.ok().flatten(); let local_digest = docker::get_local_image_digest(&image_name).await.ok().flatten();
// 2. Get remote digest from the Gitea container registry (OCI distribution spec) // 2. Get remote digest from the GHCR container registry (OCI distribution spec)
let remote_digest = fetch_remote_digest("latest").await?; let remote_digest = fetch_remote_digest("latest").await?;
// No remote digest available — nothing to compare // No remote digest available — nothing to compare
@@ -176,25 +178,36 @@ pub async fn check_image_update(
})) }))
} }
/// Fetch the digest of a tag from the Gitea container registry using the /// Fetch the digest of a tag from GHCR using the OCI / Docker Registry HTTP API v2.
/// OCI / Docker Registry HTTP API v2.
/// ///
/// We issue a HEAD request to /v2/<repo>/manifests/<tag> and read the /// GHCR requires authentication even for public images, so we first obtain an
/// `Docker-Content-Digest` header that the registry returns. /// anonymous token, then issue a HEAD request to /v2/<repo>/manifests/<tag>
/// and read the `Docker-Content-Digest` header.
async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> { async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag);
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15)) .timeout(std::time::Duration::from_secs(15))
.build() .build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?; .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
// 1. Obtain anonymous bearer token from GHCR
let token = match fetch_ghcr_token(&client).await {
Ok(t) => t,
Err(e) => {
log::warn!("Failed to obtain GHCR token: {}", e);
return Ok(None);
}
};
// 2. HEAD the manifest with the token
let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag);
let response = client let response = client
.head(&url) .head(&url)
.header( .header(
"Accept", "Accept",
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json",
) )
.header("Authorization", format!("Bearer {}", token))
.send() .send()
.await; .await;
@@ -221,3 +234,23 @@ async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
} }
} }
} }
/// Fetch an anonymous bearer token from GHCR for pulling public images.
async fn fetch_ghcr_token(client: &reqwest::Client) -> Result<String, String> {
#[derive(Deserialize)]
struct TokenResponse {
token: String,
}
let resp: TokenResponse = client
.get(GHCR_TOKEN_URL)
.header("User-Agent", "triple-c-updater")
.send()
.await
.map_err(|e| format!("GHCR token request failed: {}", e))?
.json()
.await
.map_err(|e| format!("Failed to parse GHCR token response: {}", e))?;
Ok(resp.token)
}

View File

@@ -12,7 +12,7 @@ pub struct ContainerInfo {
pub const LOCAL_IMAGE_NAME: &str = "triple-c"; pub const LOCAL_IMAGE_NAME: &str = "triple-c";
pub const IMAGE_TAG: &str = "latest"; pub const IMAGE_TAG: &str = "latest";
pub const REGISTRY_IMAGE: &str = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest"; pub const REGISTRY_IMAGE: &str = "ghcr.io/shadowdao/triple-c-sandbox:latest";
pub fn local_build_image_name() -> String { pub fn local_build_image_name() -> String {
format!("{LOCAL_IMAGE_NAME}:{IMAGE_TAG}") format!("{LOCAL_IMAGE_NAME}:{IMAGE_TAG}")

View File

@@ -18,19 +18,19 @@ pub struct ReleaseAsset {
pub size: u64, pub size: u64,
} }
/// Gitea API release response (internal). /// GitHub API release response (internal).
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct GiteaRelease { pub struct GitHubRelease {
pub tag_name: String, pub tag_name: String,
pub html_url: String, pub html_url: String,
pub body: String, pub body: String,
pub assets: Vec<GiteaAsset>, pub assets: Vec<GitHubAsset>,
pub published_at: String, pub published_at: String,
} }
/// Gitea API asset response (internal). /// GitHub API asset response (internal).
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct GiteaAsset { pub struct GitHubAsset {
pub name: String, pub name: String,
pub browser_download_url: String, pub browser_download_url: String,
pub size: u64, pub size: u64,

View File

@@ -4,7 +4,7 @@ import { useSettings } from "../../hooks/useSettings";
import type { ImageSource } from "../../lib/types"; import type { ImageSource } from "../../lib/types";
import Tooltip from "../ui/Tooltip"; import Tooltip from "../ui/Tooltip";
const REGISTRY_IMAGE = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest"; const REGISTRY_IMAGE = "ghcr.io/shadowdao/triple-c-sandbox:latest";
const IMAGE_SOURCE_OPTIONS: { value: ImageSource; label: string; description: string }[] = [ const IMAGE_SOURCE_OPTIONS: { value: ImageSource; label: string; description: string }[] = [
{ value: "registry", label: "Registry", description: "Pull from container registry" }, { value: "registry", label: "Registry", description: "Pull from container registry" },

View File

@@ -35,6 +35,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const [detectedUrl, setDetectedUrl] = useState<string | null>(null); const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null); const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const isAtBottomRef = useRef(true);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
@@ -131,10 +132,19 @@ export default function TerminalView({ sessionId, active }: Props) {
sendInput(sessionId, data); sendInput(sessionId, data);
}); });
// Track scroll position to show "Jump to Current" button // Track scroll position to show "Jump to Current" button.
// Debounce state updates via rAF to avoid excessive re-renders during rapid output.
let scrollStateRafId: number | null = null;
const scrollDisposable = term.onScroll(() => { const scrollDisposable = term.onScroll(() => {
const buf = term.buffer.active; const buf = term.buffer.active;
setIsAtBottom(buf.viewportY >= buf.baseY); const atBottom = buf.viewportY >= buf.baseY;
isAtBottomRef.current = atBottom;
if (scrollStateRafId === null) {
scrollStateRafId = requestAnimationFrame(() => {
scrollStateRafId = null;
setIsAtBottom(isAtBottomRef.current);
});
}
}); });
// Track text selection to show copy hint in status bar // Track text selection to show copy hint in status bar
@@ -187,7 +197,15 @@ export default function TerminalView({ sessionId, active }: Props) {
const outputPromise = onOutput(sessionId, (data) => { const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return; if (aborted) return;
term.write(data); const shouldFollow = isAtBottomRef.current;
term.write(data, () => {
// Keep viewport pinned to bottom when user hasn't scrolled up
if (shouldFollow) {
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true);
}
});
detector.feed(data); detector.feed(data);
// Scan for SSO refresh marker in terminal output // Scan for SSO refresh marker in terminal output
@@ -229,8 +247,13 @@ export default function TerminalView({ sessionId, active }: Props) {
resizeRafId = requestAnimationFrame(() => { resizeRafId = requestAnimationFrame(() => {
resizeRafId = null; resizeRafId = null;
if (!containerRef.current || containerRef.current.offsetWidth === 0) return; if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
const wasAtBottom = isAtBottomRef.current;
fitAddon.fit(); fitAddon.fit();
resize(sessionId, term.cols, term.rows); resize(sessionId, term.cols, term.rows);
// Maintain scroll position after resize reflow
if (wasAtBottom) {
term.scrollToBottom();
}
}); });
}); });
resizeObserver.observe(containerRef.current); resizeObserver.observe(containerRef.current);
@@ -249,6 +272,7 @@ export default function TerminalView({ sessionId, active }: Props) {
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true }); containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
outputPromise.then((fn) => fn?.()); outputPromise.then((fn) => fn?.());
exitPromise.then((fn) => fn?.()); exitPromise.then((fn) => fn?.());
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId); if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
resizeObserver.disconnect(); resizeObserver.disconnect();
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ } try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
@@ -314,8 +338,14 @@ export default function TerminalView({ sessionId, active }: Props) {
}, [detectedUrl]); }, [detectedUrl]);
const handleScrollToBottom = useCallback(() => { const handleScrollToBottom = useCallback(() => {
termRef.current?.scrollToBottom(); const term = termRef.current;
setIsAtBottom(true); if (term) {
// Re-fit first to fix viewport desync (same thing a resize does)
fitRef.current?.fit();
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true);
}
}, []); }, []);
return ( return (