Complete Phase 4b direct fullscreen fetch and ship app/agent updates
24
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
9
PLAN.md
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
42
agent/install-windows-task.ps1
Normal 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"
|
||||
621
agent/main.go
@@ -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)
|
||||
|
||||
@@ -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
|
||||
10
agent/uninstall-windows-task.ps1
Normal 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"
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
BIN
memoric-app/assets/icon.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 295 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 282 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 704 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 586 B |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
@@ -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(
|
||||
@@ -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"))
|
||||
}
|
||||