Compare commits
No commits in common. "main" and "chore/contact-info" have entirely different histories.
main
...
chore/cont
831 changed files with 21110 additions and 149249 deletions
|
|
@ -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\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
38
.env.example
38
.env.example
|
|
@ -1,38 +0,0 @@
|
|||
NODE_ENV=development
|
||||
|
||||
# Public app URL
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
# Better Auth
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
BETTER_AUTH_SECRET=your-secret-key-at-least-32-chars-long
|
||||
|
||||
# Convex (dev server URL)
|
||||
NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
|
||||
CONVEX_INTERNAL_URL=http://127.0.0.1:3210
|
||||
# Intervalo (ms) para aceitar token revogado ao sincronizar acessos remotos (opcional)
|
||||
REMOTE_ACCESS_TOKEN_GRACE_MS=900000
|
||||
# Token interno opcional para o dashboard de saude (/admin/health) e queries internas
|
||||
INTERNAL_HEALTH_TOKEN=dev-health-token
|
||||
# Segredo para crons HTTP (reutilize em prod se preferir um unico token)
|
||||
REPORTS_CRON_SECRET=reports-cron-secret
|
||||
# Diretório para arquivamento local de tickets (JSONL/backup)
|
||||
ARCHIVE_DIR=./archives
|
||||
|
||||
# PostgreSQL database (versao 18)
|
||||
# Para desenvolvimento local, use Docker:
|
||||
# docker run -d --name postgres-chamados -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
|
||||
DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados
|
||||
|
||||
# SMTP Configuration (production values in docs/SMTP.md)
|
||||
SMTP_HOST=smtp.c.inova.com.br
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=envio@rever.com.br
|
||||
SMTP_PASS=CAAJQm6ZT6AUdhXRTDYu
|
||||
SMTP_FROM_NAME=Sistema de Chamados
|
||||
SMTP_FROM_EMAIL=envio@rever.com.br
|
||||
|
||||
# Dev-only bypass to simplify local testing (do NOT enable in prod)
|
||||
# DEV_BYPASS_AUTH=0
|
||||
# NEXT_PUBLIC_DEV_BYPASS_AUTH=0
|
||||
|
|
@ -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
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
|
||||
67
.github/workflows.disabled/desktop-release.yml
vendored
67
.github/workflows.disabled/desktop-release.yml
vendored
|
|
@ -1,67 +0,0 @@
|
|||
name: Desktop Release (Tauri)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'desktop-v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux
|
||||
runner: ubuntu-latest
|
||||
- platform: windows
|
||||
runner: windows-latest
|
||||
- platform: macos
|
||||
runner: macos-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable && corepack prepare pnpm@10.20.0 --activate
|
||||
|
||||
- name: Install Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Linux deps
|
||||
if: matrix.platform == 'linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev build-essential curl wget file
|
||||
|
||||
- name: Install pnpm deps
|
||||
run: pnpm -C apps/desktop install --frozen-lockfile
|
||||
|
||||
|
||||
- name: Build desktop
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_APP_URL: https://tickets.esdrasrenan.com.br
|
||||
VITE_API_BASE_URL: https://tickets.esdrasrenan.com.br
|
||||
run: pnpm -C apps/desktop tauri build
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-${{ matrix.platform }}
|
||||
path: apps/desktop/src-tauri/target/release/bundle
|
||||
62
.github/workflows.disabled/quality-checks.yml
vendored
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
|
||||
82
.gitignore
vendored
82
.gitignore
vendored
|
|
@ -1,73 +1,9 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# React Email
|
||||
/.react-email/
|
||||
/emails/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
*.sqlite
|
||||
# external experiments
|
||||
nova-calendar-main/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
!apps/desktop/.env.example
|
||||
|
||||
# Accidental Windows duplicate downloads (e.g., "env (1)")
|
||||
env (*)
|
||||
env (1)
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# backups locais
|
||||
.archive/
|
||||
|
||||
# arquivos locais temporários
|
||||
Captura de tela *.png
|
||||
Screenshot*.png
|
||||
# Ignore NTFS ADS streams accidentally committed from Windows downloads
|
||||
*:*Zone.Identifier
|
||||
*:\:Zone.Identifier
|
||||
# Infrastructure secrets
|
||||
.ci.env
|
||||
|
||||
# ferramentas externas
|
||||
rustdesk/
|
||||
|
||||
# Prisma generated files
|
||||
src/generated/
|
||||
apps/desktop/service/target/
|
||||
# Root ignore for monorepo
|
||||
web/node_modules/
|
||||
web/.next/
|
||||
web/.turbo/
|
||||
web/out/
|
||||
web/.env.local
|
||||
web/.env*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
# Runtime image with Node 22 + Bun 1.3.4 and build toolchain preinstalled
|
||||
FROM node:22-bullseye-slim
|
||||
|
||||
ENV BUN_INSTALL=/root/.bun
|
||||
ENV PATH="$BUN_INSTALL/bin:$PATH"
|
||||
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gnupg \
|
||||
unzip \
|
||||
build-essential \
|
||||
python3 \
|
||||
make \
|
||||
pkg-config \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Bun 1.3.4
|
||||
RUN curl -fsSL https://bun.sh/install \
|
||||
| bash -s -- bun-v1.3.4 \
|
||||
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bunx
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# We'll mount the app code at runtime; image just provides runtimes/toolchains.
|
||||
CMD ["bash"]
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,93 +0,0 @@
|
|||
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
Inter Variable Font
|
||||
===================
|
||||
|
||||
This download contains Inter as both variable fonts and static fonts.
|
||||
|
||||
Inter is a variable font with these axes:
|
||||
opsz
|
||||
wght
|
||||
|
||||
This means all the styles are contained in these files:
|
||||
Inter/Inter-VariableFont_opsz,wght.ttf
|
||||
Inter/Inter-Italic-VariableFont_opsz,wght.ttf
|
||||
|
||||
If your app fully supports variable fonts, you can now pick intermediate styles
|
||||
that aren’t available as static fonts. Not all apps support variable fonts, and
|
||||
in those cases you can use the static font files for Inter:
|
||||
Inter/static/Inter_18pt-Thin.ttf
|
||||
Inter/static/Inter_18pt-ExtraLight.ttf
|
||||
Inter/static/Inter_18pt-Light.ttf
|
||||
Inter/static/Inter_18pt-Regular.ttf
|
||||
Inter/static/Inter_18pt-Medium.ttf
|
||||
Inter/static/Inter_18pt-SemiBold.ttf
|
||||
Inter/static/Inter_18pt-Bold.ttf
|
||||
Inter/static/Inter_18pt-ExtraBold.ttf
|
||||
Inter/static/Inter_18pt-Black.ttf
|
||||
Inter/static/Inter_24pt-Thin.ttf
|
||||
Inter/static/Inter_24pt-ExtraLight.ttf
|
||||
Inter/static/Inter_24pt-Light.ttf
|
||||
Inter/static/Inter_24pt-Regular.ttf
|
||||
Inter/static/Inter_24pt-Medium.ttf
|
||||
Inter/static/Inter_24pt-SemiBold.ttf
|
||||
Inter/static/Inter_24pt-Bold.ttf
|
||||
Inter/static/Inter_24pt-ExtraBold.ttf
|
||||
Inter/static/Inter_24pt-Black.ttf
|
||||
Inter/static/Inter_28pt-Thin.ttf
|
||||
Inter/static/Inter_28pt-ExtraLight.ttf
|
||||
Inter/static/Inter_28pt-Light.ttf
|
||||
Inter/static/Inter_28pt-Regular.ttf
|
||||
Inter/static/Inter_28pt-Medium.ttf
|
||||
Inter/static/Inter_28pt-SemiBold.ttf
|
||||
Inter/static/Inter_28pt-Bold.ttf
|
||||
Inter/static/Inter_28pt-ExtraBold.ttf
|
||||
Inter/static/Inter_28pt-Black.ttf
|
||||
Inter/static/Inter_18pt-ThinItalic.ttf
|
||||
Inter/static/Inter_18pt-ExtraLightItalic.ttf
|
||||
Inter/static/Inter_18pt-LightItalic.ttf
|
||||
Inter/static/Inter_18pt-Italic.ttf
|
||||
Inter/static/Inter_18pt-MediumItalic.ttf
|
||||
Inter/static/Inter_18pt-SemiBoldItalic.ttf
|
||||
Inter/static/Inter_18pt-BoldItalic.ttf
|
||||
Inter/static/Inter_18pt-ExtraBoldItalic.ttf
|
||||
Inter/static/Inter_18pt-BlackItalic.ttf
|
||||
Inter/static/Inter_24pt-ThinItalic.ttf
|
||||
Inter/static/Inter_24pt-ExtraLightItalic.ttf
|
||||
Inter/static/Inter_24pt-LightItalic.ttf
|
||||
Inter/static/Inter_24pt-Italic.ttf
|
||||
Inter/static/Inter_24pt-MediumItalic.ttf
|
||||
Inter/static/Inter_24pt-SemiBoldItalic.ttf
|
||||
Inter/static/Inter_24pt-BoldItalic.ttf
|
||||
Inter/static/Inter_24pt-ExtraBoldItalic.ttf
|
||||
Inter/static/Inter_24pt-BlackItalic.ttf
|
||||
Inter/static/Inter_28pt-ThinItalic.ttf
|
||||
Inter/static/Inter_28pt-ExtraLightItalic.ttf
|
||||
Inter/static/Inter_28pt-LightItalic.ttf
|
||||
Inter/static/Inter_28pt-Italic.ttf
|
||||
Inter/static/Inter_28pt-MediumItalic.ttf
|
||||
Inter/static/Inter_28pt-SemiBoldItalic.ttf
|
||||
Inter/static/Inter_28pt-BoldItalic.ttf
|
||||
Inter/static/Inter_28pt-ExtraBoldItalic.ttf
|
||||
Inter/static/Inter_28pt-BlackItalic.ttf
|
||||
|
||||
Get started
|
||||
-----------
|
||||
|
||||
1. Install the font files you want to use
|
||||
|
||||
2. Use your app's font picker to view the font family and all the
|
||||
available styles
|
||||
|
||||
Learn more about variable fonts
|
||||
-------------------------------
|
||||
|
||||
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
|
||||
https://variablefonts.typenetwork.com
|
||||
https://medium.com/variable-fonts
|
||||
|
||||
In desktop apps
|
||||
|
||||
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
|
||||
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
|
||||
|
||||
Online
|
||||
|
||||
https://developers.google.com/fonts/docs/getting_started
|
||||
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
|
||||
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
|
||||
|
||||
Installing fonts
|
||||
|
||||
MacOS: https://support.apple.com/en-us/HT201749
|
||||
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
|
||||
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
|
||||
|
||||
Android Apps
|
||||
|
||||
https://developers.google.com/fonts/docs/android
|
||||
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
|
||||
|
||||
License
|
||||
-------
|
||||
Please read the full license text (OFL.txt) to understand the permissions,
|
||||
restrictions and requirements for usage, redistribution, and modification.
|
||||
|
||||
You can use them in your products & projects – print or digital,
|
||||
commercial or otherwise.
|
||||
|
||||
This isn't legal advice, please consider consulting a lawyer and see the full
|
||||
license for all details.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
131
README.md
131
README.md
|
|
@ -1,131 +0,0 @@
|
|||
## 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.
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Bun >= 1.3 (recomendado 1.3.1). Após instalar via script oficial, adicione `export PATH="$HOME/.bun/bin:$PATH"` ao seu shell (ex.: `.bashrc`) para ter `bun` disponível globalmente.
|
||||
- Node.js >= 20 (necessário para ferramentas auxiliares como Prisma CLI e Next.js em modo fallback).
|
||||
- CLI do Convex (`bunx convex dev` instalará automaticamente no primeiro uso, se ainda não estiver presente).
|
||||
- GitHub Actions/autodeploy dependem dessas versões e do CLI do Convex disponível; use `npx convex --help` para confirmar.
|
||||
|
||||
## Configuração rápida
|
||||
|
||||
1. Instale as dependências:
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
2. Ajuste o arquivo `.env` (ou crie a partir de `.env.example`) e confirme os valores de:
|
||||
- `NEXT_PUBLIC_CONVEX_URL` (gerado pelo Convex Dev)
|
||||
- `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DATABASE_URL` (PostgreSQL, ex: `postgresql://postgres:dev@localhost:5432/sistema_chamados`)
|
||||
3. Aplique as migrações e gere o client Prisma:
|
||||
```bash
|
||||
bunx prisma migrate deploy
|
||||
bun run prisma:generate
|
||||
```
|
||||
4. Popule usuários padrão do Better Auth:
|
||||
```bash
|
||||
bun run auth:seed
|
||||
```
|
||||
> Sempre que trocar de máquina ou quiser “zerar” o ambiente local, basta repetir os passos 3 e 4 com a mesma `DATABASE_URL`.
|
||||
|
||||
### Resetar rapidamente o ambiente local
|
||||
|
||||
1. Suba um PostgreSQL local (Docker recomendado):
|
||||
```bash
|
||||
docker run -d --name postgres-dev -p 5432:5432 -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=sistema_chamados postgres:18
|
||||
```
|
||||
2. Aplique as migracoes:
|
||||
```bash
|
||||
bunx prisma migrate deploy
|
||||
```
|
||||
3. Recrie/garanta as contas padrao de login:
|
||||
```bash
|
||||
bun run auth:seed
|
||||
```
|
||||
4. Suba o servidor normalmente com `bun run dev`.
|
||||
|
||||
### Subir serviços locais
|
||||
|
||||
- (Opcional) Para re-sincronizar manualmente as filas padrão, execute `bun run queues:ensure`.
|
||||
- Em um terminal, rode o backend em tempo real do Convex com `bun run convex:dev:bun` (ou `bun run convex:dev` para o runtime Node).
|
||||
- Em outro terminal, suba o frontend Next.js (Turbopack) com `bun run dev:bun` (`bun run dev:webpack` serve como fallback).
|
||||
- Com o Convex rodando, acesse `http://localhost:3000/dev/seed` uma vez para popular dados de demonstração (tickets, usuários, comentários).
|
||||
|
||||
> Se o CLI perguntar sobre configuração do projeto Convex, escolha criar um novo deployment local (opção padrão) e confirme. As credenciais são armazenadas em `.convex/` automaticamente.
|
||||
|
||||
### Documentação
|
||||
- Índice de docs: `docs/README.md`
|
||||
- Operações (produção): `docs/OPERATIONS.md` (versão EN) e `docs/OPERACAO-PRODUCAO.md` (PT-BR)
|
||||
- Guia de DEV: `docs/DEV.md`
|
||||
- Testes automatizados (Vitest/Playwright): `docs/testes-vitest.md`
|
||||
- Stack Swarm: `stack.yml` (roteado por Traefik, rede `traefik_public`).
|
||||
|
||||
### Variáveis de ambiente
|
||||
|
||||
- Exemplo na raiz: `.env.example` — copie para `.env` e preencha segredos.
|
||||
- App Desktop: `apps/desktop/.env.example` — copie para `apps/desktop/.env` e ajuste `VITE_APP_URL`.
|
||||
- Nunca faça commit de arquivos `.env` com valores reais (já ignorados em `.gitignore`).
|
||||
|
||||
### 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`.
|
||||
|
||||
## Scripts úteis
|
||||
|
||||
- `bun run dev:bun` — padrão atual para o Next.js com runtime Bun (`bun run dev:webpack` permanece como fallback).
|
||||
- `bun run convex:dev:bun` — runtime Bun para o Convex (`bun run convex:dev` mantém o fluxo antigo usando Node).
|
||||
- `bun run build:bun` / `bun run start:bun` — build e serve com Bun usando Turbopack (padrão atual).
|
||||
- `bun run dev:webpack` — fallback do Next.js em modo desenvolvimento (webpack).
|
||||
- `bun run lint` — ESLint com as regras do projeto.
|
||||
- `bun test` — suíte de testes unitários usando o runner do Bun (o teste de screenshot fica automaticamente ignorado se o matcher não existir).
|
||||
- `bun run build` — executa `next build --turbopack` (runtime Node, caso prefira evitar o `--bun`).
|
||||
- `bun run build:webpack` — executa `next build --webpack` como fallback oficial.
|
||||
- `bun run auth:seed` — atualiza/cria contas padrao do Better Auth (credenciais em `agents.md`).
|
||||
- `bunx prisma migrate deploy` — aplica migracoes ao banco PostgreSQL.
|
||||
- `bun run convex:dev` — roda o Convex em modo desenvolvimento com Node, gerando tipos em `convex/_generated`.
|
||||
|
||||
## Transferir dispositivo entre colaboradores
|
||||
|
||||
Quando uma dispositivo trocar de responsável:
|
||||
|
||||
1. Abra `Admin > Dispositivos`, selecione o equipamento e clique em **Resetar agente**.
|
||||
2. No equipamento, execute o reset local do agente (`rever-agent reset` ou reinstale o serviço) e reprovisione com o código da empresa.
|
||||
3. Após o agente gerar um novo token, associe a dispositivo ao novo colaborador no painel.
|
||||
|
||||
Sem o reset de agente, o Convex reaproveita o token anterior e o inventário continua vinculado ao usuário antigo.
|
||||
|
||||
## Estrutura principal
|
||||
|
||||
- `app/` dentro de `src/` — rotas e layouts do Next.js (App Router).
|
||||
- `components/` — componentes reutilizáveis (UI, formulários, layouts).
|
||||
- `convex/` — queries, mutations e seeds do Convex.
|
||||
- `prisma/` — schema e migracoes do Prisma (PostgreSQL).
|
||||
- `scripts/` — utilitários em Node para sincronização e seeds adicionais.
|
||||
- `agents.md` — guia operacional e contexto funcional (em PT-BR).
|
||||
- `PROXIMOS_PASSOS.md` — backlog de melhorias futuras.
|
||||
|
||||
## Credenciais de demonstração
|
||||
|
||||
Após executar `bun run auth:seed`, as credenciais padrão ficam disponíveis conforme descrito em `agents.md` (seção “Credenciais padrão”). Ajuste variáveis `SEED_USER_*` se precisar sobrepor usuários ou senhas durante o seed.
|
||||
|
||||
## Próximos passos
|
||||
|
||||
Consulte `PROXIMOS_PASSOS.md` para acompanhar o backlog funcional e o progresso das iniciativas planejadas.
|
||||
|
||||
### Executar com Bun
|
||||
|
||||
- `bun install` é o fluxo padrão (o arquivo `bun.lock` deve ser versionado; use `bun install --frozen-lockfile` em CI).
|
||||
- `bun run dev:bun`, `bun run convex:dev:bun`, `bun run build:bun` e `bun run start:bun` já estão configurados; internamente executam `bun run --bun <script>` para usar o runtime do Bun sem abrir mão dos scripts existentes. O `cross-env` garante os valores esperados de `NODE_ENV` (`development`/`production`).
|
||||
- O bundler padrão é o Turbopack; se precisar comparar/debugar com webpack, use `bun run build:webpack`.
|
||||
- `bun test` utiliza o test runner do Bun. O teste de snapshot de screenshot é automaticamente ignorado quando o matcher não está disponível; testes de navegador completos continuam via `bun run test:browser` (Vitest + Playwright).
|
||||
|
||||
<!-- ci: smoke test 3 -->
|
||||
|
||||
## Diagnóstico de sessão da dispositivo (Desktop)
|
||||
|
||||
- Quando o portal for aberto via app desktop, use a página `https://seu-app/portal/debug` para validar cookies e contexto:
|
||||
- `/api/auth/get-session` deve idealmente mostrar `user.role = "machine"` (em alguns ambientes WebView pode retornar `null`, o que não é bloqueante).
|
||||
- `/api/machines/session` deve retornar `200` com `assignedUserId/assignedUserEmail`.
|
||||
- O frontend agora preenche `machineContext` mesmo que `get-session` retorne `null`, e deriva o papel efetivo a partir desse contexto.
|
||||
- Se `machines/session` retornar `401/403`, revise CORS/credenciais e o fluxo de handshake documentados em `docs/OPERACAO-PRODUCAO.md`.
|
||||
596
agents.md
596
agents.md
|
|
@ -1,214 +1,392 @@
|
|||
# Plano de Desenvolvimento — Sistema de Chamados
|
||||
# Plano de Desenvolvimento - Sistema de Chamados
|
||||
|
||||
## Meta imediata
|
||||
Construir o nucleo de tickets compartilhado entre web e desktop (Tauri), garantindo base solida para canais, SLAs e automacoes futuras.
|
||||
|
||||
> **Diretriz máxima**: documentação, comunicação e respostas sempre em português brasileiro.
|
||||
|
||||
## Contatos
|
||||
### Contato principal
|
||||
- **Esdras Renan** — monkeyesdras@gmail.com
|
||||
|
||||
## Fase A - Fundamentos da plataforma
|
||||
1. **Scaffold e DX**
|
||||
- Criar projeto Next.js (App Router) com Typescript, ESLint, Tailwind, shadcn/ui.
|
||||
- Configurar alias de paths, lint/prettier opinativo.
|
||||
- Ajustar `globals.css` para tokens de cor/tipografia conforme layout base.
|
||||
2. **Design system inicial**
|
||||
- Importar componentes `dashboard-01` e `sidebar-01` via shadcn.
|
||||
- Ajustar paleta (tons de cinza + destaque primario) e tipografia (Inter/Manrope).
|
||||
- Implementar layout shell (sidebar + header) reutilizavel.
|
||||
3. **Autenticacao placeholder**
|
||||
- Configurar stub de sessao (cookie + middleware) para navegacao protegida.
|
||||
|
||||
### Status da fase
|
||||
- OK Scaffold Next.js + Tailwind + shadcn/ui criado em `web/`.
|
||||
- OK Layout base atualizado (sidebar, header, cards, grafico) com identidade da aplicacao.
|
||||
- OK Auth placeholder via cookie + middleware e bootstrap de usuario no Convex.
|
||||
|
||||
## Fase B - Nucleo de tickets
|
||||
1. **Modelagem compartilhada**
|
||||
- Definir esquema Prisma para Ticket, TicketEvent, User (minimo), Queue/View.
|
||||
- Publicar Zod schemas/Types para uso no frontend.
|
||||
2. **Fluxo principal**
|
||||
- Pagina `tickets` com tabela (TanStack) suportando filtros basicos.
|
||||
- Pagina de ticket com timeline de eventos/comentarios (dados mockados).
|
||||
- Implementar modo play preliminar (simula proxima tarefa da fila).
|
||||
3. **Mutations**
|
||||
- Formulario de criacao/edicao com validacao.
|
||||
- Comentarios publico/privado (UX + componentes).
|
||||
|
||||
### Status parcial
|
||||
- OK `prisma/schema.prisma` criado com entidades centrais (User, Team, Ticket, Comment, Event, SLA).
|
||||
- OK Schemas Zod e mocks compartilhados em `src/lib/schemas` e `src/lib/mocks`.
|
||||
- OK Paginas `/tickets`, `/tickets/[id]` e `/play` prontas com componentes dedicados (filtros, tabela, timeline, modo play).
|
||||
- OK Integração com backend Convex (consultas/mutações + file storage). Prisma mantido apenas como referência.
|
||||
|
||||
## Fase C - Servicos complementares (posterior)
|
||||
- SLAs (BullMQ + Redis), notificacoes, ingest de e-mail, portal cliente, etc.
|
||||
|
||||
## Backlog imediato
|
||||
- [x] Expor portal do cliente com listagem de tickets filtrada por `viewerId` (Convex + UI)
|
||||
- [x] Completar painel administrativo (times, filas, campos e SLAs) com RBAC server/client
|
||||
- [ ] Finalizar sincronização Better Auth ↔ Convex para resets de senha e revogações automáticas de convites
|
||||
- [ ] Expandir suite de testes (UI + Convex) cobrindo guardas, relatórios e mapeadores críticos
|
||||
- [x] Implementar fluxo completo de convites (criação, envio, revogação e aceite) para administradores
|
||||
- [ ] Habilitar ações avançadas para agentes (edição de categorias, reassigação rápida) com as devidas permissões
|
||||
- [ ] Integrar campos personalizados e categorias dinâmicas nos formulários de criação/edição de tickets
|
||||
|
||||
## Credenciais padrão (Better Auth)
|
||||
| Papel | Usuário | Senha |
|
||||
| --- | --- | --- |
|
||||
| Administrador | `admin@sistema.dev` | `admin123` |
|
||||
| Painel telão | `suporte@rever.com.br` | `agent123` |
|
||||
|
||||
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.
|
||||
|
||||
> Execute `bun run auth:seed` após configurar `.env` para (re)criar os usuários acima (campos `SEED_USER_*` podem sobrescrever credenciais).
|
||||
|
||||
## Backend Convex
|
||||
- Seeds de usuários/tickets demo: `convex/seed.ts`.
|
||||
- Para DEV: rode `bun run convex:dev:bun` e acesse `/dev/seed` uma vez para popular dados realistas.
|
||||
|
||||
## Stack atual (18/12/2025)
|
||||
- **Next.js**: `16.0.10` (Turbopack por padrão; webpack fica como fallback).
|
||||
- Whitelist de domínios em `src/config/allowed-hosts.ts` é aplicada pelo `middleware.ts`.
|
||||
- **React / React DOM**: `19.2.1`.
|
||||
- **Trilha de testes**: Vitest (`bun test`) sem modo watch por padrão (`--run --passWithNoTests`).
|
||||
- **CI**: workflow `Quality Checks` (`.github/workflows/quality-checks.yml`) roda `bun install`, `bun run prisma:generate`, `bun run lint`, `bun test`, `bun run build:bun`. Variáveis críticas (`BETTER_AUTH_SECRET`, `NEXT_PUBLIC_APP_URL`, etc.) são definidas apenas no runner — não afetam a VPS.
|
||||
- **Disciplina pós-mudanças**: sempre que fizer alterações locais, rode **obrigatoriamente** `bun run lint`, `bun run build:bun` e `bun test` antes de entregar ou abrir PR. Esses comandos são mandatórios também para os agentes/automations, garantindo que o projeto continua íntegro.
|
||||
- **Deploy**: pipeline `ci-cd-web-desktop.yml` (runner self-hosted). Build roda com Bun 1.3 + Node 20. Web é publicado em `/home/renan/apps/sistema` e o Swarm aponta `sistema_web` para essa pasta.
|
||||
|
||||
## Setup local (atualizado)
|
||||
1. `bun install`
|
||||
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
|
||||
- Local (DEV): PostgreSQL local (ex.: `postgres:18`) com `DATABASE_URL=postgresql://postgres:dev@localhost:5432/sistema_chamados`.
|
||||
- Produção: PostgreSQL no Swarm (serviço `postgres` em uso hoje; `postgres18` provisionado para migração). Migrations em PROD devem apontar para o `DATABASE_URL` ativo (ver `docs/OPERATIONS.md`).
|
||||
- Limpeza de legados: `node scripts/remove-legacy-demo-users.mjs` remove contas demo antigas (Cliente Demo, gestores fictícios etc.).
|
||||
|
||||
### Verificações antes de PR/deploy
|
||||
```bash
|
||||
bun run lint
|
||||
bun test
|
||||
bun run build:bun
|
||||
```
|
||||
|
||||
## Aplicativo Desktop (Tauri)
|
||||
- Código-fonte: `apps/desktop` (Tauri v2 + Vite + React 19).
|
||||
- URLs:
|
||||
- Produção: `https://tickets.esdrasrenan.com.br`
|
||||
- DEV: configure `apps/desktop/.env` (exemplo fornecido).
|
||||
- Comandos:
|
||||
- `bun run --cwd apps/desktop tauri dev` — desenvolvimento (porta 1420).
|
||||
- `bun run --cwd apps/desktop tauri build` — gera instaladores.
|
||||
- **Fluxo do agente**:
|
||||
1. Coleta perfil da dispositivo (hostname, OS, MAC, seriais, métricas).
|
||||
2. Provisiona via `POST /api/machines/register` usando `MACHINE_PROVISIONING_SECRET`, informando perfil de acesso (Colaborador/Gestor) + dados do colaborador.
|
||||
3. Envia heartbeats periódicos (`/api/machines/heartbeat`) com inventário básico + estendido (discos SMART, GPUs, serviços, softwares, CPU window).
|
||||
4. Realiza handshake em `APP_URL/machines/handshake?token=...&redirect=...` para receber cookies Better Auth + sessão (colaborador → `/portal`, gestor → `/dashboard`).
|
||||
5. Token persistido no cofre do SO (Keyring); store guarda apenas metadados.
|
||||
6. Envio manual de inventário via botão (POST `/api/machines/inventory`).
|
||||
7. Updates automáticos: plugin `@tauri-apps/plugin-updater` consulta `latest.json` publicado nos releases do GitHub.
|
||||
- **Admin ▸ Dispositivos**: permite ajustar perfil/email associado, visualizar inventário completo e remover dispositivo.
|
||||
|
||||
### Sessão "machine" no frontend
|
||||
- Ao autenticar como dispositivo, o front chama `/api/machines/session`, popula `machineContext` (assignedUser*, persona) e deriva role/`viewerId`.
|
||||
- Mesmo quando `get-session` é `null` na WebView, o portal utiliza `machineContext` para saber o colaborador/gestor logado.
|
||||
- UI remove opção "Sair" no menu do usuário quando detecta sessão de dispositivo.
|
||||
- `/portal/debug` exibe JSON de `get-session` e `machines/session` (útil para diagnosticar cookies/bearer).
|
||||
|
||||
### Observações adicionais
|
||||
- Planejamos usar um cookie `desktop_shell` no futuro para diferenciar acessos do desktop vs navegador (não implementado).
|
||||
|
||||
## Qualidade e testes
|
||||
- **Lint**: `bun run lint` (ESLint flat config).
|
||||
- **Testes unitários/integrados (Vitest)**:
|
||||
- Cobertura atual inclui utilitários (`tests/*.test.ts`), rotas `/api/machines/*` e `sendSmtpMail`.
|
||||
- Executar `bun test -- --watch` apenas quando precisar de modo interativo.
|
||||
- **Build**: `bun run build:bun` (`next build --turbopack`). Quando precisar do fallback oficial, rode `bun run build:webpack`.
|
||||
- **CI**: falhas mais comuns
|
||||
- `ERR_BUN_LOCKFILE_OUTDATED`: confirme que o `bun.lock` foi regenerado (`bun install`) após alterar dependências, especialmente do app desktop.
|
||||
- Variáveis Better Auth ausentes (`BETTER_AUTH_SECRET`): definidas no workflow (`Quality Checks`).
|
||||
- Falha de host: confira `src/config/allowed-hosts.ts`; o middleware retorna 403 quando o domínio do Traefik não está listado.
|
||||
|
||||
## Produção / Deploy
|
||||
- Runner self-hosted (VPS). Build roda fora de `/srv/apps/sistema` e rsync publica em `/home/renan/apps/sistema`.
|
||||
- Swarm: `stack.yml` monta `/home/renan/apps/sistema.current` → `/app` (via symlink).
|
||||
- Para liberar novo release manualmente:
|
||||
```bash
|
||||
ln -sfn /home/renan/apps/sistema.build.<novo> /home/renan/apps/sistema.current
|
||||
docker service update --force sistema_web
|
||||
```
|
||||
- Resolver `P3009` (migration falhou) no PostgreSQL ativo:
|
||||
```bash
|
||||
docker service scale sistema_web=0
|
||||
docker run --rm -it --network traefik_public \
|
||||
--env-file /home/renan/apps/sistema.current/.env \
|
||||
-v /home/renan/apps/sistema.current:/app \
|
||||
oven/bun:1 bash -lc "bun install --frozen-lockfile && bun x prisma migrate resolve --rolled-back <migration> && bun x prisma migrate deploy"
|
||||
docker service scale sistema_web=1
|
||||
```
|
||||
|
||||
## Estado do portal / app web
|
||||
- Autenticação Better Auth com `AuthGuard`.
|
||||
- Sidebar inferior agrega avatar, link para `/settings` e logout (oculto em sessões de dispositivo).
|
||||
- Formulários de ticket (novo/editar/comentários) usam editor rico + anexos; placeholders e validação PT-BR.
|
||||
- Relatórios e painéis utilizam `AppShell` + `SiteHeader`.
|
||||
- `usePersistentCompanyFilter` mantém filtro global de empresa em relatórios/admin.
|
||||
- Exportações CSV: backlog, canais, CSAT, SLA, horas (rotas `/api/reports/*.csv`).
|
||||
- PDF do ticket (`/api/tickets/[id]/export/pdf`).
|
||||
- Play interno/externo com métricas por tipo.
|
||||
- Admin > Empresas: cadastro + “Cliente avulso?”, horas contratadas, vínculos de usuários.
|
||||
- Admin > Usuários/Equipe:
|
||||
- Abas separadas: "Equipe" (administradores e agentes) e "Usuários" (gestores e colaboradores).
|
||||
- Multi‑seleção + ações em massa: excluir usuários, remover agentes de dispositivo e revogar convites pendentes.
|
||||
- Filtros por papel, empresa e espaço (tenant) quando aplicável; busca unificada.
|
||||
- Convites: campo "Espaço (ID interno)" removido da UI de geração.
|
||||
- Admin > Usuários: vincular colaborador à empresa.
|
||||
- Alertas enviados: acessível agora em Configurações → Administração do workspace (link direto para /admin/alerts). Removido da sidebar.
|
||||
- Dashboard: cards por fila e indicadores principais.
|
||||
|
||||
## Fluxos suportados
|
||||
- **Equipe interna** (`admin`, `agent`, `collaborator`): cria/acompanha tickets, comenta, altera status/fila, gera relatórios.
|
||||
- **Gestores** (`manager`): visualizam tickets da empresa, comentam publicamente, acessam dashboards.
|
||||
- **Colaboradores** (`collaborator`): portal (`/portal`), tickets próprios, comentários públicos, editor rico, anexos.
|
||||
- **Sessão Dispositivo**: desktop registra heartbeat/inventário e redireciona colaborador/gestor ao portal apropriado com cookies válidos.
|
||||
|
||||
### Correções recentes
|
||||
- Temporizador do ticket (atendimento em andamento): a UI passa a aplicar atualização otimista na abertura/pausa da sessão para que o tempo corrente não "salte" para minutos indevidos. O back‑end continua a fonte da verdade (total acumulado é reconciliado ao pausar).
|
||||
|
||||
## Backlog recomendado
|
||||
1. E-mails automáticos quando uso de horas ≥ 90% do contratado.
|
||||
2. Ações rápidas (status/fila) diretamente na lista de tickets.
|
||||
3. Limites de anexos por tenant + monitoramento.
|
||||
4. Layout do PDF do ticket alinhado ao visual da aplicação.
|
||||
5. Experimentos com React Compiler (Next 16).
|
||||
|
||||
## Referências rápidas
|
||||
- **Endpoints agent desktop**:
|
||||
- `POST /api/machines/register`
|
||||
- `POST /api/machines/heartbeat`
|
||||
- `POST /api/machines/inventory`
|
||||
- **Relatórios XLSX**:
|
||||
- Backlog: `/api/reports/backlog.xlsx?range=7d|30d|90d[&companyId=...]`
|
||||
- Canais: `/api/reports/tickets-by-channel.xlsx?...`
|
||||
- CSAT: `/api/reports/csat.xlsx?...`
|
||||
- SLA: `/api/reports/sla.xlsx?...`
|
||||
- Horas: `/api/reports/hours-by-client.xlsx?...`
|
||||
- Inventário de dispositivos: `/api/reports/machines-inventory.xlsx?[companyId=...]`
|
||||
- **Docs complementares**:
|
||||
- `docs/DEV.md` — guia diário atualizado.
|
||||
- `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog.
|
||||
- `docs/OPERATIONS.md` — runbook do Swarm.
|
||||
- `docs/admin-inventory-ui.md`, `docs/plano-app-desktop-maquinas.md` — detalhes do inventário/agente.
|
||||
|
||||
## Regras de Codigo
|
||||
|
||||
### Tooltips Nativos do Navegador
|
||||
|
||||
**NAO use o atributo `title` em elementos HTML** (button, span, a, div, etc).
|
||||
|
||||
O atributo `title` causa tooltips nativos do navegador que sao inconsistentes visualmente e nao seguem o design system da aplicacao.
|
||||
|
||||
```tsx
|
||||
// ERRADO - causa tooltip nativo do navegador
|
||||
<button title="Remover item">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
|
||||
// CORRETO - sem tooltip nativo
|
||||
<button>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
|
||||
// CORRETO - se precisar de tooltip, use o componente Tooltip do shadcn/ui
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Remover item</TooltipContent>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
**Excecoes:**
|
||||
- Props `title` de componentes customizados (CardTitle, DialogTitle, etc) sao permitidas pois nao geram tooltips nativos.
|
||||
|
||||
### Acessibilidade
|
||||
|
||||
Para manter acessibilidade em botoes apenas com icone, prefira usar `aria-label`:
|
||||
|
||||
```tsx
|
||||
<button aria-label="Remover item">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
```
|
||||
### Iniciativa atual — Autenticação real e personas
|
||||
- [x] Migrar placeholder para Better Auth + Prisma (handlers Next, cliente React e sync Convex).
|
||||
- [x] Expor roles (`admin`, `agent`, `customer`) e aplicar guardas (`requireUser/Staff/Admin/Customer`) no Convex.
|
||||
- [x] Ajustar middleware e componentes para usar `viewerId`/`actorId`, evitando vazamento de dados entre tenants.
|
||||
- [x] Criar portal do cliente para abertura/consulta de chamados e comentários públicos.
|
||||
- [x] Consolidar painel administrativo (times, filas, campos e SLAs) com UI protegida por RBAC completo.
|
||||
- [x] Entregar fluxo de convites Better Auth (criação, envio, revogação) e gerenciamento de agentes.
|
||||
- [ ] Unificar ciclo de vida de credenciais (reset de senha, expiração automática e reenvio de convites).
|
||||
|
||||
## Proximas entregas sugeridas
|
||||
1. Consolidar onboarding/offboarding de agentes com resets de senha, reenvio automático e auditoria de convites Better Auth.
|
||||
2. Expor categorias, subcategorias e campos personalizados dinamicamente nas telas de criação/edição de tickets (web e desktop).
|
||||
3. Definir permissões intermediárias para agentes (edição limitada de categorias/campos) e refletir no Convex.
|
||||
4. Expandir relatórios operacionais (workSummary, métricas por canal/categoria) usando os novos campos personalizados.
|
||||
5. Automatizar pipeline CI (lint + vitest) integrando checagens obrigatórias antes de merge.
|
||||
|
||||
## Acompanhamento
|
||||
Atualizar este arquivo a cada marco relevante (setup concluido, nucleo funcional, etc.).
|
||||
|
||||
---
|
||||
_Última atualização: 18/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._
|
||||
|
||||
# Guia do Projeto (para agentes e contribuidores)
|
||||
|
||||
Este repositório foi atualizado para usar Convex como backend em tempo real para o núcleo de tickets. Abaixo, um guia prático conforme o padrão de AGENTS.md para orientar contribuições futuras.
|
||||
|
||||
## Decisões técnicas atuais
|
||||
- Backend: Convex (funções + banco + storage) em `web/convex/`.
|
||||
- Esquema: `web/convex/schema.ts`.
|
||||
- Tickets API: `web/convex/tickets.ts` (list/getById/create/addComment/updateStatus/playNext).
|
||||
- Upload de arquivos: `web/convex/files.ts` (Convex Storage).
|
||||
- Filas: `web/convex/queues.ts` (resumo por fila).
|
||||
- Seed/bootstrap: `web/convex/seed.ts`, `web/convex/bootstrap.ts`.
|
||||
- Autenticação: Better Auth + Prisma (SQLite) com roles (`admin`, `agent`, `customer`) sincronizadas com Convex
|
||||
- Login: `web/src/app/login/page.tsx` + `web/src/components/login/login-form.tsx`
|
||||
- Middleware e guards: `web/middleware.ts`, helpers em `web/src/lib/auth{,z, -server}.ts`
|
||||
- Cliente React: `web/src/lib/auth-client.tsx` (sincroniza sessão Better Auth ↔ Convex, expõe helpers de role)
|
||||
- Frontend (Next.js + shadcn/ui)
|
||||
- Páginas principais: `/tickets`, `/tickets/[id]`, `/tickets/new`, `/play`.
|
||||
- UI ligada ao Convex com `convex/react`.
|
||||
- Toasts: `sonner` via `Toaster` em `web/src/app/layout.tsx`.
|
||||
- Mapeamento/validação de dados
|
||||
- Convex retorna datas como `number` (epoch). A UI usa `Date`.
|
||||
- Sempre converter/validar via Zod em `web/src/lib/mappers/ticket.ts`.
|
||||
- Não retornar `Date` a partir de funções do Convex.
|
||||
- Prisma: mantido apenas como referência de domínio (não é fonte de dados ativa).
|
||||
|
||||
## Como rodar
|
||||
- Pré‑requisitos: Node LTS + pnpm.
|
||||
- Passos:
|
||||
- `cd web && pnpm i`
|
||||
- `pnpm convex:dev` (mantém gerando tipos e rodando backend dev)
|
||||
- Criar `.env.local` com `NEXT_PUBLIC_CONVEX_URL=<url exibida pelo convex dev>`
|
||||
- Em outro terminal: `pnpm dev`
|
||||
- Login em `/login`; seed opcional em `/dev/seed`.
|
||||
|
||||
## Convenções de código
|
||||
- Não use `Date` em payloads do Convex; use `number` (epoch ms).
|
||||
- Normalize dados no front via mappers Zod antes de renderizar.
|
||||
- UI com shadcn/ui; priorize componentes existentes e consistência visual.
|
||||
- Labels e mensagens em PT‑BR (status, timeline, toasts, etc.).
|
||||
- Atualizações otimistas com rollback em erro + toasts de feedback.
|
||||
- Comentários de supressão: prefira `@ts-expect-error` com justificativa curta para módulos gerados do Convex; evite `@ts-ignore`.
|
||||
|
||||
## Estrutura útil
|
||||
- `web/convex/*` — API backend Convex.
|
||||
- `web/src/lib/mappers/*` — Conversores server→UI com Zod.
|
||||
- `web/src/components/tickets/*` — Tabela, filtros, detalhe, timeline, comentários, play.
|
||||
|
||||
## Scripts (pnpm)
|
||||
- `pnpm convex:dev` — Convex (dev + geração de tipos)
|
||||
- `pnpm dev` — Next.js (App Router)
|
||||
- `pnpm build` / `pnpm start` — build/produção
|
||||
|
||||
## Backlog imediato (próximos passos)
|
||||
- Form “Novo ticket” em Dialog shadcn + React Hook Form + Zod + toasts.
|
||||
- Atribuição/transferência de fila no detalhe (selects com update otimista).
|
||||
- Melhorias de layout adicionais no painel “Detalhes” (quebras, largura responsiva) e unificação de textos PT‑BR.
|
||||
- Testes unitários dos mapeadores com Vitest.
|
||||
|
||||
## Checklist de PRs
|
||||
- [ ] Funções Convex retornam apenas tipos suportados (sem `Date`).
|
||||
- [ ] Dados validados/convertidos via Zod mappers antes da UI.
|
||||
- [ ] Textos/labels em PT‑BR.
|
||||
- [ ] Eventos de UI com feedback (toast) e rollback em erro.
|
||||
- [ ] Documentação atualizada se houver mudanças em fluxo/env.
|
||||
|
||||
---
|
||||
|
||||
## Próximas Entregas (Roadmap detalhado)
|
||||
|
||||
1) UX/Visual (shadcn/ui)
|
||||
- Padronizar cartões em todas as telas (Play, Visualizações) com o mesmo padrão aplicado em Conversa/Detalhes/Timeline (bordas, sombra, paddings).
|
||||
- Aplicar microtipografia consistente: headings H1/H2, tracking, tamanhos, cores em PT‑BR.
|
||||
- Skeletons de carregamento nos principais painéis (lista de tickets, recentes, play next).
|
||||
- Melhorar tabela: estados hover/focus, ícones de canal, largura de colunas previsível e truncamento.
|
||||
|
||||
2) Comentários e anexos
|
||||
- Dropzone também no “Novo ticket” (já implementado) com registro de comentário inicial e anexos.
|
||||
- Grid de anexos com miniaturas e legenda; manter atributo `download` com o nome original.
|
||||
- Preview em modal para imagens (feito) e suporte a múltiplas linhas no grid.
|
||||
- Botão para copiar link de arquivo (futuro, usar URL do storage).
|
||||
|
||||
3) Timeline e eventos
|
||||
- Mensagens amigáveis em PT‑BR (feito para CREATED/STATUS/ASSIGNEE/QUEUE).
|
||||
- Incluir sempre `actorName`/`actorAvatar` no payload; evitar JSON cru na UI.
|
||||
- Exibir avatar e nome do ator nas entradas (parcialmente feito).
|
||||
|
||||
4) Dados e camada Convex
|
||||
- Sempre retornar datas como `number` (epoch) e converter no front via mappers Zod.
|
||||
- Padronizar import do Convex com `@/convex/_generated/api` (alias criado).
|
||||
- Evitar `useQuery` com args vazios — proteger chamadas (gates) e, quando necessário, fallback de mock para IDs `ticket-*`.
|
||||
|
||||
5) Autenticação / Sessão (placeholder)
|
||||
- Cookie `demoUser` e bootstrap de usuário no Convex (feito). Trocar por Auth.js/Clerk quando for o momento.
|
||||
|
||||
6) Testes
|
||||
- Vitest configurado; adicionar casos para mapeadores (já iniciado) e smoke tests básicos de páginas.
|
||||
- Não usar Date em assertions de payload — sempre comparar epoch ou `instanceof Date` após mapeamento.
|
||||
|
||||
7) Acessibilidade e internacionalização
|
||||
- Labels e mensagens 100% em PT‑BR; evitar termos como `QUEUE_CHANGED` na UI.
|
||||
- Navegação por teclado em Dialogs/Selects; aria-labels em botões de ação.
|
||||
|
||||
8) Observabilidade (posterior)
|
||||
- Logs de evento estruturados no Convex; traces simples no client para ações críticas.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints Convex (resumo)
|
||||
- `tickets.list({ tenantId, status?, priority?, channel?, queueId?, search?, limit? })`
|
||||
- `tickets.getById({ tenantId, id })`
|
||||
- `tickets.create({ tenantId, subject, summary?, priority, channel, queueId?, requesterId })`
|
||||
- `tickets.addComment({ ticketId, authorId, visibility, body, attachments?[] })`
|
||||
- `tickets.updateStatus({ ticketId, status, actorId })` — gera evento com `toLabel` e `actorName`.
|
||||
- `tickets.changeAssignee({ ticketId, assigneeId, actorId })` — gera evento com `assigneeName`.
|
||||
- `tickets.changeQueue({ ticketId, queueId, actorId })` — gera evento com `queueName`.
|
||||
- `tickets.playNext({ tenantId, queueId?, agentId })` — atribui ticket e registra evento.
|
||||
- `tickets.updatePriority({ ticketId, priority, actorId })` — altera prioridade e registra `PRIORITY_CHANGED`.
|
||||
- `tickets.remove({ ticketId, actorId })` — remove ticket, eventos e comentários (tenta excluir anexos do storage).
|
||||
- `queues.summary({ tenantId })`
|
||||
- `files.generateUploadUrl()` — usar via `useAction`.
|
||||
- `users.ensureUser({ tenantId, email, name, avatarUrl?, role?, teams? })`
|
||||
|
||||
Observações:
|
||||
- Não retornar `Date` nas funções Convex; usar `number` e converter na UI com os mappers em `src/lib/mappers`.
|
||||
- Evitar passar `{}` para `useQuery` — args devem estar definidos ou a query não deve ser invocada.
|
||||
|
||||
---
|
||||
|
||||
## Padrões de Código
|
||||
- UI: shadcn/ui (Field, Dialog, Select, Badge, Table, Spinner) + Tailwind.
|
||||
- Dados: Zod para validação; mappers para converter server→UI (epoch→Date, null→undefined).
|
||||
- Texto: PT‑BR em labels, toasts e timeline.
|
||||
- UX: updates otimistas + toasts (status, assignee, fila, comentários).
|
||||
- Imports do Convex: sempre `@/convex/_generated/api`.
|
||||
|
||||
---
|
||||
|
||||
## Como abrir PR
|
||||
- Crie uma branch descritiva (ex.: `feat/tickets-attachments-grid`).
|
||||
- Preencha a descrição com: contexto, mudanças, como testar (pnpm scripts), screenshots quando útil.
|
||||
- Checklist:
|
||||
- [ ] Sem `Date` no retorno Convex.
|
||||
- [ ] Labels PT‑BR.
|
||||
- [ ] Skeleton/Loading onde couber.
|
||||
- [ ] Mappers atualizados se tocar em payloads.
|
||||
- [ ] AGENTS.md atualizado se houver mudança de padrões.
|
||||
|
||||
---
|
||||
|
||||
## Atualizações recentes (dez/2025)
|
||||
|
||||
- RBAC do Convex reforçado: `tickets.list`, `tickets.getById`, `workSummary` e mutações sensíveis (`changeQueue`, `updateCategories`, `startWork/pauseWork`, `updatePriority`) agora exigem `viewerId/actorId` e validam `requireStaff` com `tenantId`.
|
||||
- Componentes de tickets (tabela, painel de recentes, play next, cabeçalho/detalhe) passam a usar o contexto Better Auth para prover `viewerId`, com `useQuery` protegido por `"skip"` enquanto não há sessão.
|
||||
- Testes (`pnpm vitest run`) executados após as alterações para garantir regressão zero.
|
||||
|
||||
## Progresso recente (mar/2025)
|
||||
|
||||
Resumo do que foi implementado desde o último marco:
|
||||
|
||||
- Rich text (Tiptap) com SSR seguro para comentários e descrição inicial do ticket
|
||||
- Componente: `web/src/components/ui/rich-text-editor.tsx`
|
||||
- Comentários: `web/src/components/tickets/ticket-comments.rich.tsx` (visibilidade Público/Interno, anexos tipados)
|
||||
- Novo ticket (Dialog + Página): campos de descrição usam rich text; primeiro comentário é registrado quando houver conteúdo.
|
||||
- Tipagem estrita (remoção de `any`) no front e no Convex
|
||||
- Uso consistente de `Id<>` e `Doc<>` (Convex) e schemas Zod (record tipado em v4).
|
||||
- Queries `useQuery` com "skip" quando necessário; mapeadores atualizados.
|
||||
- Filtros server-side
|
||||
- `tickets.list` agora escolhe o melhor índice (por `status`, `queueId` ou `tenant`) e só então aplica filtros complementares.
|
||||
- UI do detalhe do ticket (Header)
|
||||
- Prioridade como dropdown-badge translúcida: `web/src/components/tickets/priority-select.tsx` (nova Convex `tickets.updatePriority`).
|
||||
- Seleção de responsável com avatar no menu.
|
||||
- Ação de exclusão com modal (ícones, confirmação): `web/src/components/tickets/delete-ticket-dialog.tsx` (Convex `tickets.remove`).
|
||||
- Correções e DX
|
||||
- Tiptap: `immediatelyRender: false` + `setContent({ emitUpdate: false })` para evitar mismatch de hidratação.
|
||||
- Validação de assunto no Dialog “Novo ticket” (trim + `setError`) para prevenir `ZodError` em runtime.
|
||||
|
||||
Arquivos principais tocados:
|
||||
- Convex: `web/convex/schema.ts`, `web/convex/tickets.ts` (novas mutations + tipagem `Doc/Id`).
|
||||
- UI: `ticket-summary-header.tsx`, `ticket-detail-view.tsx`, `ticket-comments.rich.tsx`, `new-ticket-dialog.tsx`, `play-next-ticket-card.tsx`.
|
||||
- Tipos e mapeadores: `web/src/lib/schemas/ticket.ts`, `web/src/lib/mappers/ticket.ts`.
|
||||
|
||||
## Guia de layout/UX aplicado
|
||||
|
||||
- Header do ticket
|
||||
- Ordem: `#ref` • PrioritySelect (badge) • Status (badge/select) • Ações (Excluir)
|
||||
- Tipografia: título forte, resumo como texto auxiliar, metadados em texto pequeno.
|
||||
- Combos de Categoria/ Subcategoria exibidos como selects dependentes com salvamento automático (sem botões dedicados).
|
||||
- Comentários
|
||||
- Composer com rich text + Dropzone; seletor de visibilidade.
|
||||
- Lista com avatar, nome, carimbo relativo e conteúdo rich text.
|
||||
- Prioridades (labels)
|
||||
- LOW (cinza), MEDIUM (azul), HIGH (âmbar), URGENT (vermelho) — badge translúcida no trigger do select.
|
||||
|
||||
## Próximos passos sugeridos (UI/Funcionais)
|
||||
|
||||
Curto prazo (incremental):
|
||||
- [ ] Transformar Status em dropdown-badge (mesmo padrão de Prioridade).
|
||||
- [ ] Estados vazios com `Empty` (ícone, título, descrição, CTA) na lista de comentários e tabela.
|
||||
- [ ] Edição inline no header (Assunto/Resumo) com botões Reset/Salvar (mutations dedicadas).
|
||||
- [ ] Polir cards (bordas/padding/sombra) nas telas Play/Tickets para padronizar com Header/Conversa.
|
||||
|
||||
Médio prazo:
|
||||
- [ ] Combobox (command) para responsável com busca.
|
||||
- [ ] Paginação/ordenção server-side em `tickets.list`.
|
||||
- [ ] Unificar mensagens de timeline e payloads (sempre `actorName`/`actorAvatar`).
|
||||
- [ ] Testes Vitest para mapeadores e smoke tests básicos das páginas.
|
||||
|
||||
## Como validar manualmente
|
||||
- Rich text: comentar em `/tickets/[id]` com formatação, anexos e alternando visibilidade.
|
||||
- Prioridade: alterar no cabeçalho; observar evento de timeline e toasts.
|
||||
- Exclusão: acionar modal no cabeçalho e confirmar; conferir redirecionamento para `/tickets`.
|
||||
- Novo ticket: usar Dialog; assunto com menos de 3 chars deve bloquear submit com erro no campo.
|
||||
|
||||
---
|
||||
|
||||
## Atualizações recentes (abr/2025)
|
||||
|
||||
Resumo do que foi integrado nesta rodada para o núcleo de tickets e UX:
|
||||
|
||||
- Header do ticket
|
||||
- Status como dropdown‑badge (padrão visual alinhado às badges existentes).
|
||||
- Edição inline de Assunto/Resumo com Cancelar/Salvar e toasts.
|
||||
- Ação de Play/Pause (toggle de atendimento) com eventos WORK_STARTED/WORK_PAUSED na timeline.
|
||||
- Layout dos campos reorganizado: labels acima e controles abaixo (evita redundância do valor + dropdown lado a lado).
|
||||
- Tabela e comentários
|
||||
- Empty states padronizados com Empty + CTA de novo ticket.
|
||||
- Notificações
|
||||
- Toaster centralizado no rodapé (bottom‑center) com estilo consistente.
|
||||
- Título do app
|
||||
- Atualizado para “Sistema de chamados”.
|
||||
|
||||
Backend Convex
|
||||
- ickets.updateSubject e ickets.updateSummary adicionadas para edição do cabeçalho.
|
||||
- ickets.toggleWork adicionada; campo opcional working no schema de ickets.
|
||||
|
||||
Próximos passos sugeridos
|
||||
- Status dropdown‑badge também na tabela (edição rápida opcional com confirmação).
|
||||
- Combobox (command) para busca de responsável no select.
|
||||
- Tokens de cor: manter badges padrão do design atual; quando migração completa para paleta Rever estiver definida, aplicar via globals.css para herdar em todos os componentes.
|
||||
- Testes (Vitest): adicionar casos de mappers e smoke tests de páginas.
|
||||
|
||||
Observações de codificação
|
||||
- Evitar `any`; usar TicketStatus/TicketPriority e Id<>/Doc<> do Convex.
|
||||
- Não retornar Date do Convex; sempre epoch (number) e converter via mappers Zod.
|
||||
|
||||
## Atualizações recentes (out/2025)
|
||||
- Cabeçalho de ticket agora persiste automaticamente mudanças de categoria/subcategoria, mostrando toasts e bloqueando os selects enquanto a mutação é processada.
|
||||
- Normalização de nomes de fila/time aplicada também ao retorno de `tickets.playNext`, garantindo rótulos "Chamados"/"Laboratório" em todos os fluxos.
|
||||
- ESLint ignora `convex/_generated/**` e supressões migradas para `@ts-expect-error` com justificativa explícita.
|
||||
- Mutação `tickets.remove` não requer mais `actorId`; o diálogo de exclusão apenas envia `ticketId`.
|
||||
|
||||
## Atualizações recentes (nov/2025)
|
||||
- Dialog de novo ticket redesenhado: duas colunas com botão “Criar” no cabeçalho, dropzone mais compacta, categorias primária/secundária empilhadas e rótulos explícitos.
|
||||
- Validação do assunto relaxada para evitar `ZodError` prematuro; verificação manual permanece na submissão.
|
||||
- Placeholder cinza claro "Escreva um comentário..." aplicado ao editor Tiptap e seção renomeada para “Comentários”.
|
||||
- Linhas da tabela de tickets agora são totalmente clicáveis (mouse e teclado), reforçando acessibilidade e atalho de navegação.
|
||||
- Toasts e layouts refinados para manter consistência entre criação, listagem e detalhe dos tickets.
|
||||
|
||||
## Atualizações recentes (out/2025)
|
||||
- Tabela de tickets refinada com ícones de canal, prioridade ajustável inline e indicadores suavizados (fila/status/categoria) para reduzir ruído visual.
|
||||
- Definido plano de migração para Better Auth com RBAC (admin/agent/customer), portal do cliente e painel administrativo para filas/categorias/agentes.
|
||||
- Próximo passo: iniciar fase de implementação da autenticação real, substituindo middleware placeholder e alinhando Convex aos novos papéis.
|
||||
- Better Auth agora usa banco SQLite local (`db.sqlite`) e o schema Prisma foi migrado com sucesso via `pnpm exec prisma migrate dev --name init`.
|
||||
- Configuração do `postcss.config.mjs` corrigida para usar `@tailwindcss/postcss` como plugin executável, liberando a suíte do Vitest (`pnpm exec vitest run`).
|
||||
- Script `pnpm auth:seed` cria/atualiza o usuário inicial (`admin@sistema.dev` / `admin123`) usando `better-auth/crypto` para hash de senha.
|
||||
- Página de login refeita com layout em duas colunas (header + imagem lateral) e formulário integrado ao Better Auth (`LoginForm`).
|
||||
- Middleware atualizado aplica RBAC inicial (clientes direcionados ao portal, rotas `/admin` reservadas a administradores) e helpers de role expostos em `src/lib/authz.ts`; página `/portal` criada como placeholder do futuro autosserviço.
|
||||
|
||||
## Próximos passos estratégicos
|
||||
|
||||
### Produto / Experiência
|
||||
- [ ] Unificar revisão visual do modal de novo ticket com microinterações (estado de salvamento, validações inline).
|
||||
- [ ] Implementar filtros salváveis e quick actions na listagem (ex.: alterar status diretamente).
|
||||
- [ ] Exibir indicadores de anexos na tabela e nos cartões de “tickets recentes”.
|
||||
|
||||
### Técnica
|
||||
- [ ] Corrigir configuração do `postcss.config.mjs` (plugin inválido impede execução do Vitest) e restaurar cobertura de testes automatizados.
|
||||
- [ ] Formalizar camada de autenticação (Auth.js ou Clerk) com refresh de sessão e proteção de rotas no Convex (`auth.getUserIdentity`).
|
||||
- [ ] Mapear RBAC inicial (admin/agente/visualização) e refletir nas mutations do Convex.
|
||||
- [ ] Configurar ambientes `staging`/`production` do Convex com variáveis (.env) versionadas via doppler/1Password.
|
||||
- [ ] Automatizar lint/test/build no CI (GitHub Actions) e bloquear merge sem execução.
|
||||
|
||||
### Administrativa / Operacional
|
||||
- [ ] Inventariar acessos: quem possui permissão no Convex, GitHub e futuros serviços (Redis, email, armazenamento S3?).
|
||||
- [ ] Criar checklists de onboarding/offboarding de agentes (criação de usuário, associação a filas, provisionamento de avatar).
|
||||
- [ ] Definir plano de capacidade para armazenamento de anexos (quotas por tenant, política de retenção) e alertas.
|
||||
- [ ] Preparar mock de integrações externas (e-mail entrante, WhatsApp) para futuras etapas.
|
||||
- [ ] Documentar fluxo de suporte interno (quem revisa PRs, janelas de deploy, rollback).
|
||||
|
||||
Manter este arquivo atualizado ao concluir cada item estratégico ou quando surgirem novas dependências administrativas.
|
||||
|
||||
## Atualizações recentes (mai/2026)
|
||||
|
||||
- Login corporativo refinado com instruções revisadas para primeiro acesso e mensagens de erro totalmente em PT-BR.
|
||||
- Script `pnpm auth:seed` executado para garantir o usuário administrador padrão (`admin@sistema.dev` / `admin123`).
|
||||
- Toast de autenticação inválida agora informa "E-mail ou senha inválidos", alinhando o feedback com o restante da interface.
|
||||
|
||||
### Próximos passos imediatos
|
||||
- [ ] Implementar fluxo completo de convites (criação, expiração, revogação) integrado ao Better Auth e Convex.
|
||||
- [ ] Adicionar testes Vitest/E2E cobrindo dashboards, relatórios e guardas de RBAC no front.
|
||||
- [ ] Mapear permissões de edição avançada para agentes (categorias, campos rápidos) antes de liberar novas mutações.
|
||||
|
||||
## Atualizações recentes (jun/2026)
|
||||
|
||||
- RBAC do Convex reforçado em times, filas, campos, SLAs e relatórios; todas as chamadas exigem `viewerId`/`actorId` conforme o papel (admin ou staff).
|
||||
- Painel administrativo atualizado para consumir as novas assinaturas protegidas, com validações de sessão Better Auth e feedback de toasts.
|
||||
- Dashboard principal passou a exibir métricas reais via `reports.dashboardOverview` e séries históricas por canal com `reports.ticketsByChannel`.
|
||||
- Portal do cliente publicado com isolamento por `viewerId`, garantindo que clientes visualizem apenas seus chamados.
|
||||
|
||||
## Atualizações recentes (ago/2026)
|
||||
|
||||
- Convites Better Auth finalizados ponta a ponta: novos modelos Prisma, utilitários de servidor, rotas Next e tabela `userInvites` no Convex com sincronização e RBAC.
|
||||
- Painel administrativo reorganizado com `CategoriesManager`, permitindo CRUD completo de categorias e subcategorias, inclusive cadastro em lote na criação.
|
||||
- Campos personalizados de tickets agora são validados e persistidos no Convex (`tickets.customFields`) com normalização por tipo, `displayValue` e mapeamento seguro no frontend.
|
||||
- Consultas e componentes que consomem `queues.summary` passaram a enviar `viewerId`, eliminando erros de autorização na UI de tickets.
|
||||
- Suite de testes estendida com `invite-utils.test.ts` e configuração `vitest.setup.ts`, garantindo ambiente consistente com variáveis Better Auth.
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
# Ambiente local do App Desktop (Vite/Tauri)
|
||||
# Copie para `apps/desktop/.env` e ajuste.
|
||||
|
||||
# URL da aplicação web (Next.js) que será carregada dentro do app desktop.
|
||||
# Em produção, o app já usa por padrão: https://tickets.esdrasrenan.com.br
|
||||
VITE_APP_URL=http://localhost:3000
|
||||
|
||||
# Base da API (para as rotas /api/machines/*)
|
||||
# Se não definir, cai no mesmo valor de VITE_APP_URL
|
||||
VITE_API_BASE_URL=
|
||||
|
||||
# RustDesk provisioning (opcionais; se vazios, o app usa o TOML padrão embutido)
|
||||
VITE_RUSTDESK_CONFIG_STRING=
|
||||
VITE_RUSTDESK_DEFAULT_PASSWORD=FMQ9MA>e73r.FI<b*34Vmx_8P
|
||||
|
||||
# Assinatura Tauri (dev/CI). Em producao, pode sobrescrever por env seguro.
|
||||
TAURI_SIGNING_PRIVATE_KEY=dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5WkhWOUtzd1BvV0ZlSjEvNzYwaHYxdEloNnV4cmZlNGhha1BNbmNtZEkrZ0FBQkFBQUFBQUFBQUFBQUlBQUFBQS9JbCtsd3VFbHN4empFRUNiU0dva1hKK3ZYUzE2S1V6Q1FhYkRUWGtGMTBkUmJodi9PaXVub3hEMisyTXJoYU5UeEdwZU9aMklacG9ualNWR1NaTm1PMVBpVXYrNTltZU1YOFdwYzdkOHd2STFTc0x4ZktpNXFENnFTdW0xNzY3WC9EcGlIRGFmK2c9Cg==
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD=revertech
|
||||
|
||||
# Opcional: IP do host para desenvolvimento com HMR fora do localhost
|
||||
# Ex.: 192.168.0.10
|
||||
TAURI_DEV_HOST=
|
||||
24
apps/desktop/.gitignore
vendored
24
apps/desktop/.gitignore
vendored
|
|
@ -1,24 +0,0 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
apps/desktop/.vscode/extensions.json
vendored
3
apps/desktop/.vscode/extensions.json
vendored
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
# Sistema de Chamados — App Desktop (Tauri)
|
||||
|
||||
Cliente desktop (Tauri v2 + Vite) que:
|
||||
- Coleta perfil/métricas da dispositivo via comandos Rust.
|
||||
- Registra a dispositivo com um código de provisionamento.
|
||||
- Envia heartbeat periódico ao backend (`/api/machines/heartbeat`).
|
||||
- Redireciona para a UI web do sistema após provisionamento.
|
||||
- Armazena o token da dispositivo com segurança no cofre do SO (Keyring).
|
||||
- Exibe abas de Resumo, Inventário, Diagnóstico e Configurações; permite “Enviar inventário agora”.
|
||||
|
||||
## URLs e ambiente
|
||||
|
||||
- Em produção, o app usa por padrão `https://tickets.esdrasrenan.com.br`.
|
||||
- Em desenvolvimento, use `apps/desktop/.env` (copiado do `.env.example`):
|
||||
|
||||
```
|
||||
VITE_APP_URL=http://localhost:3000
|
||||
# Opcional: se vazio, usa o mesmo do APP_URL
|
||||
VITE_API_BASE_URL=
|
||||
```
|
||||
|
||||
## Comandos
|
||||
|
||||
- Dev (abre janela Tauri e Vite em 1420):
|
||||
- `bun run --cwd apps/desktop tauri dev`
|
||||
- Build frontend (somente Vite):
|
||||
- `bun run --cwd apps/desktop build`
|
||||
- Build executável (bundle):
|
||||
- `bun run --cwd apps/desktop tauri build`
|
||||
|
||||
Saída dos pacotes: `apps/desktop/src-tauri/target/release/bundle/`.
|
||||
|
||||
### Windows (NSIS) — instalação e dados
|
||||
- Instalador NSIS com suporte a “perMachine” (Arquivos de Programas) e diretório customizável (ex.: `C:\Raven`).
|
||||
- Atalho é criado na Área de Trabalho apontando para o executável instalado.
|
||||
- Dados do app (token/config) ficam em AppData local do usuário (via `@tauri-apps/plugin-store` com `appLocalDataDir`).
|
||||
|
||||
#### NSIS — Idiomas e modo de instalação
|
||||
- Idioma: o instalador inclui Português do Brasil e exibe seletor de idioma.
|
||||
- Arquivo: `apps/desktop/src-tauri/tauri.conf.json:54` → `"displayLanguageSelector": true`
|
||||
- Arquivo: `apps/desktop/src-tauri/tauri.conf.json:57` → `"languages": ["PortugueseBR"]`
|
||||
- Comportamento: usa o idioma do SO; sem correspondência, cai no primeiro da lista.
|
||||
- Referência de idiomas NSIS: NSIS “Language files/PortugueseBR”.
|
||||
- Modo de instalação: Program Files (requer elevação/UAC).
|
||||
- Arquivo: `apps/desktop/src-tauri/tauri.conf.json:56` → `"installMode": "perMachine"`
|
||||
- Alternativas: `"currentUser"` (padrão) ou `"both"` (usuário escolhe; exige UAC).
|
||||
|
||||
|
||||
Build rápido e leve em dev:
|
||||
```bash
|
||||
bun run --cwd apps/desktop tauri build --bundles nsis
|
||||
```
|
||||
|
||||
Assinatura do updater (opcional em dev):
|
||||
```powershell
|
||||
$privB64 = '<COLE_SUA_CHAVE_PRIVADA_EM_BASE64>'
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($privB64))
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = 'SENHA_AQUI'
|
||||
bun run --cwd apps/desktop tauri build --bundles nsis
|
||||
```
|
||||
|
||||
## Pré‑requisitos Tauri
|
||||
- Rust toolchain instalado.
|
||||
- Dependências nativas por SO (webkit2gtk no Linux, WebView2/VS Build Tools no Windows, Xcode CLT no macOS).
|
||||
Consulte https://tauri.app/start/prerequisites/
|
||||
|
||||
## Fluxo (resumo)
|
||||
1) Ao abrir, o app coleta o perfil da dispositivo e exibe um resumo.
|
||||
2) Informe o “código de provisionamento” (chave definida no servidor) e confirme.
|
||||
3) O servidor retorna um `machineToken`; o app salva e inicia o heartbeat.
|
||||
4) O app abre `APP_URL/machines/handshake?token=...` no WebView para autenticar a sessão na UI.
|
||||
5) Pelas abas, é possível revisar inventário local e disparar sincronização manual.
|
||||
|
||||
## Segurança do token
|
||||
- O `machineToken` é salvo no cofre nativo do SO via plugin Keyring (Linux Secret Service, Windows Credential Manager, macOS Keychain).
|
||||
- O arquivo de preferências (`Store`) guarda apenas metadados não sensíveis (IDs, URLs, datas).
|
||||
|
||||
## Suporte
|
||||
- Logs do Rust aparecem no console do Tauri (dev) e em stderr (release). Em caso de falha de rede, o app exibe alertas na própria UI.
|
||||
- Para alterar endpoints/domínios, use as variáveis de ambiente acima.
|
||||
|
|
@ -1,461 +0,0 @@
|
|||
# Guia completo – CI/CD Web + Desktop
|
||||
|
||||
> Este material detalha, passo a passo, como configurar o pipeline que entrega o front/backend (Next.js + Convex) e os instaladores do aplicativo Tauri, usando apenas uma VPS Linux (Ubuntu) e um computador/VM Windows. Siga na ordem sugerida e marque cada etapa conforme concluir.
|
||||
|
||||
---
|
||||
|
||||
## 1. Visão geral rápida
|
||||
|
||||
- Objetivo: ao fazer push na branch `main`, a VPS atualiza o site/backend. Ao criar uma tag `vX.Y.Z`, o app desktop é reconstruído, assinado e disponibilizado em `/updates` para auto-update.
|
||||
- Ferramentas principais:
|
||||
- GitHub Actions com dois runners self-hosted (Linux e Windows).
|
||||
- Docker Compose (ou scripts equivalentes) para subir Next.js/Convex na VPS.
|
||||
- Tauri para build dos instaladores desktop.
|
||||
- Nginx servindo arquivos estáticos de update.
|
||||
- Fluxo:
|
||||
1. Desenvolvedor envia código para o GitHub.
|
||||
2. Job **deploy** roda na própria VPS (runner Linux) e atualiza containers/processos.
|
||||
3. Ao criar uma tag `v*.*.*`, job **desktop_release** roda no runner Windows, gera instaladores, assina e envia `latest.json` + binários para a VPS.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pré-requisitos obrigatórios
|
||||
|
||||
1. Repositório GitHub com o código do projeto (`sistema-de-chamados`).
|
||||
2. VPS Ubuntu com:
|
||||
- Acesso SSH com usuário sudo (ex.: `renan`).
|
||||
- Docker + Docker Compose (ou ambiente que você desejar usar em produção).
|
||||
- Nginx (ou outro servidor web capaz de servir `/updates` via HTTPS).
|
||||
3. Computador/VM Windows 10 ou 11 (64 bits) que ficará ligado durante os builds:
|
||||
- Acesso administrador.
|
||||
- Espaço livre para builds (mínimo 15 GB).
|
||||
4. Conta GitHub com permissão Admin no repositório (para registrar runners e secrets).
|
||||
5. SSH key dedicada para o pipeline acessar a VPS (não reaproveite a sua pessoal).
|
||||
|
||||
---
|
||||
|
||||
## 3. Preparação do repositório
|
||||
|
||||
1. Na raiz do projeto, confirme os caminhos usados pelo workflow:
|
||||
- `APP_DIR`: diretório na VPS onde o código (ou docker-compose) ficará. Exemplo: `/srv/apps/sistema`.
|
||||
- `VPS_UPDATES_DIR`: diretório público servido pelo Nginx. Exemplo: `/var/www/updates`.
|
||||
2. Garanta que o arquivo `apps/desktop/src-tauri/tauri.conf.json` será atualizado com:
|
||||
- Chave pública do updater (`updater.pubkey`).
|
||||
- URL do `latest.json` (por exemplo `https://seu-dominio.com/updates/latest.json`).
|
||||
- Exemplo de bloco a adicionar mais tarde:
|
||||
```json5
|
||||
"updater": {
|
||||
"active": true,
|
||||
"endpoints": ["https://seu-dominio.com/updates/latest.json"],
|
||||
"pubkey": "FINGERPRINT_PUBLIC_KEY"
|
||||
}
|
||||
```
|
||||
3. Crie (ou mantenha) um arquivo `.github/workflows/ci-cd-web-desktop.yml` para o workflow. O conteúdo será incluído na etapa 9 após todos os preparativos. Como você utiliza Docker Swarm com Portainer, já separe o `stack.yml` (ou compose compatível) que o workflow irá acionar.
|
||||
|
||||
---
|
||||
|
||||
## 4. Ajustes iniciais na VPS (Ubuntu)
|
||||
|
||||
1. Atualize pacotes:
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
```
|
||||
2. Instale Docker (o Swarm usa o próprio engine; mantenha o plugin compose se quiser testar localmente):
|
||||
```bash
|
||||
sudo apt install -y docker.io docker-compose-plugin
|
||||
sudo systemctl enable docker
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
> Saia e entre novamente na sessão SSH para aplicar o grupo Docker.
|
||||
3. Se o Swarm ainda não estiver ativo, inicialize-o no nó manager:
|
||||
```bash
|
||||
docker info | grep Swarm
|
||||
# se retornar "inactive", rode:
|
||||
sudo docker swarm init
|
||||
```
|
||||
4. Verifique o Portainer:
|
||||
- Acesse o painel na porta configurada (padrão `https://seu-dominio:9443`).
|
||||
- Confirme que o cluster Swarm está saudável e que o nó aparece como manager.
|
||||
5. Crie diretórios usados pelo deploy:
|
||||
```bash
|
||||
sudo mkdir -p /srv/apps/sistema
|
||||
sudo mkdir -p /var/www/updates
|
||||
sudo chown -R $USER:$USER /srv/apps/sistema
|
||||
sudo chown -R $USER:$USER /var/www/updates
|
||||
```
|
||||
6. (Opcional) Clone o repositório atual dentro de `/srv/apps/sistema` se você mantém arquivos como `stack.yml` ali:
|
||||
```bash
|
||||
git clone git@github.com:SEU_USUARIO/sistema-de-chamados.git /srv/apps/sistema
|
||||
```
|
||||
7. Teste manualmente seu processo de deploy (Docker Swarm/Portainer ou scripts equivalentes) antes de automatizar. Exemplo via CLI:
|
||||
```bash
|
||||
docker stack deploy --with-registry-auth -c stack.yml sistema
|
||||
docker stack services sistema
|
||||
```
|
||||
Se preferir Portainer, faça o deploy manual pelo painel para validar. Confirme que o site sobe corretamente e que `/var/www/updates` é servido pelo Nginx (ver etapa 7).
|
||||
|
||||
8. Sobre o Convex:
|
||||
- **Convex Cloud (recomendado):** apenas garanta que suas variáveis `NEXT_PUBLIC_CONVEX_URL` e `CONVEX_DEPLOYMENT` apontam para o deploy gerenciado. Não é necessário subir container.
|
||||
- **Convex self-hosted:** inclua um serviço adicional no `stack.yml` (ex.: `convex`) com a imagem oficial (`ghcr.io/get-convex/convex:latest`). Configure volume para o diretório de dados e exponha a porta 3210 internamente. Atualize o Next.js para apontar para `http://convex:3210` dentro da rede do Swarm.
|
||||
|
||||
9. Exemplo de `stack.yml` integrado (baseado no modelo que você já usa no Portainer):
|
||||
```yaml
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
web:
|
||||
image: ghcr.io/SEU_USUARIO/sistema-web:latest # ajuste para a imagem real
|
||||
deploy:
|
||||
mode: replicated
|
||||
replicas: 2
|
||||
placement:
|
||||
constraints:
|
||||
- node.role == manager
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.0"
|
||||
memory: 1.5G
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.sistema.rule=Host(`app.seu-dominio.com.br`)
|
||||
- traefik.http.routers.sistema.entrypoints=websecure
|
||||
- traefik.http.routers.sistema.tls=true
|
||||
- traefik.http.routers.sistema.tls.certresolver=le
|
||||
- traefik.http.services.sistema.loadbalancer.server.port=3000
|
||||
env_file:
|
||||
- ./envs/web.env # variáveis do Next.js
|
||||
networks:
|
||||
- traefik_public
|
||||
- sistema_network
|
||||
|
||||
convex:
|
||||
image: ghcr.io/get-convex/convex:latest
|
||||
deploy:
|
||||
mode: replicated
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.role == manager
|
||||
command: ["start", "--port", "3210"]
|
||||
volumes:
|
||||
- convex_data:/convex/data
|
||||
networks:
|
||||
- sistema_network
|
||||
|
||||
networks:
|
||||
traefik_public:
|
||||
external: true
|
||||
sistema_network:
|
||||
external: false
|
||||
|
||||
volumes:
|
||||
convex_data:
|
||||
external: false
|
||||
```
|
||||
- Adapte nomes das imagens (`web`, `convex`) e os labels do Traefik conforme seu ambiente.
|
||||
- Caso use Portainer, faça upload desse arquivo na interface e execute o deploy da stack.
|
||||
|
||||
---
|
||||
|
||||
## 5. Gerar chaves do updater Tauri
|
||||
|
||||
1. Em qualquer dispositivo com Bun instalado (pode ser seu computador local):
|
||||
```bash
|
||||
bun install
|
||||
bun install --cwd apps/desktop
|
||||
bun run --cwd apps/desktop tauri signer generate
|
||||
```
|
||||
2. O comando gera:
|
||||
- Chave privada (`tauri.private.key`).
|
||||
- Chave pública (`tauri.public.key`).
|
||||
3. Guarde os arquivos em local seguro. Você usará o conteúdo da chave privada nos secrets `TAURI_PRIVATE_KEY` e `TAURI_KEY_PASSWORD`. A chave pública vai no `tauri.conf.json`.
|
||||
4. Copie a chave pública para o arquivo `apps/desktop/src-tauri/tauri.conf.json` no bloco `"updater"` (conforme indicado na etapa 3).
|
||||
|
||||
---
|
||||
|
||||
## 6. Configurar Nginx para servir as atualizações
|
||||
|
||||
1. Certifique-se de ter um domínio apontando para a VPS e um certificado TLS válido (Let's Encrypt é suficiente).
|
||||
2. Crie (ou edite) o arquivo `/etc/nginx/sites-available/sistema-updates.conf` com algo semelhante:
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl;
|
||||
server_name seu-dominio.com;
|
||||
|
||||
# Configuração SSL (ajuste conforme seu certificado)
|
||||
ssl_certificate /etc/letsencrypt/live/seu-dominio.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/seu-dominio.com/privkey.pem;
|
||||
|
||||
location /updates/ {
|
||||
alias /var/www/updates/;
|
||||
autoindex off;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
}
|
||||
```
|
||||
3. Crie o link simbólico e teste:
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/sistema-updates.conf /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
4. Verifique pelo navegador: `https://seu-dominio.com/updates/` deve listar vazio (ou mostrar erro 403 se o autoindex estiver desativado, o que é aceitável). Apenas confirme que não retorna 404.
|
||||
|
||||
---
|
||||
|
||||
## 7. Registrar runner self-hosted na VPS (Linux)
|
||||
|
||||
1. No GitHub, acesse o repositório → *Settings* → *Actions* → *Runners* → *New self-hosted runner*.
|
||||
2. Escolha Linux x64 e anote a URL e o token fornecidos.
|
||||
3. Na VPS, prepare um usuário dedicado (opcional, mas recomendado):
|
||||
```bash
|
||||
sudo adduser --disabled-password --gecos "" actions
|
||||
sudo usermod -aG docker actions
|
||||
sudo su - actions
|
||||
```
|
||||
4. Baixe e instale o runner (substitua `<URL>` e `<TOKEN>`):
|
||||
```bash
|
||||
mkdir actions-runner && cd actions-runner
|
||||
curl -o actions-runner.tar.gz -L <URL>
|
||||
tar xzf actions-runner.tar.gz
|
||||
./config.sh --url https://github.com/SEU_USUARIO/sistema-de-chamados \
|
||||
--token <TOKEN> \
|
||||
--labels "self-hosted,linux,vps"
|
||||
```
|
||||
5. Instale como serviço:
|
||||
```bash
|
||||
sudo ./svc.sh install
|
||||
sudo ./svc.sh start
|
||||
```
|
||||
6. Volte ao GitHub e confirme que o runner aparece como `online`.
|
||||
7. Teste executando um workflow simples (pode ser o pipeline de deploy após concluir todas as etapas). Lembre-se: o runner precisa ter permissão de escrita para `/srv/apps/sistema` e `/var/www/updates`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Registrar runner self-hosted no Windows
|
||||
|
||||
1. Baixe e instale os pré-requisitos:
|
||||
- Git para Windows.
|
||||
- Bun 1.3+: instale via instalador oficial (`iwr https://bun.sh/install.ps1 | invoke-expression`) e garanta que `bun` esteja no `PATH`.
|
||||
- Node.js 20 (opcional, caso precise rodar scripts em Node durante o build).
|
||||
- Rust toolchain: https://rustup.rs (instale padrão).
|
||||
- Visual Studio Build Tools (C++ build tools) ou `Desktop development with C++`.
|
||||
- WebView2 Runtime (https://developer.microsoft.com/microsoft-edge/webview2/).
|
||||
2. Opcional: instale as dependências do Tauri rodando uma vez:
|
||||
```powershell
|
||||
bun install
|
||||
bun install --cwd apps/desktop
|
||||
bun run --cwd apps/desktop tauri info
|
||||
```
|
||||
3. No GitHub → *Settings* → *Actions* → *Runners* → *New self-hosted runner* → escolha Windows x64 e copie URL/token.
|
||||
4. Em `C:\actions-runner` (recomendado):
|
||||
```powershell
|
||||
mkdir C:\actions-runner
|
||||
cd C:\actions-runner
|
||||
Invoke-WebRequest -Uri <URL> -OutFile actions-runner.zip
|
||||
Expand-Archive -Path actions-runner.zip -DestinationPath .
|
||||
.\config.cmd --url https://github.com/SEU_USUARIO/sistema-de-chamados `
|
||||
--token <TOKEN> `
|
||||
--labels "self-hosted,windows,desktop"
|
||||
```
|
||||
5. Instale como serviço (PowerShell administrador):
|
||||
```powershell
|
||||
.\svc install
|
||||
.\svc start
|
||||
```
|
||||
6. Confirme no GitHub que o runner aparece como `online`.
|
||||
7. Mantenha a dispositivo ligada e conectada durante o período em que o workflow precisa rodar:
|
||||
- Para releases desktop, o runner só precisa estar ligado enquanto o job `desktop_release` estiver em execução (crie a tag e aguarde o workflow terminar).
|
||||
- Após a conclusão, você pode desligar o computador até a próxima release.
|
||||
8. Observação importante: o runner Windows pode ser sua dispositivo pessoal. Garanta apenas que:
|
||||
- Você confia no código que será executado (o runner processa os jobs do repositório).
|
||||
- O serviço do runner esteja ativo enquanto o workflow rodar (caso desligue o PC, as releases ficam na fila).
|
||||
- Há espaço em disco suficiente e nenhuma política corporativa bloqueando a instalação dos pré-requisitos.
|
||||
|
||||
---
|
||||
|
||||
## 9. Configurar secrets e variables no GitHub
|
||||
|
||||
1. Acesse o repositório → *Settings* → *Secrets and variables* → *Actions*.
|
||||
2. Adicione os secrets:
|
||||
- `VPS_HOST` → domínio ou IP da VPS.
|
||||
- `VPS_USER` → usuário com acesso SSH (ex.: `renan`).
|
||||
- `VPS_SSH_KEY` → conteúdo **completo** da chave privada gerada apenas para o pipeline (ver abaixo).
|
||||
- `TAURI_PRIVATE_KEY` → conteúdo do arquivo `tauri.private.key`.
|
||||
- `TAURI_KEY_PASSWORD` → senha informada ao gerar a chave (se deixou em branco, repita em branco aqui).
|
||||
3. Gerar chave SSH exclusiva para o pipeline (se ainda não fez):
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "github-actions@seu-dominio" -f ~/.ssh/github-actions
|
||||
```
|
||||
- Suba o conteúdo de `~/.ssh/github-actions` (privada) para o secret `VPS_SSH_KEY`.
|
||||
- Adicione a chave pública `~/.ssh/github-actions.pub` em `~/.ssh/authorized_keys` do usuário na VPS.
|
||||
4. Adicione **Environment variables** (opcional) para evitar editar o YAML:
|
||||
- `APP_DIR` → `/srv/apps/sistema`
|
||||
- `VPS_UPDATES_DIR` → `/var/www/updates`
|
||||
(Se preferir, mantenha-as definidas direto no workflow.)
|
||||
|
||||
---
|
||||
|
||||
## 10. Criar o workflow GitHub Actions
|
||||
|
||||
1. No repositório, crie o arquivo `.github/workflows/ci-cd-web-desktop.yml` com o conteúdo:
|
||||
```yaml
|
||||
name: CI/CD - Web + Desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
VPS_UPDATES_DIR: /var/www/updates
|
||||
APP_DIR: /srv/apps/sistema
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy Web/Backend (VPS)
|
||||
runs-on: [self-hosted, linux, vps]
|
||||
if: startsWith(github.ref, 'refs/heads/main')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.3.1
|
||||
|
||||
- name: Deploy stack (Docker Swarm)
|
||||
working-directory: ${{ env.APP_DIR }}
|
||||
run: |
|
||||
# git pull origin main || true
|
||||
# Atualize o arquivo stack.yml ou compose compatível antes do deploy.
|
||||
docker stack deploy --with-registry-auth -c stack.yml sistema
|
||||
docker stack services sistema
|
||||
|
||||
desktop_release:
|
||||
name: Release Desktop (Tauri)
|
||||
runs-on: [self-hosted, windows, desktop]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.3.1
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install deps
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build + Sign + Release (tauri-action)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: "Desktop ${{ github.ref_name }}"
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
updaterJsonKeepName: true
|
||||
|
||||
- name: Upload latest.json + bundles para 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
|
||||
```
|
||||
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á).
|
||||
3. Faça commit desse arquivo e suba para o GitHub (`git add .github/workflows/ci-cd-web-desktop.yml`, `git commit`, `git push`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Testar o pipeline
|
||||
|
||||
1. **Teste do runner Linux:**
|
||||
- Faça uma alteração simples na branch `main`.
|
||||
- `git push origin main`.
|
||||
- No GitHub, verifique o workflow → job `deploy`.
|
||||
- Confirme via SSH na VPS (ex.: `docker stack services sistema`) ou pelo Portainer que os serviços foram atualizados.
|
||||
2. **Teste do runner Windows:**
|
||||
- Atualize `apps/desktop/src-tauri/tauri.conf.json` com a chave pública e URL do updater.
|
||||
- Faça commit e `git push`.
|
||||
- Crie a tag: `git tag v1.0.0` → `git push origin v1.0.0`.
|
||||
- Verifique no GitHub → job `desktop_release`.
|
||||
- Após concluir, confira em `/var/www/updates` se existem `latest.json` e os instaladores gerados.
|
||||
3. Instale o app desktop (Windows, macOS ou Linux conforme artefatos) e abra-o:
|
||||
- O aplicativo deve carregar a interface web apontando para sua URL.
|
||||
- Ao publicar nova tag (ex.: `v1.0.1`), o app deve oferecer update automático.
|
||||
|
||||
---
|
||||
|
||||
## 12. Rotina diária de uso
|
||||
|
||||
1. Desenvolvimento comum:
|
||||
- Trabalhe em branch própria.
|
||||
- Abra PR para `main`.
|
||||
- Ao fazer merge na `main`, o job `deploy` roda e publica a nova versão da stack no Swarm (visível no Portainer).
|
||||
2. Nova versão desktop:
|
||||
- Ajuste o app, aumente o campo `version` no `tauri.conf.json`.
|
||||
- `git commit` e `git push`.
|
||||
- Crie tag `vX.Y.Z` e envie (`git tag v1.2.0`, `git push origin v1.2.0`).
|
||||
- Aguarde a finalização do job `desktop_release`.
|
||||
- Usuários recebem o update automático na próxima abertura.
|
||||
3. Renovação de certificado:
|
||||
- Garanta que o certificado TLS usado pelo Nginx é renovado (p. ex. `certbot renew`).
|
||||
4. Manter runners:
|
||||
- VPS: monitore serviço `actions.runner.*`. Reinicie se necessário (`sudo ./svc.sh restart`).
|
||||
- Windows: mantenha dispositivo ligada e atualizada. Se o serviço parar, abra `services.msc` → `GitHub Actions Runner` → Start.
|
||||
|
||||
---
|
||||
|
||||
## 13. Boas práticas e segurança
|
||||
|
||||
- Proteja a chave privada do updater; trate como segredo de produção.
|
||||
- Use usuário dedicado na VPS para o runner e restrinja permissões apenas aos diretórios necessários.
|
||||
- Faça backup periódico de `/var/www/updates` (para poder servir instaladores antigos se necessário).
|
||||
- Nunca faça commit do arquivo `.env` nem das chaves privadas.
|
||||
- Atualize Docker, Node e Rust periodicamente.
|
||||
|
||||
---
|
||||
|
||||
## 14. Solução de problemas comuns
|
||||
|
||||
| Sintoma | Possível causa | Como corrigir |
|
||||
| --- | --- | --- |
|
||||
| Job `deploy` falha com “permission denied” | Runner não tem acesso ao diretório do app | Ajuste permissões (`sudo chown -R actions:actions /srv/apps/sistema`). |
|
||||
| Job `desktop_release` falha na etapa `tauri-action` | Toolchain incompleto no Windows | Reinstale Rust, WebView2 e componentes C++ do Visual Studio. |
|
||||
| Artefatos não chegam à VPS | Caminho incorreto ou chave SSH inválida | Verifique `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY` e se a pasta `/var/www/updates` existe. |
|
||||
| App não encontra update | URL ou chave pública divergente no `tauri.conf.json` | Confirme que `endpoints` bate com o domínio HTTPS e que `pubkey` é exatamente a chave pública gerada. |
|
||||
| Runner aparece offline no GitHub | Serviço parado ou dispositivo desligada | VPS: `sudo ./svc.sh status`; Windows: abra `Services` e reinicie o `GitHub Actions Runner`. |
|
||||
|
||||
---
|
||||
|
||||
## 15. Checklist final de implantação
|
||||
|
||||
1. [ ] VPS atualizada, Docker/Nginx funcionando.
|
||||
2. [ ] Diretórios `/srv/apps/sistema` e `/var/www/updates` criados com permissões corretas.
|
||||
3. [ ] Nginx servindo `https://seu-dominio.com/updates/`.
|
||||
4. [ ] Runner Linux registrado com labels `self-hosted,linux,vps`.
|
||||
5. [ ] Runner Windows registrado com labels `self-hosted,windows,desktop`.
|
||||
6. [ ] Chaves do updater Tauri geradas e chave pública no `tauri.conf.json`.
|
||||
7. [ ] Secrets e variables configurados no GitHub (`VPS_*`, `TAURI_*`).
|
||||
8. [ ] Workflow `.github/workflows/ci-cd-web-desktop.yml` criado e commitado.
|
||||
9. [ ] Deploy automático testado com push em `main`.
|
||||
10. [ ] Release desktop testada com tag `v1.0.0`.
|
||||
|
||||
Com todos os itens marcados, o pipeline estará pronto para ser usado sempre que você fizer novas entregas.
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="stylesheet" href="/src/index.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Raven — Agente Desktop</title>
|
||||
<script type="module" src="/src/main.tsx" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
{
|
||||
"name": "appsdesktop",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "node ./scripts/tauri-with-stub.mjs",
|
||||
"gen:icon": "node ./scripts/build-icon.mjs",
|
||||
"build:service": "cd service && cargo build --release",
|
||||
"build:all": "bun run build:service && bun run tauri build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2",
|
||||
"convex": "^1.31.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"baseline-browser-mapping": "^2.9.2",
|
||||
"png-to-ico": "^3.0.1",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"version": "0.1.6",
|
||||
"notes": "Correções e melhorias do desktop",
|
||||
"pub_date": "2025-10-14T12:00:00Z",
|
||||
"platforms": {
|
||||
"windows-x86_64": {
|
||||
"signature": "ZFc1MGNuVnpkR1ZrSUdOdmJXMWxiblE2SUhOcFoyNWhkSFZ5WlNCbWNtOXRJSFJoZFhKcElITmxZM0psZENCclpYa0tVbFZVZDNFeFUwRlJRalJVUjJOU1NqUnpTVmhXU1ZoeVUwZElNSGxETW5KSE1FTnBWa3BWU1dzelVYVlRNV1JTV0Vrdk1XMUZVa0Z3YTBWc2QySnZhVnBxUWs5bVoyODNNbEZaYUZsMFVHTlRLMUFyT0hJMVdGZ3lWRkZYT1V3ekwzZG5QUXAwY25WemRHVmtJR052YlcxbGJuUTZJSFJwYldWemRHRnRjRG94TnpZd016azVOVEkzQ1dacGJHVTZVbUYyWlc1Zk1DNHhMalZmZURZMExYTmxkSFZ3TG1WNFpRcHdkME15THpOVlZtUXpiSG9yZGpRd1pFZHFhV1JvVkZCb0wzVnNabWh1ZURJdmFtUlZOalEwTkRSVVdVY3JUVGhLTUdrNU5scFNUSFZVWkRsc1lYVTJUR2dyWTNWeWJuWTVhRGh3ZVVnM1dFWjVhSFZDUVQwOUNnPT0=",
|
||||
"url": "https://github.com/esdrasrenan/sistema-de-chamados/raw/main/apps/desktop/public/releases/Raven_0.1.6_x64-setup.exe"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
|
@ -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
1931
apps/desktop/service/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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())
|
||||
}
|
||||
7
apps/desktop/src-tauri/.gitignore
vendored
7
apps/desktop/src-tauri/.gitignore
vendored
|
|
@ -1,7 +0,0 @@
|
|||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
6779
apps/desktop/src-tauri/Cargo.lock
generated
6779
apps/desktop/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,50 +0,0 @@
|
|||
[package]
|
||||
name = "appsdesktop"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "appsdesktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.4.1", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.9", features = ["wry", "devtools", "tray-icon"] }
|
||||
tauri-plugin-dialog = "2.4.2"
|
||||
tauri-plugin-opener = "2.5.0"
|
||||
tauri-plugin-store = "2.4.0"
|
||||
tauri-plugin-updater = "2.9.0"
|
||||
tauri-plugin-process = "2.3.0"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] }
|
||||
get_if_addrs = "0.5"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking", "stream"], default-features = false }
|
||||
futures-util = "0.3"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
||||
once_cell = "1.19"
|
||||
thiserror = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
parking_lot = "0.12"
|
||||
hostname = "0.4"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
convex = "0.10.2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
dirs = "5"
|
||||
# SSE usa reqwest com stream, nao precisa de websocket
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.55"
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
fn main() {
|
||||
// Custom manifest keeps Common-Controls v6 dependency to avoid TaskDialogIndirect errors.
|
||||
let windows = tauri_build::WindowsAttributes::new().app_manifest(
|
||||
r#"
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*" />
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
</assembly>
|
||||
"#,
|
||||
);
|
||||
|
||||
let attrs = tauri_build::Attributes::new().windows_attributes(windows);
|
||||
|
||||
tauri_build::try_build(attrs).expect("failed to run Tauri build script");
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for all windows",
|
||||
"windows": ["main", "chat-*", "chat-hub"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"core:event:allow-emit",
|
||||
"core:window:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-start-dragging",
|
||||
"dialog:allow-open",
|
||||
"opener:default",
|
||||
"store:default",
|
||||
"store:allow-load",
|
||||
"store:allow-set",
|
||||
"store:allow-get",
|
||||
"store:allow-save",
|
||||
"store:allow-delete",
|
||||
"updater:default",
|
||||
"process:default",
|
||||
"notification:default",
|
||||
"notification:allow-notify",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-is-permission-granted"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2 KiB |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue