Compare commits
No commits in common. "main" and "desktop-v0.1.1" have entirely different histories.
main
...
desktop-v0
|
|
@ -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\")"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
82
.env.example
|
|
@ -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
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
|
||||||
# Better Auth
|
# Better Auth
|
||||||
|
# Gere um segredo forte (ex.: `openssl rand -hex 32`)
|
||||||
|
BETTER_AUTH_SECRET=change-me
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
BETTER_AUTH_SECRET=your-secret-key-at-least-32-chars-long
|
|
||||||
|
|
||||||
# Convex (dev server URL)
|
# Banco de dados (Prisma)
|
||||||
NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
|
DATABASE_URL=file:./prisma/db.sqlite
|
||||||
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
|
|
||||||
|
|
||||||
# PostgreSQL database (versao 18)
|
# Seeds automáticos (Better Auth)
|
||||||
# Para desenvolvimento local, use Docker:
|
# Por padrão (true), garantindo apenas existência dos usuários padrão sem resetar senhas
|
||||||
# docker run -d --name postgres-chamados -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
|
SEED_ENSURE_ONLY=true
|
||||||
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
|
|
||||||
|
|
||||||
# SMTP Configuration (production values in docs/SMTP.md)
|
# Provisionamento e inventário de máquinas
|
||||||
SMTP_HOST=smtp.c.inova.com.br
|
# Segredo obrigatório para registrar/atualizar máquinas (Convex)
|
||||||
SMTP_PORT=587
|
MACHINE_PROVISIONING_SECRET=change-me-provisioning
|
||||||
SMTP_SECURE=false
|
# Tempo de vida do token de máquina (ms) — padrão 30 dias
|
||||||
SMTP_USER=envio@rever.com.br
|
MACHINE_TOKEN_TTL_MS=2592000000
|
||||||
SMTP_PASS=CAAJQm6ZT6AUdhXRTDYu
|
# Opcional: segredo dedicado para webhook do FleetDM (senão usa o de provisionamento)
|
||||||
SMTP_FROM_NAME=Sistema de Chamados
|
FLEET_SYNC_SECRET=
|
||||||
SMTP_FROM_EMAIL=envio@rever.com.br
|
|
||||||
|
|
||||||
# Dev-only bypass to simplify local testing (do NOT enable in prod)
|
# SMTP (envio de e-mails)
|
||||||
# DEV_BYPASS_AUTH=0
|
SMTP_ADDRESS=
|
||||||
# NEXT_PUBLIC_DEV_BYPASS_AUTH=0
|
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=
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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
|
|
||||||
639
.github/workflows.disabled/ci-cd-web-desktop.yml
vendored
|
|
@ -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
|
|
||||||
62
.github/workflows.disabled/quality-checks.yml
vendored
|
|
@ -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
|
|
||||||
423
.github/workflows/ci-cd-web-desktop.yml
vendored
Normal file
|
|
@ -0,0 +1,423 @@
|
||||||
|
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: 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
|
||||||
|
|
@ -36,7 +36,7 @@ jobs:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Enable Corepack
|
- 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)
|
- name: Install Rust (stable)
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
@ -50,10 +50,8 @@ jobs:
|
||||||
- name: Install pnpm deps
|
- name: Install pnpm deps
|
||||||
run: pnpm -C apps/desktop install --frozen-lockfile
|
run: pnpm -C apps/desktop install --frozen-lockfile
|
||||||
|
|
||||||
|
|
||||||
- name: Build desktop
|
- name: Build desktop
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
VITE_APP_URL: https://tickets.esdrasrenan.com.br
|
VITE_APP_URL: https://tickets.esdrasrenan.com.br
|
||||||
|
|
@ -65,3 +63,4 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: desktop-${{ matrix.platform }}
|
name: desktop-${{ matrix.platform }}
|
||||||
path: apps/desktop/src-tauri/target/release/bundle
|
path: apps/desktop/src-tauri/target/release/bundle
|
||||||
|
|
||||||
82
.gitignore
vendored
|
|
@ -1,40 +1,34 @@
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.*
|
.pnp.*
|
||||||
.yarn/*
|
.yarn/*
|
||||||
!.yarn/patches
|
!.yarn/patches
|
||||||
!.yarn/plugins
|
!.yarn/plugins
|
||||||
!.yarn/releases
|
!.yarn/releases
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
# React Email
|
# production
|
||||||
/.react-email/
|
/build
|
||||||
/emails/out/
|
|
||||||
|
# misc
|
||||||
# production
|
.DS_Store
|
||||||
/build
|
*.pem
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
*.sqlite
|
*.sqlite
|
||||||
# external experiments
|
|
||||||
nova-calendar-main/
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
# debug
|
yarn-debug.log*
|
||||||
npm-debug.log*
|
yarn-error.log*
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
|
|
@ -42,10 +36,6 @@ yarn-error.log*
|
||||||
!.env.example
|
!.env.example
|
||||||
!apps/desktop/.env.example
|
!apps/desktop/.env.example
|
||||||
|
|
||||||
# Accidental Windows duplicate downloads (e.g., "env (1)")
|
|
||||||
env (*)
|
|
||||||
env (1)
|
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
|
@ -59,15 +49,3 @@ next-env.d.ts
|
||||||
# arquivos locais temporários
|
# arquivos locais temporários
|
||||||
Captura de tela *.png
|
Captura de tela *.png
|
||||||
Screenshot*.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/
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
BIN
Inter,Manrope/Inter/OFL.txt:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/README.txt:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_18pt-Black.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_18pt-Bold.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_18pt-Italic.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_18pt-Light.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_18pt-Medium.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_18pt-Thin.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_24pt-Black.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_24pt-Bold.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_24pt-Italic.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_24pt-Light.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_24pt-Medium.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_24pt-Thin.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_28pt-Black.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_28pt-Bold.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_28pt-Italic.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_28pt-Light.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_28pt-Medium.ttf:Zone.Identifier
Normal file
BIN
Inter,Manrope/Inter/static/Inter_28pt-Thin.ttf:Zone.Identifier
Normal file
101
README.md
|
|
@ -1,65 +1,51 @@
|
||||||
## Sistema de Chamados
|
## 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
|
## 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
|
||||||
- Node.js >= 20 (necessário para ferramentas auxiliares como Prisma CLI e Next.js em modo fallback).
|
- pnpm >= 8
|
||||||
- CLI do Convex (`bunx convex dev` instalará automaticamente no primeiro uso, se ainda não estiver presente).
|
- CLI do Convex (`pnpm dlx convex dev` instalará automaticamente no primeiro uso)
|
||||||
- GitHub Actions/autodeploy dependem dessas versões e do CLI do Convex disponível; use `npx convex --help` para confirmar.
|
|
||||||
|
|
||||||
## Configuração rápida
|
## Configuração rápida
|
||||||
|
|
||||||
1. Instale as dependências:
|
1. Instale as dependências:
|
||||||
```bash
|
```bash
|
||||||
bun install
|
pnpm install
|
||||||
```
|
```
|
||||||
2. Ajuste o arquivo `.env` (ou crie a partir de `.env.example`) e confirme os valores de:
|
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)
|
- `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:
|
3. Aplique as migrações e gere o client Prisma:
|
||||||
```bash
|
```bash
|
||||||
bunx prisma migrate deploy
|
pnpm prisma migrate deploy
|
||||||
bun run prisma:generate
|
pnpm prisma:generate
|
||||||
```
|
```
|
||||||
4. Popule usuários padrão do Better Auth:
|
4. Popule usuários padrão do Better Auth:
|
||||||
```bash
|
```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`.
|
5. (Opcional) Para re-sincronizar manualmente as filas padrão, execute:
|
||||||
|
|
||||||
### Resetar rapidamente o ambiente local
|
|
||||||
|
|
||||||
1. Suba um PostgreSQL local (Docker recomendado):
|
|
||||||
```bash
|
```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
|
```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
|
```bash
|
||||||
bun run auth:seed
|
pnpm dev
|
||||||
```
|
```
|
||||||
4. Suba o servidor normalmente com `bun run dev`.
|
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.
|
||||||
|
|
||||||
### 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).
|
|
||||||
|
|
||||||
> 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.
|
> 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
|
### Deploy em produção (Traefik + Convex self‑hosted)
|
||||||
- Índice de docs: `docs/README.md`
|
- Guia completo: `docs/OPERACAO-PRODUCAO.md:1`.
|
||||||
- Operações (produção): `docs/OPERATIONS.md` (versão EN) e `docs/OPERACAO-PRODUCAO.md` (PT-BR)
|
- Histórico de setup/decisões: `docs/SETUP-HISTORICO.md:1`.
|
||||||
- Guia de DEV: `docs/DEV.md`
|
- Stack Swarm: `stack.yml:1` (roteado por Traefik, rede `traefik_public`).
|
||||||
- Testes automatizados (Vitest/Playwright): `docs/testes-vitest.md`
|
|
||||||
- Stack Swarm: `stack.yml` (roteado por Traefik, rede `traefik_public`).
|
|
||||||
|
|
||||||
### Variáveis de ambiente
|
### 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)
|
### 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
|
## Scripts úteis
|
||||||
|
|
||||||
- `bun run dev:bun` — padrão atual para o Next.js com runtime Bun (`bun run dev:webpack` permanece como fallback).
|
- `pnpm lint` — ESLint com as regras do projeto.
|
||||||
- `bun run convex:dev:bun` — runtime Bun para o Convex (`bun run convex:dev` mantém o fluxo antigo usando Node).
|
- `pnpm exec vitest run` — suíte de testes unitários.
|
||||||
- `bun run build:bun` / `bun run start:bun` — build e serve com Bun usando Turbopack (padrão atual).
|
- `pnpm auth:seed` — atualiza/cria contas padrão do Better Auth (credenciais em `agents.md`).
|
||||||
- `bun run dev:webpack` — fallback do Next.js em modo desenvolvimento (webpack).
|
- `pnpm prisma migrate deploy` — aplica migrações ao banco SQLite local.
|
||||||
- `bun run lint` — ESLint com as regras do projeto.
|
- `pnpm convex:dev` — roda o Convex em modo desenvolvimento, gerando tipos em `convex/_generated`.
|
||||||
- `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.
|
|
||||||
|
|
||||||
## Estrutura principal
|
## Estrutura principal
|
||||||
|
|
||||||
- `app/` dentro de `src/` — rotas e layouts do Next.js (App Router).
|
- `app/` dentro de `src/` — rotas e layouts do Next.js (App Router).
|
||||||
- `components/` — componentes reutilizáveis (UI, formulários, layouts).
|
- `components/` — componentes reutilizáveis (UI, formulários, layouts).
|
||||||
- `convex/` — queries, mutations e seeds do Convex.
|
- `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.
|
- `scripts/` — utilitários em Node para sincronização e seeds adicionais.
|
||||||
- `agents.md` — guia operacional e contexto funcional (em PT-BR).
|
- `agents.md` — guia operacional e contexto funcional (em PT-BR).
|
||||||
- `PROXIMOS_PASSOS.md` — backlog de melhorias futuras.
|
- `PROXIMOS_PASSOS.md` — backlog de melhorias futuras.
|
||||||
|
|
||||||
## Credenciais de demonstração
|
## 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
|
## Próximos passos
|
||||||
|
|
||||||
Consulte `PROXIMOS_PASSOS.md` para acompanhar o backlog funcional e o progresso das iniciativas planejadas.
|
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 -->
|
<!-- 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`.
|
|
||||||
|
|
|
||||||
371
agents.md
|
|
@ -1,214 +1,199 @@
|
||||||
# Plano de Desenvolvimento — Sistema de Chamados
|
# Plano de Desenvolvimento — Sistema de Chamados
|
||||||
|
|
||||||
> **Diretriz máxima**: documentação, comunicação e respostas sempre em português brasileiro.
|
|
||||||
|
|
||||||
## Contatos
|
|
||||||
- **Esdras Renan** — monkeyesdras@gmail.com
|
|
||||||
|
|
||||||
|
> **Diretriz máxima:** todas as respostas, comunicações e documentações devem ser redigidas em português brasileiro.
|
||||||
|
|
||||||
|
## Contato principal
|
||||||
|
- **Esdras Renan** — monkeyesdras@gmail.com
|
||||||
|
|
||||||
## Credenciais padrão (Better Auth)
|
## Credenciais padrão (Better Auth)
|
||||||
| Papel | Usuário | Senha |
|
- Administrador: `admin@sistema.dev` / `admin123`
|
||||||
| --- | --- | --- |
|
- Agente Demo: `agente.demo@sistema.dev` / `agent123`
|
||||||
| Administrador | `admin@sistema.dev` | `admin123` |
|
- Cliente Demo: `cliente.demo@sistema.dev` / `cliente123`
|
||||||
| Painel telão | `suporte@rever.com.br` | `agent123` |
|
> Execute `pnpm auth:seed` após configurar `.env`. O script atualiza as contas acima ou cria novas conforme variáveis `SEED_USER_*`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
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.
|
## 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).
|
||||||
|
|
||||||
> Execute `bun run auth:seed` após configurar `.env` para (re)criar os usuários acima (campos `SEED_USER_*` podem sobrescrever credenciais).
|
## 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.
|
||||||
|
|
||||||
## Backend Convex
|
### Exemplo de `.env.local` (DEV)
|
||||||
- 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.
|
# 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
|
||||||
|
|
||||||
## Stack atual (18/12/2025)
|
# Convex (DEV)
|
||||||
- **Next.js**: `16.0.10` (Turbopack por padrão; webpack fica como fallback).
|
NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Setup local (atualizado)
|
# Banco local (Prisma)
|
||||||
1. `bun install`
|
DATABASE_URL=file:./prisma/db.sqlite
|
||||||
2. Copie `.env.example` → `.env.local`.
|
|
||||||
- Principais variáveis para DEV:
|
|
||||||
```
|
|
||||||
NODE_ENV=development
|
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
|
||||||
BETTER_AUTH_SECRET=dev-only-long-random-string
|
|
||||||
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
|
# SMTP de desenvolvimento (ex.: Mailpit)
|
||||||
- Local (DEV): PostgreSQL local (ex.: `postgres:18`) com `DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados`.
|
SMTP_ADDRESS=localhost
|
||||||
- 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`).
|
SMTP_PORT=1025
|
||||||
- Limpeza de legados: `node scripts/remove-legacy-demo-users.mjs` remove contas demo antigas (Cliente Demo, gestores fictícios etc.).
|
SMTP_TLS=false
|
||||||
|
SMTP_USERNAME=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
MAILER_SENDER_EMAIL="Dev <no-reply@localhost>"
|
||||||
|
|
||||||
### Verificações antes de PR/deploy
|
# (Opcional) OAuth DEV – não usado por padrão neste projeto
|
||||||
```bash
|
GITHUB_CLIENT_ID=
|
||||||
bun run lint
|
GITHUB_CLIENT_SECRET=
|
||||||
bun test
|
GOOGLE_CLIENT_ID=
|
||||||
bun run build:bun
|
GOOGLE_CLIENT_SECRET=
|
||||||
```
|
```
|
||||||
|
|
||||||
## Aplicativo Desktop (Tauri)
|
Observações:
|
||||||
- Código-fonte: `apps/desktop` (Tauri v2 + Vite + React 19).
|
- `COOKIE_DOMAIN` não é necessário em DEV neste projeto.
|
||||||
- URLs:
|
- Variáveis de provisionamento de máquinas (`MACHINE_PROVISIONING_SECRET`, etc.) só se você for testar as rotas de máquinas/inventário.
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Sessão "machine" no frontend
|
### Passo a passo local
|
||||||
- Ao autenticar como dispositivo, o front chama `/api/machines/session`, popula `machineContext` (assignedUser*, persona) e deriva role/`viewerId`.
|
1) `pnpm install`
|
||||||
- Mesmo quando `get-session` é `null` na WebView, o portal utiliza `machineContext` para saber o colaborador/gestor logado.
|
2) `pnpm prisma:generate`
|
||||||
- UI remove opção "Sair" no menu do usuário quando detecta sessão de dispositivo.
|
3) `pnpm convex:dev` (terminal A)
|
||||||
- `/portal/debug` exibe JSON de `get-session` e `machines/session` (útil para diagnosticar cookies/bearer).
|
4) `pnpm dev` (terminal B)
|
||||||
|
5) (Opcional) `pnpm auth:seed` e visitar `http://localhost:3000/dev/seed`
|
||||||
|
|
||||||
### Observações adicionais
|
## Deploy via GitHub Actions (produção)
|
||||||
- Planejamos usar um cookie `desktop_shell` no futuro para diferenciar acessos do desktop vs navegador (não implementado).
|
- Fluxo: `git push main` ⇒ runner self‑hosted 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
|
## Bancos e seeds — DEV x PROD
|
||||||
- **Lint**: `bun run lint` (ESLint flat config).
|
- DEV: use os seeds à vontade (usuários com `pnpm auth:seed`, dados demo do Convex em `/dev/seed`).
|
||||||
- **Testes unitários/integrados (Vitest)**:
|
- PROD: evite seeds automáticos; para criar um admin use `SEED_USER_*` e `pnpm auth:seed` em um container Node efêmero.
|
||||||
- Cobertura atual inclui utilitários (`tests/*.test.ts`), rotas `/api/machines/*` e `sendSmtpMail`.
|
- Alterações de schema: sempre via migrações (`prisma migrate`). O CI aplica `migrate deploy` no start do container web.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Produção / Deploy
|
## Dicas rápidas
|
||||||
- Runner self-hosted (VPS). Build roda fora de `/srv/apps/sistema` e rsync publica em `/home/renan/apps/sistema`.
|
- Imagens em `public/`: trocou o arquivo → push. Para bust de cache, versionar o nome (ex.: `logo.v2.png`) ou usar query (`?v=sha`).
|
||||||
- Swarm: `stack.yml` monta `/home/renan/apps/sistema.current` → `/app` (via symlink).
|
- Problemas de permissão de build: garanta que `.next` pertence ao usuário do runner (se necessário, remover `.next` no host e rebuildar).
|
||||||
- Para liberar novo release manualmente:
|
- 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.
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Estado do portal / app web
|
## Checklist para novo computador
|
||||||
- Autenticação Better Auth com `AuthGuard`.
|
1. Instale Node.js 20+ e habilite o Corepack (`corepack enable`) para usar o `pnpm`.
|
||||||
- Sidebar inferior agrega avatar, link para `/settings` e logout (oculto em sessões de dispositivo).
|
2. Garanta o `pnpm` atualizado (`corepack prepare pnpm@latest --activate`) antes de clonar o repositório.
|
||||||
- Formulários de ticket (novo/editar/comentários) usam editor rico + anexos; placeholders e validação PT-BR.
|
3. Clone o projeto: `git clone git@github.com:esdrasrenan/sistema-de-chamados.git` e entre na pasta.
|
||||||
- Relatórios e painéis utilizam `AppShell` + `SiteHeader`.
|
4. Copie o arquivo `.env` já configurado do computador atual para a raiz do repositório (nunca faça commit desse arquivo).
|
||||||
- `usePersistentCompanyFilter` mantém filtro global de empresa em relatórios/admin.
|
5. Instale as dependências com `pnpm install`.
|
||||||
- Exportações CSV: backlog, canais, CSAT, SLA, horas (rotas `/api/reports/*.csv`).
|
6. Gere os clientes locais necessários: `pnpm prisma:generate`.
|
||||||
- PDF do ticket (`/api/tickets/[id]/export/pdf`).
|
7. Semeie as credenciais Better Auth: `pnpm auth:seed`.
|
||||||
- Play interno/externo com métricas por tipo.
|
8. Se for trabalhar com filas padrão, execute `pnpm queues:ensure`.
|
||||||
- Admin > Empresas: cadastro + “Cliente avulso?”, horas contratadas, vínculos de usuários.
|
9. Inicie o backend Convex em um terminal (`pnpm convex:dev`) e, em outro, suba a aplicação Next.js (`pnpm dev`).
|
||||||
- Admin > Usuários/Equipe:
|
10. Acesse `http://localhost:3000` e teste login com os usuários padrão listados acima antes de continuar o desenvolvimento.
|
||||||
- Abas separadas: "Equipe" (administradores e agentes) e "Usuários" (gestores e colaboradores).
|
|
||||||
- Multi‑seleção + ações em massa: excluir usuários, remover agentes de dispositivo e revogar convites pendentes.
|
## Estado atual
|
||||||
- Filtros por papel, empresa e espaço (tenant) quando aplicável; busca unificada.
|
- Autenticação Better Auth com guardas client-side (`AuthGuard`) bloqueando rotas protegidas.
|
||||||
- Convites: campo "Espaço (ID interno)" removido da UI de geração.
|
- 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.
|
- 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 de filas (Chamados/Laboratório/Visitas) e indicadores principais.
|
||||||
- Dashboard: cards por fila 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)
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
## Fluxos suportados
|
## Referências de endpoints úteis
|
||||||
- **Equipe interna** (`admin`, `agent`, `collaborator`): cria/acompanha tickets, comenta, altera status/fila, gera relatórios.
|
- Backlog CSV: `/api/reports/backlog.csv?range=7d|30d|90d[&companyId=...]`
|
||||||
- **Gestores** (`manager`): visualizam tickets da empresa, comentam publicamente, acessam dashboards.
|
- Canais CSV: `/api/reports/tickets-by-channel.csv?range=7d|30d|90d[&companyId=...]`
|
||||||
- **Colaboradores** (`collaborator`): portal (`/portal`), tickets próprios, comentários públicos, editor rico, anexos.
|
- CSAT CSV: `/api/reports/csat.csv?range=7d|30d|90d`
|
||||||
- **Sessão Dispositivo**: desktop registra heartbeat/inventário e redireciona colaborador/gestor ao portal apropriado com cookies válidos.
|
- SLA CSV: `/api/reports/sla.csv`
|
||||||
|
- Horas por cliente CSV: `/api/reports/hours-by-client.csv?range=7d|30d|90d`
|
||||||
### 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 back‑end continua a fonte da verdade (total acumulado é reconciliado ao pausar).
|
## Referências de inventário de máquinas
|
||||||
|
- UI (Admin > Máquinas): filtros, pesquisa e export detalhados — ver docs/admin-inventory-ui.md
|
||||||
## Backlog recomendado
|
- Endpoints do agente:
|
||||||
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).
|
|
||||||
|
|
||||||
## Referências rápidas
|
|
||||||
- **Endpoints agent desktop**:
|
|
||||||
- `POST /api/machines/register`
|
- `POST /api/machines/register`
|
||||||
- `POST /api/machines/heartbeat`
|
- `POST /api/machines/heartbeat`
|
||||||
- `POST /api/machines/inventory`
|
- `POST /api/machines/inventory`
|
||||||
- **Relatórios XLSX**:
|
|
||||||
- Backlog: `/api/reports/backlog.xlsx?range=7d|30d|90d[&companyId=...]`
|
## Rotina antes de abrir PR
|
||||||
- Canais: `/api/reports/tickets-by-channel.xlsx?...`
|
- `pnpm lint`
|
||||||
- CSAT: `/api/reports/csat.xlsx?...`
|
- `pnpm build --turbopack`
|
||||||
- SLA: `/api/reports/sla.xlsx?...`
|
- `pnpm exec vitest run`
|
||||||
- Horas: `/api/reports/hours-by-client.xlsx?...`
|
- Revisar toasts/labels em PT-BR e ausência de segredos no diff.
|
||||||
- Inventário de dispositivos: `/api/reports/machines-inventory.xlsx?[companyId=...]`
|
|
||||||
- **Docs complementares**:
|
## Convenções
|
||||||
- `docs/DEV.md` — guia diário atualizado.
|
- Convex deve retornar apenas tipos primitivos; converta datas via mappers em `src/lib/mappers`.
|
||||||
- `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog.
|
- Manter textos em PT-BR e evitar comentários supérfluos no código.
|
||||||
- `docs/OPERATIONS.md` — runbook do Swarm.
|
- Reutilizar componentes shadcn existentes e seguir o estilo do arquivo editado.
|
||||||
- `docs/admin-inventory-ui.md`, `docs/plano-app-desktop-maquinas.md` — detalhes do inventário/agente.
|
- Validações client-side críticas devem sinalizar erros inline e exibir toast.
|
||||||
|
|
||||||
## Regras de Codigo
|
## Estrutura útil
|
||||||
|
- `convex/` — queries e mutations (ex.: `tickets.ts`, `users.ts`).
|
||||||
### Tooltips Nativos do Navegador
|
- `src/components/tickets/` — UI interna (dialog, listas, header, timeline).
|
||||||
|
- `src/components/portal/` — formulários e fluxos do portal do cliente.
|
||||||
**NAO use o atributo `title` em elementos HTML** (button, span, a, div, etc).
|
- `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.
|
|
||||||
|
## Histórico resumido
|
||||||
```tsx
|
- Scaffold Next.js + Turbopack configurado com Better Auth e Convex.
|
||||||
// ERRADO - causa tooltip nativo do navegador
|
- Portal do cliente entregue com isolamento por `viewerId`.
|
||||||
<button title="Remover item">
|
- Fluxo de convites e painel administrativo operacionais.
|
||||||
<Trash2 className="size-4" />
|
- Iteração atual focada em UX de criação de tickets, consistência de layout e guardas de sessão.
|
||||||
</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)._
|
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,6 @@ VITE_APP_URL=http://localhost:3000
|
||||||
# Se não definir, cai no mesmo valor de VITE_APP_URL
|
# Se não definir, cai no mesmo valor de VITE_APP_URL
|
||||||
VITE_API_BASE_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
|
# Opcional: IP do host para desenvolvimento com HMR fora do localhost
|
||||||
# Ex.: 192.168.0.10
|
# Ex.: 192.168.0.10
|
||||||
TAURI_DEV_HOST=
|
TAURI_DEV_HOST=
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
# Sistema de Chamados — App Desktop (Tauri)
|
# Sistema de Chamados — App Desktop (Tauri)
|
||||||
|
|
||||||
Cliente desktop (Tauri v2 + Vite) que:
|
Cliente desktop (Tauri v2 + Vite) que:
|
||||||
- Coleta perfil/métricas da dispositivo via comandos Rust.
|
- Coleta perfil/métricas da máquina via comandos Rust.
|
||||||
- Registra a dispositivo com um código de provisionamento.
|
- Registra a máquina com um código de provisionamento.
|
||||||
- Envia heartbeat periódico ao backend (`/api/machines/heartbeat`).
|
- Envia heartbeat periódico ao backend (`/api/machines/heartbeat`).
|
||||||
- Redireciona para a UI web do sistema após provisionamento.
|
- 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”.
|
- Exibe abas de Resumo, Inventário, Diagnóstico e Configurações; permite “Enviar inventário agora”.
|
||||||
|
|
||||||
## URLs e ambiente
|
## URLs e ambiente
|
||||||
|
|
@ -22,42 +22,13 @@ VITE_API_BASE_URL=
|
||||||
## Comandos
|
## Comandos
|
||||||
|
|
||||||
- Dev (abre janela Tauri e Vite em 1420):
|
- 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):
|
- Build frontend (somente Vite):
|
||||||
- `bun run --cwd apps/desktop build`
|
- `pnpm -C apps/desktop build`
|
||||||
- Build executável (bundle):
|
- 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/`.
|
Saída dos pacotes: `apps/desktop/src-tauri/target/release/bundle/` (AppImage/deb/msi/dmg conforme SO).
|
||||||
|
|
||||||
### 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pré‑requisitos Tauri
|
## Pré‑requisitos Tauri
|
||||||
- Rust toolchain instalado.
|
- Rust toolchain instalado.
|
||||||
|
|
@ -65,7 +36,7 @@ bun run --cwd apps/desktop tauri build --bundles nsis
|
||||||
Consulte https://tauri.app/start/prerequisites/
|
Consulte https://tauri.app/start/prerequisites/
|
||||||
|
|
||||||
## Fluxo (resumo)
|
## 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.
|
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.
|
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.
|
4) O app abre `APP_URL/machines/handshake?token=...` no WebView para autenticar a sessão na UI.
|
||||||
|
|
|
||||||
|
|
@ -159,11 +159,10 @@
|
||||||
|
|
||||||
## 5. Gerar chaves do updater Tauri
|
## 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
|
```bash
|
||||||
bun install
|
pnpm install
|
||||||
bun install --cwd apps/desktop
|
pnpm --filter appsdesktop tauri signer generate
|
||||||
bun run --cwd apps/desktop tauri signer generate
|
|
||||||
```
|
```
|
||||||
2. O comando gera:
|
2. O comando gera:
|
||||||
- Chave privada (`tauri.private.key`).
|
- Chave privada (`tauri.private.key`).
|
||||||
|
|
@ -237,16 +236,19 @@
|
||||||
|
|
||||||
1. Baixe e instale os pré-requisitos:
|
1. Baixe e instale os pré-requisitos:
|
||||||
- Git para Windows.
|
- 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 (instalação inclui npm).
|
||||||
- Node.js 20 (opcional, caso precise rodar scripts em Node durante o build).
|
- 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).
|
- Rust toolchain: https://rustup.rs (instale padrão).
|
||||||
- Visual Studio Build Tools (C++ build tools) ou `Desktop development with C++`.
|
- Visual Studio Build Tools (C++ build tools) ou `Desktop development with C++`.
|
||||||
- WebView2 Runtime (https://developer.microsoft.com/microsoft-edge/webview2/).
|
- WebView2 Runtime (https://developer.microsoft.com/microsoft-edge/webview2/).
|
||||||
2. Opcional: instale as dependências do Tauri rodando uma vez:
|
2. Opcional: instale as dependências do Tauri rodando uma vez:
|
||||||
```powershell
|
```powershell
|
||||||
bun install
|
pnpm install
|
||||||
bun install --cwd apps/desktop
|
pnpm --filter appsdesktop tauri info
|
||||||
bun run --cwd apps/desktop tauri info
|
|
||||||
```
|
```
|
||||||
3. No GitHub → *Settings* → *Actions* → *Runners* → *New self-hosted runner* → escolha Windows x64 e copie URL/token.
|
3. No GitHub → *Settings* → *Actions* → *Runners* → *New self-hosted runner* → escolha Windows x64 e copie URL/token.
|
||||||
4. Em `C:\actions-runner` (recomendado):
|
4. Em `C:\actions-runner` (recomendado):
|
||||||
|
|
@ -265,10 +267,10 @@
|
||||||
.\svc start
|
.\svc start
|
||||||
```
|
```
|
||||||
6. Confirme no GitHub que o runner aparece como `online`.
|
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).
|
- 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.
|
- 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).
|
- 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).
|
- 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.
|
- Há espaço em disco suficiente e nenhuma política corporativa bloqueando a instalação dos pré-requisitos.
|
||||||
|
|
@ -324,10 +326,14 @@
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup pnpm & Node
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
bun-version: 1.3.1
|
version: 9
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
- name: Deploy stack (Docker Swarm)
|
- name: Deploy stack (Docker Swarm)
|
||||||
working-directory: ${{ env.APP_DIR }}
|
working-directory: ${{ env.APP_DIR }}
|
||||||
|
|
@ -344,16 +350,20 @@
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup pnpm & Node
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
bun-version: 1.3.1
|
version: 9
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
- name: Setup Rust toolchain
|
- name: Setup Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Install deps
|
- name: Install deps
|
||||||
run: bun install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build + Sign + Release (tauri-action)
|
- name: Build + Sign + Release (tauri-action)
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
|
@ -379,7 +389,7 @@
|
||||||
target: ${{ env.VPS_UPDATES_DIR }}
|
target: ${{ env.VPS_UPDATES_DIR }}
|
||||||
overwrite: true
|
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`).
|
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`).
|
- Garanta que o certificado TLS usado pelo Nginx é renovado (p. ex. `certbot renew`).
|
||||||
4. Manter runners:
|
4. Manter runners:
|
||||||
- VPS: monitore serviço `actions.runner.*`. Reinicie se necessário (`sudo ./svc.sh restart`).
|
- 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. |
|
| 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. |
|
| 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. |
|
| 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`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,21 +7,13 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "node ./scripts/tauri-with-stub.mjs",
|
"tauri": "tauri"
|
||||||
"gen:icon": "node ./scripts/build-icon.mjs",
|
|
||||||
"build:service": "cd service && cargo build --release",
|
|
||||||
"build:all": "bun run build:service && bun run tauri build"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tauri-apps/api": "^2.9.1",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-process": "^2",
|
|
||||||
"@tauri-apps/plugin-store": "^2",
|
"@tauri-apps/plugin-store": "^2",
|
||||||
"@tauri-apps/plugin-updater": "^2",
|
|
||||||
"convex": "^1.31.0",
|
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
|
|
@ -29,8 +21,6 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"baseline-browser-mapping": "^2.9.2",
|
|
||||||
"png-to-ico": "^3.0.1",
|
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"vite": "^6.0.3"
|
"vite": "^6.0.3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 1.9 MiB |
|
|
@ -1 +0,0 @@
|
||||||
dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVUd3ExU0FRQjRUR2NSSjRzSVhWSVhyU0dIMHlDMnJHMENpVkpVSWszUXVTMWRSWEkvMW1FUkFwa0Vsd2JvaVpqQk9mZ283MlFZaFl0UGNTK1ArOHI1WFgyVFFXOUwzL3dnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzYwMzk5NTI3CWZpbGU6UmF2ZW5fMC4xLjVfeDY0LXNldHVwLmV4ZQpwd0MyLzNVVmQzbHordjQwZEdqaWRoVFBoL3VsZmhueDIvamRVNjQ0NDRUWUcrTThKMGk5NlpSTHVUZDlsYXU2TGgrY3VybnY5aDhweUg3WEZ5aHVCQT09Cg==
|
|
||||||
|
|
@ -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) })
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -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
|
|
||||||
1931
apps/desktop/service/Cargo.lock
generated
|
|
@ -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
|
|
||||||
|
|
@ -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)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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(())
|
|
||||||
}
|
|
||||||
|
|
@ -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", ®_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
|
|
||||||
}
|
|
||||||
|
|
@ -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())
|
|
||||||
}
|
|
||||||
1149
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -15,36 +15,22 @@ name = "appsdesktop_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.4.1", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.9", features = ["wry", "devtools", "tray-icon"] }
|
tauri = { version = "2", features = ["wry"] }
|
||||||
tauri-plugin-dialog = "2.4.2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-opener = "2.5.0"
|
tauri-plugin-store = "2.4"
|
||||||
tauri-plugin-store = "2.4.0"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-updater = "2.9.0"
|
tauri-plugin-process = "2"
|
||||||
tauri-plugin-process = "2.3.0"
|
|
||||||
tauri-plugin-notification = "2"
|
|
||||||
tauri-plugin-deep-link = "2"
|
|
||||||
tauri-plugin-single-instance = "2"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] }
|
sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] }
|
||||||
get_if_addrs = "0.5"
|
get_if_addrs = "0.5"
|
||||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking", "stream"], default-features = false }
|
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||||
futures-util = "0.3"
|
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
||||||
once_cell = "1.19"
|
once_cell = "1.19"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
hostname = "0.4"
|
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"
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,3 @@
|
||||||
fn main() {
|
fn main() {
|
||||||
// Custom manifest keeps Common-Controls v6 dependency to avoid TaskDialogIndirect errors.
|
tauri_build::build()
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,10 @@
|
||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for all windows",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main", "chat-*", "chat-hub"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"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",
|
"opener:default",
|
||||||
"store:default",
|
"store:default",
|
||||||
"store:allow-load",
|
"store:allow-load",
|
||||||
|
|
@ -24,10 +13,6 @@
|
||||||
"store:allow-save",
|
"store:allow-save",
|
||||||
"store:allow-delete",
|
"store:allow-delete",
|
||||||
"updater:default",
|
"updater:default",
|
||||||
"process:default",
|
"process:default"
|
||||||
"notification:default",
|
|
||||||
"notification:allow-notify",
|
|
||||||
"notification:allow-request-permission",
|
|
||||||
"notification:allow-is-permission-granted"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 974 B |
|
Before Width: | Height: | Size: 5.5 KiB |