Complete Phase 4b direct fullscreen fetch and ship app/agent updates

This commit is contained in:
2026-05-18 14:31:20 +08:00
parent 46772a1448
commit 71872210d2
158 changed files with 1155 additions and 167 deletions

24
.gitignore vendored
View File

@@ -4,19 +4,25 @@ __pycache__/
venv/
# Flutter / Android build artifacts
poc/android/build/
poc/android/.dart_tool/
poc/android/.flutter-plugins
poc/android/.flutter-plugins-dependencies
poc/android/android/.gradle/
poc/android/android/local.properties
android/build/
android/.dart_tool/
android/.flutter-plugins
android/.flutter-plugins-dependencies
android/android/.gradle/
android/android/local.properties
# Generated icon (regenerated from gen_icon.py)
poc/android/assets/icon.png
android/assets/icon.png
# Test artifacts
poc/test.jpg
poc/fetched.jpg
test.jpg
fetched.jpg
# Agent runtime artifacts
agent/*.log
agent/memoric-agent
agent/clouderized-memories-agent
agent/photos/
# OS
.DS_Store

View File

@@ -88,6 +88,29 @@ Status: ✅ Completed (2026-05-18)
- On same LAN, phone uploads directly to agent without broker relay.
- Off-LAN upload still works through broker fallback.
## Phase 4b: Direct Fullscreen Photo Fetch (LAN Data Plane)
Status: ✅ Completed (2026-05-18)
- Extend direct-hint routing to full-resolution photo viewing.
- Use broker-provided `lan_endpoints` as candidate direct targets.
- Keep broker `/photo/{filename}` tunnel path as automatic fallback.
- Define fullscreen routing policy:
- App tries direct fetch from agent first when hints + token are available.
- On direct failure (timeout/auth/unreachable), app retries via broker URL immediately.
- Fallback should be transparent to user (no manual retry needed).
- Keep thumbnail behavior unchanged:
- Gallery thumbnails stay broker-served.
- Lazy broker thumbnail normalization remains tied to broker full-photo path.
- Add lightweight path visibility in testing:
- Record whether fullscreen load path was `direct` or `broker_fallback`.
### Acceptance
- On same LAN, fullscreen photo opens via direct agent fetch when reachable.
- Off-LAN or failed direct attempts still open fullscreen photo through broker fallback.
- User-facing fullscreen flow remains one tap with no extra prompts.
## Phase 5: Platform Integration & Hardening
- Align Linux service + Windows task launchers with new network/config settings.

View File

@@ -11,6 +11,7 @@ that works end-to-end, not a half-finished layer.
|-------|-------------|--------|
| 0 | POC — tunnel, upload relay, reverse fetch | ✅ Done |
| 1 | Android app — pick and upload photos | ✅ Done |
| 2 | Agent UI v1 — status + storage settings (LAN API/UI) | ✅ Done (2026-05-18) |
| TODO | Remaining work backlog (phase grouping TBD) | ⬜ Pending |
---
@@ -30,7 +31,7 @@ that works end-to-end, not a half-finished layer.
- System-level Gitea webhook installed — new repos auto-provision without manual setup
- `memory.clouderized.com` added to operator custom domain allowlist
**POC code:** `poc/memory/` (broker), `poc/agent/` (Go agent), `poc/android/` (Flutter app)
**Codebase paths:** `memory/` (broker), `agent/` (Go agent), `memoric-app/` (Flutter app)
---
@@ -42,7 +43,7 @@ that works end-to-end, not a half-finished layer.
`memory.clouderized.com`. Upload status shown per photo (pending / uploading / done / failed).
**Done:**
- Flutter project scaffolded at `poc/android/`
- Flutter project scaffolded at `memoric-app/`
- `image_picker` + `http` + `path` dependencies added
- Upload UI built — photo grid with status badges
- Android permissions set (READ_MEDIA_IMAGES, INTERNET)
@@ -54,7 +55,7 @@ that works end-to-end, not a half-finished layer.
---
## TODO (Post-Phase 1 Backlog)
## TODO (Post-Phase 2 Backlog)
Phase grouping beyond Phase 1 is intentionally deferred. Use this backlog to
pick what will become the next implementation phase(s).
@@ -128,7 +129,7 @@ This section consolidates the prior `DEVELOPMENT_PLAN.md` progress notes.
- ✅ Updated app name in `pubspec.yaml`: `name: memoric`
- ✅ Updated iOS display name in `ios/Runner/Info.plist`: `CFBundleDisplayName = MemoriC`
- ✅ Updated iOS bundle name in `ios/Runner/Info.plist`: `CFBundleName = MemoriC`
- ✅ Created Android app name in `android/app/src/main/res/values/strings.xml`
- ✅ Created Android app name in `memoric-app/android/app/src/main/res/values/strings.xml`
- ✅ Verified debug APK build
### Hour 2: Real Device Testing Preparation

View File

@@ -19,7 +19,7 @@ Built on the Clouderized platform (dogfooding).
| [PLAN.md](PLAN.md) | Build plan — phases and milestones |
| [memory/](memory/) | Broker service (FastAPI) |
| [agent/](agent/) | Home agent (Go) |
| [android/](android/) | Android app (Flutter) |
| [memoric-app/](memoric-app/) | Android app (Flutter) |
## Run Agent As User Service (Linux)

View File

@@ -4,9 +4,12 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="${HOME}/.local/share/memoric-agent"
SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
AGENT_CONFIG_DIR="${HOME}/.config/memoric-agent"
AGENT_ENV_FILE="${AGENT_CONFIG_DIR}/agent.env"
mkdir -p "${APP_DIR}"
mkdir -p "${SYSTEMD_USER_DIR}"
mkdir -p "${AGENT_CONFIG_DIR}"
echo "[1/4] Building agent binary..."
cd "${SCRIPT_DIR}"
@@ -15,13 +18,27 @@ go build -o "${APP_DIR}/memoric-agent" .
echo "[2/4] Installing systemd unit..."
cp "${SCRIPT_DIR}/systemd/memoric-agent.service" "${SYSTEMD_USER_DIR}/memoric-agent.service"
if [[ ! -f "${AGENT_ENV_FILE}" ]]; then
cat > "${AGENT_ENV_FILE}" <<'ENVEOF'
# MemoriC agent runtime overrides (optional)
# STORAGE_DIR=/home/your-user/memoric/photos
# AGENT_BIND_HOST=0.0.0.0
# AGENT_BIND_PORT=5051
# AGENT_ALLOWED_SUBNETS=192.168.0.0/16,10.0.0.0/8,127.0.0.0/8
# AGENT_PAIRING_TOKEN=change-me
ENVEOF
echo "Created ${AGENT_ENV_FILE}"
fi
echo "[3/4] Reloading user daemon..."
systemctl --user daemon-reload
echo "[4/4] Enabling and starting memoric-agent..."
systemctl --user enable --now memoric-agent
echo "[4/4] Enabling and restarting memoric-agent..."
systemctl --user enable memoric-agent
systemctl --user restart memoric-agent
echo
echo "MemoriC agent service installed."
echo "Optional runtime overrides: ${AGENT_ENV_FILE}"
echo "Check status with: systemctl --user status memoric-agent"
echo "View logs with: journalctl --user -u memoric-agent -f"

View File

@@ -0,0 +1,42 @@
Param(
[string]$TaskName = "MemoriCAgent",
[string]$InstallDir = "$env:LOCALAPPDATA\MemoriC-Agent",
[string]$StorageDir = "$env:USERPROFILE\memoric\photos"
)
$ErrorActionPreference = "Stop"
Write-Host "[1/6] Creating install directories..."
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
New-Item -ItemType Directory -Force -Path $StorageDir | Out-Null
Write-Host "[2/6] Building Windows agent binary..."
Push-Location $PSScriptRoot
go build -o "$InstallDir\memoric-agent.exe" .
Pop-Location
Write-Host "[3/6] Writing launcher script..."
$launcher = @"
@echo off
set STORAGE_DIR=$StorageDir
"$InstallDir\memoric-agent.exe"
"@
Set-Content -Path "$InstallDir\run-agent.cmd" -Value $launcher -Encoding ASCII
Write-Host "[4/6] Stopping existing task instance (if running)..."
schtasks /End /TN $TaskName 2>$null | Out-Null
Write-Host "[5/6] Registering scheduled task..."
schtasks /Create /SC ONLOGON /TN $TaskName /TR "`"$InstallDir\run-agent.cmd`"" /RL LIMITED /F | Out-Null
Write-Host "[6/6] Starting task..."
schtasks /Run /TN $TaskName | Out-Null
Write-Host ""
Write-Host "MemoriC agent task installed/updated and restarted."
Write-Host "Task name: $TaskName"
Write-Host "Install dir: $InstallDir"
Write-Host "Storage dir: $StorageDir"
Write-Host ""
Write-Host "Check status with:"
Write-Host " schtasks /Query /TN $TaskName /V /FO LIST"

View File

@@ -5,14 +5,19 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/gorilla/websocket"
@@ -39,11 +44,13 @@ type Config struct {
}
type Message struct {
Type string `json:"type"`
Filename string `json:"filename,omitempty"`
Data string `json:"data,omitempty"`
RequestID string `json:"request_id,omitempty"`
Error string `json:"error,omitempty"`
Type string `json:"type"`
Filename string `json:"filename,omitempty"`
Data string `json:"data,omitempty"`
RequestID string `json:"request_id,omitempty"`
Error string `json:"error,omitempty"`
LANEndpoints []string `json:"lan_endpoints,omitempty"`
BindPort int `json:"bind_port,omitempty"`
}
type AgentState struct {
@@ -57,6 +64,9 @@ type AgentState struct {
allowedPrefixes []netip.Prefix
token string
bindAddr string
configPath string
config Config
migration MigrationStatus
}
type StatusResponse struct {
@@ -71,6 +81,32 @@ type StatusResponse struct {
StartedAt string `json:"started_at"`
}
type StorageValidateRequest struct {
Path string `json:"path"`
}
type StorageValidation struct {
Valid bool `json:"valid"`
Exists bool `json:"exists"`
Writable bool `json:"writable"`
Empty bool `json:"empty"`
AbsPath string `json:"abs_path,omitempty"`
Issue string `json:"issue,omitempty"`
Suggestion string `json:"suggestion,omitempty"`
}
type MigrationStatus struct {
Status string `json:"status"`
MigrationActive bool `json:"migration_active"`
SourceDir string `json:"source_dir,omitempty"`
DestDir string `json:"dest_dir,omitempty"`
FilesMoved int `json:"files_moved,omitempty"`
DirsCreated int `json:"dirs_created,omitempty"`
LastError string `json:"last_error,omitempty"`
FailedPaths []string `json:"failed_paths,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
}
func defaultStorageDir() string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
@@ -215,6 +251,275 @@ func (s *AgentState) writeJSON(w http.ResponseWriter, code int, v any) {
_ = json.NewEncoder(w).Encode(v)
}
func (s *AgentState) getStorageDir() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.storageDir
}
func (s *AgentState) setStorageDir(path string) {
s.mu.Lock()
s.storageDir = path
s.config.StorageDir = path
s.mu.Unlock()
}
func (s *AgentState) persistConfig() error {
s.mu.RLock()
cfg := s.config
cfg.StorageDir = s.storageDir
path := s.configPath
s.mu.RUnlock()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(b, '\n'), 0644)
}
func validateStoragePath(raw string) StorageValidation {
raw = strings.TrimSpace(raw)
if raw == "" {
return StorageValidation{
Valid: false,
Issue: "path is required",
Suggestion: "Provide a non-empty directory path.",
}
}
abs, err := filepath.Abs(raw)
if err != nil {
return StorageValidation{
Valid: false,
Issue: fmt.Sprintf("cannot resolve absolute path: %v", err),
Suggestion: "Use a valid local filesystem path.",
}
}
v := StorageValidation{AbsPath: abs}
info, statErr := os.Stat(abs)
if statErr != nil {
if errors.Is(statErr, fs.ErrNotExist) {
return StorageValidation{
Valid: false,
AbsPath: abs,
Exists: false,
Issue: "directory does not exist",
Suggestion: "Create the directory first, then retry.",
}
}
return StorageValidation{
Valid: false,
AbsPath: abs,
Issue: fmt.Sprintf("stat failed: %v", statErr),
Suggestion: "Ensure the path is accessible.",
}
}
if !info.IsDir() {
return StorageValidation{
Valid: false,
AbsPath: abs,
Exists: true,
Issue: "path is not a directory",
Suggestion: "Choose a directory path.",
}
}
v.Exists = true
entries, readErr := os.ReadDir(abs)
if readErr != nil {
v.Valid = false
v.Issue = fmt.Sprintf("cannot read directory: %v", readErr)
v.Suggestion = "Ensure the directory is readable."
return v
}
v.Empty = len(entries) == 0
probe := filepath.Join(abs, ".memoric-agent-write-test")
if werr := os.WriteFile(probe, []byte("ok"), 0600); werr != nil {
v.Valid = false
v.Issue = fmt.Sprintf("directory is not writable: %v", werr)
v.Suggestion = "Grant write permission to the agent user."
return v
}
_ = os.Remove(probe)
v.Writable = true
v.Valid = true
return v
}
func isSubPath(parent, child string) bool {
rel, err := filepath.Rel(parent, child)
if err != nil {
return false
}
if rel == "." {
return true
}
return !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && rel != ".."
}
func copyFile(src, dst string, mode fs.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
return nil
}
func (s *AgentState) setMigrationStatus(st MigrationStatus) {
s.mu.Lock()
s.migration = st
s.mu.Unlock()
}
func (s *AgentState) getMigrationStatus() MigrationStatus {
s.mu.RLock()
defer s.mu.RUnlock()
out := s.migration
if out.FailedPaths != nil {
out.FailedPaths = append([]string(nil), out.FailedPaths...)
}
return out
}
func (s *AgentState) migrateStorage(src, dst string) MigrationStatus {
status := MigrationStatus{
Status: "running",
MigrationActive: true,
SourceDir: src,
DestDir: dst,
}
s.setMigrationStatus(status)
info, err := os.Stat(src)
if err != nil || !info.IsDir() {
status.Status = "error"
status.MigrationActive = false
status.LastError = "source storage directory is not accessible"
status.CompletedAt = time.Now().UTC().Format(time.RFC3339)
return status
}
if src == dst {
status.Status = "rejected"
status.MigrationActive = false
status.LastError = "destination must differ from current storage directory"
status.CompletedAt = time.Now().UTC().Format(time.RFC3339)
return status
}
if isSubPath(src, dst) || isSubPath(dst, src) {
status.Status = "rejected"
status.MigrationActive = false
status.LastError = "source and destination cannot be nested inside each other"
status.CompletedAt = time.Now().UTC().Format(time.RFC3339)
return status
}
dstValidation := validateStoragePath(dst)
if !dstValidation.Valid || !dstValidation.Exists || !dstValidation.Writable || !dstValidation.Empty {
status.Status = "rejected"
status.MigrationActive = false
status.LastError = "destination failed validation (must exist, writable, and empty)"
if dstValidation.Issue != "" {
status.LastError = dstValidation.Issue
}
status.CompletedAt = time.Now().UTC().Format(time.RFC3339)
return status
}
walkErr := filepath.WalkDir(src, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
status.FailedPaths = append(status.FailedPaths, path+": "+walkErr.Error())
return walkErr
}
if path == src {
return nil
}
rel, err := filepath.Rel(src, path)
if err != nil {
status.FailedPaths = append(status.FailedPaths, path+": "+err.Error())
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
if err := os.MkdirAll(target, 0755); err != nil {
status.FailedPaths = append(status.FailedPaths, path+": "+err.Error())
return err
}
status.DirsCreated++
return nil
}
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
status.FailedPaths = append(status.FailedPaths, path+": "+err.Error())
return err
}
err = os.Rename(path, target)
if err == nil {
status.FilesMoved++
return nil
}
if !errors.Is(err, syscall.EXDEV) {
status.FailedPaths = append(status.FailedPaths, path+": "+err.Error())
return err
}
srcInfo, statErr := os.Stat(path)
if statErr != nil {
status.FailedPaths = append(status.FailedPaths, path+": "+statErr.Error())
return statErr
}
if copyErr := copyFile(path, target, srcInfo.Mode()); copyErr != nil {
status.FailedPaths = append(status.FailedPaths, path+": "+copyErr.Error())
return copyErr
}
if removeErr := os.Remove(path); removeErr != nil {
status.FailedPaths = append(status.FailedPaths, path+": "+removeErr.Error())
return removeErr
}
status.FilesMoved++
return nil
})
if walkErr != nil {
status.Status = "error"
status.MigrationActive = false
status.LastError = "migration failed before cutover; active storage path unchanged"
status.CompletedAt = time.Now().UTC().Format(time.RFC3339)
return status
}
s.setStorageDir(dst)
if err := s.persistConfig(); err != nil {
s.setStorageDir(src)
status.Status = "error"
status.MigrationActive = false
status.LastError = fmt.Sprintf("migration completed but config persist failed, path reverted: %v", err)
status.CompletedAt = time.Now().UTC().Format(time.RFC3339)
return status
}
status.Status = "ok"
status.MigrationActive = false
status.CompletedAt = time.Now().UTC().Format(time.RFC3339)
return status
}
func (s *AgentState) clientAllowed(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
@@ -262,6 +567,34 @@ func (s *AgentState) requireMutableAuth(next http.HandlerFunc) http.HandlerFunc
}
}
func sanitizeFilename(name string) (string, error) {
cleaned := strings.TrimSpace(name)
if cleaned == "" {
return "", errors.New("filename is required")
}
base := filepath.Base(cleaned)
if base == "." || base == "/" || base == string(filepath.Separator) {
return "", errors.New("invalid filename")
}
if strings.Contains(base, "..") || strings.ContainsRune(base, filepath.Separator) {
return "", errors.New("invalid filename")
}
return base, nil
}
func (s *AgentState) saveUpload(filename string, data []byte) error {
safeName, err := sanitizeFilename(filename)
if err != nil {
return err
}
path := filepath.Join(s.getStorageDir(), safeName)
if err := os.WriteFile(path, data, 0644); err != nil {
return err
}
s.incUpload()
return nil
}
func (s *AgentState) startHTTPServer(allowedSubnets []string) {
mux := http.NewServeMux()
@@ -273,6 +606,80 @@ func (s *AgentState) startHTTPServer(allowedSubnets []string) {
s.writeJSON(w, http.StatusOK, s.status(allowedSubnets))
}))
mux.HandleFunc("/api/storage/validate", s.requireSubnet(s.requireMutableAuth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
var req StorageValidateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
v := validateStoragePath(req.Path)
s.writeJSON(w, http.StatusOK, v)
})))
mux.HandleFunc("/api/storage/change", s.requireSubnet(s.requireMutableAuth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
var req StorageValidateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
v := validateStoragePath(req.Path)
if !v.Valid || !v.Exists || !v.Writable || !v.Empty {
s.writeJSON(w, http.StatusBadRequest, map[string]any{
"status": "rejected",
"validation": v,
})
return
}
src := s.getStorageDir()
result := s.migrateStorage(src, v.AbsPath)
s.setMigrationStatus(result)
if result.Status != "ok" {
code := http.StatusInternalServerError
if result.Status == "rejected" {
code = http.StatusBadRequest
}
if result.LastError != "" {
s.setError(errors.New(result.LastError))
}
s.writeJSON(w, code, result)
return
}
s.writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"storage_dir": v.AbsPath,
"migration": result,
"message": "storage migration completed and active path switched",
})
})))
mux.HandleFunc("/api/storage/migration", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
st := s.getMigrationStatus()
if st.Status == "" {
st = MigrationStatus{
Status: "idle",
MigrationActive: false,
SourceDir: s.getStorageDir(),
}
}
s.writeJSON(w, http.StatusOK, map[string]any{
"phase": "phase_3",
"migration_supported": true,
"migration": st,
})
}))
// Mutable placeholder endpoint for Phase 1 auth enforcement.
mux.HandleFunc("/api/control/ping", s.requireSubnet(s.requireMutableAuth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -282,6 +689,154 @@ func (s *AgentState) startHTTPServer(allowedSubnets []string) {
s.writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "message": "authorized mutable endpoint"})
})))
mux.HandleFunc("/api/upload", s.requireSubnet(s.requireMutableAuth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
if err := r.ParseMultipartForm(64 << 20); err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart upload"})
return
}
fh, header, err := r.FormFile("file")
if err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing file field"})
return
}
defer fh.Close()
rawFilename := strings.TrimSpace(r.FormValue("filename"))
if rawFilename == "" {
rawFilename = header.Filename
}
safeName, err := sanitizeFilename(rawFilename)
if err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
data, err := io.ReadAll(fh)
if err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read upload stream"})
return
}
if err := s.saveUpload(safeName, data); err != nil {
s.setError(err)
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to store upload"})
return
}
s.writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"filename": safeName,
"bytes": strconv.Itoa(len(data)),
"path": filepath.Join(s.getStorageDir(), safeName),
})
})))
mux.HandleFunc("/api/photo/", s.requireSubnet(s.requireMutableAuth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/photo/")
safeName, err := sanitizeFilename(name)
if err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
path := filepath.Join(s.getStorageDir(), safeName)
b, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
s.writeJSON(w, http.StatusNotFound, map[string]string{"error": "photo not found"})
return
}
s.setError(err)
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read photo"})
return
}
s.incFetch()
contentType := http.DetectContentType(b)
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", strconv.Itoa(len(b)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
})))
mux.HandleFunc("/", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MemoriC Agent</title>
<style>
body { font-family: sans-serif; margin: 24px; line-height: 1.4; color: #1f2937; }
h1 { margin-top: 0; }
.box { border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
.row { margin: 8px 0; }
input { width: 100%; max-width: 680px; padding: 8px; }
button { margin-top: 8px; padding: 8px 12px; }
pre { background: #f8fafc; padding: 8px; border-radius: 6px; overflow: auto; }
</style>
</head>
<body>
<h1>MemoriC Agent</h1>
<div class="box">
<h3>Status</h3>
<div id="status">Loading...</div>
<div class="row"><button id="refresh">Refresh</button></div>
</div>
<div class="box">
<h3>Storage Settings</h3>
<div class="row"><label>Storage Directory</label></div>
<input id="path" placeholder="/path/to/photos" />
<div class="row"><button id="validate">Validate</button> <button id="change">Migrate + Switch</button></div>
<pre id="result"></pre>
</div>
<script>
async function loadStatus() {
const res = await fetch('/api/status');
const data = await res.json();
document.getElementById('status').innerText =
'Broker: ' + (data.broker_connected ? 'connected' : 'disconnected') +
'\nBind: ' + data.bind_addr +
'\nStorage: ' + data.storage_dir +
'\nLast Error: ' + (data.last_error || '-');
document.getElementById('path').value = data.storage_dir || '';
}
async function postJSON(url, body) {
const token = prompt('Enter pairing token');
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (token || '')},
body: JSON.stringify(body)
});
const data = await res.json();
document.getElementById('result').innerText = JSON.stringify(data, null, 2);
await loadStatus();
}
async function loadMigration() {
const res = await fetch('/api/storage/migration');
const data = await res.json();
const prev = document.getElementById('result').innerText;
if (!prev || prev === 'Loading...') {
document.getElementById('result').innerText = JSON.stringify(data, null, 2);
}
}
document.getElementById('refresh').onclick = loadStatus;
document.getElementById('validate').onclick = () => postJSON('/api/storage/validate', {path: document.getElementById('path').value});
document.getElementById('change').onclick = () => postJSON('/api/storage/change', {path: document.getElementById('path').value});
loadStatus();
loadMigration();
</script>
</body>
</html>`))
}))
srv := &http.Server{
Addr: s.bindAddr,
Handler: mux,
@@ -294,6 +849,45 @@ func (s *AgentState) startHTTPServer(allowedSubnets []string) {
}
}
func getLANEndpoints(bindHost string, bindPort int) []string {
seen := make(map[string]struct{})
out := make([]string, 0, 8)
add := func(ip netip.Addr) {
if !ip.IsValid() {
return
}
if !(ip.IsPrivate() || ip.IsLoopback()) {
return
}
key := net.JoinHostPort(ip.String(), strconv.Itoa(bindPort))
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, key)
}
// Prefer the source IP selected by OS routing for outbound traffic.
if udpConn, err := net.Dial("udp", "1.1.1.1:53"); err == nil {
if la, ok := udpConn.LocalAddr().(*net.UDPAddr); ok && la != nil && la.IP != nil {
if addr, err := netip.ParseAddr(strings.TrimSpace(la.IP.String())); err == nil {
add(addr)
}
}
_ = udpConn.Close()
}
if h := strings.TrimSpace(bindHost); h != "" && h != "0.0.0.0" && h != "::" {
if addr, err := netip.ParseAddr(h); err == nil {
add(addr)
}
}
sort.Strings(out)
return out
}
func main() {
cfg := loadConfig()
prefixes, err := parseAllowedSubnets(cfg.AllowedSubnets)
@@ -311,6 +905,8 @@ func main() {
allowedPrefixes: prefixes,
token: strings.TrimSpace(cfg.PairingToken),
bindAddr: fmt.Sprintf("%s:%d", cfg.BindHost, cfg.BindPort),
configPath: configPath(),
config: cfg,
}
log.Println("photo storage directory:", cfg.StorageDir)
@@ -328,6 +924,15 @@ func main() {
defer conn.Close()
state.setConnected(true)
log.Println("connected to broker at", brokerURL)
hello, _ := json.Marshal(Message{
Type: "agent_hello",
LANEndpoints: getLANEndpoints(cfg.BindHost, cfg.BindPort),
BindPort: cfg.BindPort,
})
if err := conn.WriteMessage(websocket.TextMessage, hello); err != nil {
state.setError(err)
log.Printf("failed sending agent_hello: %v", err)
}
for {
_, raw, err := conn.ReadMessage()
@@ -352,17 +957,15 @@ func main() {
log.Println("decode error:", err)
continue
}
path := filepath.Join(cfg.StorageDir, msg.Filename)
if err := os.WriteFile(path, data, 0644); err != nil {
if err := state.saveUpload(msg.Filename, data); err != nil {
state.setError(err)
log.Println("write error:", err)
continue
}
state.incUpload()
log.Printf("saved %s (%d bytes)", msg.Filename, len(data))
case "fetch":
path := filepath.Join(cfg.StorageDir, msg.Filename)
path := filepath.Join(state.getStorageDir(), msg.Filename)
data, err := os.ReadFile(path)
if err != nil {
log.Printf("fetch %s: not found", msg.Filename)

View File

@@ -6,7 +6,7 @@ Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/.local/share/memoric-agent
Environment=STORAGE_DIR=%h/memoric/photos
EnvironmentFile=-%h/.config/memoric-agent/agent.env
ExecStart=%h/.local/share/memoric-agent/memoric-agent
Restart=always
RestartSec=5

View File

@@ -0,0 +1,10 @@
Param(
[string]$TaskName = "MemoriCAgent"
)
$ErrorActionPreference = "Stop"
schtasks /End /TN $TaskName 2>$null | Out-Null
schtasks /Delete /TN $TaskName /F 2>$null | Out-Null
Write-Host "Removed task: $TaskName"

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

BIN
memoric-app/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -4,8 +4,10 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:image/image.dart' as img;
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p;
import 'package:shared_preferences/shared_preferences.dart';
const brokerUrl = 'https://memory.clouderized.com';
@@ -34,9 +36,32 @@ class PhotoItem {
class GalleryPhoto {
final String thumbnailUrl;
final String fullUrl;
final String brokerFullUrl;
final String? filename;
const GalleryPhoto({required this.thumbnailUrl, required this.fullUrl});
const GalleryPhoto({
required this.thumbnailUrl,
required this.brokerFullUrl,
required this.filename,
});
}
class DirectUploadConfig {
final bool enabled;
final String endpoint;
final String token;
const DirectUploadConfig({
required this.enabled,
required this.endpoint,
required this.token,
});
static const defaults = DirectUploadConfig(
enabled: false,
endpoint: '',
token: '',
);
}
class UploadPage extends StatefulWidget {
@@ -54,10 +79,14 @@ class _UploadPageState extends State<UploadPage> {
String? _galleryError;
bool _agentConnected = false;
Timer? _statusTimer;
DirectUploadConfig _directConfig = DirectUploadConfig.defaults;
late final TextEditingController _tokenController;
@override
void initState() {
super.initState();
_tokenController = TextEditingController();
_loadDirectUploadConfig();
_loadGallery();
_statusTimer = Timer.periodic(
const Duration(seconds: 30),
@@ -68,9 +97,40 @@ class _UploadPageState extends State<UploadPage> {
@override
void dispose() {
_statusTimer?.cancel();
_tokenController.dispose();
super.dispose();
}
Future<void> _loadDirectUploadConfig() async {
final prefs = await SharedPreferences.getInstance();
final cfg = DirectUploadConfig(
enabled: prefs.getBool('direct_upload_enabled') ?? false,
endpoint: prefs.getString('direct_upload_endpoint') ?? '',
token: prefs.getString('direct_upload_token') ?? '',
);
if (!mounted) return;
setState(() {
_directConfig = cfg;
_tokenController.text = cfg.token;
});
}
Future<void> _saveDirectUploadConfig() async {
final token = _tokenController.text.trim();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('direct_upload_enabled', true);
await prefs.remove('direct_upload_endpoint');
await prefs.setString('direct_upload_token', token);
if (!mounted) return;
setState(() {
_directConfig = DirectUploadConfig(
enabled: true,
endpoint: '',
token: token,
);
});
}
Future<void> _pickPhotos() async {
final picked = await _picker.pickMultiImage();
if (picked.isEmpty) return;
@@ -86,15 +146,7 @@ class _UploadPageState extends State<UploadPage> {
for (final item in _photos.where((i) => i.status == UploadStatus.pending)) {
setState(() => item.status = UploadStatus.uploading);
try {
final filename = p.basename(item.file.path);
final req = http.MultipartRequest(
'POST',
Uri.parse('$brokerUrl/upload/$filename'),
);
req.files.add(
await http.MultipartFile.fromPath('file', item.file.path),
);
final res = await req.send();
final res = await _uploadWithFallback(item.file);
setState(() {
item.status = res.statusCode == 200
? UploadStatus.done
@@ -107,6 +159,126 @@ class _UploadPageState extends State<UploadPage> {
await _loadGallery();
}
Future<http.StreamedResponse> _uploadWithFallback(File file) async {
final hinted = await _tryBrokerHintedDirectUpload(file);
if (hinted != null && hinted.statusCode == 200) {
await _indexDirectUploadToBroker(file);
return hinted;
}
return _uploadViaBroker(file);
}
Future<http.StreamedResponse?> _tryBrokerHintedDirectUpload(File file) async {
final token = _directConfig.token.trim();
if (token.isEmpty) return null;
final hints = await _fetchAgentHints();
if (hints.isEmpty) return null;
for (final endpoint in hints) {
final res = await _tryDirectUploadToEndpoint(file, endpoint, token);
if (res != null && res.statusCode == 200) return res;
}
return null;
}
Future<List<Uri>> _fetchAgentHints() async {
try {
final res = await http.get(Uri.parse('$brokerUrl/agent/hints'));
if (res.statusCode != 200) return const [];
final body = jsonDecode(res.body);
if (body is! Map<String, dynamic>) return const [];
final endpoints = body['lan_endpoints'];
if (endpoints is! List) return const [];
final out = <Uri>[];
for (final raw in endpoints) {
final uri = _normalizeDirectEndpoint(raw.toString());
if (uri != null) out.add(uri);
}
return out;
} catch (_) {
return const [];
}
}
Future<http.StreamedResponse?> _tryDirectUploadToEndpoint(
File file,
Uri endpoint,
String token,
) async {
try {
final req = http.MultipartRequest(
'POST',
endpoint.resolve('/api/upload'),
);
req.headers['Authorization'] = 'Bearer $token';
req.files.add(await http.MultipartFile.fromPath('file', file.path));
req.fields['filename'] = p.basename(file.path);
return await req.send();
} catch (_) {
return null;
}
}
Future<http.StreamedResponse> _uploadViaBroker(File file) async {
final filename = p.basename(file.path);
final req = http.MultipartRequest(
'POST',
Uri.parse('$brokerUrl/upload/$filename'),
);
req.files.add(await http.MultipartFile.fromPath('file', file.path));
return req.send();
}
Uri? _normalizeDirectEndpoint(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return null;
final withScheme = trimmed.contains('://') ? trimmed : 'http://$trimmed';
final uri = Uri.tryParse(withScheme);
if (uri == null || uri.host.isEmpty) return null;
return uri;
}
Future<void> _indexDirectUploadToBroker(File file) async {
try {
final thumbBytes = await _buildPhoneThumbnail(file);
if (thumbBytes == null) return;
final req = http.MultipartRequest(
'POST',
Uri.parse('$brokerUrl/upload-index'),
);
req.fields['filename'] = p.basename(file.path);
req.fields['size_bytes'] = (await file.length()).toString();
req.fields['content_type'] = 'image/jpeg';
req.files.add(
http.MultipartFile.fromBytes(
'file',
thumbBytes,
filename: 'thumb_${p.basename(file.path)}.jpg',
),
);
await req.send();
} catch (_) {
// Keep upload success semantics: indexing failure should not fail direct upload.
}
}
Future<List<int>?> _buildPhoneThumbnail(File file) async {
try {
final raw = await file.readAsBytes();
final decoded = img.decodeImage(raw);
if (decoded == null) return null;
final thumb = img.copyResize(
decoded,
width: decoded.width >= decoded.height ? 400 : null,
height: decoded.height > decoded.width ? 400 : null,
interpolation: img.Interpolation.average,
);
return img.encodeJpg(thumb, quality: 80);
} catch (_) {
return null;
}
}
Future<void> _loadGallery() async {
setState(() {
_galleryLoading = true;
@@ -196,14 +368,17 @@ class _UploadPageState extends State<UploadPage> {
final thumb = _resolveUrl(thumbRaw);
if (thumb.isEmpty) return null;
final rawFilename = item['filename'];
final filename = rawFilename is String && rawFilename.trim().isNotEmpty
? rawFilename.trim()
: null;
final explicitFull = item['photo_url'] ?? item['full_url'] ?? item['url'];
String full;
if (explicitFull is String && explicitFull.trim().isNotEmpty) {
full = _resolveUrl(explicitFull);
} else if (item['filename'] is String &&
(item['filename'] as String).isNotEmpty) {
final encoded = Uri.encodeComponent(item['filename'] as String);
} else if (filename != null) {
final encoded = Uri.encodeComponent(filename);
full = '$brokerUrl/photo/$encoded';
} else if (item['id'] != null) {
full = '$brokerUrl/photo/${item['id']}';
@@ -211,7 +386,11 @@ class _UploadPageState extends State<UploadPage> {
full = thumb;
}
return GalleryPhoto(thumbnailUrl: thumb, fullUrl: full);
return GalleryPhoto(
thumbnailUrl: thumb,
brokerFullUrl: full,
filename: filename,
);
}
String _resolveUrl(String value) {
@@ -223,20 +402,21 @@ class _UploadPageState extends State<UploadPage> {
}
Future<void> _openFullPhoto(GalleryPhoto photo) async {
final messenger = ScaffoldMessenger.of(context);
await _refreshAgentStatus();
if (!mounted || !_agentConnected) {
messenger.showSnackBar(
const SnackBar(
content: Text('Agent is disconnected. Full photo is unavailable.'),
),
);
return;
final token = _directConfig.token.trim();
final hints = token.isEmpty ? const <Uri>[] : await _fetchAgentHints();
String? directUrl;
if (photo.filename != null && hints.isNotEmpty) {
final encoded = Uri.encodeComponent(photo.filename!);
directUrl = hints.first.resolve('/api/photo/$encoded').toString();
}
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => FullPhotoPage(imageUrl: photo.fullUrl),
builder: (_) => FullPhotoPage(
brokerImageUrl: photo.brokerFullUrl,
directImageUrl: directUrl,
directToken: token,
),
),
);
}
@@ -339,27 +519,105 @@ class _UploadPageState extends State<UploadPage> {
}
Widget _buildUploadsTab() {
if (_photos.isEmpty) {
return const Center(child: Text('No photos selected yet.'));
}
final hasPhotos = _photos.isNotEmpty;
return Column(
children: [
_buildDirectUploadSettings(),
Expanded(
child: hasPhotos
? GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
),
itemCount: _photos.length,
itemBuilder: (_, i) => _PhotoTile(item: _photos[i]),
)
: const Center(child: Text('No photos selected yet.')),
),
],
);
}
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
Widget _buildDirectUploadSettings() {
return Card(
margin: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Direct upload is automatic using broker-provided agent LAN hints. '
'If direct fails, upload falls back to broker.',
),
const SizedBox(height: 10),
TextField(
controller: _tokenController,
decoration: const InputDecoration(labelText: 'Pairing token'),
obscureText: true,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: _saveDirectUploadConfig,
child: const Text('Save LAN Settings'),
),
),
],
),
),
itemCount: _photos.length,
itemBuilder: (_, i) => _PhotoTile(item: _photos[i]),
);
}
}
class FullPhotoPage extends StatelessWidget {
final String imageUrl;
class FullPhotoPage extends StatefulWidget {
final String brokerImageUrl;
final String? directImageUrl;
final String directToken;
const FullPhotoPage({super.key, required this.imageUrl});
const FullPhotoPage({
super.key,
required this.brokerImageUrl,
required this.directImageUrl,
required this.directToken,
});
@override
State<FullPhotoPage> createState() => _FullPhotoPageState();
}
class _FullPhotoPageState extends State<FullPhotoPage> {
late String _activeUrl;
late Map<String, String>? _activeHeaders;
late String _path;
@override
void initState() {
super.initState();
final direct = widget.directImageUrl;
if (direct != null && widget.directToken.trim().isNotEmpty) {
_activeUrl = direct;
_activeHeaders = {'Authorization': 'Bearer ${widget.directToken}'};
_path = 'direct';
return;
}
_activeUrl = widget.brokerImageUrl;
_activeHeaders = null;
_path = 'broker_fallback';
}
void _fallbackToBroker() {
if (_path == 'broker_fallback') return;
setState(() {
_activeUrl = widget.brokerImageUrl;
_activeHeaders = null;
_path = 'broker_fallback';
});
}
@override
Widget build(BuildContext context) {
@@ -368,21 +626,33 @@ class FullPhotoPage extends StatelessWidget {
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: const Text('Photo'),
title: Text('Photo ($_path)'),
),
body: InteractiveViewer(
minScale: 0.8,
maxScale: 4,
child: Center(
child: Image.network(
imageUrl,
_activeUrl,
headers: _activeHeaders,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => const Center(
child: Text(
'Failed to load full photo.',
style: TextStyle(color: Colors.white),
),
),
errorBuilder: (_, __, ___) {
if (_path == 'direct') {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_fallbackToBroker();
});
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
return const Center(
child: Text(
'Failed to load full photo.',
style: TextStyle(color: Colors.white),
),
);
},
loadingBuilder: (_, child, loadingProgress) {
if (loadingProgress == null) return child;
return const Center(

View File

@@ -6,7 +6,9 @@ import FlutterMacOS
import Foundation
import file_selector_macos
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}

Some files were not shown because too many files have changed in this diff Show More