3 Commits
Author SHA1 Message Date
Lucas RayanandClaude Opus 5.5 024f7a7387 Senhas de gerenciadores de senha voltam a entrar no histórico
Pacote / verificar (push) Canceled after 0s
Pacote / trivy (push) Canceled after 0s
Pacote / pacote (push) Canceled after 0s
Pacote / release (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:48:44 -03:00
Lucas RayanandClaude Opus 5.5 6e45d64ee0 Comandos 'copiado consertar' e 'copiado ajuda'
Pacote / verificar (push) Canceled after 0s
Pacote / trivy (push) Canceled after 0s
Pacote / pacote (push) Canceled after 0s
Pacote / release (push) Canceled after 0s
- consertar: recria/recarrega o atalho Win+V e inicia o app se estiver parado
- instalar.sh e desinstalar.sh passam a usar os comandos do próprio app

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:46:53 -03:00
Lucas RayanandClaude Opus 5.5 16affedd2b Segurança: histórico privado, filtro de senhas e Trivy na esteira
Pacote / verificar (push) Canceled after 0s
Pacote / trivy (push) Canceled after 0s
Pacote / pacote (push) Canceled after 0s
Pacote / release (push) Canceled after 0s
- Só itens fixados vão para o disco, com permissão 600/700
- Ignora conteúdo marcado por gerenciadores de senha
- Limites de tamanho e validação do histórico salvo
- Comando 'copiado esquecer'
- Trivy (repo e pacote), ações fixadas por hash, permissões mínimas
- Release publica SHA256SUMS e o instalador confere o checksum

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:42:03 -03:00
5 changed files with 292 additions and 71 deletions

No files matched your search

+86 -12
View File
@@ -5,21 +5,70 @@ on:
branches: [main]
tags: ["v*"]
pull_request:
schedule:
- cron: "0 9 * * 1" # toda segunda: reavalia com a base de vulnerabilidades nova
# por padrão nenhum job pode escrever nada; cada um pede só o que precisa
permissions:
contents: write
contents: read
jobs:
pacote:
verificar:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Verificar código
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Checar código
run: |
python3 -m py_compile copiado.py
shellcheck instalar.sh desinstalar.sh empacotamento/gerar-deb.sh
trivy:
runs-on: ubuntu-24.04
permissions:
contents: read
security-events: write # envia o relatório para a aba Security do GitHub
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Trivy (relatório para a aba Security)
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: .
scanners: vuln,secret,misconfig
format: sarif
output: trivy.sarif
exit-code: "0"
- uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
if: always()
with:
sarif_file: trivy.sarif
category: trivy
- name: Trivy (barra a esteira em HIGH/CRITICAL)
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: .
scanners: vuln,secret,misconfig
severity: HIGH,CRITICAL
format: table
exit-code: "1"
skip-setup-trivy: true
pacote:
needs: [verificar, trivy]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Definir versão
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
@@ -29,7 +78,9 @@ jobs:
fi
- name: Gerar .deb
run: empacotamento/gerar-deb.sh "$VERSAO"
run: |
empacotamento/gerar-deb.sh "$VERSAO"
cd dist && sha256sum copiado_all.deb > SHA256SUMS
- name: Testar instalação do pacote
run: |
@@ -37,16 +88,39 @@ jobs:
sudo apt-get install -y ./dist/copiado_all.deb
test -x /usr/bin/copiado
test -f /etc/xdg/autostart/copiado.desktop
mkdir -p conteudo && dpkg-deb -x dist/copiado_all.deb conteudo
- uses: actions/upload-artifact@v4
- name: Trivy no conteúdo do pacote
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
name: copiado-${{ env.VERSAO }}
path: dist/copiado_all.deb
scan-type: fs
scan-ref: conteudo
scanners: vuln,secret
severity: HIGH,CRITICAL
exit-code: "1"
- name: Publicar release
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: copiado
path: |
dist/copiado_all.deb
dist/SHA256SUMS
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: pacote
runs-on: ubuntu-24.04
permissions:
contents: write # só este job pode publicar
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: copiado
path: dist
- name: Publicar release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
gh release create "$GITHUB_REF_NAME" dist/copiado_all.deb \
--title "Copiado $VERSAO" --generate-notes
gh release create "$TAG" dist/copiado_all.deb dist/SHA256SUMS \
--repo "$GITHUB_REPOSITORY" --title "Copiado ${TAG#v}" --generate-notes
+27 -10
View File
@@ -25,7 +25,24 @@ Para **atualizar**, rode o mesmo comando de novo.
| Apagar tudo (menos os fixados) | **Limpar tudo** |
| Fechar sem colar | **Esc** ou clicar fora |
Guarda os 25 últimos itens (texto e imagens) e continua lá depois de reiniciar o PC.
Guarda os 25 últimos itens (texto e imagens). Como no Windows, ao reiniciar o PC
só os itens **fixados** continuam; o resto existe apenas na memória.
## Privacidade e segurança
A área de transferência pode ter senhas, tokens e dados pessoais, então o Copiado:
- **Só grava no disco os itens fixados**, em `~/.local/share/copiado/`, com
permissão só para você (pasta `700`, arquivos `600`).
- **Não guarda textos acima de 1 MB nem imagens acima de 20 MB.**
- **Valida o arquivo de histórico** ao abrir: entradas estranhas ou imagens fora da
pasta do Copiado são descartadas.
- **Não usa rede** e não executa nada do que foi copiado.
O instalador confere o checksum SHA-256 do pacote antes de instalar, e a esteira
de CI/CD roda o [Trivy](https://trivy.dev) (vulnerabilidades, segredos e configurações)
em todo push, em toda release e toda segunda-feira. Os resultados ficam na aba
**Security** do repositório.
## Desinstalar
@@ -33,16 +50,16 @@ Guarda os 25 últimos itens (texto e imagens) e continua lá depois de reiniciar
curl -fsSL https://raw.githubusercontent.com/lucasrguerra/copiado/main/desinstalar.sh | bash
```
## Se algo der errado
## Comandos
- **Win+V não abre nada:** faça o Cinnamon recarregar os atalhos:
```bash
gsettings set org.cinnamon.desktop.keybindings custom-list "[]"; gsettings set org.cinnamon.desktop.keybindings custom-list "['custom0']"
```
Se ainda assim não abrir, veja se outro atalho usa Win+V em
*Configurações do Sistema → Teclado → Atalhos*.
- **Abre, mas o histórico está vazio:** o app pode não estar rodando. Rode
`copiado &`
| Comando | Para quê |
|---|---|
| `copiado consertar` | **Win+V não abre ou o histórico está vazio?** Recria o atalho, faz o Cinnamon recarregá-lo e inicia o app se estiver parado. |
| `copiado esquecer` | Apaga todo o histórico, inclusive os fixados. |
| `copiado ajuda` | Lista os comandos. |
Se mesmo depois do `copiado consertar` o Win+V não abrir, veja se outro atalho usa
Win+V em *Configurações do Sistema → Teclado → Atalhos*.
## Publicar uma versão nova
+166 -12
View File
@@ -2,8 +2,11 @@
"""Copiado: histórico da área de transferência no estilo Windows 11 (Win+V).
Uso:
copiado.py inicia em segundo plano (vigia o Ctrl+C)
copiado.py show abre a janelinha do histórico
copiado inicia em segundo plano (vigia o Ctrl+C)
copiado show abre a janelinha do histórico
copiado esquecer apaga todo o histórico, inclusive os fixados
copiado consertar recria o atalho Win+V e inicia o app se estiver parado
copiado ajuda mostra esta ajuda
"""
import hashlib
import json
@@ -22,6 +25,8 @@ MAX_ITENS = 25
LARGURA, ALTURA = 380, 480
PASTA = os.path.join(GLib.get_user_data_dir(), "copiado")
ARQ_HIST = os.path.join(PASTA, "historico.json")
MAX_TEXTO = 1024 * 1024 # textos maiores que 1 MB não são guardados
MAX_IMAGEM = 20 * 1024 * 1024 # imagens maiores que 20 MB (PNG) também não
CSS = b"""
#fundo {
@@ -63,26 +68,60 @@ scrollbar slider { background-color: rgba(255,255,255,0.25); border-radius: 4px;
"""
def item_valido(item):
"""Aceita só itens bem formados; imagens só dentro da nossa pasta."""
if not isinstance(item, dict) or not isinstance(item.get("dado"), str):
return False
if item.get("tipo") == "texto":
return len(item["dado"]) <= MAX_TEXTO
if item.get("tipo") == "imagem":
caminho = os.path.realpath(item["dado"])
return (os.path.dirname(caminho) == os.path.realpath(PASTA)
and caminho.endswith(".png") and os.path.isfile(caminho))
return False
class Historico:
def __init__(self):
os.makedirs(PASTA, exist_ok=True)
# só o próprio usuário pode ler o histórico (pasta 700, arquivos 600)
os.umask(0o077)
os.makedirs(PASTA, mode=0o700, exist_ok=True)
os.chmod(PASTA, 0o700)
for nome in os.listdir(PASTA):
caminho = os.path.join(PASTA, nome)
if os.path.isfile(caminho) and not os.path.islink(caminho):
os.chmod(caminho, 0o600)
self.itens = [] # {"tipo": "texto"|"imagem", "dado": str, "fixo": bool}
try:
with open(ARQ_HIST) as f:
self.itens = json.load(f)
carregados = json.load(f)
if isinstance(carregados, list):
# como no Windows: ao ligar o PC, só os fixados continuam
self.itens = [{"tipo": i["tipo"], "dado": i["dado"], "fixo": True}
for i in carregados if item_valido(i) and i.get("fixo") is True]
except (OSError, ValueError):
pass
self.salvar()
def salvar(self):
with open(ARQ_HIST + ".tmp", "w") as f:
json.dump(self.itens, f, ensure_ascii=False)
os.replace(ARQ_HIST + ".tmp", ARQ_HIST)
# só os fixados vão para o disco; o resto fica apenas na memória
fixos = [i for i in self.itens if i["fixo"]]
tmp = ARQ_HIST + ".tmp"
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, "w") as f:
json.dump(fixos, f, ensure_ascii=False)
os.replace(tmp, ARQ_HIST)
usadas = {i["dado"] for i in self.itens if i["tipo"] == "imagem"}
for nome in os.listdir(PASTA):
caminho = os.path.join(PASTA, nome)
if nome.endswith(".png") and caminho not in usadas:
os.remove(caminho)
def esquecer_tudo(self):
"""Apaga o histórico inteiro, inclusive os fixados."""
self.itens = []
self.salvar()
def adicionar(self, tipo, dado):
antigo = next((i for i in self.itens if i["tipo"] == tipo and i["dado"] == dado), None)
fixo = antigo["fixo"] if antigo else False
@@ -125,8 +164,13 @@ class App(Gtk.Application):
self.criar_janela()
def do_command_line(self, linha):
if "show" in linha.get_arguments()[1:]:
args = linha.get_arguments()[1:]
if "show" in args:
self.mostrar()
elif "esquecer" in args:
self.hist.esquecer_tudo()
if self.janela.get_visible():
self.preencher()
return 0
# ---------- captura ----------
@@ -136,17 +180,21 @@ class App(Gtk.Application):
if img is None:
return
dados = img.save_to_bufferv("png", [], [])[1]
nome = hashlib.sha1(dados).hexdigest()[:16] + ".png"
if len(dados) > MAX_IMAGEM:
return
nome = hashlib.sha256(dados).hexdigest()[:32] + ".png"
caminho = os.path.join(PASTA, nome)
if caminho == self.ignorar:
return
if not os.path.exists(caminho):
with open(caminho, "wb") as f:
fd = os.open(caminho, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(dados)
self.hist.adicionar("imagem", caminho)
else:
texto = clip.wait_for_text()
if not texto or not texto.strip() or texto == self.ignorar:
if (not texto or not texto.strip() or len(texto) > MAX_TEXTO
or texto == self.ignorar):
return
self.hist.adicionar("texto", texto)
if self.janela.get_visible():
@@ -349,5 +397,111 @@ class App(Gtk.Application):
return False
# ---------- comandos de manutenção (não abrem janela) ----------
ATALHOS = "org.cinnamon.desktop.keybindings"
CAMINHO_ATALHO = "/org/cinnamon/desktop/keybindings/custom-keybindings/{}/"
def gsettings(*args):
return subprocess.run(["gsettings", *args], capture_output=True,
text=True, check=True).stdout.strip()
def ler_lista(valor):
# gsettings devolve algo como "['custom0']" ou "@as []"
return json.loads(valor.replace("@as ", "").replace("'", '"'))
def esquema(ident):
return f"{ATALHOS}.custom-keybinding:" + CAMINHO_ATALHO.format(ident)
def configurar_atalho():
"""Cria (ou corrige) o atalho Win+V e faz o Cinnamon recarregá-lo."""
lista = ler_lista(gsettings("get", ATALHOS, "custom-list"))
ident = next((i for i in lista
if gsettings("get", esquema(i), "binding") == "['<Super>v']"), None)
if ident is None:
ident = next(f"custom{n}" for n in range(1000) if f"custom{n}" not in lista)
lista.append(ident)
gsettings("set", esquema(ident), "name", "Copiado")
gsettings("set", esquema(ident), "command", "copiado show")
gsettings("set", esquema(ident), "binding", "['<Super>v']")
# o Cinnamon só relê os atalhos quando a lista muda
gsettings("set", ATALHOS, "custom-list", "[]")
time.sleep(0.5)
gsettings("set", ATALHOS, "custom-list", json.dumps(lista).replace('"', "'"))
return ident
def remover_atalho():
lista = ler_lista(gsettings("get", ATALHOS, "custom-list"))
nossos = [i for i in lista if "copiado" in gsettings("get", esquema(i), "command")]
for ident in nossos:
gsettings("reset-recursively", esquema(ident))
restantes = [i for i in lista if i not in nossos]
gsettings("set", ATALHOS, "custom-list", json.dumps(restantes).replace('"', "'"))
def esta_rodando():
bus = Gio.bus_get_sync(Gio.BusType.SESSION)
resp = bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus",
"org.freedesktop.DBus", "NameHasOwner",
GLib.Variant("(s)", ("io.github.copiado",)),
GLib.VariantType("(b)"), Gio.DBusCallFlags.NONE, -1, None)
return resp.unpack()[0]
def iniciar_em_segundo_plano():
subprocess.Popen([sys.executable, os.path.realpath(__file__)],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
for _ in range(30):
time.sleep(0.2)
if esta_rodando():
return True
return False
def consertar():
try:
ident = configurar_atalho()
print(f"✓ Atalho Win+V configurado e recarregado ({ident}).")
except (OSError, subprocess.CalledProcessError) as erro:
print(f"✗ Não consegui configurar o atalho: {erro}")
return 1
if esta_rodando():
print("✓ O Copiado já está rodando.")
elif iniciar_em_segundo_plano():
print("✓ O Copiado estava parado e foi iniciado.")
else:
print("✗ Não consegui iniciar o Copiado. Rode 'copiado' para ver o erro.")
return 1
print("Pronto! Aperte Win+V.")
print("Se ainda não abrir, veja se outro atalho usa Win+V em")
print("Configurações do Sistema → Teclado → Atalhos.")
return 0
def main(argv):
comando = argv[1] if len(argv) > 1 else ""
if comando in ("ajuda", "-h", "--help"):
print(__doc__.strip())
return 0
if comando == "consertar":
return consertar()
if comando == "instalar-atalho":
configurar_atalho()
return 0
if comando == "remover-atalho":
remover_atalho()
return 0
if comando not in ("", "show", "esquecer"):
print(f"Comando desconhecido: {comando}\n")
print(__doc__.strip())
return 2
return App().run(argv)
if __name__ == "__main__":
sys.exit(App().run(sys.argv))
sys.exit(main(sys.argv))
+1 -10
View File
@@ -1,15 +1,6 @@
#!/usr/bin/env bash
# Remove o Copiado e o atalho Win+V
BASE=org.cinnamon.desktop.keybindings
LISTA=$(gsettings get $BASE custom-list)
for ID in $(python3 -c "import ast,sys; print(' '.join(ast.literal_eval(sys.argv[1].replace('@as ',''))))" "$LISTA"); do
SCHEMA="$BASE.custom-keybinding:/org/cinnamon/desktop/keybindings/custom-keybindings/$ID/"
if gsettings get "$SCHEMA" command | grep -q copiado; then
LISTA=$(python3 -c "import ast,sys; l=ast.literal_eval(sys.argv[1].replace('@as ','')); print([x for x in l if x!='$ID'])" "$LISTA")
gsettings set $BASE custom-list "$LISTA"
gsettings reset-recursively "$SCHEMA"
fi
done
command -v copiado >/dev/null && copiado remover-atalho
pkill -f "/usr/bin/copiado" 2>/dev/null
sudo apt-get remove -y copiado
rm -rf "$HOME/.local/share/copiado"
+12 -27
View File
@@ -1,13 +1,19 @@
#!/usr/bin/env bash
# Instala (ou atualiza) o Copiado a partir do pacote publicado no GitHub
set -e
URL="https://github.com/lucasrguerra/copiado/releases/latest/download/copiado_all.deb"
BASE=org.cinnamon.desktop.keybindings
URL="https://github.com/lucasrguerra/copiado/releases/latest/download"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
echo "==> Baixando a versão mais recente..."
curl -fsSL -o "$TMP/copiado_all.deb" "$URL"
curl -fsSL --proto '=https' --tlsv1.2 -o "$TMP/copiado_all.deb" "$URL/copiado_all.deb"
curl -fsSL --proto '=https' --tlsv1.2 -o "$TMP/SHA256SUMS" "$URL/SHA256SUMS"
echo "==> Conferindo a integridade do pacote..."
(cd "$TMP" && sha256sum --check --strict --quiet SHA256SUMS) || {
echo "ERRO: o pacote baixado não confere com o checksum publicado. Instalação cancelada."
exit 1
}
chmod 644 "$TMP/copiado_all.deb"
echo "==> Instalando o pacote..."
@@ -27,31 +33,10 @@ if [ -f "$HOME/.local/share/clipboard-winv/historico.json" ]; then
rm -rf "$HOME/.local/share/clipboard-winv"
fi
echo "==> Criando atalho Win+V..."
LISTA=$(gsettings get $BASE custom-list)
ID=""
for i in $(python3 -c "import ast,sys; print(' '.join(ast.literal_eval(sys.argv[1].replace('@as ',''))))" "$LISTA"); do
B=$(gsettings get "$BASE.custom-keybinding:/org/cinnamon/desktop/keybindings/custom-keybindings/$i/" binding)
[ "$B" = "['<Super>v']" ] && ID=$i && break
done
if [ -z "$ID" ]; then
for i in $(seq 0 99); do
echo "$LISTA" | grep -q "'custom$i'" || { ID="custom$i"; break; }
done
LISTA=$(python3 -c "import ast,sys; l=ast.literal_eval(sys.argv[1].replace('@as ','')); l.append('$ID'); print(l)" "$LISTA")
fi
SCHEMA="$BASE.custom-keybinding:/org/cinnamon/desktop/keybindings/custom-keybindings/$ID/"
gsettings set "$SCHEMA" name "Copiado"
gsettings set "$SCHEMA" command "copiado show"
gsettings set "$SCHEMA" binding "['<Super>v']"
# só agora registra a lista (e força o Cinnamon a recarregar os atalhos)
gsettings set $BASE custom-list "[]"
echo "==> Criando atalho Win+V e iniciando..."
pkill -f "/usr/bin/copiado" 2>/dev/null || true
sleep 0.5
gsettings set $BASE custom-list "$LISTA"
echo "==> Iniciando..."
pkill -x copiado 2>/dev/null || pkill -f "/usr/bin/copiado" 2>/dev/null || true
setsid copiado >/dev/null 2>&1 &
copiado consertar >/dev/null
echo
echo "Pronto! Versão instalada: $(dpkg-query -W -f='${Version}' copiado). Aperte Win+V."