Compare commits

..

No commits in common. "main" and "desktop-v0.1.3" have entirely different histories.

705 changed files with 25857 additions and 129378 deletions

View file

@ -1,91 +0,0 @@
{
"permissions": {
"allow": [
"Bash(ssh:*)",
"Bash(bun run lint)",
"Bash(bun run prisma:generate:*)",
"Bash(bun run build:bun:*)",
"WebSearch",
"Bash(bun add:*)",
"Bash(bun run tauri:*)",
"Bash(curl:*)",
"Bash(dir \"D:\\Projetos IA\\sistema-de-chamados\")",
"Bash(findstr:*)",
"Bash(cat:*)",
"Bash(chmod:*)",
"Bash(find:*)",
"Bash(grep:*)",
"WebFetch(domain:medium.com)",
"WebFetch(domain:henrywithu.com)",
"WebFetch(domain:hub.docker.com)",
"Bash(python3:*)",
"WebFetch(domain:www.npmjs.com)",
"WebFetch(domain:docs.strapi.io)",
"Bash(tablename)",
"Bash(\"\"\" OWNER TO renan; FROM pg_tables WHERE schemaname = public;\"\" | docker exec -i c95ebc27eb82 psql -U sistema -d strapi_blog\")",
"Bash(sequence_name)",
"Bash(\"\"\" OWNER TO renan; FROM information_schema.sequences WHERE sequence_schema = public;\"\" | docker exec -i c95ebc27eb82 psql -U sistema -d strapi_blog\")",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(cargo check:*)",
"Bash(bun run:*)",
"Bash(icacls \"D:\\Projetos IA\\sistema-de-chamados\\codex_ed25519\")",
"Bash(copy \"D:\\Projetos IA\\sistema-de-chamados\\codex_ed25519\" \"%TEMP%\\codex_key\")",
"Bash(icacls \"%TEMP%\\codex_key\" /inheritance:r /grant:r \"%USERNAME%:R\")",
"Bash(cmd /c \"echo %TEMP%\")",
"Bash(cmd /c \"dir \"\"%TEMP%\\codex_key\"\"\")",
"Bash(where:*)",
"Bash(ssh-keygen:*)",
"Bash(/c/Program\\ Files/Git/usr/bin/ssh:*)",
"Bash(npx convex deploy:*)",
"Bash(dir \"%LOCALAPPDATA%\\Raven\")",
"Bash(dir \"%APPDATA%\\Raven\")",
"Bash(dir \"%LOCALAPPDATA%\\com.raven.app\")",
"Bash(dir \"%APPDATA%\\com.raven.app\")",
"Bash(tasklist:*)",
"Bash(dir /s /b %LOCALAPPDATA%*raven*)",
"Bash(cmd /c \"tasklist | findstr /i raven\")",
"Bash(cmd /c \"dir /s /b %LOCALAPPDATA%\\*raven* 2>nul\")",
"Bash(powershell -Command \"Get-Process | Where-Object {$_ProcessName -like ''*raven*'' -or $_ProcessName -like ''*appsdesktop*''} | Select-Object ProcessName, Id\")",
"Bash(node:*)",
"Bash(bun scripts/test-all-emails.tsx:*)",
"Bash(bun scripts/send-test-react-email.tsx:*)",
"Bash(dir:*)",
"Bash(git reset:*)",
"Bash(npx convex:*)",
"Bash(bun tsc:*)",
"Bash(scp:*)",
"Bash(docker run:*)",
"Bash(cmd /c \"docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18\")",
"Bash(cmd /c \"docker ps -a --filter name=postgres-dev\")",
"Bash(cmd /c \"docker --version && docker ps -a\")",
"Bash(powershell -Command \"docker --version\")",
"Bash(powershell -Command \"docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18\")",
"Bash(dir \"D:\\Projetos IA\\sistema-de-chamados\" /b)",
"Bash(bunx prisma migrate:*)",
"Bash(bunx prisma db push:*)",
"Bash(bun run auth:seed:*)",
"Bash(set DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados:*)",
"Bash(bun tsx:*)",
"Bash(DATABASE_URL=\"postgresql://postgres:dev@localhost:5432/sistema_chamados\" bun tsx:*)",
"Bash(docker stop:*)",
"Bash(docker rm:*)",
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(checklist): exibe descricao do template e do item no ticket\n\n- Adiciona campo templateDescription ao schema do checklist\n- Copia descricao do template ao aplicar checklist no ticket\n- Exibe ambas descricoes na visualizacao do ticket (template em italico)\n- Adiciona documentacao de desenvolvimento local (docs/LOCAL-DEV.md)\n- Corrige prisma-client.mjs para usar PostgreSQL em vez de SQLite\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
"Bash(timeout 90 git push:*)",
"Bash(docker ps:*)",
"Bash(docker start:*)",
"Bash(docker inspect:*)",
"Bash(docker exec:*)",
"Bash(timeout 90 git push)",
"Bash(bun test:*)",
"Bash(git restore:*)",
"Bash(cd:*)",
"Bash(dir \"D:\\Projetos IA\\sistema-de-chamados\\src\\components\\ui\" /b)",
"Bash(timeout 120 bun:*)",
"Bash(bun run tauri:build:*)",
"Bash(git remote:*)",
"Bash(powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"D:/Projetos IA/sistema-de-chamados/scripts/test-windows-collection.ps1\")"
]
}
}

View file

@ -1,38 +1,62 @@
NODE_ENV=development
# Ambiente local — Sistema de Chamados
# Copie este arquivo para `.env` e preencha os valores sensíveis.
# Nunca faça commit de `.env` com segredos reais.
# Public app URL
# Convex
CONVEX_DEPLOYMENT=anonymous:anonymous-sistema-de-chamados
NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
CONVEX_SYNC_SECRET=dev-sync-secret
# Next.js / App URL
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Better Auth
# Gere um segredo forte (ex.: `openssl rand -hex 32`)
BETTER_AUTH_SECRET=change-me
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=your-secret-key-at-least-32-chars-long
# Convex (dev server URL)
NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
CONVEX_INTERNAL_URL=http://127.0.0.1:3210
# Intervalo (ms) para aceitar token revogado ao sincronizar acessos remotos (opcional)
REMOTE_ACCESS_TOKEN_GRACE_MS=900000
# Token interno opcional para o dashboard de saude (/admin/health) e queries internas
INTERNAL_HEALTH_TOKEN=dev-health-token
# Segredo para crons HTTP (reutilize em prod se preferir um unico token)
REPORTS_CRON_SECRET=reports-cron-secret
# Diretório para arquivamento local de tickets (JSONL/backup)
ARCHIVE_DIR=./archives
# Banco de dados (Prisma)
DATABASE_URL=file:./prisma/db.sqlite
# PostgreSQL database (versao 18)
# Para desenvolvimento local, use Docker:
# docker run -d --name postgres-chamados -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
# Seeds automáticos (Better Auth)
# Por padrão (true), garantindo apenas existência dos usuários padrão sem resetar senhas
SEED_ENSURE_ONLY=true
# SMTP Configuration (production values in docs/SMTP.md)
SMTP_HOST=smtp.c.inova.com.br
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=envio@rever.com.br
SMTP_PASS=CAAJQm6ZT6AUdhXRTDYu
SMTP_FROM_NAME=Sistema de Chamados
SMTP_FROM_EMAIL=envio@rever.com.br
# Provisionamento e inventário de máquinas
# Segredo obrigatório para registrar/atualizar máquinas (Convex)
MACHINE_PROVISIONING_SECRET=change-me-provisioning
# Tempo de vida do token de máquina (ms) — padrão 30 dias
MACHINE_TOKEN_TTL_MS=2592000000
# Opcional: segredo dedicado para webhook do FleetDM (senão usa o de provisionamento)
FLEET_SYNC_SECRET=
# Dev-only bypass to simplify local testing (do NOT enable in prod)
# DEV_BYPASS_AUTH=0
# NEXT_PUBLIC_DEV_BYPASS_AUTH=0
# SMTP (envio de e-mails)
SMTP_ADDRESS=
SMTP_PORT=465
SMTP_DOMAIN=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_AUTHENTICATION=login
SMTP_ENABLE_STARTTLS_AUTO=false
SMTP_TLS=true
MAILER_SENDER_EMAIL="Suporte <no-reply@seu-dominio.com>"
# Alertas (actions do Convex)
# Hora local (America/Sao_Paulo) para rodar alertas automáticos
ALERTS_LOCAL_HOUR=8
# Seeds e sincronizações auxiliares
SYNC_TENANT_ID=tenant-atlas
SYNC_DEFAULT_ASSIGNEE=agent@example.com
SEED_TENANT_ID=tenant-atlas
SEED_ADMIN_PASSWORD=admin123
SEED_AGENT_PASSWORD=agent123
SEED_USER_TENANT=tenant-atlas
SEED_USER_EMAIL=
SEED_USER_PASSWORD=
SEED_USER_NAME=
SEED_USER_ROLE=admin
# Desenvolvimento Desktop (Tauri/Vite)
# Em redes locais, defina o IP do host para HMR.
TAURI_DEV_HOST=

View file

@ -1,492 +0,0 @@
name: CI/CD Web + Desktop
on:
push:
branches: [ main ]
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
force_web_deploy:
description: 'Forcar deploy do Web (ignorar filtro)?'
type: boolean
required: false
default: false
force_convex_deploy:
description: 'Forcar deploy do Convex (ignorar filtro)?'
type: boolean
required: false
default: false
env:
APP_DIR: /srv/apps/sistema
VPS_UPDATES_DIR: /var/www/updates
jobs:
changes:
name: Detect changes
runs-on: [ self-hosted, linux, vps ]
timeout-minutes: 5
outputs:
convex: ${{ steps.filter.outputs.convex }}
web: ${{ steps.filter.outputs.web }}
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Paths filter
id: filter
uses: https://github.com/dorny/paths-filter@v3
with:
filters: |
convex:
- 'convex/**'
web:
- 'src/**'
- 'public/**'
- 'prisma/**'
- 'next.config.ts'
- 'package.json'
- 'bun.lock'
- 'tsconfig.json'
- 'middleware.ts'
- 'stack.yml'
deploy:
name: Deploy (VPS Linux)
needs: changes
timeout-minutes: 30
if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }}
runs-on: [ self-hosted, linux, vps ]
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Determine APP_DIR (fallback safe path)
id: appdir
run: |
TS=$(date +%s)
FALLBACK_DIR="$HOME/apps/web.build.$TS"
mkdir -p "$FALLBACK_DIR"
echo "Using APP_DIR (fallback)=$FALLBACK_DIR"
echo "EFFECTIVE_APP_DIR=$FALLBACK_DIR" >> "$GITHUB_ENV"
- name: Setup Bun
uses: https://github.com/oven-sh/setup-bun@v2
with:
bun-version: 1.3.4
- name: Sync workspace to APP_DIR (preserving local env)
run: |
mkdir -p "$EFFECTIVE_APP_DIR"
RSYNC_FLAGS="-az --inplace --no-times --no-perms --no-owner --no-group --delete"
EXCLUDE_ENV="--exclude '.env*' --exclude 'apps/desktop/.env*' --exclude 'convex/.env*'"
if [ "$EFFECTIVE_APP_DIR" != "${APP_DIR:-/srv/apps/sistema}" ]; then
EXCLUDE_ENV=""
fi
rsync $RSYNC_FLAGS \
--filter='protect .next.old*' \
--exclude '.next.old*' \
--filter='protect node_modules' \
--filter='protect node_modules/**' \
--filter='protect .pnpm-store' \
--filter='protect .pnpm-store/**' \
--filter='protect .env' \
--filter='protect .env*' \
--filter='protect apps/desktop/.env*' \
--filter='protect convex/.env*' \
--exclude '.git' \
--exclude '.next' \
--exclude 'node_modules' \
--exclude 'node_modules/**' \
--exclude '.pnpm-store' \
--exclude '.pnpm-store/**' \
$EXCLUDE_ENV \
./ "$EFFECTIVE_APP_DIR"/
- name: Acquire Convex admin key
id: key
run: |
echo "Waiting for Convex container..."
CID=""
for attempt in $(seq 1 12); do
CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}')
if [ -n "$CID" ]; then
echo "Convex container ready (CID=$CID)"
break
fi
echo "Attempt $attempt/12: container not ready yet; waiting 5s..."
sleep 5
done
CONVEX_IMAGE="ghcr.io/get-convex/convex-backend:latest"
if [ -n "$CID" ]; then
KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "No running convex container detected; attempting offline admin key extraction..."
VOLUME="sistema_convex_data"
if docker volume inspect "$VOLUME" >/dev/null 2>&1; then
KEY=$(docker run --rm --entrypoint /bin/sh -v "$VOLUME":/convex/data "$CONVEX_IMAGE" -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "Volume $VOLUME nao encontrado; nao foi possivel extrair a chave admin"
fi
fi
echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT
echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)"
if [ -z "$KEY" ]; then
echo "ERRO: Nao foi possivel obter a chave admin do Convex"
docker service ps sistema_convex_backend || true
exit 1
fi
- name: Copy production .env if present
run: |
DEFAULT_DIR="${APP_DIR:-/srv/apps/sistema}"
if [ "$EFFECTIVE_APP_DIR" != "$DEFAULT_DIR" ] && [ -f "$DEFAULT_DIR/.env" ]; then
echo "Copying production .env from $DEFAULT_DIR to $EFFECTIVE_APP_DIR"
cp -f "$DEFAULT_DIR/.env" "$EFFECTIVE_APP_DIR/.env"
fi
- name: Ensure Next.js cache directory exists and is writable
run: |
cd "$EFFECTIVE_APP_DIR"
mkdir -p .next/cache
chmod -R u+rwX .next || true
- name: Cache Next.js build cache (.next/cache)
uses: https://github.com/actions/cache@v4
with:
path: ${{ env.EFFECTIVE_APP_DIR }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ hashFiles('next.config.ts') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-
${{ runner.os }}-nextjs-
- name: Lint check (fail fast before build)
run: |
cd "$EFFECTIVE_APP_DIR"
docker run --rm \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
sistema_web:node22-bun \
bash -lc "set -euo pipefail; bun install --frozen-lockfile --filter '!appsdesktop'; bun run lint"
- name: Install and build (Next.js)
env:
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
run: |
cd "$EFFECTIVE_APP_DIR"
docker run --rm \
-e PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING="$PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING" \
-e NODE_OPTIONS="--max-old-space-size=4096" \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
sistema_web:node22-bun \
bash -lc "set -euo pipefail; bun install --frozen-lockfile --filter '!appsdesktop'; bun run prisma:generate; bun run build:bun"
- name: Fix Docker-created file permissions
run: |
# Docker cria arquivos como root - corrigir para o usuario runner (UID 1000)
docker run --rm -v "$EFFECTIVE_APP_DIR":/target alpine:3 \
chown -R 1000:1000 /target
echo "Permissoes do build corrigidas"
- name: Atualizar symlink do APP_DIR estavel (deploy atomico)
run: |
set -euo pipefail
ROOT="$HOME/apps"
STABLE_LINK="$ROOT/sistema.current"
mkdir -p "$ROOT"
# Sanidade: se esses arquivos nao existirem, o container vai falhar no boot.
test -f "$EFFECTIVE_APP_DIR/scripts/start-web.sh" || { echo "ERROR: scripts/start-web.sh nao encontrado em $EFFECTIVE_APP_DIR" >&2; exit 1; }
test -f "$EFFECTIVE_APP_DIR/stack.yml" || { echo "ERROR: stack.yml nao encontrado em $EFFECTIVE_APP_DIR" >&2; exit 1; }
test -d "$EFFECTIVE_APP_DIR/node_modules" || { echo "ERROR: node_modules nao encontrado em $EFFECTIVE_APP_DIR (necessario para next start)" >&2; exit 1; }
test -d "$EFFECTIVE_APP_DIR/.next" || { echo "ERROR: .next nao encontrado em $EFFECTIVE_APP_DIR (build nao gerado)" >&2; exit 1; }
PREV=""
if [ -L "$STABLE_LINK" ]; then
PREV="$(readlink -f "$STABLE_LINK" || true)"
fi
echo "PREV_APP_DIR=$PREV" >> "$GITHUB_ENV"
ln -sfn "$EFFECTIVE_APP_DIR" "$STABLE_LINK"
# Compat: mantem $HOME/apps/sistema como symlink quando possivel (nao mexe se for pasta).
if [ -L "$ROOT/sistema" ] || [ ! -e "$ROOT/sistema" ]; then
ln -sfn "$STABLE_LINK" "$ROOT/sistema"
fi
echo "APP_DIR estavel -> $(readlink -f "$STABLE_LINK")"
- name: Swarm deploy (stack.yml)
run: |
APP_DIR_STABLE="$HOME/apps/sistema.current"
if [ ! -d "$APP_DIR_STABLE" ]; then
echo "ERROR: Stable APP_DIR does not exist: $APP_DIR_STABLE" >&2; exit 1
fi
cd "$APP_DIR_STABLE"
set -o allexport
if [ -f .env ]; then
echo "Loading .env from $APP_DIR_STABLE"
. ./.env
else
echo "WARNING: No .env found at $APP_DIR_STABLE - stack vars may be empty!"
fi
set +o allexport
echo "Using APP_DIR (stable)=$APP_DIR_STABLE"
echo "NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-<not set>}"
echo "NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-<not set>}"
APP_DIR="$APP_DIR_STABLE" RELEASE_SHA=${{ github.sha }} docker stack deploy --with-registry-auth -c stack.yml sistema
- name: Wait for services to be healthy
run: |
echo "Aguardando servicos ficarem saudaveis..."
for i in $(seq 1 18); do
WEB_STATUS=$(docker service ls --filter "name=sistema_web" --format "{{.Replicas}}" 2>/dev/null || echo "0/0")
CONVEX_STATUS=$(docker service ls --filter "name=sistema_convex_backend" --format "{{.Replicas}}" 2>/dev/null || echo "0/0")
echo "Tentativa $i/18: web=$WEB_STATUS convex=$CONVEX_STATUS"
if echo "$WEB_STATUS" | grep -q "2/2" && echo "$CONVEX_STATUS" | grep -q "1/1"; then
echo "Todos os servicos estao saudaveis!"
exit 0
fi
sleep 10
done
echo "ERRO: Timeout aguardando servicos. Status atual:"
docker service ls --filter "label=com.docker.stack.namespace=sistema" || true
docker service ps sistema_web --no-trunc || true
docker service logs sistema_web --since 5m --raw 2>/dev/null | tail -n 200 || true
if [ -n "${PREV_APP_DIR:-}" ]; then
echo "Rollback: revertendo APP_DIR estavel para: $PREV_APP_DIR"
ln -sfn "$PREV_APP_DIR" "$HOME/apps/sistema.current"
cd "$HOME/apps/sistema.current"
set -o allexport
if [ -f .env ]; then
. ./.env
fi
set +o allexport
APP_DIR="$HOME/apps/sistema.current" RELEASE_SHA=${{ github.sha }} docker stack deploy --with-registry-auth -c stack.yml sistema || true
fi
exit 1
- name: Cleanup old build workdirs (keep last 2)
run: |
set -e
ROOT="$HOME/apps"
KEEP=2
PATTERN='web.build.*'
ACTIVE="$(readlink -f "$HOME/apps/sistema.current" 2>/dev/null || true)"
echo "Scanning $ROOT for old $PATTERN dirs"
LIST=$(find "$ROOT" -maxdepth 1 -type d -name "$PATTERN" | sort -r || true)
echo "$LIST" | sed -n "1,${KEEP}p" | sed 's/^/Keeping: /' || true
echo "$LIST" | sed "1,${KEEP}d" | while read dir; do
[ -z "$dir" ] && continue
if [ -n "$ACTIVE" ] && [ "$(readlink -f "$dir")" = "$ACTIVE" ]; then
echo "Skipping active dir (in use by APP_DIR): $dir"; continue
fi
echo "Removing $dir"
chmod -R u+rwX "$dir" 2>/dev/null || true
rm -rf "$dir" || {
echo "Local rm failed, falling back to docker (root) cleanup for $dir..."
docker run --rm -v "$dir":/target alpine:3 sh -lc 'chown -R 1000:1000 /target 2>/dev/null || true; chmod -R u+rwX /target 2>/dev/null || true; rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true' || true
rm -rf "$dir" 2>/dev/null || rmdir "$dir" 2>/dev/null || true
}
done
echo "Disk usage (top 10 under $ROOT):"
du -sh "$ROOT"/* 2>/dev/null | sort -rh | head -n 10 || true
convex_deploy:
name: Deploy Convex functions
needs: changes
timeout-minutes: 20
if: ${{ github.event_name == 'workflow_dispatch' || needs.changes.outputs.convex == 'true' }}
runs-on: [ self-hosted, linux, vps ]
env:
APP_DIR: /srv/apps/sistema
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Determine APP_DIR (fallback safe path)
id: appdir
run: |
TS=$(date +%s)
FALLBACK_DIR="$HOME/apps/convex.build.$TS"
mkdir -p "$FALLBACK_DIR"
echo "Using APP_DIR (fallback)=$FALLBACK_DIR"
echo "EFFECTIVE_APP_DIR=$FALLBACK_DIR" >> "$GITHUB_ENV"
- name: Sync workspace to APP_DIR (preserving local env)
run: |
mkdir -p "$EFFECTIVE_APP_DIR"
RSYNC_FLAGS="-az --inplace --no-times --no-perms --no-owner --no-group --delete"
rsync $RSYNC_FLAGS \
--filter='protect .next.old*' \
--exclude '.next.old*' \
--exclude '.env*' \
--exclude 'apps/desktop/.env*' \
--exclude 'convex/.env*' \
--filter='protect node_modules' \
--filter='protect node_modules/**' \
--filter='protect .pnpm-store' \
--filter='protect .pnpm-store/**' \
--exclude '.git' \
--exclude '.next' \
--exclude 'node_modules' \
--exclude 'node_modules/**' \
--exclude '.pnpm-store' \
--exclude '.pnpm-store/**' \
./ "$EFFECTIVE_APP_DIR"/
- name: Acquire Convex admin key
id: key
run: |
echo "Waiting for Convex container..."
CID=""
for attempt in $(seq 1 12); do
CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}')
if [ -n "$CID" ]; then
echo "Convex container ready (CID=$CID)"
break
fi
echo "Attempt $attempt/12: container not ready yet; waiting 5s..."
sleep 5
done
CONVEX_IMAGE="ghcr.io/get-convex/convex-backend:latest"
if [ -n "$CID" ]; then
KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "No running convex container detected; attempting offline admin key extraction..."
VOLUME="sistema_convex_data"
if docker volume inspect "$VOLUME" >/dev/null 2>&1; then
KEY=$(docker run --rm --entrypoint /bin/sh -v "$VOLUME":/convex/data "$CONVEX_IMAGE" -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "Volume $VOLUME nao encontrado; nao foi possivel extrair a chave admin"
fi
fi
echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT
echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)"
if [ -z "$KEY" ]; then
echo "ERRO: Nao foi possivel obter a chave admin do Convex"
docker service ps sistema_convex_backend || true
exit 1
fi
- name: Bring convex.json from live app if present
run: |
if [ -f "$APP_DIR/convex.json" ]; then
echo "Copying $APP_DIR/convex.json -> $EFFECTIVE_APP_DIR/convex.json"
cp -f "$APP_DIR/convex.json" "$EFFECTIVE_APP_DIR/convex.json"
else
echo "No existing convex.json found at $APP_DIR; convex CLI will need self-hosted vars"
fi
- name: Set Convex env vars (self-hosted)
env:
CONVEX_SELF_HOSTED_URL: https://convex.esdrasrenan.com.br
CONVEX_SELF_HOSTED_ADMIN_KEY: ${{ steps.key.outputs.ADMIN_KEY }}
MACHINE_PROVISIONING_SECRET: ${{ secrets.MACHINE_PROVISIONING_SECRET }}
MACHINE_TOKEN_TTL_MS: ${{ secrets.MACHINE_TOKEN_TTL_MS }}
FLEET_SYNC_SECRET: ${{ secrets.FLEET_SYNC_SECRET }}
run: |
set -e
docker run --rm -i \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
-e CONVEX_SELF_HOSTED_URL \
-e CONVEX_SELF_HOSTED_ADMIN_KEY \
-e MACHINE_PROVISIONING_SECRET \
-e MACHINE_TOKEN_TTL_MS \
-e FLEET_SYNC_SECRET \
-e CONVEX_TMPDIR=/app/.convex-tmp \
node:20-bullseye bash -lc "set -euo pipefail; curl -fsSL https://bun.sh/install | bash >/tmp/bun-install.log; export BUN_INSTALL=\"\${BUN_INSTALL:-/root/.bun}\"; export PATH=\"\$BUN_INSTALL/bin:\$PATH\"; export CONVEX_TMPDIR=/app/.convex-tmp; bun install --frozen-lockfile; \
if [ -n \"$MACHINE_PROVISIONING_SECRET\" ]; then bunx convex env set MACHINE_PROVISIONING_SECRET \"$MACHINE_PROVISIONING_SECRET\"; fi; \
if [ -n \"$MACHINE_TOKEN_TTL_MS\" ]; then bunx convex env set MACHINE_TOKEN_TTL_MS \"$MACHINE_TOKEN_TTL_MS\"; fi; \
if [ -n \"$FLEET_SYNC_SECRET\" ]; then bunx convex env set FLEET_SYNC_SECRET \"$FLEET_SYNC_SECRET\"; fi; \
bunx convex env list"
- name: Prepare Convex deploy workspace
run: |
cd "$EFFECTIVE_APP_DIR"
if [ -f .env ]; then
echo "Renaming .env -> .env.bak (Convex self-hosted deploy)"
mv -f .env .env.bak
fi
mkdir -p .convex-tmp
- name: Deploy functions to Convex self-hosted
env:
CONVEX_SELF_HOSTED_URL: https://convex.esdrasrenan.com.br
CONVEX_SELF_HOSTED_ADMIN_KEY: ${{ steps.key.outputs.ADMIN_KEY }}
run: |
docker run --rm -i \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
-e CI=true \
-e CONVEX_SELF_HOSTED_URL \
-e CONVEX_SELF_HOSTED_ADMIN_KEY \
-e CONVEX_TMPDIR=/app/.convex-tmp \
node:20-bullseye bash -lc "set -euo pipefail; curl -fsSL https://bun.sh/install | bash >/tmp/bun-install.log; export BUN_INSTALL=\"\${BUN_INSTALL:-/root/.bun}\"; export PATH=\"\$BUN_INSTALL/bin:\$PATH\"; export CONVEX_TMPDIR=/app/.convex-tmp; bun install --frozen-lockfile; bunx convex deploy"
- name: Cleanup old convex build workdirs (keep last 2)
run: |
set -e
ROOT="$HOME/apps"
KEEP=2
PATTERN='convex.build.*'
LIST=$(find "$ROOT" -maxdepth 1 -type d -name "$PATTERN" | sort -r || true)
echo "$LIST" | sed -n "1,${KEEP}p" | sed 's/^/Keeping: /' || true
echo "$LIST" | sed "1,${KEEP}d" | while read dir; do
[ -z "$dir" ] && continue
echo "Removing $dir"
chmod -R u+rwX "$dir" 2>/dev/null || true
rm -rf "$dir" || {
echo "Local rm failed, falling back to docker (root) cleanup for $dir..."
docker run --rm -v "$dir":/target alpine:3 sh -lc 'chown -R 1000:1000 /target 2>/dev/null || true; chmod -R u+rwX /target 2>/dev/null || true; rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true' || true
rm -rf "$dir" 2>/dev/null || rmdir "$dir" 2>/dev/null || true
}
done
# NOTA: Job comentado porque nao ha runner Windows configurado.
# Descomentar quando configurar um runner com labels: [self-hosted, windows, desktop]
#
# desktop_release:
# name: Desktop Release (Windows)
# timeout-minutes: 30
# if: ${{ startsWith(github.ref, 'refs/tags/v') }}
# runs-on: [ self-hosted, windows, desktop ]
# defaults:
# run:
# working-directory: apps/desktop
# steps:
# - name: Checkout
# uses: https://github.com/actions/checkout@v4
#
# - name: Setup pnpm
# uses: https://github.com/pnpm/action-setup@v4
# with:
# version: 10.20.0
#
# - name: Setup Node.js
# uses: https://github.com/actions/setup-node@v4
# with:
# node-version: 20
#
# - name: Install deps (desktop)
# run: pnpm install --frozen-lockfile
#
# - name: Build with Tauri
# uses: https://github.com/tauri-apps/tauri-action@v0
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
# TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
# with:
# projectPath: apps/desktop
#
# - name: Upload bundles to VPS
# run: |
# # Upload via SCP (configurar chave SSH no runner Windows)
# # scp -r src-tauri/target/release/bundle/* user@vps:/var/www/updates/
# echo "TODO: Configurar upload para VPS"

View file

@ -1,54 +0,0 @@
name: Quality Checks
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint-test-build:
name: Lint, Test and Build
runs-on: [ self-hosted, linux, vps ]
env:
BETTER_AUTH_SECRET: test-secret
NEXT_PUBLIC_APP_URL: http://localhost:3000
BETTER_AUTH_URL: http://localhost:3000
NEXT_PUBLIC_CONVEX_URL: http://localhost:3210
DATABASE_URL: file:./prisma/db.dev.sqlite
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Setup Bun
uses: https://github.com/oven-sh/setup-bun@v2
with:
bun-version: 1.3.4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Cache Next.js build cache
uses: https://github.com/actions/cache@v4
with:
path: |
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-${{ hashFiles('**/*.{js,jsx,ts,tsx}') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}-
- name: Generate Prisma client
env:
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
run: bun run prisma:generate
- name: Lint
run: bun run lint
- name: Test
run: bun test
- name: Build
run: bun run build:bun

View file

@ -1,639 +0,0 @@
name: CI/CD Web + Desktop
on:
push:
branches: [ main ]
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
force_web_deploy:
description: 'Forçar deploy do Web (ignorar filtro)?'
required: false
default: 'false'
force_convex_deploy:
description: 'Forçar deploy do Convex (ignorar filtro)?'
required: false
default: 'false'
env:
APP_DIR: /srv/apps/sistema
VPS_UPDATES_DIR: /var/www/updates
RUN_MACHINE_SMOKE: ${{ vars.RUN_MACHINE_SMOKE || secrets.RUN_MACHINE_SMOKE || 'false' }}
jobs:
changes:
name: Detect changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
convex: ${{ steps.filter.outputs.convex }}
web: ${{ steps.filter.outputs.web }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Paths filter
id: filter
uses: dorny/paths-filter@v3
with:
filters: |
convex:
- 'convex/**'
web:
- 'src/**'
- 'public/**'
- 'prisma/**'
- 'next.config.ts'
- 'package.json'
- 'pnpm-lock.yaml'
- 'tsconfig.json'
- 'middleware.ts'
- 'stack.yml'
deploy:
name: Deploy (VPS Linux)
needs: changes
timeout-minutes: 30
# Executa em qualquer push na main (independente do filtro) ou quando disparado manualmente
if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }}
runs-on: [ self-hosted, linux, vps ]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Determine APP_DIR (fallback safe path)
id: appdir
run: |
TS=$(date +%s)
# Use a web-specific build dir to avoid clashes with convex job
FALLBACK_DIR="$HOME/apps/web.build.$TS"
mkdir -p "$FALLBACK_DIR"
echo "Using APP_DIR (fallback)=$FALLBACK_DIR"
echo "EFFECTIVE_APP_DIR=$FALLBACK_DIR" >> "$GITHUB_ENV"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.20.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.1
- name: Verify Bun runtime
run: bun --version
- name: Permissions diagnostic (server paths)
run: |
set +e
echo "== Basic context =="
whoami || true
id || true
groups || true
umask || true
echo "HOME=$HOME"
echo "APP_DIR(default)=${APP_DIR:-/srv/apps/sistema}"
echo "EFFECTIVE_APP_DIR=$EFFECTIVE_APP_DIR"
echo "\n== Permissions check =="
check_path() {
P="$1"
echo "-- $P"
if [ -e "$P" ]; then
stat -c '%A %U:%G %n' "$P" 2>/dev/null || ls -ld "$P" || true
echo -n "WRITABLE? "; [ -w "$P" ] && echo yes || echo no
if command -v namei >/dev/null 2>&1; then
namei -l "$P" || true
fi
TMP="$P/.permtest.$$"
(echo test > "$TMP" 2>/dev/null && echo "CREATE_FILE: ok" && rm -f "$TMP") || echo "CREATE_FILE: failed"
else
echo "(missing)"
fi
}
check_path "/srv/apps/sistema"
check_path "/srv/apps/sistema/src/app/machines/handshake"
check_path "/srv/apps/sistema/apps/desktop/node_modules"
check_path "/srv/apps/sistema/node_modules"
check_path "$EFFECTIVE_APP_DIR"
check_path "$EFFECTIVE_APP_DIR/node_modules"
- name: Sync workspace to APP_DIR (preserving local env)
run: |
mkdir -p "$EFFECTIVE_APP_DIR"
RSYNC_FLAGS="-az --inplace --no-times --no-perms --no-owner --no-group --delete"
# Excluir .env apenas quando copiando para o diretório padrão (/srv) para preservar segredos locais
EXCLUDE_ENV="--exclude '.env*' --exclude 'apps/desktop/.env*' --exclude 'convex/.env*'"
if [ "$EFFECTIVE_APP_DIR" != "${APP_DIR:-/srv/apps/sistema}" ]; then
EXCLUDE_ENV=""
fi
rsync $RSYNC_FLAGS \
--filter='protect .next.old*' \
--exclude '.next.old*' \
--filter='protect node_modules' \
--filter='protect node_modules/**' \
--filter='protect .pnpm-store' \
--filter='protect .pnpm-store/**' \
--filter='protect .env' \
--filter='protect .env*' \
--filter='protect apps/desktop/.env*' \
--filter='protect convex/.env*' \
--exclude '.git' \
--exclude '.next' \
--exclude 'node_modules' \
--exclude 'node_modules/**' \
--exclude '.pnpm-store' \
--exclude '.pnpm-store/**' \
$EXCLUDE_ENV \
./ "$EFFECTIVE_APP_DIR"/
- name: Acquire Convex admin key
id: key
run: |
echo "Waiting for Convex container..."
CID=""
# Aguarda ate 60s (12 tentativas x 5s) pelo container ficar pronto
# Nao forca restart - deixa o Swarm gerenciar via health checks
for attempt in $(seq 1 12); do
CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}')
if [ -n "$CID" ]; then
echo "Convex container ready (CID=$CID)"
break
fi
echo "Attempt $attempt/12: container not ready yet; waiting 5s..."
sleep 5
done
CONVEX_IMAGE="ghcr.io/get-convex/convex-backend:latest"
if [ -n "$CID" ]; then
KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "No running convex container detected; attempting offline admin key extraction..."
VOLUME="sistema_convex_data"
if docker volume inspect "$VOLUME" >/dev/null 2>&1; then
KEY=$(docker run --rm --entrypoint /bin/sh -v "$VOLUME":/convex/data "$CONVEX_IMAGE" -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "Volume $VOLUME nao encontrado; nao foi possivel extrair a chave admin"
fi
fi
echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT
echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)"
if [ -z "$KEY" ]; then
echo "ERRO: Nao foi possivel obter a chave admin do Convex"
docker service ps sistema_convex_backend || true
exit 1
fi
- name: Copy production .env if present
run: |
DEFAULT_DIR="${APP_DIR:-/srv/apps/sistema}"
if [ "$EFFECTIVE_APP_DIR" != "$DEFAULT_DIR" ] && [ -f "$DEFAULT_DIR/.env" ]; then
echo "Copying production .env from $DEFAULT_DIR to $EFFECTIVE_APP_DIR"
cp -f "$DEFAULT_DIR/.env" "$EFFECTIVE_APP_DIR/.env"
fi
- name: Prune workspace for server-only build
run: |
cd "$EFFECTIVE_APP_DIR"
# Keep only root (web) as a package in this effective workspace
printf "packages:\n - .\n\nignoredBuiltDependencies:\n - '@prisma/client'\n - '@prisma/engines'\n - '@tailwindcss/oxide'\n - esbuild\n - prisma\n - sharp\n - unrs-resolver\n" > pnpm-workspace.yaml
- name: Ensure Next.js cache directory exists and is writable
run: |
cd "$EFFECTIVE_APP_DIR"
mkdir -p .next/cache
chmod -R u+rwX .next || true
- name: Cache Next.js build cache (.next/cache)
uses: actions/cache@v4
with:
path: ${{ env.EFFECTIVE_APP_DIR }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml', 'bun.lock') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx', 'src/**/*.js', 'src/**/*.jsx', 'next.config.ts') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml', 'bun.lock') }}-
- name: Lint check (fail fast before build)
run: |
cd "$EFFECTIVE_APP_DIR"
docker run --rm \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
sistema_web:node22-bun \
bash -lc "set -euo pipefail; bun install --frozen-lockfile --filter '!appsdesktop'; bun run lint"
- name: Install and build (Next.js)
env:
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
run: |
cd "$EFFECTIVE_APP_DIR"
docker run --rm \
-e PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING="$PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING" \
-e NODE_OPTIONS="--max-old-space-size=4096" \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
sistema_web:node22-bun \
bash -lc "set -euo pipefail; bun install --frozen-lockfile --filter '!appsdesktop'; bun run prisma:generate; bun run build:bun"
- name: Publish build to stable APP_DIR directory
run: |
set -e
DEST="$HOME/apps/sistema"
mkdir -p "$DEST"
mkdir -p "$DEST/.next/static"
# One-time fix for old root-owned files (esp. .pnpm-store) left by previous containers
docker run --rm -v "$DEST":/target alpine:3 sh -lc 'chown -R 1000:1000 /target 2>/dev/null || true; chmod -R u+rwX /target 2>/dev/null || true' || true
# Preserve previously published static assets to keep stale chunks available for clients mid-navigation
if [ -d "$EFFECTIVE_APP_DIR/.next/static" ]; then
rsync -a \
"$EFFECTIVE_APP_DIR/.next/static/" "$DEST/.next/static/"
fi
# Publish new build; exclude .pnpm-store to avoid Permission denied on old entries
rsync -a --delete \
--chown=1000:1000 \
--exclude '.pnpm-store' --exclude '.pnpm-store/**' \
--exclude '.next/static' \
"$EFFECTIVE_APP_DIR"/ "$DEST"/
echo "Published build to: $DEST"
- name: Swarm deploy (stack.yml)
run: |
APP_DIR_STABLE="$HOME/apps/sistema"
if [ ! -d "$APP_DIR_STABLE" ]; then
echo "ERROR: Stable APP_DIR does not exist: $APP_DIR_STABLE" >&2; exit 1
fi
cd "$APP_DIR_STABLE"
# Exporta variáveis do .env (do diretório de produção) para substituição no stack
# IMPORTANTE: Usar o .env do APP_DIR_STABLE, não do EFFECTIVE_APP_DIR (build temporário)
set -o allexport
if [ -f .env ]; then
echo "Loading .env from $APP_DIR_STABLE"
. ./.env
else
echo "WARNING: No .env found at $APP_DIR_STABLE - stack vars may be empty!"
fi
set +o allexport
echo "Using APP_DIR (stable)=$APP_DIR_STABLE"
echo "NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-<not set>}"
echo "NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-<not set>}"
APP_DIR="$APP_DIR_STABLE" RELEASE_SHA=${{ github.sha }} docker stack deploy --with-registry-auth -c stack.yml sistema
- name: Wait for services to be healthy
run: |
echo "Aguardando servicos ficarem saudaveis..."
# Aguarda ate 3 minutos (18 tentativas x 10s) pelos servicos
for i in $(seq 1 18); do
WEB_STATUS=$(docker service ls --filter "name=sistema_web" --format "{{.Replicas}}" 2>/dev/null || echo "0/0")
CONVEX_STATUS=$(docker service ls --filter "name=sistema_convex_backend" --format "{{.Replicas}}" 2>/dev/null || echo "0/0")
echo "Tentativa $i/18: web=$WEB_STATUS convex=$CONVEX_STATUS"
# Verifica se web tem 2/2 replicas e convex tem 1/1
if echo "$WEB_STATUS" | grep -q "2/2" && echo "$CONVEX_STATUS" | grep -q "1/1"; then
echo "Todos os servicos estao saudaveis!"
exit 0
fi
sleep 10
done
echo "AVISO: Timeout aguardando servicos. Status atual:"
docker service ls --filter "label=com.docker.stack.namespace=sistema"
# Nao falha o deploy, apenas avisa (o Swarm continua o rolling update em background)
- name: Smoke test — register + heartbeat
run: |
set -e
if [ "${RUN_MACHINE_SMOKE:-false}" != "true" ]; then
echo "RUN_MACHINE_SMOKE != true — pulando smoke test"; exit 0
fi
# Load MACHINE_PROVISIONING_SECRET from production .env on the host
if [ -f /srv/apps/sistema/.env ]; then
set -o allexport
. /srv/apps/sistema/.env
set +o allexport
fi
if [ -z "${MACHINE_PROVISIONING_SECRET:-}" ]; then
echo "MACHINE_PROVISIONING_SECRET ausente — pulando smoke test"; exit 0
fi
HOSTNAME_TEST="ci-smoke-$(date +%s)"
BODY='{"provisioningSecret":"'"$MACHINE_PROVISIONING_SECRET"'","tenantId":"tenant-atlas","hostname":"'"$HOSTNAME_TEST"'","os":{"name":"Linux","version":"6.1.0","architecture":"x86_64"},"macAddresses":["AA:BB:CC:DD:EE:FF"],"serialNumbers":[],"metadata":{"inventory":{"cpu":"i7","ramGb":16}},"registeredBy":"ci-smoke"}'
HTTP=$(curl -sS -o resp.json -w "%{http_code}" -H 'Content-Type: application/json' -d "$BODY" https://tickets.esdrasrenan.com.br/api/machines/register || true)
echo "Register HTTP=$HTTP"
if [ "$HTTP" != "201" ]; then
echo "Register failed:"; tail -c 600 resp.json || true; exit 1; fi
TOKEN=$(node -e 'try{const j=require("fs").readFileSync("resp.json","utf8");process.stdout.write(JSON.parse(j).machineToken||"");}catch(e){process.stdout.write("")}' )
if [ -z "$TOKEN" ]; then echo "Missing token in register response"; exit 1; fi
HB=$(curl -sS -o /dev/null -w "%{http_code}" -H 'Content-Type: application/json' -d '{"machineToken":"'"$TOKEN"'","status":"online","metrics":{"cpuPct":5,"memFreePct":70}}' https://tickets.esdrasrenan.com.br/api/machines/heartbeat || true)
echo "Heartbeat HTTP=$HB"
if [ "$HB" != "200" ]; then echo "Heartbeat failed"; exit 1; fi
- name: Cleanup old build workdirs (keep last 2)
run: |
set -e
ROOT="$HOME/apps"
KEEP=2
PATTERN='web.build.*'
ACTIVE="$HOME/apps/sistema"
echo "Scanning $ROOT for old $PATTERN dirs"
LIST=$(find "$ROOT" -maxdepth 1 -type d -name "$PATTERN" | sort -r || true)
echo "$LIST" | sed -n "1,${KEEP}p" | sed 's/^/Keeping: /' || true
echo "$LIST" | sed "1,${KEEP}d" | while read dir; do
[ -z "$dir" ] && continue
if [ -n "$ACTIVE" ] && [ "$(readlink -f "$dir")" = "$ACTIVE" ]; then
echo "Skipping active dir (in use by APP_DIR): $dir"; continue
fi
echo "Removing $dir"
chmod -R u+rwX "$dir" 2>/dev/null || true
rm -rf "$dir" || {
echo "Local rm failed, falling back to docker (root) cleanup for $dir..."
docker run --rm -v "$dir":/target alpine:3 sh -lc 'chown -R 1000:1000 /target 2>/dev/null || true; chmod -R u+rwX /target 2>/dev/null || true; rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true' || true
rm -rf "$dir" 2>/dev/null || rmdir "$dir" 2>/dev/null || true
}
done
echo "Disk usage (top 10 under $ROOT):"
du -sh "$ROOT"/* 2>/dev/null | sort -rh | head -n 10 || true
- name: Restart web service with new code (skip — stack deploy already updated)
if: ${{ always() && false }}
run: |
docker service update --force sistema_web
# Comentado: o stack deploy já atualiza os serviços com update_config.order: start-first
# Forçar update aqui causa downtime porque ignora a estratégia de rolling update
# - name: Restart Convex backend service (optional)
# run: |
# docker service update --force sistema_convex_backend
convex_deploy:
name: Deploy Convex functions
needs: changes
timeout-minutes: 20
# Executa quando convex/** mudar ou via workflow_dispatch
if: ${{ github.event_name == 'workflow_dispatch' || needs.changes.outputs.convex == 'true' }}
runs-on: [ self-hosted, linux, vps ]
env:
APP_DIR: /srv/apps/sistema
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Determine APP_DIR (fallback safe path)
id: appdir
run: |
TS=$(date +%s)
# Use a convex-specific build dir to avoid clashes with web job
FALLBACK_DIR="$HOME/apps/convex.build.$TS"
mkdir -p "$FALLBACK_DIR"
echo "Using APP_DIR (fallback)=$FALLBACK_DIR"
echo "EFFECTIVE_APP_DIR=$FALLBACK_DIR" >> "$GITHUB_ENV"
- name: Sync workspace to APP_DIR (preserving local env)
run: |
mkdir -p "$EFFECTIVE_APP_DIR"
RSYNC_FLAGS="-az --inplace --no-times --no-perms --no-owner --no-group --delete"
rsync $RSYNC_FLAGS \
--filter='protect .next.old*' \
--exclude '.next.old*' \
--exclude '.env*' \
--exclude 'apps/desktop/.env*' \
--exclude 'convex/.env*' \
--filter='protect node_modules' \
--filter='protect node_modules/**' \
--filter='protect .pnpm-store' \
--filter='protect .pnpm-store/**' \
--exclude '.git' \
--exclude '.next' \
--exclude 'node_modules' \
--exclude 'node_modules/**' \
--exclude '.pnpm-store' \
--exclude '.pnpm-store/**' \
./ "$EFFECTIVE_APP_DIR"/
- name: Acquire Convex admin key
id: key
run: |
echo "Waiting for Convex container..."
CID=""
# Aguarda ate 60s (12 tentativas x 5s) pelo container ficar pronto
# Nao forca restart - deixa o Swarm gerenciar via health checks
for attempt in $(seq 1 12); do
CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}')
if [ -n "$CID" ]; then
echo "Convex container ready (CID=$CID)"
break
fi
echo "Attempt $attempt/12: container not ready yet; waiting 5s..."
sleep 5
done
CONVEX_IMAGE="ghcr.io/get-convex/convex-backend:latest"
if [ -n "$CID" ]; then
KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "No running convex container detected; attempting offline admin key extraction..."
VOLUME="sistema_convex_data"
if docker volume inspect "$VOLUME" >/dev/null 2>&1; then
KEY=$(docker run --rm --entrypoint /bin/sh -v "$VOLUME":/convex/data "$CONVEX_IMAGE" -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "Volume $VOLUME nao encontrado; nao foi possivel extrair a chave admin"
fi
fi
echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT
echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)"
if [ -z "$KEY" ]; then
echo "ERRO: Nao foi possivel obter a chave admin do Convex"
docker service ps sistema_convex_backend || true
exit 1
fi
- name: Bring convex.json from live app if present
run: |
if [ -f "$APP_DIR/convex.json" ]; then
echo "Copying $APP_DIR/convex.json -> $EFFECTIVE_APP_DIR/convex.json"
cp -f "$APP_DIR/convex.json" "$EFFECTIVE_APP_DIR/convex.json"
else
echo "No existing convex.json found at $APP_DIR; convex CLI will need self-hosted vars"
fi
- name: Set Convex env vars (self-hosted)
env:
CONVEX_SELF_HOSTED_URL: https://convex.esdrasrenan.com.br
CONVEX_SELF_HOSTED_ADMIN_KEY: ${{ steps.key.outputs.ADMIN_KEY }}
MACHINE_PROVISIONING_SECRET: ${{ secrets.MACHINE_PROVISIONING_SECRET }}
MACHINE_TOKEN_TTL_MS: ${{ secrets.MACHINE_TOKEN_TTL_MS }}
FLEET_SYNC_SECRET: ${{ secrets.FLEET_SYNC_SECRET }}
run: |
set -e
docker run --rm -i \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
-e CONVEX_SELF_HOSTED_URL \
-e CONVEX_SELF_HOSTED_ADMIN_KEY \
-e MACHINE_PROVISIONING_SECRET \
-e MACHINE_TOKEN_TTL_MS \
-e FLEET_SYNC_SECRET \
-e CONVEX_TMPDIR=/app/.convex-tmp \
node:20-bullseye bash -lc "set -euo pipefail; curl -fsSL https://bun.sh/install | bash >/tmp/bun-install.log; export BUN_INSTALL=\"\${BUN_INSTALL:-/root/.bun}\"; export PATH=\"\$BUN_INSTALL/bin:\$PATH\"; export CONVEX_TMPDIR=/app/.convex-tmp; bun install --frozen-lockfile; \
if [ -n \"$MACHINE_PROVISIONING_SECRET\" ]; then bunx convex env set MACHINE_PROVISIONING_SECRET \"$MACHINE_PROVISIONING_SECRET\"; fi; \
if [ -n \"$MACHINE_TOKEN_TTL_MS\" ]; then bunx convex env set MACHINE_TOKEN_TTL_MS \"$MACHINE_TOKEN_TTL_MS\"; fi; \
if [ -n \"$FLEET_SYNC_SECRET\" ]; then bunx convex env set FLEET_SYNC_SECRET \"$FLEET_SYNC_SECRET\"; fi; \
bunx convex env list"
- name: Prepare Convex deploy workspace
run: |
cd "$EFFECTIVE_APP_DIR"
if [ -f .env ]; then
echo "Renaming .env -> .env.bak (Convex self-hosted deploy)"
mv -f .env .env.bak
fi
# Dedicated tmp dir outside convex/_generated so CLI cleanups don't remove it
mkdir -p .convex-tmp
- name: Deploy functions to Convex self-hosted
env:
CONVEX_SELF_HOSTED_URL: https://convex.esdrasrenan.com.br
CONVEX_SELF_HOSTED_ADMIN_KEY: ${{ steps.key.outputs.ADMIN_KEY }}
run: |
docker run --rm -i \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
-e CI=true \
-e CONVEX_SELF_HOSTED_URL \
-e CONVEX_SELF_HOSTED_ADMIN_KEY \
-e CONVEX_TMPDIR=/app/.convex-tmp \
node:20-bullseye bash -lc "set -euo pipefail; curl -fsSL https://bun.sh/install | bash >/tmp/bun-install.log; export BUN_INSTALL=\"\${BUN_INSTALL:-/root/.bun}\"; export PATH=\"\$BUN_INSTALL/bin:\$PATH\"; export CONVEX_TMPDIR=/app/.convex-tmp; bun install --frozen-lockfile; bunx convex deploy"
- name: Cleanup old convex build workdirs (keep last 2)
run: |
set -e
ROOT="$HOME/apps"
KEEP=2
PATTERN='convex.build.*'
LIST=$(find "$ROOT" -maxdepth 1 -type d -name "$PATTERN" | sort -r || true)
echo "$LIST" | sed -n "1,${KEEP}p" | sed 's/^/Keeping: /' || true
echo "$LIST" | sed "1,${KEEP}d" | while read dir; do
[ -z "$dir" ] && continue
echo "Removing $dir"
chmod -R u+rwX "$dir" 2>/dev/null || true
rm -rf "$dir" || {
echo "Local rm failed, falling back to docker (root) cleanup for $dir..."
docker run --rm -v "$dir":/target alpine:3 sh -lc 'chown -R 1000:1000 /target 2>/dev/null || true; chmod -R u+rwX /target 2>/dev/null || true; rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true' || true
rm -rf "$dir" 2>/dev/null || rmdir "$dir" 2>/dev/null || true
}
done
desktop_release:
name: Desktop Release (Windows)
timeout-minutes: 30
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
runs-on: [ self-hosted, windows, desktop ]
defaults:
run:
working-directory: apps/desktop
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.20.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install deps (desktop)
run: pnpm install --frozen-lockfile
- name: Build with Tauri
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
with:
projectPath: apps/desktop
- name: Upload latest.json + bundles to VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
source: |
**/bundle/**/latest.json
**/bundle/**/*
target: ${{ env.VPS_UPDATES_DIR }}
overwrite: true
diagnose_convex:
name: Diagnose Convex (env + register test)
timeout-minutes: 10
if: ${{ github.event_name == 'workflow_dispatch' }}
runs-on: [ self-hosted, linux, vps ]
steps:
- name: Print service env and .env subset
run: |
echo "=== Convex service env ==="
docker service inspect sistema_convex_backend --format '{{range .Spec.TaskTemplate.ContainerSpec.Env}}{{println .}}{{end}}' || true
echo
echo "=== /srv/apps/sistema/.env subset ==="
[ -f /srv/apps/sistema/.env ] && grep -E '^(MACHINE_PROVISIONING_SECRET|MACHINE_TOKEN_TTL_MS|FLEET_SYNC_SECRET|NEXT_PUBLIC_CONVEX_URL)=' -n /srv/apps/sistema/.env || echo '(no .env)'
- name: Acquire Convex admin key
id: key
run: |
echo "Waiting for Convex container..."
CID=""
# Aguarda ate 60s (12 tentativas x 5s) pelo container ficar pronto
for attempt in $(seq 1 12); do
CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}')
if [ -n "$CID" ]; then
echo "Convex container ready (CID=$CID)"
break
fi
echo "Attempt $attempt/12: container not ready yet; waiting 5s..."
sleep 5
done
CONVEX_IMAGE="ghcr.io/get-convex/convex-backend:latest"
if [ -n "$CID" ]; then
KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "No running convex container detected; attempting offline admin key extraction..."
VOLUME="sistema_convex_data"
if docker volume inspect "$VOLUME" >/dev/null 2>&1; then
KEY=$(docker run --rm --entrypoint /bin/sh -v "$VOLUME":/convex/data "$CONVEX_IMAGE" -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
else
echo "Volume $VOLUME nao encontrado; nao foi possivel extrair a chave admin"
fi
fi
echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT
echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)"
- name: List Convex env and set missing
env:
CONVEX_SELF_HOSTED_URL: https://convex.esdrasrenan.com.br
ADMIN_KEY: ${{ steps.key.outputs.ADMIN_KEY }}
run: |
set -e
if [ -f /srv/apps/sistema/.env ]; then
set -o allexport
. /srv/apps/sistema/.env
set +o allexport
fi
docker run --rm -i \
-v /srv/apps/sistema:/app -w /app \
-e CONVEX_SELF_HOSTED_URL -e CONVEX_SELF_HOSTED_ADMIN_KEY="$ADMIN_KEY" \
-e MACHINE_PROVISIONING_SECRET -e MACHINE_TOKEN_TTL_MS -e FLEET_SYNC_SECRET \
node:20-bullseye bash -lc "set -euo pipefail; curl -fsSL https://bun.sh/install | bash >/tmp/bun-install.log; export BUN_INSTALL=\"\${BUN_INSTALL:-/root/.bun}\"; export PATH=\"\$BUN_INSTALL/bin:\$PATH\"; bun install --frozen-lockfile; \
unset CONVEX_DEPLOYMENT; bunx convex env list; \
if [ -n \"$MACHINE_PROVISIONING_SECRET\" ]; then bunx convex env set MACHINE_PROVISIONING_SECRET \"$MACHINE_PROVISIONING_SECRET\"; fi; \
if [ -n \"$MACHINE_TOKEN_TTL_MS\" ]; then bunx convex env set MACHINE_TOKEN_TTL_MS \"$MACHINE_TOKEN_TTL_MS\"; fi; \
if [ -n \"$FLEET_SYNC_SECRET\" ]; then bunx convex env set FLEET_SYNC_SECRET \"$FLEET_SYNC_SECRET\"; fi; \
bunx convex env list"
- name: Test register from runner
run: |
HOST="vm-teste-$(date +%s)"
DATA='{"provisioningSecret":"'"${MACHINE_PROVISIONING_SECRET:-"71daa9ef54cb224547e378f8121ca898b614446c142a132f73c2221b4d53d7d6"}"'","tenantId":"tenant-atlas","hostname":"'"$HOST"'","os":{"name":"Linux","version":"6.1.0","architecture":"x86_64"},"macAddresses":["AA:BB:CC:DD:EE:FF"],"serialNumbers":[],"metadata":{"inventario":{"cpu":"i7","ramGb":16}},"registeredBy":"diag-test"}'
HTTP=$(curl -sS -o resp.json -w "%{http_code}" -H 'Content-Type: application/json' -d "$DATA" https://tickets.esdrasrenan.com.br/api/machines/register || true)
echo "Register HTTP=$HTTP" && tail -c 400 resp.json || true

View file

@ -1,62 +0,0 @@
name: Quality Checks
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint-test-build:
name: Lint, Test and Build
runs-on: ubuntu-latest
env:
BETTER_AUTH_SECRET: test-secret
NEXT_PUBLIC_APP_URL: http://localhost:3000
BETTER_AUTH_URL: http://localhost:3000
NEXT_PUBLIC_CONVEX_URL: http://localhost:3210
DATABASE_URL: file:./prisma/db.dev.sqlite
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.1
- name: Verify Bun
run: bun --version
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Cache Next.js build cache
uses: actions/cache@v4
with:
path: |
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml', 'bun.lock') }}-${{ hashFiles('**/*.{js,jsx,ts,tsx}') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml', 'bun.lock') }}-
- name: Generate Prisma client
env:
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
run: bun run prisma:generate
- name: Lint
run: bun run lint
- name: Test
run: bun test
- name: Build
run: bun run build:bun

430
.github/workflows/ci-cd-web-desktop.yml vendored Normal file
View file

@ -0,0 +1,430 @@
name: CI/CD Web + Desktop
on:
push:
branches: [ main ]
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
force_web_deploy:
description: 'Forçar deploy do Web (ignorar filtro)?'
required: false
default: 'false'
force_convex_deploy:
description: 'Forçar deploy do Convex (ignorar filtro)?'
required: false
default: 'false'
env:
APP_DIR: /srv/apps/sistema
VPS_UPDATES_DIR: /var/www/updates
jobs:
changes:
name: Detect changes
runs-on: ubuntu-latest
outputs:
convex: ${{ steps.filter.outputs.convex }}
web: ${{ steps.filter.outputs.web }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Paths filter
id: filter
uses: dorny/paths-filter@v3
with:
filters: |
convex:
- 'convex/**'
web:
- 'src/**'
- 'public/**'
- 'prisma/**'
- 'next.config.ts'
- 'package.json'
- 'pnpm-lock.yaml'
- 'tsconfig.json'
- 'middleware.ts'
- 'stack.yml'
deploy:
name: Deploy (VPS Linux)
needs: changes
# Executa em qualquer push na main (independente do filtro) ou quando disparado manualmente
if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }}
runs-on: [ self-hosted, linux, vps ]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Determine APP_DIR (fallback safe path)
id: appdir
run: |
TS=$(date +%s)
FALLBACK_DIR="$HOME/apps/sistema.build.$TS"
mkdir -p "$FALLBACK_DIR"
echo "Using APP_DIR (fallback)=$FALLBACK_DIR"
echo "EFFECTIVE_APP_DIR=$FALLBACK_DIR" >> "$GITHUB_ENV"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Permissions diagnostic (server paths)
run: |
set +e
echo "== Basic context =="
whoami || true
id || true
groups || true
umask || true
echo "HOME=$HOME"
echo "APP_DIR(default)=${APP_DIR:-/srv/apps/sistema}"
echo "EFFECTIVE_APP_DIR=$EFFECTIVE_APP_DIR"
echo "\n== Permissions check =="
check_path() {
P="$1"
echo "-- $P"
if [ -e "$P" ]; then
stat -c '%A %U:%G %n' "$P" 2>/dev/null || ls -ld "$P" || true
echo -n "WRITABLE? "; [ -w "$P" ] && echo yes || echo no
if command -v namei >/dev/null 2>&1; then
namei -l "$P" || true
fi
TMP="$P/.permtest.$$"
(echo test > "$TMP" 2>/dev/null && echo "CREATE_FILE: ok" && rm -f "$TMP") || echo "CREATE_FILE: failed"
else
echo "(missing)"
fi
}
check_path "/srv/apps/sistema"
check_path "/srv/apps/sistema/src/app/machines/handshake"
check_path "/srv/apps/sistema/apps/desktop/node_modules"
check_path "/srv/apps/sistema/node_modules"
check_path "$EFFECTIVE_APP_DIR"
check_path "$EFFECTIVE_APP_DIR/node_modules"
- name: Sync workspace to APP_DIR (preserving local env)
run: |
mkdir -p "$EFFECTIVE_APP_DIR"
RSYNC_FLAGS="-az --inplace --no-times --no-perms --no-owner --no-group --delete"
# Excluir .env apenas quando copiando para o diretório padrão (/srv) para preservar segredos locais
EXCLUDE_ENV="--exclude '.env*' --exclude 'apps/desktop/.env*' --exclude 'convex/.env*'"
if [ "$EFFECTIVE_APP_DIR" != "${APP_DIR:-/srv/apps/sistema}" ]; then
EXCLUDE_ENV=""
fi
rsync $RSYNC_FLAGS \
--filter='protect .next.old*' \
--exclude '.next.old*' \
--filter='protect node_modules' \
--filter='protect node_modules/**' \
--filter='protect .pnpm-store' \
--filter='protect .pnpm-store/**' \
--filter='protect .env' \
--filter='protect .env*' \
--filter='protect apps/desktop/.env*' \
--filter='protect convex/.env*' \
--exclude '.git' \
--exclude '.next' \
--exclude 'node_modules' \
--exclude 'node_modules/**' \
--exclude '.pnpm-store' \
--exclude '.pnpm-store/**' \
$EXCLUDE_ENV \
./ "$EFFECTIVE_APP_DIR"/
- name: Copy production .env if present
run: |
DEFAULT_DIR="${APP_DIR:-/srv/apps/sistema}"
if [ "$EFFECTIVE_APP_DIR" != "$DEFAULT_DIR" ] && [ -f "$DEFAULT_DIR/.env" ]; then
echo "Copying production .env from $DEFAULT_DIR to $EFFECTIVE_APP_DIR"
cp -f "$DEFAULT_DIR/.env" "$EFFECTIVE_APP_DIR/.env"
fi
- name: Prune workspace for server-only build
run: |
cd "$EFFECTIVE_APP_DIR"
# Keep only root (web) as a package in this effective workspace
printf "packages:\n - .\n\nignoredBuiltDependencies:\n - '@prisma/client'\n - '@prisma/engines'\n - '@tailwindcss/oxide'\n - esbuild\n - prisma\n - sharp\n - unrs-resolver\n" > pnpm-workspace.yaml
# Remove desktop app to avoid pnpm touching its node_modules on this runner
rm -rf apps/desktop || true
- name: Clean Next.js cache (.next) to avoid EACCES
run: |
cd "$EFFECTIVE_APP_DIR"
if [ -e .next ]; then
echo "Removing existing .next (may be root-owned from previous container)"
rm -rf .next || (mv .next ".next.old.$(date +%s)" || true)
fi
mkdir -p .next
chmod -R u+rwX .next || true
- name: Install and build (Next.js)
run: |
cd "$EFFECTIVE_APP_DIR"
corepack enable || true
pnpm --filter web install --no-frozen-lockfile
pnpm prisma:generate
pnpm build
- name: Swarm deploy (stack.yml)
run: |
cd "$EFFECTIVE_APP_DIR"
# Exporta variáveis do .env para substituição no stack (ex.: MACHINE_PROVISIONING_SECRET)
set -o allexport
if [ -f .env ]; then . ./.env; fi
set +o allexport
APP_DIR="$EFFECTIVE_APP_DIR" RELEASE_SHA=${{ github.sha }} docker stack deploy --with-registry-auth -c stack.yml sistema
- name: Ensure Convex service envs and restart
run: |
cd "$EFFECTIVE_APP_DIR"
set -o allexport
if [ -f .env ]; then . ./.env; fi
set +o allexport
echo "Ensuring Convex envs on service: sistema_convex_backend"
if [ -n "${MACHINE_PROVISIONING_SECRET:-}" ]; then
docker service update --env-add MACHINE_PROVISIONING_SECRET="${MACHINE_PROVISIONING_SECRET}" sistema_convex_backend || true
fi
if [ -n "${MACHINE_TOKEN_TTL_MS:-}" ]; then
docker service update --env-add MACHINE_TOKEN_TTL_MS="${MACHINE_TOKEN_TTL_MS}" sistema_convex_backend || true
fi
if [ -n "${FLEET_SYNC_SECRET:-}" ]; then
docker service update --env-add FLEET_SYNC_SECRET="${FLEET_SYNC_SECRET}" sistema_convex_backend || true
fi
echo "Current envs:"
docker service inspect sistema_convex_backend --format '{{range .Spec.TaskTemplate.ContainerSpec.Env}}{{println .}}{{end}}' || true
echo "Forcing service restart..."
docker service update --force sistema_convex_backend || true
- name: Smoke test — register + heartbeat
run: |
set -e
# Load MACHINE_PROVISIONING_SECRET from production .env on the host
if [ -f /srv/apps/sistema/.env ]; then
set -o allexport
. /srv/apps/sistema/.env
set +o allexport
fi
if [ -z "${MACHINE_PROVISIONING_SECRET:-}" ]; then
echo "MACHINE_PROVISIONING_SECRET ausente — pulando smoke test"; exit 0
fi
HOSTNAME_TEST="ci-smoke-$(date +%s)"
BODY='{"provisioningSecret":"'"$MACHINE_PROVISIONING_SECRET"'","tenantId":"tenant-atlas","hostname":"'"$HOSTNAME_TEST"'","os":{"name":"Linux","version":"6.1.0","architecture":"x86_64"},"macAddresses":["AA:BB:CC:DD:EE:FF"],"serialNumbers":[],"metadata":{"inventory":{"cpu":"i7","ramGb":16}},"registeredBy":"ci-smoke"}'
HTTP=$(curl -sS -o resp.json -w "%{http_code}" -H 'Content-Type: application/json' -d "$BODY" https://tickets.esdrasrenan.com.br/api/machines/register || true)
echo "Register HTTP=$HTTP"
if [ "$HTTP" != "201" ]; then
echo "Register failed:"; tail -c 600 resp.json || true; exit 1; fi
TOKEN=$(node -e 'try{const j=require("fs").readFileSync("resp.json","utf8");process.stdout.write(JSON.parse(j).machineToken||"");}catch(e){process.stdout.write("")}' )
if [ -z "$TOKEN" ]; then echo "Missing token in register response"; exit 1; fi
HB=$(curl -sS -o /dev/null -w "%{http_code}" -H 'Content-Type: application/json' -d '{"machineToken":"'"$TOKEN"'","status":"online","metrics":{"cpuPct":5,"memFreePct":70}}' https://tickets.esdrasrenan.com.br/api/machines/heartbeat || true)
echo "Heartbeat HTTP=$HB"
if [ "$HB" != "200" ]; then echo "Heartbeat failed"; exit 1; fi
- name: Cleanup old build workdirs (keep last 3)
run: |
ls -1dt $HOME/apps/sistema.build.* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true
- name: Restart web service with new code
run: |
docker service update --force sistema_web || true
- name: Restart Convex backend service (optional)
run: |
docker service update --force sistema_convex_backend || true
convex_deploy:
name: Deploy Convex functions
needs: changes
# Executa em workflow_dispatch, push na main, ou quando convex/** mudar
if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' || needs.changes.outputs.convex == 'true' }}
runs-on: [ self-hosted, linux, vps ]
env:
APP_DIR: /srv/apps/sistema
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Determine APP_DIR (fallback safe path)
id: appdir
run: |
TS=$(date +%s)
FALLBACK_DIR="$HOME/apps/sistema.build.$TS"
mkdir -p "$FALLBACK_DIR"
echo "Using APP_DIR (fallback)=$FALLBACK_DIR"
echo "EFFECTIVE_APP_DIR=$FALLBACK_DIR" >> "$GITHUB_ENV"
- name: Sync workspace to APP_DIR (preserving local env)
run: |
mkdir -p "$EFFECTIVE_APP_DIR"
RSYNC_FLAGS="-az --inplace --no-times --no-perms --no-owner --no-group --delete"
rsync $RSYNC_FLAGS \
--filter='protect .next.old*' \
--exclude '.next.old*' \
--exclude '.env*' \
--exclude 'apps/desktop/.env*' \
--exclude 'convex/.env*' \
--filter='protect node_modules' \
--filter='protect node_modules/**' \
--filter='protect .pnpm-store' \
--filter='protect .pnpm-store/**' \
--exclude '.git' \
--exclude '.next' \
--exclude 'node_modules' \
--exclude 'node_modules/**' \
--exclude '.pnpm-store' \
--exclude '.pnpm-store/**' \
./ "$EFFECTIVE_APP_DIR"/
- name: Set Convex env vars (self-hosted)
env:
CONVEX_SELF_HOSTED_URL: ${{ secrets.CONVEX_SELF_HOSTED_URL }}
CONVEX_SELF_HOSTED_ADMIN_KEY: ${{ secrets.CONVEX_SELF_HOSTED_ADMIN_KEY }}
MACHINE_PROVISIONING_SECRET: ${{ secrets.MACHINE_PROVISIONING_SECRET }}
MACHINE_TOKEN_TTL_MS: ${{ secrets.MACHINE_TOKEN_TTL_MS }}
FLEET_SYNC_SECRET: ${{ secrets.FLEET_SYNC_SECRET }}
run: |
set -e
docker run --rm -i \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
-e CONVEX_SELF_HOSTED_URL \
-e CONVEX_SELF_HOSTED_ADMIN_KEY \
-e MACHINE_PROVISIONING_SECRET \
-e MACHINE_TOKEN_TTL_MS \
-e FLEET_SYNC_SECRET \
node:20-bullseye bash -lc "set -euo pipefail; unset CONVEX_DEPLOYMENT; corepack enable; corepack prepare pnpm@9 --activate; pnpm install --frozen-lockfile --prod=false; \
if [ -n \"$MACHINE_PROVISIONING_SECRET\" ]; then pnpm exec convex env set MACHINE_PROVISIONING_SECRET \"$MACHINE_PROVISIONING_SECRET\" -y; fi; \
if [ -n \"$MACHINE_TOKEN_TTL_MS\" ]; then pnpm exec convex env set MACHINE_TOKEN_TTL_MS \"$MACHINE_TOKEN_TTL_MS\" -y; fi; \
if [ -n \"$FLEET_SYNC_SECRET\" ]; then pnpm exec convex env set FLEET_SYNC_SECRET \"$FLEET_SYNC_SECRET\" -y; fi; \
pnpm exec convex env list"
- name: Ensure .env is not present for Convex deploy
run: |
cd "$EFFECTIVE_APP_DIR"
if [ -f .env ]; then
echo "Renaming .env -> .env.bak (Convex self-hosted deploy)"
mv -f .env .env.bak
fi
- name: Deploy functions to Convex self-hosted
env:
CONVEX_SELF_HOSTED_URL: ${{ secrets.CONVEX_SELF_HOSTED_URL }}
CONVEX_SELF_HOSTED_ADMIN_KEY: ${{ secrets.CONVEX_SELF_HOSTED_ADMIN_KEY }}
run: |
docker run --rm -i \
-v "$EFFECTIVE_APP_DIR":/app \
-w /app \
-e CI=true \
-e CONVEX_SELF_HOSTED_URL \
-e CONVEX_SELF_HOSTED_ADMIN_KEY \
node:20-bullseye bash -lc "set -euo pipefail; unset CONVEX_DEPLOYMENT; corepack enable; corepack prepare pnpm@9 --activate; pnpm install --frozen-lockfile --prod=false; pnpm exec convex deploy"
desktop_release:
name: Desktop Release (Windows)
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
runs-on: [ self-hosted, windows, desktop ]
defaults:
run:
working-directory: apps/desktop
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install deps (desktop)
run: pnpm install --frozen-lockfile
- name: Build with Tauri
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
with:
projectPath: apps/desktop
- name: Inject Tauri public key (post)
if: ${{ secrets.TAURI_PUBLIC_KEY != '' }}
env:
TAURI_PUBLIC_KEY: ${{ secrets.TAURI_PUBLIC_KEY }}
run: |
sed -i "s/REPLACE_WITH_TAURI_PUBLIC_KEY/${TAURI_PUBLIC_KEY//\//\\\/}/" apps/desktop/src-tauri/tauri.conf.json || true
- name: Upload latest.json + bundles to VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
source: |
**/bundle/**/latest.json
**/bundle/**/*
target: ${{ env.VPS_UPDATES_DIR }}
overwrite: true
diagnose_convex:
name: Diagnose Convex (env + register test)
if: ${{ github.event_name == 'workflow_dispatch' }}
runs-on: [ self-hosted, linux, vps ]
steps:
- name: Print service env and .env subset
run: |
echo "=== Convex service env ==="
docker service inspect sistema_convex_backend --format '{{range .Spec.TaskTemplate.ContainerSpec.Env}}{{println .}}{{end}}' || true
echo
echo "=== /srv/apps/sistema/.env subset ==="
[ -f /srv/apps/sistema/.env ] && grep -E '^(MACHINE_PROVISIONING_SECRET|MACHINE_TOKEN_TTL_MS|FLEET_SYNC_SECRET|NEXT_PUBLIC_CONVEX_URL)=' -n /srv/apps/sistema/.env || echo '(no .env)'
- name: Acquire Convex admin key
id: key
run: |
CID=$(docker ps --format '{{.ID}} {{.Names}}' | awk '/sistema_convex_backend/{print $1; exit}')
if [ -z "$CID" ]; then echo "No convex container"; exit 1; fi
KEY=$(docker exec -i "$CID" /bin/sh -lc './generate_admin_key.sh' | tr -d '\r' | grep -o 'convex-self-hosted|[^ ]*' | tail -n1)
echo "ADMIN_KEY=$KEY" >> $GITHUB_OUTPUT
echo "Admin key acquired? $([ -n "$KEY" ] && echo yes || echo no)"
- name: List Convex env and set missing
env:
CONVEX_SELF_HOSTED_URL: https://convex.esdrasrenan.com.br
ADMIN_KEY: ${{ steps.key.outputs.ADMIN_KEY }}
run: |
set -e
if [ -f /srv/apps/sistema/.env ]; then
set -o allexport
. /srv/apps/sistema/.env
set +o allexport
fi
docker run --rm -i \
-v /srv/apps/sistema:/app -w /app \
-e CONVEX_SELF_HOSTED_URL -e CONVEX_SELF_HOSTED_ADMIN_KEY="$ADMIN_KEY" \
-e MACHINE_PROVISIONING_SECRET -e MACHINE_TOKEN_TTL_MS -e FLEET_SYNC_SECRET \
node:20-bullseye bash -lc "set -euo pipefail; corepack enable; corepack prepare pnpm@9 --activate; pnpm i --frozen-lockfile --prod=false; \
unset CONVEX_DEPLOYMENT; pnpm exec convex env list; \
if [ -n \"$MACHINE_PROVISIONING_SECRET\" ]; then pnpm exec convex env set MACHINE_PROVISIONING_SECRET \"$MACHINE_PROVISIONING_SECRET\" -y; fi; \
if [ -n \"$MACHINE_TOKEN_TTL_MS\" ]; then pnpm exec convex env set MACHINE_TOKEN_TTL_MS \"$MACHINE_TOKEN_TTL_MS\" -y; fi; \
if [ -n \"$FLEET_SYNC_SECRET\" ]; then pnpm exec convex env set FLEET_SYNC_SECRET \"$FLEET_SYNC_SECRET\" -y; fi; \
pnpm exec convex env list"
- name: Test register from runner
run: |
HOST="vm-teste-$(date +%s)"
DATA='{"provisioningSecret":"'"${MACHINE_PROVISIONING_SECRET:-"71daa9ef54cb224547e378f8121ca898b614446c142a132f73c2221b4d53d7d6"}"'","tenantId":"tenant-atlas","hostname":"'"$HOST"'","os":{"name":"Linux","version":"6.1.0","architecture":"x86_64"},"macAddresses":["AA:BB:CC:DD:EE:FF"],"serialNumbers":[],"metadata":{"inventario":{"cpu":"i7","ramGb":16}},"registeredBy":"diag-test"}'
HTTP=$(curl -sS -o resp.json -w "%{http_code}" -H 'Content-Type: application/json' -d "$DATA" https://tickets.esdrasrenan.com.br/api/machines/register || true)
echo "Register HTTP=$HTTP" && tail -c 400 resp.json || true

View file

@ -36,7 +36,7 @@ jobs:
node-version: 20
- name: Enable Corepack
run: corepack enable && corepack prepare pnpm@10.20.0 --activate
run: corepack enable && corepack prepare pnpm@9 --activate
- name: Install Rust (stable)
uses: dtolnay/rust-toolchain@stable
@ -50,10 +50,16 @@ jobs:
- name: Install pnpm deps
run: pnpm -C apps/desktop install --frozen-lockfile
- name: Inject Tauri public key
if: ${{ secrets.TAURI_PUBLIC_KEY != '' }}
env:
TAURI_PUBLIC_KEY: ${{ secrets.TAURI_PUBLIC_KEY }}
run: |
set -euo pipefail
sed -i "s/REPLACE_WITH_TAURI_PUBLIC_KEY/${TAURI_PUBLIC_KEY//\//\\\/}/" apps/desktop/src-tauri/tauri.conf.json
- name: Build desktop
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_APP_URL: https://tickets.esdrasrenan.com.br

22
.gitignore vendored
View file

@ -17,10 +17,6 @@
/.next/
/out/
# React Email
/.react-email/
/emails/out/
# production
/build
@ -28,8 +24,6 @@
.DS_Store
*.pem
*.sqlite
# external experiments
nova-calendar-main/
# debug
npm-debug.log*
@ -42,10 +36,6 @@ yarn-error.log*
!.env.example
!apps/desktop/.env.example
# Accidental Windows duplicate downloads (e.g., "env (1)")
env (*)
env (1)
# vercel
.vercel
@ -59,15 +49,3 @@ next-env.d.ts
# arquivos locais temporários
Captura de tela *.png
Screenshot*.png
# Ignore NTFS ADS streams accidentally committed from Windows downloads
*:*Zone.Identifier
*:\:Zone.Identifier
# Infrastructure secrets
.ci.env
# ferramentas externas
rustdesk/
# Prisma generated files
src/generated/
apps/desktop/service/target/

View file

@ -1,29 +0,0 @@
# Runtime image with Node 22 + Bun 1.3.4 and build toolchain preinstalled
FROM node:22-bullseye-slim
ENV BUN_INSTALL=/root/.bun
ENV PATH="$BUN_INSTALL/bin:$PATH"
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
gnupg \
unzip \
build-essential \
python3 \
make \
pkg-config \
git \
&& rm -rf /var/lib/apt/lists/*
# Install Bun 1.3.4
RUN curl -fsSL https://bun.sh/install \
| bash -s -- bun-v1.3.4 \
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bun \
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bunx
WORKDIR /app
# We'll mount the app code at runtime; image just provides runtimes/toolchains.
CMD ["bash"]

Binary file not shown.

Binary file not shown.

101
README.md
View file

@ -1,65 +1,51 @@
## Sistema de Chamados
Aplicacao **Next.js 16 (App Router)** com **React 19**, **Convex** e **Better Auth** para gestao de tickets da Rever. A stack ainda inclui **Prisma 7** (PostgreSQL), **Tailwind** e **Turbopack** como bundler padrao (webpack permanece disponivel como fallback). Todo o codigo-fonte fica na raiz do monorepo seguindo as convencoes do App Router.
Aplicação Next.js 15 com Convex e Better Auth para gestão de tickets da Rever. Todo o código-fonte está organizado diretamente na raiz do repositório, conforme convenções do Next.js.
## Requisitos
- Bun >= 1.3 (recomendado 1.3.1). Após instalar via script oficial, adicione `export PATH="$HOME/.bun/bin:$PATH"` ao seu shell (ex.: `.bashrc`) para ter `bun` disponível globalmente.
- Node.js >= 20 (necessário para ferramentas auxiliares como Prisma CLI e Next.js em modo fallback).
- CLI do Convex (`bunx convex dev` instalará automaticamente no primeiro uso, se ainda não estiver presente).
- GitHub Actions/autodeploy dependem dessas versões e do CLI do Convex disponível; use `npx convex --help` para confirmar.
- Node.js >= 20
- pnpm >= 8
- CLI do Convex (`pnpm dlx convex dev` instalará automaticamente no primeiro uso)
## Configuração rápida
1. Instale as dependências:
```bash
bun install
pnpm install
```
2. Ajuste o arquivo `.env` (ou crie a partir de `.env.example`) e confirme os valores de:
- `NEXT_PUBLIC_CONVEX_URL` (gerado pelo Convex Dev)
- `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DATABASE_URL` (PostgreSQL, ex: `postgresql://postgres:dev@localhost:5432/sistema_chamados`)
- `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DATABASE_URL`
3. Aplique as migrações e gere o client Prisma:
```bash
bunx prisma migrate deploy
bun run prisma:generate
pnpm prisma migrate deploy
pnpm prisma:generate
```
4. Popule usuários padrão do Better Auth:
```bash
bun run auth:seed
pnpm auth:seed
```
> Sempre que trocar de máquina ou quiser “zerar” o ambiente local, basta repetir os passos 3 e 4 com a mesma `DATABASE_URL`.
### Resetar rapidamente o ambiente local
1. Suba um PostgreSQL local (Docker recomendado):
5. (Opcional) Para re-sincronizar manualmente as filas padrão, execute:
```bash
docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
pnpm queues:ensure
```
2. Aplique as migracoes:
6. Em um terminal, execute o backend em tempo real do Convex:
```bash
bunx prisma migrate deploy
pnpm convex:dev
```
3. Recrie/garanta as contas padrao de login:
7. Em outro terminal, suba o frontend Next.js:
```bash
bun run auth:seed
pnpm dev
```
4. Suba o servidor normalmente com `bun run dev`.
### Subir serviços locais
- (Opcional) Para re-sincronizar manualmente as filas padrão, execute `bun run queues:ensure`.
- Em um terminal, rode o backend em tempo real do Convex com `bun run convex:dev:bun` (ou `bun run convex:dev` para o runtime Node).
- Em outro terminal, suba o frontend Next.js (Turbopack) com `bun run dev:bun` (`bun run dev:webpack` serve como fallback).
- Com o Convex rodando, acesse `http://localhost:3000/dev/seed` uma vez para popular dados de demonstração (tickets, usuários, comentários).
8. Com o Convex ativo, acesse `http://localhost:3000/dev/seed` uma vez para popular dados de demonstração (tickets, usuários, comentários) diretamente no banco do Convex.
> Se o CLI perguntar sobre configuração do projeto Convex, escolha criar um novo deployment local (opção padrão) e confirme. As credenciais são armazenadas em `.convex/` automaticamente.
### Documentação
- Índice de docs: `docs/README.md`
- Operações (produção): `docs/OPERATIONS.md` (versão EN) e `docs/OPERACAO-PRODUCAO.md` (PT-BR)
- Guia de DEV: `docs/DEV.md`
- Testes automatizados (Vitest/Playwright): `docs/testes-vitest.md`
- Stack Swarm: `stack.yml` (roteado por Traefik, rede `traefik_public`).
### Deploy em produção (Traefik + Convex selfhosted)
- Guia completo: `docs/OPERACAO-PRODUCAO.md:1`.
- Histórico de setup/decisões: `docs/SETUP-HISTORICO.md:1`.
- Stack Swarm: `stack.yml:1` (roteado por Traefik, rede `traefik_public`).
### Variáveis de ambiente
@ -69,63 +55,32 @@ Aplicacao **Next.js 16 (App Router)** com **React 19**, **Convex** e **Better Au
### Guia de DEV (Prisma, Auth e Desktop/Tauri)
Para fluxos detalhados de desenvolvimento — banco de dados local (PostgreSQL/Prisma), seed do Better Auth, ajustes do Prisma CLI no DEV e build do Desktop (Tauri) — consulte `docs/DEV.md`.
Para fluxos detalhados de desenvolvimento — banco de dados local (SQLite/Prisma), seed do Better Auth, ajustes do Prisma CLI no DEV e build do Desktop (Tauri) — consulte `docs/DEV.md`.
## Scripts úteis
- `bun run dev:bun` — padrão atual para o Next.js com runtime Bun (`bun run dev:webpack` permanece como fallback).
- `bun run convex:dev:bun` — runtime Bun para o Convex (`bun run convex:dev` mantém o fluxo antigo usando Node).
- `bun run build:bun` / `bun run start:bun` — build e serve com Bun usando Turbopack (padrão atual).
- `bun run dev:webpack` — fallback do Next.js em modo desenvolvimento (webpack).
- `bun run lint` — ESLint com as regras do projeto.
- `bun test` — suíte de testes unitários usando o runner do Bun (o teste de screenshot fica automaticamente ignorado se o matcher não existir).
- `bun run build` — executa `next build --turbopack` (runtime Node, caso prefira evitar o `--bun`).
- `bun run build:webpack` — executa `next build --webpack` como fallback oficial.
- `bun run auth:seed` — atualiza/cria contas padrao do Better Auth (credenciais em `agents.md`).
- `bunx prisma migrate deploy` — aplica migracoes ao banco PostgreSQL.
- `bun run convex:dev` — roda o Convex em modo desenvolvimento com Node, gerando tipos em `convex/_generated`.
## Transferir dispositivo entre colaboradores
Quando uma dispositivo trocar de responsável:
1. Abra `Admin > Dispositivos`, selecione o equipamento e clique em **Resetar agente**.
2. No equipamento, execute o reset local do agente (`rever-agent reset` ou reinstale o serviço) e reprovisione com o código da empresa.
3. Após o agente gerar um novo token, associe a dispositivo ao novo colaborador no painel.
Sem o reset de agente, o Convex reaproveita o token anterior e o inventário continua vinculado ao usuário antigo.
- `pnpm lint` — ESLint com as regras do projeto.
- `pnpm exec vitest run` — suíte de testes unitários.
- `pnpm auth:seed` — atualiza/cria contas padrão do Better Auth (credenciais em `agents.md`).
- `pnpm prisma migrate deploy` — aplica migrações ao banco SQLite local.
- `pnpm convex:dev` — roda o Convex em modo desenvolvimento, gerando tipos em `convex/_generated`.
## Estrutura principal
- `app/` dentro de `src/` — rotas e layouts do Next.js (App Router).
- `components/` — componentes reutilizáveis (UI, formulários, layouts).
- `convex/` — queries, mutations e seeds do Convex.
- `prisma/` — schema e migracoes do Prisma (PostgreSQL).
- `prisma/` — schema, migrações e banco SQLite (`prisma/db.sqlite`).
- `scripts/` — utilitários em Node para sincronização e seeds adicionais.
- `agents.md` — guia operacional e contexto funcional (em PT-BR).
- `PROXIMOS_PASSOS.md` — backlog de melhorias futuras.
## Credenciais de demonstração
Após executar `bun run auth:seed`, as credenciais padrão ficam disponíveis conforme descrito em `agents.md` (seção “Credenciais padrão”). Ajuste variáveis `SEED_USER_*` se precisar sobrepor usuários ou senhas durante o seed.
Após executar `pnpm auth:seed`, as credenciais padrão ficam disponíveis conforme descrito em `agents.md` (seção “Credenciais padrão”). Ajuste variáveis `SEED_USER_*` se precisar sobrepor usuários ou senhas durante o seed.
## Próximos passos
Consulte `PROXIMOS_PASSOS.md` para acompanhar o backlog funcional e o progresso das iniciativas planejadas.
### Executar com Bun
- `bun install` é o fluxo padrão (o arquivo `bun.lock` deve ser versionado; use `bun install --frozen-lockfile` em CI).
- `bun run dev:bun`, `bun run convex:dev:bun`, `bun run build:bun` e `bun run start:bun` já estão configurados; internamente executam `bun run --bun <script>` para usar o runtime do Bun sem abrir mão dos scripts existentes. O `cross-env` garante os valores esperados de `NODE_ENV` (`development`/`production`).
- O bundler padrão é o Turbopack; se precisar comparar/debugar com webpack, use `bun run build:webpack`.
- `bun test` utiliza o test runner do Bun. O teste de snapshot de screenshot é automaticamente ignorado quando o matcher não está disponível; testes de navegador completos continuam via `bun run test:browser` (Vitest + Playwright).
<!-- ci: smoke test 3 -->
## Diagnóstico de sessão da dispositivo (Desktop)
- Quando o portal for aberto via app desktop, use a página `https://seu-app/portal/debug` para validar cookies e contexto:
- `/api/auth/get-session` deve idealmente mostrar `user.role = "machine"` (em alguns ambientes WebView pode retornar `null`, o que não é bloqueante).
- `/api/machines/session` deve retornar `200` com `assignedUserId/assignedUserEmail`.
- O frontend agora preenche `machineContext` mesmo que `get-session` retorne `null`, e deriva o papel efetivo a partir desse contexto.
- Se `machines/session` retornar `401/403`, revise CORS/credenciais e o fluxo de handshake documentados em `docs/OPERACAO-PRODUCAO.md`.

5965
RENAN.txt Normal file

File diff suppressed because it is too large Load diff

335
agents.md
View file

@ -1,214 +1,199 @@
# Plano de Desenvolvimento — Sistema de Chamados
> **Diretriz máxima**: documentação, comunicação e respostas sempre em português brasileiro.
> **Diretriz máxima:** todas as respostas, comunicações e documentações devem ser redigidas em português brasileiro.
## Contatos
## Contato principal
- **Esdras Renan** — monkeyesdras@gmail.com
## Credenciais padrão (Better Auth)
| Papel | Usuário | Senha |
| --- | --- | --- |
| Administrador | `admin@sistema.dev` | `admin123` |
| Painel telão | `suporte@rever.com.br` | `agent123` |
- Administrador: `admin@sistema.dev` / `admin123`
- Agente Demo: `agente.demo@sistema.dev` / `agent123`
- Cliente Demo: `cliente.demo@sistema.dev` / `cliente123`
> Execute `pnpm auth:seed` após configurar `.env`. O script atualiza as contas acima ou cria novas conforme variáveis `SEED_USER_*`.
Os demais colaboradores reais são provisionados via **Convites & acessos**. Caso existam vestígios de dados demo, execute `node scripts/remove-legacy-demo-users.mjs` para limpá-los.
## Sincronização com Convex
- Usuários e tickets demo são garantidos via `convex/seed.ts`.
- Após iniciar `pnpm convex:dev`, acesse `/dev/seed` uma vez por ambiente local para carregar dados reais de demonstração no banco do Convex.
> Execute `bun run auth:seed` após configurar `.env` para (re)criar os usuários acima (campos `SEED_USER_*` podem sobrescrever credenciais).
## Setup local rápido
1. `pnpm install`
2. Ajuste `.env` (ou crie a partir do exemplo) e confirme `NEXT_PUBLIC_CONVEX_URL` apontando para o Convex local.
3. `pnpm auth:seed`
4. (Opcional) `pnpm queues:ensure`
5. `pnpm convex:dev`
6. Em outro terminal: `pnpm dev`
## Backend Convex
- Seeds de usuários/tickets demo: `convex/seed.ts`.
- Para DEV: rode `bun run convex:dev:bun` e acesse `/dev/seed` uma vez para popular dados realistas.
## App Desktop (Agente de Máquinas)
- Código: `apps/desktop` (Tauri v2 + Vite).
- Padrões de URL:
- Produção: usa `https://tickets.esdrasrenan.com.br` por padrão (fallback em release).
- Desenvolvimento: use `apps/desktop/.env` (ver `.env.example`).
- Comandos úteis:
- `pnpm -C apps/desktop tauri dev` — dev completo (abre WebView em 1420 + backend Rust).
- `pnpm -C apps/desktop build` — build do frontend (dist).
- `pnpm -C apps/desktop tauri build` — gera instaladores (bundle) por SO.
- Saída dos pacotes: `apps/desktop/src-tauri/target/release/bundle/`.
- Fluxo:
1) Coleta perfil (hostname/OS/MAC/seriais/métricas).
2) Provisiona via `POST /api/machines/register` com `MACHINE_PROVISIONING_SECRET`.
3) Envia heartbeats a cada 5 min para `/api/machines/heartbeat` com inventário básico.
4) Abre `APP_URL/machines/handshake?token=...` para autenticar sessão na UI.
- Segurança: token salvo no cofre do SO (Keyring). Store guarda apenas metadados não sensíveis.
- Endpoint extra: `POST /api/machines/inventory` (atualiza inventário por token ou provisioningSecret).
## Stack atual (18/12/2025)
- **Next.js**: `16.0.10` (Turbopack por padrão; webpack fica como fallback).
- Whitelist de domínios em `src/config/allowed-hosts.ts` é aplicada pelo `middleware.ts`.
- **React / React DOM**: `19.2.1`.
- **Trilha de testes**: Vitest (`bun test`) sem modo watch por padrão (`--run --passWithNoTests`).
- **CI**: workflow `Quality Checks` (`.github/workflows/quality-checks.yml`) roda `bun install`, `bun run prisma:generate`, `bun run lint`, `bun test`, `bun run build:bun`. Variáveis críticas (`BETTER_AUTH_SECRET`, `NEXT_PUBLIC_APP_URL`, etc.) são definidas apenas no runner — não afetam a VPS.
- **Disciplina pós-mudanças**: sempre que fizer alterações locais, rode **obrigatoriamente** `bun run lint`, `bun run build:bun` e `bun test` antes de entregar ou abrir PR. Esses comandos são mandatórios também para os agentes/automations, garantindo que o projeto continua íntegro.
- **Deploy**: pipeline `ci-cd-web-desktop.yml` (runner self-hosted). Build roda com Bun 1.3 + Node 20. Web é publicado em `/home/renan/apps/sistema` e o Swarm aponta `sistema_web` para essa pasta.
## Desenvolvimento local — boas práticas (atualizado)
- Ambientes separados: mantenha seu `.env.local` só para DEV e o `.env` da VPS só para PROD. Nunca commitar arquivos `.env`.
- Convex em DEV: rode `pnpm convex:dev` e aponte o front para `http://127.0.0.1:3210` via `NEXT_PUBLIC_CONVEX_URL`.
- Banco local: por padrão `DATABASE_URL=file:./prisma/db.sqlite`. Se quiser isolar por projeto, use `db.dev.sqlite`.
- Seeds em DEV: use `pnpm auth:seed` (usuários Better Auth) e acesse `/dev/seed` uma vez para dados de demonstração do Convex.
- Seeds em PROD: só quando realmente necessário; não fazem parte do deploy automático.
## Setup local (atualizado)
1. `bun install`
2. Copie `.env.example``.env.local`.
- Principais variáveis para DEV:
### Exemplo de `.env.local` (DEV)
```
# Base do app
NODE_ENV=development
NEXT_PUBLIC_APP_URL=http://localhost:3000
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=dev-only-long-random-string
# Convex (DEV)
NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
```
3. `bun run auth:seed`
4. (Opcional) `bun run queues:ensure`
5. `bun run convex:dev:bun`
6. Em outro terminal: `bun run dev:bun`
7. Acesse `http://localhost:3000` e valide login com os usuários padrão.
### Banco de dados
- Local (DEV): PostgreSQL local (ex.: `postgres:18`) com `DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados`.
- Produção: PostgreSQL no Swarm (serviço `postgres` em uso hoje; `postgres18` provisionado para migração). Migrations em PROD devem apontar para o `DATABASE_URL` ativo (ver `docs/OPERATIONS.md`).
- Limpeza de legados: `node scripts/remove-legacy-demo-users.mjs` remove contas demo antigas (Cliente Demo, gestores fictícios etc.).
# Banco local (Prisma)
DATABASE_URL=file:./prisma/db.sqlite
### Verificações antes de PR/deploy
```bash
bun run lint
bun test
bun run build:bun
# SMTP de desenvolvimento (ex.: Mailpit)
SMTP_ADDRESS=localhost
SMTP_PORT=1025
SMTP_TLS=false
SMTP_USERNAME=
SMTP_PASSWORD=
MAILER_SENDER_EMAIL="Dev <no-reply@localhost>"
# (Opcional) OAuth DEV não usado por padrão neste projeto
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
```
## Aplicativo Desktop (Tauri)
- Código-fonte: `apps/desktop` (Tauri v2 + Vite + React 19).
- URLs:
- Produção: `https://tickets.esdrasrenan.com.br`
- DEV: configure `apps/desktop/.env` (exemplo fornecido).
- Comandos:
- `bun run --cwd apps/desktop tauri dev` — desenvolvimento (porta 1420).
- `bun run --cwd apps/desktop tauri build` — gera instaladores.
- **Fluxo do agente**:
1. Coleta perfil da dispositivo (hostname, OS, MAC, seriais, métricas).
2. Provisiona via `POST /api/machines/register` usando `MACHINE_PROVISIONING_SECRET`, informando perfil de acesso (Colaborador/Gestor) + dados do colaborador.
3. Envia heartbeats periódicos (`/api/machines/heartbeat`) com inventário básico + estendido (discos SMART, GPUs, serviços, softwares, CPU window).
4. Realiza handshake em `APP_URL/machines/handshake?token=...&redirect=...` para receber cookies Better Auth + sessão (colaborador → `/portal`, gestor → `/dashboard`).
5. Token persistido no cofre do SO (Keyring); store guarda apenas metadados.
6. Envio manual de inventário via botão (POST `/api/machines/inventory`).
7. Updates automáticos: plugin `@tauri-apps/plugin-updater` consulta `latest.json` publicado nos releases do GitHub.
- **Admin ▸ Dispositivos**: permite ajustar perfil/email associado, visualizar inventário completo e remover dispositivo.
Observações:
- `COOKIE_DOMAIN` não é necessário em DEV neste projeto.
- Variáveis de provisionamento de máquinas (`MACHINE_PROVISIONING_SECRET`, etc.) só se você for testar as rotas de máquinas/inventário.
### Sessão "machine" no frontend
- Ao autenticar como dispositivo, o front chama `/api/machines/session`, popula `machineContext` (assignedUser*, persona) e deriva role/`viewerId`.
- Mesmo quando `get-session` é `null` na WebView, o portal utiliza `machineContext` para saber o colaborador/gestor logado.
- UI remove opção "Sair" no menu do usuário quando detecta sessão de dispositivo.
- `/portal/debug` exibe JSON de `get-session` e `machines/session` (útil para diagnosticar cookies/bearer).
### Passo a passo local
1) `pnpm install`
2) `pnpm prisma:generate`
3) `pnpm convex:dev` (terminal A)
4) `pnpm dev` (terminal B)
5) (Opcional) `pnpm auth:seed` e visitar `http://localhost:3000/dev/seed`
### Observações adicionais
- Planejamos usar um cookie `desktop_shell` no futuro para diferenciar acessos do desktop vs navegador (não implementado).
## Deploy via GitHub Actions (produção)
- Fluxo: `git push main` ⇒ runner selfhosted na VPS sincroniza código e aplica o stack (Traefik/Swarm) sem derrubar o serviço (start-first).
- Disparo do deploy web: apenas quando há mudanças em arquivos do app (src/, public/, prisma/, next.config.ts, package.json, pnpm-lock.yaml, tsconfig.json, middleware.ts, stack.yml).
- Disparo do deploy Convex: apenas quando há mudanças em `convex/**`.
- O `.env` da VPS é preservado; caches do servidor (`node_modules`, `.pnpm-store`) não são tocados.
- Banco Prisma (SQLite) persiste em volume nomeado (`sistema_db`); não é recriado a cada deploy.
## Qualidade e testes
- **Lint**: `bun run lint` (ESLint flat config).
- **Testes unitários/integrados (Vitest)**:
- Cobertura atual inclui utilitários (`tests/*.test.ts`), rotas `/api/machines/*` e `sendSmtpMail`.
- Executar `bun test -- --watch` apenas quando precisar de modo interativo.
- **Build**: `bun run build:bun` (`next build --turbopack`). Quando precisar do fallback oficial, rode `bun run build:webpack`.
- **CI**: falhas mais comuns
- `ERR_BUN_LOCKFILE_OUTDATED`: confirme que o `bun.lock` foi regenerado (`bun install`) após alterar dependências, especialmente do app desktop.
- Variáveis Better Auth ausentes (`BETTER_AUTH_SECRET`): definidas no workflow (`Quality Checks`).
- Falha de host: confira `src/config/allowed-hosts.ts`; o middleware retorna 403 quando o domínio do Traefik não está listado.
## Bancos e seeds — DEV x PROD
- DEV: use os seeds à vontade (usuários com `pnpm auth:seed`, dados demo do Convex em `/dev/seed`).
- PROD: evite seeds automáticos; para criar um admin use `SEED_USER_*` e `pnpm auth:seed` em um container Node efêmero.
- Alterações de schema: sempre via migrações (`prisma migrate`). O CI aplica `migrate deploy` no start do container web.
## Produção / Deploy
- Runner self-hosted (VPS). Build roda fora de `/srv/apps/sistema` e rsync publica em `/home/renan/apps/sistema`.
- Swarm: `stack.yml` monta `/home/renan/apps/sistema.current``/app` (via symlink).
- Para liberar novo release manualmente:
```bash
ln -sfn /home/renan/apps/sistema.build.<novo> /home/renan/apps/sistema.current
docker service update --force sistema_web
```
- Resolver `P3009` (migration falhou) no PostgreSQL ativo:
```bash
docker service scale sistema_web=0
docker run --rm -it --network traefik_public \
--env-file /home/renan/apps/sistema.current/.env \
-v /home/renan/apps/sistema.current:/app \
oven/bun:1 bash -lc "bun install --frozen-lockfile && bun x prisma migrate resolve --rolled-back <migration> && bun x prisma migrate deploy"
docker service scale sistema_web=1
```
## Dicas rápidas
- Imagens em `public/`: trocou o arquivo → push. Para bust de cache, versionar o nome (ex.: `logo.v2.png`) ou usar query (`?v=sha`).
- Problemas de permissão de build: garanta que `.next` pertence ao usuário do runner (se necessário, remover `.next` no host e rebuildar).
- Se precisar inspecionar/backup do SQLite em PROD, prefira um bind dedicado (`/srv/apps/sistema-data:/app/data`) ou use `docker run -v sistema_db:/data` para copiar o arquivo.
## Estado do portal / app web
- Autenticação Better Auth com `AuthGuard`.
- Sidebar inferior agrega avatar, link para `/settings` e logout (oculto em sessões de dispositivo).
- Formulários de ticket (novo/editar/comentários) usam editor rico + anexos; placeholders e validação PT-BR.
- Relatórios e painéis utilizam `AppShell` + `SiteHeader`.
- `usePersistentCompanyFilter` mantém filtro global de empresa em relatórios/admin.
- Exportações CSV: backlog, canais, CSAT, SLA, horas (rotas `/api/reports/*.csv`).
- PDF do ticket (`/api/tickets/[id]/export/pdf`).
- Play interno/externo com métricas por tipo.
- Admin > Empresas: cadastro + “Cliente avulso?”, horas contratadas, vínculos de usuários.
- Admin > Usuários/Equipe:
- Abas separadas: "Equipe" (administradores e agentes) e "Usuários" (gestores e colaboradores).
- Multiseleção + ações em massa: excluir usuários, remover agentes de dispositivo e revogar convites pendentes.
- Filtros por papel, empresa e espaço (tenant) quando aplicável; busca unificada.
- Convites: campo "Espaço (ID interno)" removido da UI de geração.
## Checklist para novo computador
1. Instale Node.js 20+ e habilite o Corepack (`corepack enable`) para usar o `pnpm`.
2. Garanta o `pnpm` atualizado (`corepack prepare pnpm@latest --activate`) antes de clonar o repositório.
3. Clone o projeto: `git clone git@github.com:esdrasrenan/sistema-de-chamados.git` e entre na pasta.
4. Copie o arquivo `.env` já configurado do computador atual para a raiz do repositório (nunca faça commit desse arquivo).
5. Instale as dependências com `pnpm install`.
6. Gere os clientes locais necessários: `pnpm prisma:generate`.
7. Semeie as credenciais Better Auth: `pnpm auth:seed`.
8. Se for trabalhar com filas padrão, execute `pnpm queues:ensure`.
9. Inicie o backend Convex em um terminal (`pnpm convex:dev`) e, em outro, suba a aplicação Next.js (`pnpm dev`).
10. Acesse `http://localhost:3000` e teste login com os usuários padrão listados acima antes de continuar o desenvolvimento.
## Estado atual
- Autenticação Better Auth com guardas client-side (`AuthGuard`) bloqueando rotas protegidas.
- Menu de usuário (rodapé da sidebar) concentra acesso às configurações ("Meu perfil" → `/settings`) e logout. Removemos o item redundante "Configurações" do menu lateral.
- Formulários de novo ticket (dialog, página e portal) com seleção de responsável, placeholders claros e validação obrigatória de assunto/descrição/categorias.
- Relatórios, dashboards e páginas administrativas utilizam `AppShell`, garantindo header/sidebar consistentes.
- Use `SiteHeader` no `header` do `AppShell` para título/lead e ações.
- O conteúdo deve ficar dentro de `<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">`.
- Persistir filtro global de empresa com `usePersistentCompanyFilter` (localStorage) para manter consistência entre relatórios.
## Entregas recentes
- Exportações CSV (Backlog, Canais, CSAT, SLA e Horas por cliente) com parâmetros de período.
- PDF do ticket (via pdfkit standalone), com espaçamento e traduções PT-BR.
- Play interno/externo com somatório por tipo por ticket e relatório por cliente.
- Admin > Empresas & clientes: cadastro/edição, `Cliente avulso?` e `Horas contratadas/mês`.
- Admin > Usuários: vincular colaborador à empresa.
- Alertas enviados: acessível agora em Configurações → Administração do workspace (link direto para /admin/alerts). Removido da sidebar.
- Dashboard: cards por fila e indicadores principais.
- Dashboard: cards de filas (Chamados/Laboratório/Visitas) e indicadores principais.
- Lista de tickets: filtro por Empresa, coluna Empresa, alinhamento vertical e melhor espaçamento entre colunas.
## Entregas recentes relevantes
- Correção do redirecionamento após logout evitando retorno imediato ao dashboard.
- Validações manuais dos formulários de rich text para eliminar `ZodError` durante edição.
- Dropdown de responsáveis na criação de tickets com preenchimento automático pelo autor e evento inicial de comentário.
- Indicadores visuais de campos obrigatórios e botão "Novo ticket" funcional no cabeçalho do detalhe.
- Seeds (Better Auth e Convex) ampliados para incluir agente e cliente de teste.
## Fluxos suportados
- **Equipe interna** (`admin`, `agent`, `collaborator`): cria/acompanha tickets, comenta, altera status/fila, gera relatórios.
- **Gestores** (`manager`): visualizam tickets da empresa, comentam publicamente, acessam dashboards.
- **Colaboradores** (`collaborator`): portal (`/portal`), tickets próprios, comentários públicos, editor rico, anexos.
- **Sessão Dispositivo**: desktop registra heartbeat/inventário e redireciona colaborador/gestor ao portal apropriado com cookies válidos.
### Correções recentes
- Temporizador do ticket (atendimento em andamento): a UI passa a aplicar atualização otimista na abertura/pausa da sessão para que o tempo corrente não "salte" para minutos indevidos. O backend continua a fonte da verdade (total acumulado é reconciliado ao pausar).
### Equipe interna (admin/agent/collaborator)
- Criar tickets com categorias, responsável inicial e anexos.
- Abrir novos tickets diretamente a partir do detalhe via dialog reutilizável.
- Acessar `/settings` para ajustes pessoais e efetuar logout pelo menu.
## Backlog recomendado
1. E-mails automáticos quando uso de horas ≥ 90% do contratado.
2. Ações rápidas (status/fila) diretamente na lista de tickets.
3. Limites de anexos por tenant + monitoramento.
4. Layout do PDF do ticket alinhado ao visual da aplicação.
5. Experimentos com React Compiler (Next 16).
### Papéis
- Papéis válidos: `admin`, `manager`, `agent`, `collaborator` (papel `customer` removido).
- Gestores veem os tickets da própria empresa e só podem registrar comentários públicos.
## Referências rápidas
- **Endpoints agent desktop**:
## Próximos passos sugeridos
1. Disparo de e-mails automáticos quando uso de horas ≥ 90% do contratado.
2. Ações rápidas (status/fila) diretamente na listagem de tickets.
3. Limites e monitoramento para anexos por tenant.
4. PDF do ticket com layout idêntico ao app (logo/cores/fontes).
## Referências de endpoints úteis
- Backlog CSV: `/api/reports/backlog.csv?range=7d|30d|90d[&companyId=...]`
- Canais CSV: `/api/reports/tickets-by-channel.csv?range=7d|30d|90d[&companyId=...]`
- CSAT CSV: `/api/reports/csat.csv?range=7d|30d|90d`
- SLA CSV: `/api/reports/sla.csv`
- Horas por cliente CSV: `/api/reports/hours-by-client.csv?range=7d|30d|90d`
## Referências de inventário de máquinas
- UI (Admin > Máquinas): filtros, pesquisa e export detalhados — ver docs/admin-inventory-ui.md
- Endpoints do agente:
- `POST /api/machines/register`
- `POST /api/machines/heartbeat`
- `POST /api/machines/inventory`
- **Relatórios XLSX**:
- Backlog: `/api/reports/backlog.xlsx?range=7d|30d|90d[&companyId=...]`
- Canais: `/api/reports/tickets-by-channel.xlsx?...`
- CSAT: `/api/reports/csat.xlsx?...`
- SLA: `/api/reports/sla.xlsx?...`
- Horas: `/api/reports/hours-by-client.xlsx?...`
- Inventário de dispositivos: `/api/reports/machines-inventory.xlsx?[companyId=...]`
- **Docs complementares**:
- `docs/DEV.md` — guia diário atualizado.
- `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog.
- `docs/OPERATIONS.md` — runbook do Swarm.
- `docs/admin-inventory-ui.md`, `docs/plano-app-desktop-maquinas.md` — detalhes do inventário/agente.
## Regras de Codigo
## Rotina antes de abrir PR
- `pnpm lint`
- `pnpm build --turbopack`
- `pnpm exec vitest run`
- Revisar toasts/labels em PT-BR e ausência de segredos no diff.
### Tooltips Nativos do Navegador
## Convenções
- Convex deve retornar apenas tipos primitivos; converta datas via mappers em `src/lib/mappers`.
- Manter textos em PT-BR e evitar comentários supérfluos no código.
- Reutilizar componentes shadcn existentes e seguir o estilo do arquivo editado.
- Validações client-side críticas devem sinalizar erros inline e exibir toast.
**NAO use o atributo `title` em elementos HTML** (button, span, a, div, etc).
## Estrutura útil
- `convex/` — queries e mutations (ex.: `tickets.ts`, `users.ts`).
- `src/components/tickets/` — UI interna (dialog, listas, header, timeline).
- `src/components/portal/` — formulários e fluxos do portal do cliente.
- `scripts/` — seeds Better Auth e utilidades.
- `src/components/auth/auth-guard.tsx` — proteção de rotas client-side.
O atributo `title` causa tooltips nativos do navegador que sao inconsistentes visualmente e nao seguem o design system da aplicacao.
```tsx
// ERRADO - causa tooltip nativo do navegador
<button title="Remover item">
<Trash2 className="size-4" />
</button>
// CORRETO - sem tooltip nativo
<button>
<Trash2 className="size-4" />
</button>
// CORRETO - se precisar de tooltip, use o componente Tooltip do shadcn/ui
<Tooltip>
<TooltipTrigger asChild>
<button>
<Trash2 className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>Remover item</TooltipContent>
</Tooltip>
```
**Excecoes:**
- Props `title` de componentes customizados (CardTitle, DialogTitle, etc) sao permitidas pois nao geram tooltips nativos.
### Acessibilidade
Para manter acessibilidade em botoes apenas com icone, prefira usar `aria-label`:
```tsx
<button aria-label="Remover item">
<Trash2 className="size-4" />
</button>
```
---
_Última atualização: 18/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._
## Histórico resumido
- Scaffold Next.js + Turbopack configurado com Better Auth e Convex.
- Portal do cliente entregue com isolamento por `viewerId`.
- Fluxo de convites e painel administrativo operacionais.
- Iteração atual focada em UX de criação de tickets, consistência de layout e guardas de sessão.

View file

@ -9,14 +9,6 @@ VITE_APP_URL=http://localhost:3000
# Se não definir, cai no mesmo valor de VITE_APP_URL
VITE_API_BASE_URL=
# RustDesk provisioning (opcionais; se vazios, o app usa o TOML padrão embutido)
VITE_RUSTDESK_CONFIG_STRING=
VITE_RUSTDESK_DEFAULT_PASSWORD=FMQ9MA>e73r.FI<b*34Vmx_8P
# Assinatura Tauri (dev/CI). Em producao, pode sobrescrever por env seguro.
TAURI_SIGNING_PRIVATE_KEY=dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5WkhWOUtzd1BvV0ZlSjEvNzYwaHYxdEloNnV4cmZlNGhha1BNbmNtZEkrZ0FBQkFBQUFBQUFBQUFBQUlBQUFBQS9JbCtsd3VFbHN4empFRUNiU0dva1hKK3ZYUzE2S1V6Q1FhYkRUWGtGMTBkUmJodi9PaXVub3hEMisyTXJoYU5UeEdwZU9aMklacG9ualNWR1NaTm1PMVBpVXYrNTltZU1YOFdwYzdkOHd2STFTc0x4ZktpNXFENnFTdW0xNzY3WC9EcGlIRGFmK2c9Cg==
TAURI_SIGNING_PRIVATE_KEY_PASSWORD=revertech
# Opcional: IP do host para desenvolvimento com HMR fora do localhost
# Ex.: 192.168.0.10
TAURI_DEV_HOST=

View file

@ -1,11 +1,11 @@
# Sistema de Chamados — App Desktop (Tauri)
Cliente desktop (Tauri v2 + Vite) que:
- Coleta perfil/métricas da dispositivo via comandos Rust.
- Registra a dispositivo com um código de provisionamento.
- Coleta perfil/métricas da máquina via comandos Rust.
- Registra a máquina com um código de provisionamento.
- Envia heartbeat periódico ao backend (`/api/machines/heartbeat`).
- Redireciona para a UI web do sistema após provisionamento.
- Armazena o token da dispositivo com segurança no cofre do SO (Keyring).
- Armazena o token da máquina com segurança no cofre do SO (Keyring).
- Exibe abas de Resumo, Inventário, Diagnóstico e Configurações; permite “Enviar inventário agora”.
## URLs e ambiente
@ -22,42 +22,13 @@ VITE_API_BASE_URL=
## Comandos
- Dev (abre janela Tauri e Vite em 1420):
- `bun run --cwd apps/desktop tauri dev`
- `pnpm -C apps/desktop tauri dev`
- Build frontend (somente Vite):
- `bun run --cwd apps/desktop build`
- `pnpm -C apps/desktop build`
- Build executável (bundle):
- `bun run --cwd apps/desktop tauri build`
- `pnpm -C apps/desktop tauri build`
Saída dos pacotes: `apps/desktop/src-tauri/target/release/bundle/`.
### Windows (NSIS) — instalação e dados
- Instalador NSIS com suporte a “perMachine” (Arquivos de Programas) e diretório customizável (ex.: `C:\Raven`).
- Atalho é criado na Área de Trabalho apontando para o executável instalado.
- Dados do app (token/config) ficam em AppData local do usuário (via `@tauri-apps/plugin-store` com `appLocalDataDir`).
#### NSIS — Idiomas e modo de instalação
- Idioma: o instalador inclui Português do Brasil e exibe seletor de idioma.
- Arquivo: `apps/desktop/src-tauri/tauri.conf.json:54``"displayLanguageSelector": true`
- Arquivo: `apps/desktop/src-tauri/tauri.conf.json:57``"languages": ["PortugueseBR"]`
- Comportamento: usa o idioma do SO; sem correspondência, cai no primeiro da lista.
- Referência de idiomas NSIS: NSIS “Language files/PortugueseBR”.
- Modo de instalação: Program Files (requer elevação/UAC).
- Arquivo: `apps/desktop/src-tauri/tauri.conf.json:56``"installMode": "perMachine"`
- Alternativas: `"currentUser"` (padrão) ou `"both"` (usuário escolhe; exige UAC).
Build rápido e leve em dev:
```bash
bun run --cwd apps/desktop tauri build --bundles nsis
```
Assinatura do updater (opcional em dev):
```powershell
$privB64 = '<COLE_SUA_CHAVE_PRIVADA_EM_BASE64>'
$env:TAURI_SIGNING_PRIVATE_KEY = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($privB64))
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = 'SENHA_AQUI'
bun run --cwd apps/desktop tauri build --bundles nsis
```
Saída dos pacotes: `apps/desktop/src-tauri/target/release/bundle/` (AppImage/deb/msi/dmg conforme SO).
## Prérequisitos Tauri
- Rust toolchain instalado.
@ -65,7 +36,7 @@ bun run --cwd apps/desktop tauri build --bundles nsis
Consulte https://tauri.app/start/prerequisites/
## Fluxo (resumo)
1) Ao abrir, o app coleta o perfil da dispositivo e exibe um resumo.
1) Ao abrir, o app coleta o perfil da máquina e exibe um resumo.
2) Informe o “código de provisionamento” (chave definida no servidor) e confirme.
3) O servidor retorna um `machineToken`; o app salva e inicia o heartbeat.
4) O app abre `APP_URL/machines/handshake?token=...` no WebView para autenticar a sessão na UI.

View file

@ -159,11 +159,10 @@
## 5. Gerar chaves do updater Tauri
1. Em qualquer dispositivo com Bun instalado (pode ser seu computador local):
1. Em qualquer máquina com Node/pnpm (pode ser seu computador local):
```bash
bun install
bun install --cwd apps/desktop
bun run --cwd apps/desktop tauri signer generate
pnpm install
pnpm --filter appsdesktop tauri signer generate
```
2. O comando gera:
- Chave privada (`tauri.private.key`).
@ -237,16 +236,19 @@
1. Baixe e instale os pré-requisitos:
- Git para Windows.
- Bun 1.3+: instale via instalador oficial (`iwr https://bun.sh/install.ps1 | invoke-expression`) e garanta que `bun` esteja no `PATH`.
- Node.js 20 (opcional, caso precise rodar scripts em Node durante o build).
- Node.js 20 (instalação inclui npm).
- Habilite o Corepack: abra o PowerShell como administrador e rode:
```powershell
corepack enable
corepack prepare pnpm@latest --activate
```
- Rust toolchain: https://rustup.rs (instale padrão).
- Visual Studio Build Tools (C++ build tools) ou `Desktop development with C++`.
- WebView2 Runtime (https://developer.microsoft.com/microsoft-edge/webview2/).
2. Opcional: instale as dependências do Tauri rodando uma vez:
```powershell
bun install
bun install --cwd apps/desktop
bun run --cwd apps/desktop tauri info
pnpm install
pnpm --filter appsdesktop tauri info
```
3. No GitHub → *Settings**Actions**Runners**New self-hosted runner* → escolha Windows x64 e copie URL/token.
4. Em `C:\actions-runner` (recomendado):
@ -265,10 +267,10 @@
.\svc start
```
6. Confirme no GitHub que o runner aparece como `online`.
7. Mantenha a dispositivo ligada e conectada durante o período em que o workflow precisa rodar:
7. Mantenha a máquina ligada e conectada durante o período em que o workflow precisa rodar:
- Para releases desktop, o runner só precisa estar ligado enquanto o job `desktop_release` estiver em execução (crie a tag e aguarde o workflow terminar).
- Após a conclusão, você pode desligar o computador até a próxima release.
8. Observação importante: o runner Windows pode ser sua dispositivo pessoal. Garanta apenas que:
8. Observação importante: o runner Windows pode ser sua máquina pessoal. Garanta apenas que:
- Você confia no código que será executado (o runner processa os jobs do repositório).
- O serviço do runner esteja ativo enquanto o workflow rodar (caso desligue o PC, as releases ficam na fila).
- Há espaço em disco suficiente e nenhuma política corporativa bloqueando a instalação dos pré-requisitos.
@ -324,10 +326,14 @@
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Setup pnpm & Node
uses: pnpm/action-setup@v4
with:
bun-version: 1.3.1
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Deploy stack (Docker Swarm)
working-directory: ${{ env.APP_DIR }}
@ -344,16 +350,20 @@
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Setup pnpm & Node
uses: pnpm/action-setup@v4
with:
bun-version: 1.3.1
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install deps
run: bun install --frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Build + Sign + Release (tauri-action)
uses: tauri-apps/tauri-action@v0
@ -379,7 +389,7 @@
target: ${{ env.VPS_UPDATES_DIR }}
overwrite: true
```
2. Ajuste o bloco de deploy conforme seu processo (por exemplo, use `bun run build && pm2 restart` se não usar Docker ou substitua por chamada à API do Portainer caso faça o deploy por lá).
2. Ajuste o bloco de deploy conforme seu processo (por exemplo, use `pnpm build && pm2 restart` se não usar Docker ou substitua por chamada à API do Portainer caso faça o deploy por lá).
3. Faça commit desse arquivo e suba para o GitHub (`git add .github/workflows/ci-cd-web-desktop.yml`, `git commit`, `git push`).
---
@ -419,7 +429,7 @@
- Garanta que o certificado TLS usado pelo Nginx é renovado (p. ex. `certbot renew`).
4. Manter runners:
- VPS: monitore serviço `actions.runner.*`. Reinicie se necessário (`sudo ./svc.sh restart`).
- Windows: mantenha dispositivo ligada e atualizada. Se o serviço parar, abra `services.msc``GitHub Actions Runner` → Start.
- Windows: mantenha máquina ligada e atualizada. Se o serviço parar, abra `services.msc``GitHub Actions Runner` → Start.
---
@ -441,7 +451,7 @@
| Job `desktop_release` falha na etapa `tauri-action` | Toolchain incompleto no Windows | Reinstale Rust, WebView2 e componentes C++ do Visual Studio. |
| Artefatos não chegam à VPS | Caminho incorreto ou chave SSH inválida | Verifique `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY` e se a pasta `/var/www/updates` existe. |
| App não encontra update | URL ou chave pública divergente no `tauri.conf.json` | Confirme que `endpoints` bate com o domínio HTTPS e que `pubkey` é exatamente a chave pública gerada. |
| Runner aparece offline no GitHub | Serviço parado ou dispositivo desligada | VPS: `sudo ./svc.sh status`; Windows: abra `Services` e reinicie o `GitHub Actions Runner`. |
| Runner aparece offline no GitHub | Serviço parado ou máquina desligada | VPS: `sudo ./svc.sh status`; Windows: abra `Services` e reinicie o `GitHub Actions Runner`. |
---

View file

@ -7,21 +7,13 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "node ./scripts/tauri-with-stub.mjs",
"gen:icon": "node ./scripts/build-icon.mjs",
"build:service": "cd service && cargo build --release",
"build:all": "bun run build:service && bun run tauri build"
"tauri": "tauri"
},
"dependencies": {
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2",
"convex": "^1.31.0",
"lucide-react": "^0.544.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
@ -29,8 +21,6 @@
"devDependencies": {
"@tauri-apps/cli": "^2",
"@vitejs/plugin-react": "^4.3.4",
"baseline-browser-mapping": "^2.9.2",
"png-to-ico": "^3.0.1",
"typescript": "~5.6.2",
"vite": "^6.0.3"
}

View file

@ -1,11 +0,0 @@
{
"version": "0.1.6",
"notes": "Correções e melhorias do desktop",
"pub_date": "2025-10-14T12:00:00Z",
"platforms": {
"windows-x86_64": {
"signature": "ZFc1MGNuVnpkR1ZrSUdOdmJXMWxiblE2SUhOcFoyNWhkSFZ5WlNCbWNtOXRJSFJoZFhKcElITmxZM0psZENCclpYa0tVbFZVZDNFeFUwRlJRalJVUjJOU1NqUnpTVmhXU1ZoeVUwZElNSGxETW5KSE1FTnBWa3BWU1dzelVYVlRNV1JTV0Vrdk1XMUZVa0Z3YTBWc2QySnZhVnBxUWs5bVoyODNNbEZaYUZsMFVHTlRLMUFyT0hJMVdGZ3lWRkZYT1V3ekwzZG5QUXAwY25WemRHVmtJR052YlcxbGJuUTZJSFJwYldWemRHRnRjRG94TnpZd016azVOVEkzQ1dacGJHVTZVbUYyWlc1Zk1DNHhMalZmZURZMExYTmxkSFZ3TG1WNFpRcHdkME15THpOVlZtUXpiSG9yZGpRd1pFZHFhV1JvVkZCb0wzVnNabWh1ZURJdmFtUlZOalEwTkRSVVdVY3JUVGhLTUdrNU5scFNUSFZVWkRsc1lYVTJUR2dyWTNWeWJuWTVhRGh3ZVVnM1dFWjVhSFZDUVQwOUNnPT0=",
"url": "https://github.com/esdrasrenan/sistema-de-chamados/raw/main/apps/desktop/public/releases/Raven_0.1.6_x64-setup.exe"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

View file

@ -1 +0,0 @@
dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVUd3ExU0FRQjRUR2NSSjRzSVhWSVhyU0dIMHlDMnJHMENpVkpVSWszUXVTMWRSWEkvMW1FUkFwa0Vsd2JvaVpqQk9mZ283MlFZaFl0UGNTK1ArOHI1WFgyVFFXOUwzL3dnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzYwMzk5NTI3CWZpbGU6UmF2ZW5fMC4xLjVfeDY0LXNldHVwLmV4ZQpwd0MyLzNVVmQzbHordjQwZEdqaWRoVFBoL3VsZmhueDIvamRVNjQ0NDRUWUcrTThKMGk5NlpSTHVUZDlsYXU2TGgrY3VybnY5aDhweUg3WEZ5aHVCQT09Cg==

View file

@ -1,38 +0,0 @@
#!/usr/bin/env node
import { promises as fs } from 'node:fs'
import path from 'node:path'
import pngToIco from 'png-to-ico'
async function fileExists(p) {
try { await fs.access(p); return true } catch { return false }
}
async function main() {
const root = path.resolve(process.cwd(), 'src-tauri', 'icons')
// Inclua apenas tamanhos suportados pelo NSIS (até 256px).
// Evite 512px para não gerar ICO inválido para o instalador.
const candidates = [
'icon-256.png', // preferencial
'128x128@2x.png', // alias de 256
'icon-128.png',
'icon-64.png',
'icon-32.png',
]
const sources = []
for (const name of candidates) {
const p = path.join(root, name)
if (await fileExists(p)) sources.push(p)
}
if (sources.length === 0) {
console.error('[gen:icon] Nenhuma imagem base encontrada em src-tauri/icons')
process.exit(1)
}
console.log('[gen:icon] Gerando icon.ico a partir de:', sources.map((s) => path.basename(s)).join(', '))
const buffer = await pngToIco(sources)
const outPath = path.join(root, 'icon.ico')
await fs.writeFile(outPath, buffer)
console.log('[gen:icon] Escrito:', outPath)
}
main().catch((err) => { console.error(err); process.exit(1) })

View file

@ -1,237 +0,0 @@
#!/usr/bin/env python3
"""
Generate icon PNGs/ICO for the desktop installer using the high-resolution Raven artwork.
The script reads the square logo (`logo-raven-fund-azul.png`) and resizes it to the
target sizes with a simple bilinear filter implemented with the Python standard library,
avoiding additional dependencies.
"""
from __future__ import annotations
import math
import struct
import zlib
from binascii import crc32
from pathlib import Path
ICON_DIR = Path(__file__).resolve().parents[1] / "src-tauri" / "icons"
BASE_IMAGE = ICON_DIR / "logo-raven-fund-azul.png"
TARGET_SIZES = [32, 64, 128, 256, 512]
def read_png(path: Path) -> tuple[int, int, list[list[tuple[int, int, int, int]]]]:
data = path.read_bytes()
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
raise ValueError(f"{path} is not a PNG")
pos = 8
width = height = bit_depth = color_type = None
compressed_parts = []
while pos < len(data):
length = struct.unpack(">I", data[pos : pos + 4])[0]
pos += 4
ctype = data[pos : pos + 4]
pos += 4
chunk = data[pos : pos + length]
pos += length
pos += 4 # CRC
if ctype == b"IHDR":
width, height, bit_depth, color_type, _, _, _ = struct.unpack(">IIBBBBB", chunk)
if bit_depth != 8 or color_type not in (2, 6):
raise ValueError("Only 8-bit RGB/RGBA PNGs are supported")
elif ctype == b"IDAT":
compressed_parts.append(chunk)
elif ctype == b"IEND":
break
if width is None or height is None or bit_depth is None or color_type is None:
raise ValueError("PNG missing IHDR chunk")
raw = zlib.decompress(b"".join(compressed_parts))
bpp = 4 if color_type == 6 else 3
stride = width * bpp
rows = []
idx = 0
prev = bytearray(stride)
for _ in range(height):
filter_type = raw[idx]
idx += 1
row = bytearray(raw[idx : idx + stride])
idx += stride
if filter_type == 1:
for i in range(stride):
left = row[i - bpp] if i >= bpp else 0
row[i] = (row[i] + left) & 0xFF
elif filter_type == 2:
for i in range(stride):
row[i] = (row[i] + prev[i]) & 0xFF
elif filter_type == 3:
for i in range(stride):
left = row[i - bpp] if i >= bpp else 0
up = prev[i]
row[i] = (row[i] + ((left + up) // 2)) & 0xFF
elif filter_type == 4:
for i in range(stride):
left = row[i - bpp] if i >= bpp else 0
up = prev[i]
up_left = prev[i - bpp] if i >= bpp else 0
p = left + up - up_left
pa = abs(p - left)
pb = abs(p - up)
pc = abs(p - up_left)
if pa <= pb and pa <= pc:
pr = left
elif pb <= pc:
pr = up
else:
pr = up_left
row[i] = (row[i] + pr) & 0xFF
elif filter_type not in (0,):
raise ValueError(f"Unsupported PNG filter type {filter_type}")
rows.append(bytes(row))
prev[:] = row
pixels: list[list[tuple[int, int, int, int]]] = []
for row in rows:
if color_type == 6:
pixels.append([tuple(row[i : i + 4]) for i in range(0, len(row), 4)])
else:
pixels.append([tuple(row[i : i + 3] + b"\xff") for i in range(0, len(row), 3)])
return width, height, pixels
def write_png(path: Path, width: int, height: int, pixels: list[list[tuple[int, int, int, int]]]) -> None:
raw = bytearray()
for row in pixels:
raw.append(0) # filter type 0
for r, g, b, a in row:
raw.extend((r & 0xFF, g & 0xFF, b & 0xFF, a & 0xFF))
compressed = zlib.compress(raw, level=9)
def chunk(name: bytes, payload: bytes) -> bytes:
return (
struct.pack(">I", len(payload))
+ name
+ payload
+ struct.pack(">I", crc32(name + payload) & 0xFFFFFFFF)
)
ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
out = bytearray(b"\x89PNG\r\n\x1a\n")
out += chunk(b"IHDR", ihdr)
out += chunk(b"IDAT", compressed)
out += chunk(b"IEND", b"")
path.write_bytes(out)
def bilinear_sample(pixels: list[list[tuple[int, int, int, int]]], x: float, y: float) -> tuple[int, int, int, int]:
height = len(pixels)
width = len(pixels[0])
x = min(max(x, 0.0), width - 1.0)
y = min(max(y, 0.0), height - 1.0)
x0 = int(math.floor(x))
y0 = int(math.floor(y))
x1 = min(x0 + 1, width - 1)
y1 = min(y0 + 1, height - 1)
dx = x - x0
dy = y - y0
def lerp(a: float, b: float, t: float) -> float:
return a + (b - a) * t
result = []
for channel in range(4):
c00 = pixels[y0][x0][channel]
c10 = pixels[y0][x1][channel]
c01 = pixels[y1][x0][channel]
c11 = pixels[y1][x1][channel]
top = lerp(c00, c10, dx)
bottom = lerp(c01, c11, dx)
result.append(int(round(lerp(top, bottom, dy))))
return tuple(result)
def resize_image(pixels: list[list[tuple[int, int, int, int]]], target: int) -> list[list[tuple[int, int, int, int]]]:
src_height = len(pixels)
src_width = len(pixels[0])
scale = min(target / src_width, target / src_height)
dest_width = max(1, int(round(src_width * scale)))
dest_height = max(1, int(round(src_height * scale)))
offset_x = (target - dest_width) // 2
offset_y = (target - dest_height) // 2
background = (0, 0, 0, 0)
canvas = [[background for _ in range(target)] for _ in range(target)]
for dy in range(dest_height):
src_y = (dy + 0.5) / scale - 0.5
for dx in range(dest_width):
src_x = (dx + 0.5) / scale - 0.5
canvas[offset_y + dy][offset_x + dx] = bilinear_sample(pixels, src_x, src_y)
return canvas
def build_ico(output: Path, png_paths: list[Path]) -> None:
entries = []
offset = 6 + 16 * len(png_paths)
for path in png_paths:
data = path.read_bytes()
width, height, _ = read_png(path)
entries.append(
{
"width": width if width < 256 else 0,
"height": height if height < 256 else 0,
"size": len(data),
"offset": offset,
"payload": data,
}
)
offset += len(data)
header = struct.pack("<HHH", 0, 1, len(entries))
body = bytearray(header)
for entry in entries:
body.extend(
struct.pack(
"<BBBBHHII",
entry["width"],
entry["height"],
0,
0,
1,
32,
entry["size"],
entry["offset"],
)
)
for entry in entries:
body.extend(entry["payload"])
output.write_bytes(body)
def main() -> None:
width, height, pixels = read_png(BASE_IMAGE)
if width != height:
raise ValueError("Base icon must be square")
generated: list[Path] = []
for size in TARGET_SIZES:
resized = resize_image(pixels, size)
out_path = ICON_DIR / f"icon-{size}.png"
write_png(out_path, size, size, resized)
generated.append(out_path)
print(f"Generated {out_path} ({size}x{size})")
largest = max(generated, key=lambda p: int(p.stem.split("-")[-1]))
(ICON_DIR / "icon.png").write_bytes(largest.read_bytes())
ico_sources = sorted(
[p for p in generated if int(p.stem.split("-")[-1]) <= 256],
key=lambda p: int(p.stem.split("-")[-1]),
)
build_ico(ICON_DIR / "icon.ico", ico_sources)
print("icon.ico rebuilt.")
if __name__ == "__main__":
main()

View file

@ -1,239 +0,0 @@
#!/usr/bin/env python3
"""
Utility script to convert a PNG file (non-interlaced, 8-bit RGBA/RGB)
into a 24-bit BMP with optional letterboxing resize.
The script is intentionally lightweight and relies only on Python's
standard library so it can run in constrained build environments.
"""
from __future__ import annotations
import argparse
import struct
import sys
import zlib
from pathlib import Path
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def parse_png(path: Path):
data = path.read_bytes()
if not data.startswith(PNG_SIGNATURE):
raise ValueError("Input is not a PNG file")
idx = len(PNG_SIGNATURE)
width = height = bit_depth = color_type = None
compressed = bytearray()
interlaced = False
while idx < len(data):
if idx + 8 > len(data):
raise ValueError("Corrupted PNG (unexpected EOF)")
length = struct.unpack(">I", data[idx : idx + 4])[0]
idx += 4
chunk_type = data[idx : idx + 4]
idx += 4
chunk_data = data[idx : idx + length]
idx += length
crc = data[idx : idx + 4] # noqa: F841 - crc skipped (validated by reader)
idx += 4
if chunk_type == b"IHDR":
width, height, bit_depth, color_type, compression, filter_method, interlace = struct.unpack(
">IIBBBBB", chunk_data
)
if compression != 0 or filter_method != 0:
raise ValueError("Unsupported PNG compression/filter method")
interlaced = interlace != 0
elif chunk_type == b"IDAT":
compressed.extend(chunk_data)
elif chunk_type == b"IEND":
break
if interlaced:
raise ValueError("Interlaced PNGs are not supported by this script")
if bit_depth != 8:
raise ValueError(f"Unsupported bit depth: {bit_depth}")
if color_type not in (2, 6):
raise ValueError(f"Unsupported color type: {color_type}")
raw = zlib.decompress(bytes(compressed))
bytes_per_pixel = 3 if color_type == 2 else 4
stride = width * bytes_per_pixel
expected = (stride + 1) * height
if len(raw) != expected:
raise ValueError("Corrupted PNG data")
# Apply PNG scanline filters
image = bytearray(width * height * 4) # Force RGBA output
prev_row = [0] * (stride)
def paeth(a, b, c):
p = a + b - c
pa = abs(p - a)
pb = abs(p - b)
pc = abs(p - c)
if pa <= pb and pa <= pc:
return a
if pb <= pc:
return b
return c
out_idx = 0
for y in range(height):
offset = y * (stride + 1)
filter_type = raw[offset]
row = bytearray(raw[offset + 1 : offset + 1 + stride])
if filter_type == 1: # Sub
for i in range(stride):
left = row[i - bytes_per_pixel] if i >= bytes_per_pixel else 0
row[i] = (row[i] + left) & 0xFF
elif filter_type == 2: # Up
for i in range(stride):
row[i] = (row[i] + prev_row[i]) & 0xFF
elif filter_type == 3: # Average
for i in range(stride):
left = row[i - bytes_per_pixel] if i >= bytes_per_pixel else 0
up = prev_row[i]
row[i] = (row[i] + ((left + up) >> 1)) & 0xFF
elif filter_type == 4: # Paeth
for i in range(stride):
left = row[i - bytes_per_pixel] if i >= bytes_per_pixel else 0
up = prev_row[i]
up_left = prev_row[i - bytes_per_pixel] if i >= bytes_per_pixel else 0
row[i] = (row[i] + paeth(left, up, up_left)) & 0xFF
elif filter_type != 0:
raise ValueError(f"Unsupported PNG filter type: {filter_type}")
# Convert to RGBA
for x in range(width):
if color_type == 2:
r, g, b = row[x * 3 : x * 3 + 3]
a = 255
else:
r, g, b, a = row[x * 4 : x * 4 + 4]
image[out_idx : out_idx + 4] = bytes((r, g, b, a))
out_idx += 4
prev_row = list(row)
return width, height, image
def resize_with_letterbox(image, width, height, target_w, target_h, background, scale_factor=1.0):
if width == target_w and height == target_h and abs(scale_factor - 1.0) < 1e-6:
return image, width, height
bg_r, bg_g, bg_b = background
base_scale = min(target_w / width, target_h / height)
base_scale *= scale_factor
base_scale = max(base_scale, 1 / max(width, height)) # avoid zero / collapse
scaled_w = max(1, int(round(width * base_scale)))
scaled_h = max(1, int(round(height * base_scale)))
output = bytearray(target_w * target_h * 4)
# Fill background
for i in range(0, len(output), 4):
output[i : i + 4] = bytes((bg_r, bg_g, bg_b, 255))
offset_x = (target_w - scaled_w) // 2
offset_y = (target_h - scaled_h) // 2
for y in range(scaled_h):
src_y = min(height - 1, int(round(y / base_scale)))
for x in range(scaled_w):
src_x = min(width - 1, int(round(x / base_scale)))
src_idx = (src_y * width + src_x) * 4
dst_idx = ((y + offset_y) * target_w + (x + offset_x)) * 4
output[dst_idx : dst_idx + 4] = image[src_idx : src_idx + 4]
return output, target_w, target_h
def blend_to_rgb(image):
rgb = bytearray(len(image) // 4 * 3)
for i in range(0, len(image), 4):
r, g, b, a = image[i : i + 4]
if a == 255:
rgb[(i // 4) * 3 : (i // 4) * 3 + 3] = bytes((b, g, r)) # BMP stores BGR
else:
alpha = a / 255.0
bg = (255, 255, 255)
rr = int(round(r * alpha + bg[0] * (1 - alpha)))
gg = int(round(g * alpha + bg[1] * (1 - alpha)))
bb = int(round(b * alpha + bg[2] * (1 - alpha)))
rgb[(i // 4) * 3 : (i // 4) * 3 + 3] = bytes((bb, gg, rr))
return rgb
def write_bmp(path: Path, width: int, height: int, rgb: bytearray):
row_stride = (width * 3 + 3) & ~3 # align to 4 bytes
padding = row_stride - width * 3
pixel_data = bytearray()
for y in range(height - 1, -1, -1):
start = y * width * 3
end = start + width * 3
pixel_data.extend(rgb[start:end])
if padding:
pixel_data.extend(b"\0" * padding)
file_size = 14 + 40 + len(pixel_data)
header = struct.pack("<2sIHHI", b"BM", file_size, 0, 0, 14 + 40)
dib_header = struct.pack(
"<IIIHHIIIIII",
40, # header size
width,
height,
1, # planes
24, # bits per pixel
0, # compression
len(pixel_data),
2835, # horizontal resolution (px/m ~72dpi)
2835, # vertical resolution
0,
0,
)
path.write_bytes(header + dib_header + pixel_data)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--width", type=int, help="Target width (px)")
parser.add_argument("--height", type=int, help="Target height (px)")
parser.add_argument(
"--scale",
type=float,
default=1.0,
help="Optional multiplier applied to the fitted image size (e.g. 0.7 adds padding).",
)
parser.add_argument(
"--background",
type=str,
default="FFFFFF",
help="Background hex color used for transparent pixels (default: FFFFFF)",
)
args = parser.parse_args()
try:
width, height, image = parse_png(args.input)
if args.width and args.height:
bg = tuple(int(args.background[i : i + 2], 16) for i in (0, 2, 4))
image, width, height = resize_with_letterbox(
image, width, height, args.width, args.height, bg, max(args.scale, 0.05)
)
rgb = blend_to_rgb(image)
write_bmp(args.output, width, height, rgb)
except Exception as exc: # noqa: BLE001
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -1,80 +0,0 @@
#!/usr/bin/env python3
"""
Utility to build an .ico file from a list of PNGs of different sizes.
Uses only Python's standard library so it can run in restricted environments.
"""
from __future__ import annotations
import argparse
import struct
from pathlib import Path
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def read_png_dimensions(data: bytes) -> tuple[int, int]:
if not data.startswith(PNG_SIGNATURE):
raise ValueError("All inputs must be PNG files.")
width, height = struct.unpack(">II", data[16:24])
return width, height
def build_icon(png_paths: list[Path], output: Path) -> None:
png_data = [p.read_bytes() for p in png_paths]
entries = []
offset = 6 + 16 * len(png_data) # icon header + entries
for data in png_data:
width, height = read_png_dimensions(data)
entry = {
"width": width if width < 256 else 0,
"height": height if height < 256 else 0,
"colors": 0,
"reserved": 0,
"planes": 1,
"bit_count": 32,
"size": len(data),
"offset": offset,
"data": data,
}
entries.append(entry)
offset += entry["size"]
header = struct.pack("<HHH", 0, 1, len(entries))
table = bytearray()
for entry in entries:
table.extend(
struct.pack(
"<BBBBHHII",
entry["width"],
entry["height"],
entry["colors"],
entry["reserved"],
entry["planes"],
entry["bit_count"],
entry["size"],
entry["offset"],
)
)
payload = header + table + b"".join(entry["data"] for entry in entries)
output.write_bytes(payload)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("output", type=Path)
parser.add_argument("inputs", nargs="+", type=Path)
args = parser.parse_args()
if not args.inputs:
raise SystemExit("Provide at least one PNG input.")
build_icon(args.inputs, args.output)
if __name__ == "__main__":
main()

View file

@ -1,56 +0,0 @@
import { spawn } from "node:child_process"
import { fileURLToPath } from "node:url"
import { dirname, resolve } from "node:path"
import { existsSync } from "node:fs"
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const appRoot = resolve(__dirname, "..")
const pathKey = process.platform === "win32" ? "Path" : "PATH"
const currentPath = process.env[pathKey] ?? process.env[pathKey.toUpperCase()] ?? ""
const separator = process.platform === "win32" ? ";" : ":"
const stubDir = resolve(__dirname)
process.env[pathKey] = [stubDir, currentPath].filter(Boolean).join(separator)
if (pathKey !== "PATH") {
process.env.PATH = process.env[pathKey]
}
if (!process.env.TAURI_BUNDLE_TARGETS) {
if (process.platform === "linux") {
process.env.TAURI_BUNDLE_TARGETS = "deb rpm"
} else if (process.platform === "win32") {
process.env.TAURI_BUNDLE_TARGETS = "nsis"
}
}
// Assinatura: fallback seguro para builds locais/CI. Em prod, pode sobrescrever por env.
if (!process.env.TAURI_SIGNING_PRIVATE_KEY) {
process.env.TAURI_SIGNING_PRIVATE_KEY =
"dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5WkhWOUtzd1BvV0ZlSjEvNzYwaHYxdEloNnV4cmZlNGhha1BNbmNtZEkrZ0FBQkFBQUFBQUFBQUFBQUlBQUFBQS9JbCtsd3VFbHN4empFRUNiU0dva1hKK3ZYUzE2S1V6Q1FhYkRUWGtGMTBkUmJodi9PaXVub3hEMisyTXJoYU5UeEdwZU9aMklacG9ualNWR1NaTm1PMVBpVXYrNTltZU1YOFdwYzdkOHd2STFTc0x4ZktpNXFENnFTdW0xNzY3WC9EcGlIRGFmK2c9Cg=="
}
if (!process.env.TAURI_SIGNING_PRIVATE_KEY_PASSWORD) {
process.env.TAURI_SIGNING_PRIVATE_KEY_PASSWORD = "revertech"
}
const winTauriPath = resolve(appRoot, "node_modules", ".bin", "tauri.cmd")
const usingWinTauri = process.platform === "win32" && existsSync(winTauriPath)
const executable = process.platform === "win32" && usingWinTauri ? "cmd.exe" : "tauri"
const args =
process.platform === "win32" && usingWinTauri
? ["/C", winTauriPath, ...process.argv.slice(2)]
: process.argv.slice(2)
const child = spawn(executable, args, {
stdio: "inherit",
shell: false,
cwd: appRoot,
})
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
} else {
process.exit(code ?? 0)
}
})

View file

@ -1,9 +0,0 @@
#!/usr/bin/env bash
# Minimal stub to satisfy tools that expect xdg-open during bundling.
# Fails silently when the real binary is unavailable.
if command -v xdg-open >/dev/null 2>&1; then
exec xdg-open "$@"
else
exit 0
fi

File diff suppressed because it is too large Load diff

View file

@ -1,70 +0,0 @@
[package]
name = "raven-service"
version = "0.1.0"
description = "Raven Windows Service - Executa operacoes privilegiadas para o Raven Desktop"
authors = ["Esdras Renan"]
edition = "2021"
[[bin]]
name = "raven-service"
path = "src/main.rs"
[dependencies]
# Windows Service
windows-service = "0.7"
# Async runtime
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "net", "signal"] }
# IPC via Named Pipes
interprocess = { version = "2", features = ["tokio"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Windows Registry
winreg = "0.55"
# Error handling
thiserror = "1.0"
# HTTP client (para RustDesk)
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false }
# Date/time
chrono = { version = "0.4", features = ["serde"] }
# Crypto (para RustDesk ID)
sha2 = "0.10"
# UUID para request IDs
uuid = { version = "1", features = ["v4"] }
# Parking lot para locks
parking_lot = "0.12"
# Once cell para singletons
once_cell = "1.19"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_System_Services",
"Win32_System_Threading",
"Win32_System_Pipes",
"Win32_System_IO",
"Win32_System_SystemServices",
"Win32_Storage_FileSystem",
] }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true

View file

@ -1,290 +0,0 @@
//! Modulo IPC - Servidor de Named Pipes
//!
//! Implementa comunicacao entre o Raven UI e o Raven Service
//! usando Named Pipes do Windows com protocolo JSON-RPC simplificado.
use crate::{rustdesk, usb_policy};
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader, Write};
use thiserror::Error;
use tracing::{debug, info, warn};
#[derive(Debug, Error)]
pub enum IpcError {
#[error("Erro de IO: {0}")]
Io(#[from] std::io::Error),
#[error("Erro de serializacao: {0}")]
Json(#[from] serde_json::Error),
}
/// Requisicao JSON-RPC simplificada
#[derive(Debug, Deserialize)]
pub struct Request {
pub id: String,
pub method: String,
#[serde(default)]
pub params: serde_json::Value,
}
/// Resposta JSON-RPC simplificada
#[derive(Debug, Serialize)]
pub struct Response {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorResponse>,
}
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub code: i32,
pub message: String,
}
impl Response {
pub fn success(id: String, result: serde_json::Value) -> Self {
Self {
id,
result: Some(result),
error: None,
}
}
pub fn error(id: String, code: i32, message: String) -> Self {
Self {
id,
result: None,
error: Some(ErrorResponse { code, message }),
}
}
}
/// Inicia o servidor de Named Pipes
pub async fn run_server(pipe_name: &str) -> Result<(), IpcError> {
info!("Iniciando servidor IPC em: {}", pipe_name);
loop {
match accept_connection(pipe_name).await {
Ok(()) => {
debug!("Conexao processada com sucesso");
}
Err(e) => {
warn!("Erro ao processar conexao: {}", e);
}
}
}
}
/// Aceita uma conexao e processa requisicoes
async fn accept_connection(pipe_name: &str) -> Result<(), IpcError> {
use windows::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows::Win32::Security::{
InitializeSecurityDescriptor, SetSecurityDescriptorDacl,
PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR,
};
use windows::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX;
use windows::Win32::System::Pipes::{
ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe,
PIPE_READMODE_MESSAGE, PIPE_TYPE_MESSAGE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT,
};
use windows::Win32::System::SystemServices::SECURITY_DESCRIPTOR_REVISION;
use windows::core::PCWSTR;
// Cria o named pipe com seguranca que permite acesso a todos os usuarios
let pipe_name_wide: Vec<u16> = pipe_name.encode_utf16().chain(std::iter::once(0)).collect();
// Cria security descriptor com DACL nulo (permite acesso a todos)
let mut sd = SECURITY_DESCRIPTOR::default();
unsafe {
let sd_ptr = PSECURITY_DESCRIPTOR(&mut sd as *mut _ as *mut _);
let _ = InitializeSecurityDescriptor(sd_ptr, SECURITY_DESCRIPTOR_REVISION);
// DACL nulo = acesso irrestrito
let _ = SetSecurityDescriptorDacl(sd_ptr, true, None, false);
}
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: &mut sd as *mut _ as *mut _,
bInheritHandle: false.into(),
};
let pipe_handle = unsafe {
CreateNamedPipeW(
PCWSTR::from_raw(pipe_name_wide.as_ptr()),
PIPE_ACCESS_DUPLEX,
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
PIPE_UNLIMITED_INSTANCES,
4096, // out buffer
4096, // in buffer
0, // default timeout
Some(&sa), // seguranca permissiva
)
};
// Verifica se o handle e valido
if pipe_handle == INVALID_HANDLE_VALUE {
return Err(IpcError::Io(std::io::Error::last_os_error()));
}
// Aguarda conexao de um cliente
info!("Aguardando conexao de cliente...");
let connect_result = unsafe {
ConnectNamedPipe(pipe_handle, None)
};
if let Err(e) = connect_result {
// ERROR_PIPE_CONNECTED (535) significa que o cliente ja estava conectado
// o que e aceitavel
let error_code = e.code().0 as u32;
if error_code != 535 {
warn!("Erro ao aguardar conexao: {:?}", e);
}
}
info!("Cliente conectado");
// Processa requisicoes do cliente
let result = process_client(pipe_handle);
// Desconecta o cliente
unsafe {
let _ = DisconnectNamedPipe(pipe_handle);
}
result
}
/// Processa requisicoes de um cliente conectado
fn process_client(pipe_handle: windows::Win32::Foundation::HANDLE) -> Result<(), IpcError> {
use std::os::windows::io::{FromRawHandle, RawHandle};
use std::fs::File;
// Cria File handle a partir do pipe
let raw_handle = pipe_handle.0 as RawHandle;
let file = unsafe { File::from_raw_handle(raw_handle) };
let reader = BufReader::new(file.try_clone()?);
let mut writer = file;
// Le linhas (cada linha e uma requisicao JSON)
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
if e.kind() == std::io::ErrorKind::BrokenPipe {
info!("Cliente desconectou");
break;
}
return Err(e.into());
}
};
if line.is_empty() {
continue;
}
debug!("Requisicao recebida: {}", line);
// Parse da requisicao
let response = match serde_json::from_str::<Request>(&line) {
Ok(request) => handle_request(request),
Err(e) => Response::error(
"unknown".to_string(),
-32700,
format!("Parse error: {}", e),
),
};
// Serializa e envia resposta
let response_json = serde_json::to_string(&response)?;
debug!("Resposta: {}", response_json);
writeln!(writer, "{}", response_json)?;
writer.flush()?;
}
// IMPORTANTE: Nao fechar o handle aqui, pois DisconnectNamedPipe precisa dele
std::mem::forget(writer);
Ok(())
}
/// Processa uma requisicao e retorna a resposta
fn handle_request(request: Request) -> Response {
info!("Processando metodo: {}", request.method);
match request.method.as_str() {
"health_check" => handle_health_check(request.id),
"apply_usb_policy" => handle_apply_usb_policy(request.id, request.params),
"get_usb_policy" => handle_get_usb_policy(request.id),
"provision_rustdesk" => handle_provision_rustdesk(request.id, request.params),
"get_rustdesk_status" => handle_get_rustdesk_status(request.id),
_ => Response::error(
request.id,
-32601,
format!("Metodo nao encontrado: {}", request.method),
),
}
}
// =============================================================================
// Handlers de Requisicoes
// =============================================================================
fn handle_health_check(id: String) -> Response {
Response::success(
id,
serde_json::json!({
"status": "ok",
"service": "RavenService",
"version": env!("CARGO_PKG_VERSION"),
"timestamp": chrono::Utc::now().timestamp_millis()
}),
)
}
fn handle_apply_usb_policy(id: String, params: serde_json::Value) -> Response {
let policy = match params.get("policy").and_then(|p| p.as_str()) {
Some(p) => p,
None => {
return Response::error(id, -32602, "Parametro 'policy' e obrigatorio".to_string())
}
};
match usb_policy::apply_policy(policy) {
Ok(result) => Response::success(id, serde_json::to_value(result).unwrap()),
Err(e) => Response::error(id, -32000, format!("Erro ao aplicar politica: {}", e)),
}
}
fn handle_get_usb_policy(id: String) -> Response {
match usb_policy::get_current_policy() {
Ok(policy) => Response::success(
id,
serde_json::json!({
"policy": policy
}),
),
Err(e) => Response::error(id, -32000, format!("Erro ao obter politica: {}", e)),
}
}
fn handle_provision_rustdesk(id: String, params: serde_json::Value) -> Response {
let config_string = params.get("config").and_then(|c| c.as_str()).map(String::from);
let password = params.get("password").and_then(|p| p.as_str()).map(String::from);
let machine_id = params.get("machineId").and_then(|m| m.as_str()).map(String::from);
match rustdesk::ensure_rustdesk(config_string.as_deref(), password.as_deref(), machine_id.as_deref()) {
Ok(result) => Response::success(id, serde_json::to_value(result).unwrap()),
Err(e) => Response::error(id, -32000, format!("Erro ao provisionar RustDesk: {}", e)),
}
}
fn handle_get_rustdesk_status(id: String) -> Response {
match rustdesk::get_status() {
Ok(status) => Response::success(id, serde_json::to_value(status).unwrap()),
Err(e) => Response::error(id, -32000, format!("Erro ao obter status: {}", e)),
}
}

View file

@ -1,268 +0,0 @@
//! Raven Service - Servico Windows para operacoes privilegiadas
//!
//! Este servico roda como LocalSystem e executa operacoes que requerem
//! privilegios de administrador, como:
//! - Aplicar politicas de USB
//! - Provisionar e configurar RustDesk
//! - Modificar chaves de registro em HKEY_LOCAL_MACHINE
//!
//! O app Raven UI comunica com este servico via Named Pipes.
mod ipc;
mod rustdesk;
mod usb_policy;
use std::ffi::OsString;
use std::time::Duration;
use tracing::{error, info};
use windows_service::{
define_windows_service,
service::{
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
ServiceType,
},
service_control_handler::{self, ServiceControlHandlerResult},
service_dispatcher,
};
const SERVICE_NAME: &str = "RavenService";
const SERVICE_DISPLAY_NAME: &str = "Raven Desktop Service";
const SERVICE_DESCRIPTION: &str = "Servico do Raven Desktop para operacoes privilegiadas (USB, RustDesk)";
const PIPE_NAME: &str = r"\\.\pipe\RavenService";
define_windows_service!(ffi_service_main, service_main);
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Configura logging
init_logging();
// Verifica argumentos de linha de comando
let args: Vec<String> = std::env::args().collect();
if args.len() > 1 {
match args[1].as_str() {
"install" => {
install_service()?;
return Ok(());
}
"uninstall" => {
uninstall_service()?;
return Ok(());
}
"run" => {
// Modo de teste: roda sem registrar como servico
info!("Executando em modo de teste (nao como servico)");
run_standalone()?;
return Ok(());
}
_ => {}
}
}
// Inicia como servico Windows
info!("Iniciando Raven Service...");
service_dispatcher::start(SERVICE_NAME, ffi_service_main)?;
Ok(())
}
fn init_logging() {
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
// Tenta criar diretorio de logs
let log_dir = std::env::var("PROGRAMDATA")
.map(|p| std::path::PathBuf::from(p).join("RavenService").join("logs"))
.unwrap_or_else(|_| std::path::PathBuf::from("C:\\ProgramData\\RavenService\\logs"));
let _ = std::fs::create_dir_all(&log_dir);
// Arquivo de log
let log_file = log_dir.join("service.log");
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_file)
.ok();
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info"));
if let Some(file) = file {
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().with_writer(file).with_ansi(false))
.init();
} else {
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.init();
}
}
fn service_main(arguments: Vec<OsString>) {
if let Err(e) = run_service(arguments) {
error!("Erro ao executar servico: {}", e);
}
}
fn run_service(_arguments: Vec<OsString>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Servico iniciando...");
// Canal para shutdown
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let shutdown_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(shutdown_tx)));
// Registra handler de controle do servico
let shutdown_tx_clone = shutdown_tx.clone();
let status_handle = service_control_handler::register(SERVICE_NAME, move |control| {
match control {
ServiceControl::Stop | ServiceControl::Shutdown => {
info!("Recebido comando de parada");
if let Ok(mut guard) = shutdown_tx_clone.lock() {
if let Some(tx) = guard.take() {
let _ = tx.send(());
}
}
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
}
})?;
// Atualiza status para Running
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Running,
controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
info!("Servico em execucao, aguardando conexoes...");
// Cria runtime Tokio
let runtime = tokio::runtime::Runtime::new()?;
// Executa servidor IPC
runtime.block_on(async {
tokio::select! {
result = ipc::run_server(PIPE_NAME) => {
if let Err(e) = result {
error!("Erro no servidor IPC: {}", e);
}
}
_ = async {
let _ = shutdown_rx.await;
} => {
info!("Shutdown solicitado");
}
}
});
// Atualiza status para Stopped
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Stopped,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
info!("Servico parado");
Ok(())
}
fn run_standalone() -> Result<(), Box<dyn std::error::Error>> {
let runtime = tokio::runtime::Runtime::new()?;
runtime.block_on(async {
info!("Servidor IPC iniciando em modo standalone...");
tokio::select! {
result = ipc::run_server(PIPE_NAME) => {
if let Err(e) = result {
error!("Erro no servidor IPC: {}", e);
}
}
_ = tokio::signal::ctrl_c() => {
info!("Ctrl+C recebido, encerrando...");
}
}
});
Ok(())
}
fn install_service() -> Result<(), Box<dyn std::error::Error>> {
use windows_service::{
service::{ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType},
service_manager::{ServiceManager, ServiceManagerAccess},
};
info!("Instalando servico...");
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CREATE_SERVICE)?;
let exe_path = std::env::current_exe()?;
let service_info = ServiceInfo {
name: OsString::from(SERVICE_NAME),
display_name: OsString::from(SERVICE_DISPLAY_NAME),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: exe_path,
launch_arguments: vec![],
dependencies: vec![],
account_name: None, // LocalSystem
account_password: None,
};
let service = manager.create_service(&service_info, ServiceAccess::CHANGE_CONFIG)?;
// Define descricao
service.set_description(SERVICE_DESCRIPTION)?;
info!("Servico instalado com sucesso: {}", SERVICE_NAME);
println!("Servico '{}' instalado com sucesso!", SERVICE_DISPLAY_NAME);
println!("Para iniciar: sc start {}", SERVICE_NAME);
Ok(())
}
fn uninstall_service() -> Result<(), Box<dyn std::error::Error>> {
use windows_service::{
service::ServiceAccess,
service_manager::{ServiceManager, ServiceManagerAccess},
};
info!("Desinstalando servico...");
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let service = manager.open_service(
SERVICE_NAME,
ServiceAccess::STOP | ServiceAccess::DELETE | ServiceAccess::QUERY_STATUS,
)?;
// Tenta parar o servico primeiro
let status = service.query_status()?;
if status.current_state != ServiceState::Stopped {
info!("Parando servico...");
let _ = service.stop();
std::thread::sleep(Duration::from_secs(2));
}
// Remove o servico
service.delete()?;
info!("Servico desinstalado com sucesso");
println!("Servico '{}' removido com sucesso!", SERVICE_DISPLAY_NAME);
Ok(())
}

View file

@ -1,846 +0,0 @@
//! Modulo RustDesk - Provisionamento e gerenciamento do RustDesk
//!
//! Gerencia a instalacao, configuracao e provisionamento do RustDesk.
//! Como o servico roda como LocalSystem, nao precisa de elevacao.
use chrono::Utc;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::env;
use std::ffi::OsStr;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::os::windows::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
use thiserror::Error;
use tracing::{error, info, warn};
const RELEASES_API: &str = "https://api.github.com/repos/rustdesk/rustdesk/releases/latest";
const USER_AGENT: &str = "RavenService/1.0";
const SERVER_HOST: &str = "rust.rever.com.br";
const SERVER_KEY: &str = "0mxocQKmK6GvTZQYKgjrG9tlNkKOqf81gKgqwAmnZuI=";
const DEFAULT_PASSWORD: &str = "FMQ9MA>e73r.FI<b*34Vmx_8P";
const SERVICE_NAME: &str = "RustDesk";
const CACHE_DIR_NAME: &str = "Rever\\RustDeskCache";
const LOCAL_SERVICE_CONFIG: &str = r"C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\RustDesk\config";
const LOCAL_SYSTEM_CONFIG: &str = r"C:\Windows\System32\config\systemprofile\AppData\Roaming\RustDesk\config";
const SECURITY_VERIFICATION_VALUE: &str = "use-permanent-password";
const SECURITY_APPROVE_MODE_VALUE: &str = "password";
const CREATE_NO_WINDOW: u32 = 0x08000000;
static PROVISION_MUTEX: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
#[derive(Debug, Error)]
pub enum RustdeskError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("Release asset nao encontrado para Windows x86_64")]
AssetMissing,
#[error("Falha ao executar comando {command}: status {status:?}")]
CommandFailed { command: String, status: Option<i32> },
#[error("Falha ao detectar ID do RustDesk")]
MissingId,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RustdeskResult {
pub id: String,
pub password: String,
pub installed_version: Option<String>,
pub updated: bool,
pub last_provisioned_at: i64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RustdeskStatus {
pub installed: bool,
pub running: bool,
pub id: Option<String>,
pub version: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ReleaseAsset {
name: String,
browser_download_url: String,
}
#[derive(Debug, Deserialize)]
struct ReleaseResponse {
tag_name: String,
assets: Vec<ReleaseAsset>,
}
/// Provisiona o RustDesk
pub fn ensure_rustdesk(
config_string: Option<&str>,
password_override: Option<&str>,
machine_id: Option<&str>,
) -> Result<RustdeskResult, RustdeskError> {
let _guard = PROVISION_MUTEX.lock();
info!("Iniciando provisionamento do RustDesk");
// Prepara ACLs dos diretorios de servico
if let Err(e) = ensure_service_profiles_writable() {
warn!("Aviso ao preparar ACL: {}", e);
}
// Le ID existente antes de qualquer limpeza
let preserved_remote_id = read_remote_id_from_profiles();
if let Some(ref id) = preserved_remote_id {
info!("ID existente preservado: {}", id);
}
let exe_path = detect_executable_path();
let (installed_version, freshly_installed) = ensure_installed(&exe_path)?;
info!(
"RustDesk {}: {}",
if freshly_installed { "instalado" } else { "ja presente" },
exe_path.display()
);
// Para processos existentes
let _ = stop_rustdesk_processes();
// Limpa perfis apenas se instalacao fresca
if freshly_installed {
let _ = purge_existing_rustdesk_profiles();
}
// Aplica configuracao
if let Some(config) = config_string.filter(|c| !c.trim().is_empty()) {
if let Err(e) = run_with_args(&exe_path, &["--config", config]) {
warn!("Falha ao aplicar config inline: {}", e);
}
} else {
let config_path = write_config_files()?;
if let Err(e) = apply_config(&exe_path, &config_path) {
warn!("Falha ao aplicar config via CLI: {}", e);
}
}
// Define senha
let password = password_override
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
if let Err(e) = set_password(&exe_path, &password) {
warn!("Falha ao definir senha: {}", e);
} else {
let _ = ensure_password_files(&password);
let _ = propagate_password_profile();
}
// Define ID customizado
let custom_id = if let Some(ref existing_id) = preserved_remote_id {
if !freshly_installed {
Some(existing_id.clone())
} else {
define_custom_id(&exe_path, machine_id)
}
} else {
define_custom_id(&exe_path, machine_id)
};
// Inicia servico
if let Err(e) = ensure_service_running(&exe_path) {
warn!("Falha ao iniciar servico: {}", e);
}
// Obtem ID final
let final_id = match query_id_with_retries(&exe_path, 5) {
Ok(id) => id,
Err(_) => {
read_remote_id_from_profiles()
.or_else(|| custom_id.clone())
.ok_or(RustdeskError::MissingId)?
}
};
// Garante ID em todos os arquivos
ensure_remote_id_files(&final_id);
let version = query_version(&exe_path).ok().or(installed_version);
let last_provisioned_at = Utc::now().timestamp_millis();
info!("Provisionamento concluido. ID: {}, Versao: {:?}", final_id, version);
Ok(RustdeskResult {
id: final_id,
password,
installed_version: version,
updated: freshly_installed,
last_provisioned_at,
})
}
/// Retorna status do RustDesk
pub fn get_status() -> Result<RustdeskStatus, RustdeskError> {
let exe_path = detect_executable_path();
let installed = exe_path.exists();
let running = if installed {
query_service_state().map(|s| s == "running").unwrap_or(false)
} else {
false
};
let id = if installed {
query_id(&exe_path).ok().or_else(read_remote_id_from_profiles)
} else {
None
};
let version = if installed {
query_version(&exe_path).ok()
} else {
None
};
Ok(RustdeskStatus {
installed,
running,
id,
version,
})
}
// =============================================================================
// Funcoes Auxiliares
// =============================================================================
fn detect_executable_path() -> PathBuf {
let program_files = env::var("PROGRAMFILES").unwrap_or_else(|_| "C:/Program Files".to_string());
Path::new(&program_files).join("RustDesk").join("rustdesk.exe")
}
fn ensure_installed(exe_path: &Path) -> Result<(Option<String>, bool), RustdeskError> {
if exe_path.exists() {
return Ok((None, false));
}
let cache_root = PathBuf::from(env::var("PROGRAMDATA").unwrap_or_else(|_| "C:/ProgramData".to_string()))
.join(CACHE_DIR_NAME);
fs::create_dir_all(&cache_root)?;
let (installer_path, version_tag) = download_latest_installer(&cache_root)?;
run_installer(&installer_path)?;
thread::sleep(Duration::from_secs(20));
Ok((Some(version_tag), true))
}
fn download_latest_installer(cache_root: &Path) -> Result<(PathBuf, String), RustdeskError> {
let client = Client::builder()
.user_agent(USER_AGENT)
.timeout(Duration::from_secs(60))
.build()?;
let release: ReleaseResponse = client.get(RELEASES_API).send()?.error_for_status()?.json()?;
let asset = release
.assets
.iter()
.find(|a| a.name.ends_with("x86_64.exe"))
.ok_or(RustdeskError::AssetMissing)?;
let target_path = cache_root.join(&asset.name);
if target_path.exists() {
return Ok((target_path, release.tag_name));
}
info!("Baixando RustDesk: {}", asset.name);
let mut response = client.get(&asset.browser_download_url).send()?.error_for_status()?;
let mut output = File::create(&target_path)?;
response.copy_to(&mut output)?;
Ok((target_path, release.tag_name))
}
fn run_installer(installer_path: &Path) -> Result<(), RustdeskError> {
let status = hidden_command(installer_path)
.arg("--silent-install")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
if !status.success() {
return Err(RustdeskError::CommandFailed {
command: format!("{} --silent-install", installer_path.display()),
status: status.code(),
});
}
Ok(())
}
fn program_data_config_dir() -> PathBuf {
PathBuf::from(env::var("PROGRAMDATA").unwrap_or_else(|_| "C:/ProgramData".to_string()))
.join("RustDesk")
.join("config")
}
/// Retorna todos os diretorios AppData\Roaming\RustDesk\config de usuarios do sistema
/// Como o servico roda como LocalSystem, precisamos enumerar os profiles de usuarios
fn all_user_appdata_config_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
// Enumera C:\Users\*\AppData\Roaming\RustDesk\config
let users_dir = Path::new("C:\\Users");
if let Ok(entries) = fs::read_dir(users_dir) {
for entry in entries.flatten() {
let path = entry.path();
// Ignora pastas de sistema
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "Public" || name == "Default" || name == "Default User" || name == "All Users" {
continue;
}
let rustdesk_config = path.join("AppData").join("Roaming").join("RustDesk").join("config");
// Verifica se o diretorio pai existe (usuario real)
if path.join("AppData").join("Roaming").exists() {
dirs.push(rustdesk_config);
}
}
}
// Tambem tenta o APPDATA do ambiente (pode ser util em alguns casos)
if let Ok(appdata) = env::var("APPDATA") {
let path = Path::new(&appdata).join("RustDesk").join("config");
if !dirs.contains(&path) {
dirs.push(path);
}
}
dirs
}
fn service_profile_dirs() -> Vec<PathBuf> {
vec![
PathBuf::from(LOCAL_SERVICE_CONFIG),
PathBuf::from(LOCAL_SYSTEM_CONFIG),
]
}
fn remote_id_directories() -> Vec<PathBuf> {
let mut dirs = Vec::new();
dirs.push(program_data_config_dir());
dirs.extend(service_profile_dirs());
dirs.extend(all_user_appdata_config_dirs());
dirs
}
fn write_config_files() -> Result<PathBuf, RustdeskError> {
let config_contents = format!(
r#"[options]
key = "{key}"
relay-server = "{host}"
custom-rendezvous-server = "{host}"
api-server = "https://{host}"
verification-method = "{verification}"
approve-mode = "{approve}"
"#,
host = SERVER_HOST,
key = SERVER_KEY,
verification = SECURITY_VERIFICATION_VALUE,
approve = SECURITY_APPROVE_MODE_VALUE,
);
let main_path = program_data_config_dir().join("RustDesk2.toml");
write_file(&main_path, &config_contents)?;
for service_dir in service_profile_dirs() {
let service_profile = service_dir.join("RustDesk2.toml");
let _ = write_file(&service_profile, &config_contents);
}
Ok(main_path)
}
fn write_file(path: &Path, contents: &str) -> Result<(), io::Error> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
file.write_all(contents.as_bytes())
}
fn apply_config(exe_path: &Path, config_path: &Path) -> Result<(), RustdeskError> {
run_with_args(exe_path, &["--import-config", &config_path.to_string_lossy()])
}
fn set_password(exe_path: &Path, secret: &str) -> Result<(), RustdeskError> {
run_with_args(exe_path, &["--password", secret])
}
fn define_custom_id(exe_path: &Path, machine_id: Option<&str>) -> Option<String> {
let value = machine_id.and_then(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() { None } else { Some(trimmed) }
})?;
let custom_id = derive_numeric_id(value);
if run_with_args(exe_path, &["--set-id", &custom_id]).is_ok() {
info!("ID deterministico definido: {}", custom_id);
Some(custom_id)
} else {
None
}
}
fn derive_numeric_id(machine_id: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(machine_id.as_bytes());
let hash = hasher.finalize();
let mut bytes = [0u8; 8];
bytes.copy_from_slice(&hash[..8]);
let value = u64::from_le_bytes(bytes);
let num = (value % 900_000_000) + 100_000_000;
format!("{:09}", num)
}
fn ensure_service_running(exe_path: &Path) -> Result<(), RustdeskError> {
ensure_service_installed(exe_path)?;
let _ = run_sc(&["config", SERVICE_NAME, "start=", "auto"]);
let _ = run_sc(&["start", SERVICE_NAME]);
remove_rustdesk_autorun_artifacts();
Ok(())
}
fn ensure_service_installed(exe_path: &Path) -> Result<(), RustdeskError> {
if run_sc(&["query", SERVICE_NAME]).is_ok() {
return Ok(());
}
run_with_args(exe_path, &["--install-service"])
}
fn stop_rustdesk_processes() -> Result<(), RustdeskError> {
let _ = run_sc(&["stop", SERVICE_NAME]);
thread::sleep(Duration::from_secs(2));
let status = hidden_command("taskkill")
.args(["/F", "/T", "/IM", "rustdesk.exe"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
if status.success() || matches!(status.code(), Some(128)) {
Ok(())
} else {
Err(RustdeskError::CommandFailed {
command: "taskkill".into(),
status: status.code(),
})
}
}
fn purge_existing_rustdesk_profiles() -> Result<(), String> {
let files = [
"RustDesk.toml",
"RustDesk_local.toml",
"RustDesk2.toml",
"password",
"passwd",
"passwd.txt",
];
for dir in remote_id_directories() {
if !dir.exists() {
continue;
}
for name in files {
let path = dir.join(name);
if path.exists() {
let _ = fs::remove_file(&path);
}
}
}
Ok(())
}
fn ensure_password_files(secret: &str) -> Result<(), String> {
for dir in remote_id_directories() {
let password_path = dir.join("RustDesk.toml");
let _ = write_toml_kv(&password_path, "password", secret);
let local_path = dir.join("RustDesk_local.toml");
let _ = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE);
let _ = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE);
}
Ok(())
}
fn propagate_password_profile() -> io::Result<bool> {
// Encontra um diretorio de usuario que tenha arquivos de config
let user_dirs = all_user_appdata_config_dirs();
let src_dir = user_dirs.iter().find(|d| d.join("RustDesk.toml").exists());
let Some(src_dir) = src_dir else {
// Se nenhum usuario tem config, usa ProgramData como fonte
let pd = program_data_config_dir();
if !pd.join("RustDesk.toml").exists() {
return Ok(false);
}
return propagate_from_dir(&pd);
};
propagate_from_dir(src_dir)
}
fn propagate_from_dir(src_dir: &Path) -> io::Result<bool> {
let propagation_files = ["RustDesk.toml", "RustDesk_local.toml", "RustDesk2.toml"];
let mut propagated = false;
for filename in propagation_files {
let src_path = src_dir.join(filename);
if !src_path.exists() {
continue;
}
for dest_root in remote_id_directories() {
if dest_root == src_dir {
continue; // Nao copiar para si mesmo
}
let target_path = dest_root.join(filename);
if copy_overwrite(&src_path, &target_path).is_ok() {
propagated = true;
}
}
}
Ok(propagated)
}
fn ensure_remote_id_files(id: &str) {
for dir in remote_id_directories() {
let path = dir.join("RustDesk_local.toml");
let _ = write_remote_id_value(&path, id);
}
}
fn write_remote_id_value(path: &Path, id: &str) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let replacement = format!("remote_id = '{}'\n", id);
if let Ok(existing) = fs::read_to_string(path) {
let mut replaced = false;
let mut buffer = String::with_capacity(existing.len() + replacement.len());
for line in existing.lines() {
if line.trim_start().starts_with("remote_id") {
buffer.push_str(&replacement);
replaced = true;
} else {
buffer.push_str(line);
buffer.push('\n');
}
}
if !replaced {
buffer.push_str(&replacement);
}
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
file.write_all(buffer.as_bytes())
} else {
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
file.write_all(replacement.as_bytes())
}
}
fn write_toml_kv(path: &Path, key: &str, value: &str) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let sanitized = value.replace('\\', "\\\\").replace('"', "\\\"");
let replacement = format!("{key} = \"{sanitized}\"\n");
let existing = fs::read_to_string(path).unwrap_or_default();
let mut replaced = false;
let mut buffer = String::with_capacity(existing.len() + replacement.len());
for line in existing.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}=")) {
buffer.push_str(&replacement);
replaced = true;
} else {
buffer.push_str(line);
buffer.push('\n');
}
}
if !replaced {
buffer.push_str(&replacement);
}
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
file.write_all(buffer.as_bytes())
}
fn read_remote_id_from_profiles() -> Option<String> {
for dir in remote_id_directories() {
for candidate in [dir.join("RustDesk_local.toml"), dir.join("RustDesk.toml")] {
if let Some(id) = read_remote_id_file(&candidate) {
if !id.is_empty() {
return Some(id);
}
}
}
}
None
}
fn read_remote_id_file(path: &Path) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
for line in content.lines() {
if let Some(value) = parse_assignment(line, "remote_id") {
return Some(value);
}
}
None
}
fn parse_assignment(line: &str, key: &str) -> Option<String> {
let trimmed = line.trim();
if !trimmed.starts_with(key) {
return None;
}
let (_, rhs) = trimmed.split_once('=')?;
let value = rhs.trim().trim_matches(|c| c == '\'' || c == '"');
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
fn query_id_with_retries(exe_path: &Path, attempts: usize) -> Result<String, RustdeskError> {
for attempt in 0..attempts {
match query_id(exe_path) {
Ok(value) if !value.trim().is_empty() => return Ok(value),
_ => {}
}
if attempt + 1 < attempts {
thread::sleep(Duration::from_millis(800));
}
}
Err(RustdeskError::MissingId)
}
fn query_id(exe_path: &Path) -> Result<String, RustdeskError> {
let output = hidden_command(exe_path).arg("--get-id").output()?;
if !output.status.success() {
return Err(RustdeskError::CommandFailed {
command: format!("{} --get-id", exe_path.display()),
status: output.status.code(),
});
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout.is_empty() {
return Err(RustdeskError::MissingId);
}
Ok(stdout)
}
fn query_version(exe_path: &Path) -> Result<String, RustdeskError> {
let output = hidden_command(exe_path).arg("--version").output()?;
if !output.status.success() {
return Err(RustdeskError::CommandFailed {
command: format!("{} --version", exe_path.display()),
status: output.status.code(),
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn query_service_state() -> Option<String> {
let output = hidden_command("sc")
.args(["query", SERVICE_NAME])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let lower = line.to_lowercase();
if lower.contains("running") {
return Some("running".to_string());
}
if lower.contains("stopped") {
return Some("stopped".to_string());
}
}
None
}
fn run_sc(args: &[&str]) -> Result<(), RustdeskError> {
let status = hidden_command("sc")
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
if !status.success() {
return Err(RustdeskError::CommandFailed {
command: format!("sc {}", args.join(" ")),
status: status.code(),
});
}
Ok(())
}
fn run_with_args(exe_path: &Path, args: &[&str]) -> Result<(), RustdeskError> {
let status = hidden_command(exe_path)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
if !status.success() {
return Err(RustdeskError::CommandFailed {
command: format!("{} {}", exe_path.display(), args.join(" ")),
status: status.code(),
});
}
Ok(())
}
fn remove_rustdesk_autorun_artifacts() {
// Remove atalhos de inicializacao automatica
let mut startup_paths: Vec<PathBuf> = Vec::new();
if let Ok(appdata) = env::var("APPDATA") {
startup_paths.push(
Path::new(&appdata)
.join("Microsoft\\Windows\\Start Menu\\Programs\\Startup\\RustDesk.lnk"),
);
}
startup_paths.push(PathBuf::from(
r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup\RustDesk.lnk",
));
for path in startup_paths {
if path.exists() {
let _ = fs::remove_file(&path);
}
}
// Remove entradas de registro
for hive in ["HKCU", "HKLM"] {
let reg_path = format!(r"{}\Software\Microsoft\Windows\CurrentVersion\Run", hive);
let _ = hidden_command("reg")
.args(["delete", &reg_path, "/v", "RustDesk", "/f"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}
fn ensure_service_profiles_writable() -> Result<(), String> {
for dir in service_profile_dirs() {
if !can_write_dir(&dir) {
fix_profile_acl(&dir)?;
}
}
Ok(())
}
fn can_write_dir(dir: &Path) -> bool {
if fs::create_dir_all(dir).is_err() {
return false;
}
let probe = dir.join(".raven_acl_probe");
match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&probe)
{
Ok(mut file) => {
if file.write_all(b"ok").is_err() {
let _ = fs::remove_file(&probe);
return false;
}
let _ = fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
fn fix_profile_acl(target: &Path) -> Result<(), String> {
let target_str = target.display().to_string();
// Como ja estamos rodando como LocalSystem, podemos usar takeown/icacls diretamente
let _ = hidden_command("takeown")
.args(["/F", &target_str, "/R", "/D", "Y"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let status = hidden_command("icacls")
.args([
&target_str,
"/grant",
"*S-1-5-32-544:(OI)(CI)F",
"*S-1-5-19:(OI)(CI)F",
"*S-1-5-32-545:(OI)(CI)M",
"/T",
"/C",
"/Q",
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map_err(|e| format!("Erro ao executar icacls: {}", e))?;
if status.success() {
Ok(())
} else {
Err(format!("icacls retornou codigo {}", status.code().unwrap_or(-1)))
}
}
fn copy_overwrite(src: &Path, dst: &Path) -> io::Result<()> {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
if dst.is_dir() {
fs::remove_dir_all(dst)?;
} else if dst.exists() {
fs::remove_file(dst)?;
}
fs::copy(src, dst)?;
Ok(())
}
fn hidden_command(program: impl AsRef<OsStr>) -> Command {
let mut cmd = Command::new(program);
cmd.creation_flags(CREATE_NO_WINDOW);
cmd
}

View file

@ -1,259 +0,0 @@
//! Modulo USB Policy - Controle de dispositivos USB
//!
//! Implementa o controle de armazenamento USB no Windows.
//! Como o servico roda como LocalSystem, nao precisa de elevacao.
use serde::{Deserialize, Serialize};
use std::io;
use thiserror::Error;
use tracing::{error, info, warn};
use winreg::enums::*;
use winreg::RegKey;
// GUID para Removable Storage Devices (Disk)
const REMOVABLE_STORAGE_GUID: &str = "{53f56307-b6bf-11d0-94f2-00a0c91efb8b}";
// Chaves de registro
const REMOVABLE_STORAGE_PATH: &str = r"Software\Policies\Microsoft\Windows\RemovableStorageDevices";
const USBSTOR_PATH: &str = r"SYSTEM\CurrentControlSet\Services\USBSTOR";
const STORAGE_POLICY_PATH: &str = r"SYSTEM\CurrentControlSet\Control\StorageDevicePolicies";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum UsbPolicy {
Allow,
BlockAll,
Readonly,
}
impl UsbPolicy {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_uppercase().as_str() {
"ALLOW" => Some(Self::Allow),
"BLOCK_ALL" => Some(Self::BlockAll),
"READONLY" => Some(Self::Readonly),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Allow => "ALLOW",
Self::BlockAll => "BLOCK_ALL",
Self::Readonly => "READONLY",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsbPolicyResult {
pub success: bool,
pub policy: String,
pub error: Option<String>,
pub applied_at: Option<i64>,
}
#[derive(Error, Debug)]
pub enum UsbControlError {
#[error("Politica USB invalida: {0}")]
InvalidPolicy(String),
#[error("Erro de registro do Windows: {0}")]
RegistryError(String),
#[error("Permissao negada")]
PermissionDenied,
#[error("Erro de I/O: {0}")]
Io(#[from] io::Error),
}
/// Aplica uma politica de USB
pub fn apply_policy(policy_str: &str) -> Result<UsbPolicyResult, UsbControlError> {
let policy = UsbPolicy::from_str(policy_str)
.ok_or_else(|| UsbControlError::InvalidPolicy(policy_str.to_string()))?;
let now = chrono::Utc::now().timestamp_millis();
info!("Aplicando politica USB: {:?}", policy);
// 1. Aplicar Removable Storage Policy
apply_removable_storage_policy(policy)?;
// 2. Aplicar USBSTOR
apply_usbstor_policy(policy)?;
// 3. Aplicar WriteProtect se necessario
if policy == UsbPolicy::Readonly {
apply_write_protect(true)?;
} else {
apply_write_protect(false)?;
}
// 4. Atualizar Group Policy (opcional)
if let Err(e) = refresh_group_policy() {
warn!("Falha ao atualizar group policy: {}", e);
}
info!("Politica USB aplicada com sucesso: {:?}", policy);
Ok(UsbPolicyResult {
success: true,
policy: policy.as_str().to_string(),
error: None,
applied_at: Some(now),
})
}
/// Retorna a politica USB atual
pub fn get_current_policy() -> Result<String, UsbControlError> {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
// Verifica Removable Storage Policy primeiro
let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID);
if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_READ) {
let deny_read: u32 = key.get_value("Deny_Read").unwrap_or(0);
let deny_write: u32 = key.get_value("Deny_Write").unwrap_or(0);
if deny_read == 1 && deny_write == 1 {
return Ok("BLOCK_ALL".to_string());
}
if deny_read == 0 && deny_write == 1 {
return Ok("READONLY".to_string());
}
}
// Verifica USBSTOR como fallback
if let Ok(key) = hklm.open_subkey_with_flags(USBSTOR_PATH, KEY_READ) {
let start: u32 = key.get_value("Start").unwrap_or(3);
if start == 4 {
return Ok("BLOCK_ALL".to_string());
}
}
Ok("ALLOW".to_string())
}
fn apply_removable_storage_policy(policy: UsbPolicy) -> Result<(), UsbControlError> {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID);
match policy {
UsbPolicy::Allow => {
// Tenta remover as restricoes, se existirem
if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_ALL_ACCESS) {
let _ = key.delete_value("Deny_Read");
let _ = key.delete_value("Deny_Write");
let _ = key.delete_value("Deny_Execute");
}
// Tenta remover a chave inteira se estiver vazia
let _ = hklm.delete_subkey(&full_path);
}
UsbPolicy::BlockAll => {
let (key, _) = hklm
.create_subkey(&full_path)
.map_err(map_winreg_error)?;
key.set_value("Deny_Read", &1u32)
.map_err(map_winreg_error)?;
key.set_value("Deny_Write", &1u32)
.map_err(map_winreg_error)?;
key.set_value("Deny_Execute", &1u32)
.map_err(map_winreg_error)?;
}
UsbPolicy::Readonly => {
let (key, _) = hklm
.create_subkey(&full_path)
.map_err(map_winreg_error)?;
// Permite leitura, bloqueia escrita
key.set_value("Deny_Read", &0u32)
.map_err(map_winreg_error)?;
key.set_value("Deny_Write", &1u32)
.map_err(map_winreg_error)?;
key.set_value("Deny_Execute", &0u32)
.map_err(map_winreg_error)?;
}
}
Ok(())
}
fn apply_usbstor_policy(policy: UsbPolicy) -> Result<(), UsbControlError> {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
let key = hklm
.open_subkey_with_flags(USBSTOR_PATH, KEY_ALL_ACCESS)
.map_err(map_winreg_error)?;
match policy {
UsbPolicy::Allow => {
// Start = 3 habilita o driver
key.set_value("Start", &3u32)
.map_err(map_winreg_error)?;
}
UsbPolicy::BlockAll => {
// Start = 4 desabilita o driver
key.set_value("Start", &4u32)
.map_err(map_winreg_error)?;
}
UsbPolicy::Readonly => {
// Readonly mantem driver ativo
key.set_value("Start", &3u32)
.map_err(map_winreg_error)?;
}
}
Ok(())
}
fn apply_write_protect(enable: bool) -> Result<(), UsbControlError> {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
if enable {
let (key, _) = hklm
.create_subkey(STORAGE_POLICY_PATH)
.map_err(map_winreg_error)?;
key.set_value("WriteProtect", &1u32)
.map_err(map_winreg_error)?;
} else if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
let _ = key.set_value("WriteProtect", &0u32);
}
Ok(())
}
fn refresh_group_policy() -> Result<(), UsbControlError> {
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new("gpupdate")
.args(["/target:computer", "/force"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(UsbControlError::Io)?;
if !output.status.success() {
warn!(
"gpupdate retornou erro: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn map_winreg_error(error: io::Error) -> UsbControlError {
if let Some(code) = error.raw_os_error() {
if code == 5 {
return UsbControlError::PermissionDenied;
}
}
UsbControlError::RegistryError(error.to_string())
}

File diff suppressed because it is too large Load diff

View file

@ -15,36 +15,22 @@ name = "appsdesktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.4.1", features = [] }
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2.9", features = ["wry", "devtools", "tray-icon"] }
tauri-plugin-dialog = "2.4.2"
tauri-plugin-opener = "2.5.0"
tauri-plugin-store = "2.4.0"
tauri-plugin-updater = "2.9.0"
tauri-plugin-process = "2.3.0"
tauri-plugin-notification = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = "2"
tauri = { version = "2", features = ["wry"] }
tauri-plugin-opener = "2"
tauri-plugin-store = "2.4"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] }
get_if_addrs = "0.5"
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking", "stream"], default-features = false }
futures-util = "0.3"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
once_cell = "1.19"
thiserror = "1.0"
chrono = { version = "0.4", features = ["serde"] }
parking_lot = "0.12"
hostname = "0.4"
base64 = "0.22"
sha2 = "0.10"
convex = "0.10.2"
uuid = { version = "1", features = ["v4"] }
dirs = "5"
# SSE usa reqwest com stream, nao precisa de websocket
[target.'cfg(windows)'.dependencies]
winreg = "0.55"

View file

@ -1,31 +1,3 @@
fn main() {
// Custom manifest keeps Common-Controls v6 dependency to avoid TaskDialogIndirect errors.
let windows = tauri_build::WindowsAttributes::new().app_manifest(
r#"
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*" />
</dependentAssembly>
</dependency>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
"#,
);
let attrs = tauri_build::Attributes::new().windows_attributes(windows);
tauri_build::try_build(attrs).expect("failed to run Tauri build script");
tauri_build::build()
}

View file

@ -1,21 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for all windows",
"windows": ["main", "chat-*", "chat-hub"],
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:event:allow-emit",
"core:window:default",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-start-dragging",
"dialog:allow-open",
"opener:default",
"store:default",
"store:allow-load",
@ -24,10 +13,6 @@
"store:allow-save",
"store:allow-delete",
"updater:default",
"process:default",
"notification:default",
"notification:allow-notify",
"notification:allow-request-permission",
"notification:allow-is-permission-granted"
"process:default"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 974 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

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