Compare commits
@@ -0,0 +1,92 @@
|
|||||||
|
# Compila o APK do Android no GitHub e o devolve como artefato.
|
||||||
|
#
|
||||||
|
# O runner entra só como máquina de build, pelo mesmo motivo do desktop: a VPS
|
||||||
|
# tem ~2 GB de RAM livres e roda a produção, então um Gradle com o SDK do
|
||||||
|
# Android ali dentro arrisca derrubar as chamadas de quem está usando o app.
|
||||||
|
#
|
||||||
|
# O APK sai **sem assinatura**. A chave de assinatura fica na VPS e não sobe
|
||||||
|
# para cá: quem assina é /opt/ops/release-android.sh, depois de baixar este
|
||||||
|
# artefato. Um APK assinado é instalável por qualquer um, e a chave é o que
|
||||||
|
# prova que uma atualização veio de nós — ela não tem por que existir em duas
|
||||||
|
# máquinas.
|
||||||
|
name: Android APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags: ['android-v*']
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '21'
|
||||||
|
|
||||||
|
# cache/cache-dependency-path apontando para o npm deste pacote não é só
|
||||||
|
# otimização: sem eles o setup-node lê o campo `packageManager` do
|
||||||
|
# package.json da raiz, tenta usar pnpm — que este workflow não instala —
|
||||||
|
# e falha antes de rodar qualquer coisa.
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: packages/mobile/package-lock.json
|
||||||
|
|
||||||
|
# npm, não pnpm: packages/mobile fica fora do workspace de propósito
|
||||||
|
# (ver pnpm-workspace.yaml). Ele tem o próprio package-lock.json.
|
||||||
|
- name: Install Capacitor
|
||||||
|
working-directory: packages/mobile
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# Copia a config e os arquivos de public/ para dentro do projeto nativo.
|
||||||
|
# Sem isso o APK sai com o que estiver commitado em assets/, que pode
|
||||||
|
# estar velho — esses arquivos são gerados, não versionados.
|
||||||
|
- name: Sync Capacitor
|
||||||
|
working-directory: packages/mobile
|
||||||
|
run: npx cap sync android
|
||||||
|
|
||||||
|
- name: Cache Gradle
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: gradle-${{ hashFiles('packages/mobile/android/**/*.gradle', 'packages/mobile/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: gradle-
|
||||||
|
|
||||||
|
- name: Build unsigned release APK
|
||||||
|
working-directory: packages/mobile/android
|
||||||
|
run: ./gradlew assembleRelease --no-daemon --stacktrace
|
||||||
|
|
||||||
|
# O nome do arquivo muda conforme a versão do AGP (app-release-unsigned.apk
|
||||||
|
# ou app-release.apk quando não há config de assinatura). Localiza em vez
|
||||||
|
# de supor, e falha alto se nada saiu — um artefato vazio só apareceria
|
||||||
|
# como erro lá na frente, na hora de assinar.
|
||||||
|
- name: Collect APK
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p out
|
||||||
|
found=$(find packages/mobile/android/app/build/outputs/apk/release -name '*.apk' | head -1)
|
||||||
|
[ -n "$found" ] || { echo "::error::o build não produziu APK nenhum"; exit 1; }
|
||||||
|
cp "$found" out/resenhacord-unsigned.apk
|
||||||
|
ls -l out/
|
||||||
|
|
||||||
|
- name: Upload APK artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: resenhacord-apk-unsigned
|
||||||
|
path: out/resenhacord-unsigned.apk
|
||||||
|
retention-days: 14
|
||||||
|
if-no-files-found: error
|
||||||
@@ -153,14 +153,38 @@ jobs:
|
|||||||
|
|
||||||
# Remove só os anexos que esta plataforma vai repor, para os dois jobs
|
# Remove só os anexos que esta plataforma vai repor, para os dois jobs
|
||||||
# não apagarem o trabalho um do outro.
|
# não apagarem o trabalho um do outro.
|
||||||
|
#
|
||||||
|
# Padrão montado em bash puro: a versão anterior interpolava uma
|
||||||
|
# expressão do Actions dentro de um `case`, e quando ela não casou o
|
||||||
|
# laço passou em silêncio — a release ficou com dois latest.yml e o
|
||||||
|
# updater serviu o antigo, dizendo que a versão nova não existia.
|
||||||
R=$(api GET "/releases/$ID/assets")
|
R=$(api GET "/releases/$ID/assets")
|
||||||
if [ "$(code "$R")" = "200" ]; then
|
if [ "$(code "$R")" != "200" ]; then
|
||||||
body "$R" | jq -r '.[] | "\(.id) \(.name)"' | while read -r aid aname; do
|
echo "::error::não foi possível listar os anexos — HTTP $(code "$R"): $(body "$R")"
|
||||||
case "$aname" in
|
exit 1
|
||||||
${{ runner.os == 'Windows' && '*.exe|latest.yml' || '*.AppImage|*.deb|latest-linux.yml' }})
|
fi
|
||||||
echo "removendo anexo antigo: $aname"
|
|
||||||
api DELETE "/releases/$ID/assets/$aid" > /dev/null ;;
|
# Filtro dentro do jq, comparando o nome direto. A versão anterior
|
||||||
esac
|
# montava um regex e o casava contra "<id> <nome>", onde o `^` de
|
||||||
|
# `^latest\.yml$` ancorava no início da linha — depois do id — e nunca
|
||||||
|
# podia casar. O laço então só removia os .exe e deixava um latest.yml
|
||||||
|
# duplicado, que é o arquivo que decide se há atualização.
|
||||||
|
OLD=$(body "$R" | jq -r --arg os "${RUNNER_OS:-}" '
|
||||||
|
.[]
|
||||||
|
| select(
|
||||||
|
if $os == "Windows"
|
||||||
|
then (.name | endswith(".exe")) or .name == "latest.yml"
|
||||||
|
else (.name | endswith(".AppImage")) or (.name | endswith(".deb")) or .name == "latest-linux.yml"
|
||||||
|
end
|
||||||
|
)
|
||||||
|
| "\(.id) \(.name)"')
|
||||||
|
echo "anexos desta plataforma já na release: $(printf '%s' "$OLD" | grep -c . || true)"
|
||||||
|
if [ -n "$OLD" ]; then
|
||||||
|
printf '%s\n' "$OLD" | while read -r aid aname; do
|
||||||
|
[ -n "$aid" ] || continue
|
||||||
|
echo "removendo anexo antigo: $aname"
|
||||||
|
D=$(api DELETE "/releases/$ID/assets/$aid")
|
||||||
|
[ "$(code "$D")" = "204" ] || echo "::warning::falha ao remover $aname — HTTP $(code "$D")"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -181,3 +205,17 @@ jobs:
|
|||||||
done
|
done
|
||||||
[ "$sent" -gt 0 ] || { echo "::error::o build não produziu instaladores"; exit 1; }
|
[ "$sent" -gt 0 ] || { echo "::error::o build não produziu instaladores"; exit 1; }
|
||||||
echo "$sent arquivo(s) publicados"
|
echo "$sent arquivo(s) publicados"
|
||||||
|
|
||||||
|
# O updater busca latest.yml pelo nome. Duas cópias com o mesmo nome
|
||||||
|
# fazem o Gitea servir a mais antiga, e a atualização deixa de ser
|
||||||
|
# oferecida — sem erro em lugar nenhum. Falha aqui em vez de publicar
|
||||||
|
# uma release que parece boa e não atualiza.
|
||||||
|
if [ "${RUNNER_OS:-}" = "Windows" ]; then
|
||||||
|
R=$(api GET "/releases/$ID/assets")
|
||||||
|
DUP=$(body "$R" | jq -r '[.[] | select(.name == "latest.yml")] | length')
|
||||||
|
if [ "$DUP" != "1" ]; then
|
||||||
|
echo "::error::a release tem $DUP cópias de latest.yml — o updater serviria a errada"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "latest.yml: 1 cópia, como esperado"
|
||||||
|
fi
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 25 KiB |
@@ -1,33 +1,40 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 147.46 146.59">
|
<!--
|
||||||
|
Resenhacord app icon: the mark on the app's own night-blue ground (#0b0b10,
|
||||||
|
the theme colour), inside the 22 %-radius rounded square the icon pipeline
|
||||||
|
masks to. A soft violet glow behind the mark keeps the badge from reading as
|
||||||
|
a flat black square at large sizes.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
<defs>
|
<defs>
|
||||||
<style>
|
<radialGradient id="glow" cx="512" cy="480" r="420" gradientUnits="userSpaceOnUse">
|
||||||
.cls-1 {
|
<stop offset="0" stop-color="#7c6cf6" stop-opacity="0.28"/>
|
||||||
fill: #1d1d1b;
|
<stop offset="1" stop-color="#7c6cf6" stop-opacity="0"/>
|
||||||
}
|
</radialGradient>
|
||||||
|
<linearGradient id="back" x1="10" y1="12" x2="74" y2="74" gradientUnits="userSpaceOnUse">
|
||||||
.cls-2 {
|
<stop offset="0" stop-color="#c4b5fd"/>
|
||||||
fill: url(#Unbenannter_Verlauf_515);
|
<stop offset="1" stop-color="#7c6cf6"/>
|
||||||
}
|
|
||||||
|
|
||||||
.cls-3 {
|
|
||||||
fill: url(#Unbenannter_Verlauf_547);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<linearGradient id="Unbenannter_Verlauf_515" data-name="Unbenannter Verlauf 515" x1="62.13" y1="77.74" x2="118.16" y2="77.74" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop offset="0" stop-color="#fff"/>
|
|
||||||
<stop offset=".95" stop-color="#9a86b8"/>
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="Unbenannter_Verlauf_547" data-name="Unbenannter Verlauf 547" x1="9.21" y1="71.45" x2="135.41" y2="71.45" gradientUnits="userSpaceOnUse">
|
<linearGradient id="front" x1="42" y1="42" x2="94" y2="94" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0" stop-color="#fff"/>
|
<stop offset="0" stop-color="#fda4af"/>
|
||||||
<stop offset=".95" stop-color="#9a86b8"/>
|
<stop offset="1" stop-color="#f472b6"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
|
||||||
|
<mask id="gap" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
|
||||||
|
<rect width="100" height="100" fill="#fff"/>
|
||||||
|
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
|
||||||
|
</mask>
|
||||||
</defs>
|
</defs>
|
||||||
<g id="Ebene_1-2" data-name="Ebene 1">
|
<rect width="1024" height="1024" rx="225" fill="#0b0b10"/>
|
||||||
<rect class="cls-1" x="0" y="0" width="147.46" height="146.59" rx="32.42" ry="32.42"/>
|
<rect width="1024" height="1024" rx="225" fill="url(#glow)"/>
|
||||||
<g>
|
<g transform="translate(192 192) scale(6.4)">
|
||||||
<path class="cls-2" d="M100.85,75.32c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
|
<path mask="url(#gap)" fill="url(#back)"
|
||||||
<path class="cls-3" d="M102.43,119.43c-.64.25-1.29.48-1.93.7l-1.67.39c-.57.14-1.04.17-1.57.26-.51.09-1.02.16-1.51.21-.98.11-1.95.2-2.9.25-.96.07-1.88.12-2.82.16-3.72.14-7.36.14-10.98.07-1.81-.03-3.61-.1-5.41-.16l-2.67-.11c-.83-.03-1.76-.08-2.69-.13l-5.55-.32-.63-.04-.81-.09c-.54-.08-1.08-.16-1.62-.27-1.07-.22-2.13-.5-3.17-.88-2.07-.74-4.05-1.78-5.83-3.11-.9-.66-1.74-1.39-2.53-2.18l-1.91-1.98-7.37-7.7-7.37-7.7c-2.49-2.54-4.81-5.23-7.56-7.52-2.75-2.29-5.15-4.91-7.6-7.49-1.23-1.29-2.4-2.62-3.61-3.93l-.45-.49-.22-.25-.34-.38c-.59-.67-1.07-1.43-1.47-2.23-.77-1.61-1.13-3.41-.99-5.18.11-1.76.72-3.49,1.7-4.96.26-.35.51-.72.81-1.04l.45-.48.25-.25.96-.93,3.83-3.71,7.66-7.42,15.32-14.84,7.66-7.42.99-.95c.4-.38.8-.74,1.23-1.09.85-.69,1.73-1.33,2.67-1.87,1.87-1.11,3.91-1.92,6.02-2.42,1.06-.24,2.13-.4,3.2-.49.54-.04,1.08-.06,1.62-.06h.73s.67.02.67.02l5.33.11,10.66.21,21.32.42,10.72.23c4.1.16,8.16,1.58,11.38,4.07.83.6,1.56,1.31,2.29,2.01.67.76,1.36,1.51,1.93,2.35,1.16,1.65,2.12,3.46,2.7,5.4.59,1.92.97,3.93.94,5.93,0,1.87-.06,3.58-.09,5.38l-.85,42.64-.22,10.68c-.12,3.6-.47,7.31-1.88,10.77-.69,1.72-1.64,3.4-2.98,4.74-1.32,1.36-2.95,2.38-4.68,3.05-1.75.64-3.58.92-5.39,1.06-.9.02-1.82.04-2.71.02-.44-.01-.89,0-1.33-.06-.22-.02-.44-.06-.67-.1l-.33-.07c-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26l.33-.07c.22-.04.44-.08.67-.1.44-.05.89-.04,1.33-.06.89-.02,1.75-.07,2.62-.12,1.72-.2,3.42-.54,5-1.18,1.55-.67,2.95-1.64,4.06-2.88,1.13-1.22,1.92-2.74,2.48-4.33,1.16-3.19,1.37-6.71,1.34-10.18l-.2-10.64-.85-42.65c-.04-1.76-.05-3.61-.12-5.28-.04-1.56-.4-3.08-.9-4.52-.5-1.46-1.29-2.79-2.21-4.01-.45-.62-1-1.16-1.52-1.71-.58-.5-1.14-1.02-1.78-1.43-2.48-1.76-5.49-2.67-8.48-2.67l-10.6.19-21.32.42-10.66.21-5.33.11h-.67c-.23.02-.45.02-.6.03-.35,0-.7.04-1.04.08-.69.09-1.38.22-2.05.4-1.34.38-2.61.95-3.77,1.7-.58.37-1.12.8-1.64,1.25-.26.23-.5.47-.74.71l-.9.94-7.36,7.72-14.72,15.43-7.36,7.72-3.68,3.86-.92.96-.22.23s0,.01,0,.01c-.01,0,.01.02,0,.01.02.02.02.02.05.04,0,.01.05,0,.04,0,.02-.02,0-.01.02-.03l.81.83c1.24,1.28,2.5,2.53,3.71,3.82,2.43,2.59,4.91,5.14,7.04,8.02,2.13,2.88,4.69,5.35,7.08,7.98l7.27,7.8,7.27,7.8,1.76,1.89c.46.48.95.92,1.48,1.32,1.06.8,2.27,1.43,3.54,1.88.64.23,1.3.4,1.97.54.33.07.67.12,1.01.17l.51.06c.33.03.46.03.7.04l5.09.27c.84.05,1.69.1,2.63.12l2.65.1c1.76.06,3.51.12,5.25.15,3.48.06,6.96.07,10.34-.17,1.68-.1,3.36-.28,4.9-.57.4-.06.77-.14,1.12-.23.35-.09.76-.16,1.04-.27l.9-.31c.18-.11.36-.22.54-.3l.65-.29c2.43-1.07,5.26.03,6.33,2.46,1.07,2.43-.03,5.26-2.46,6.33-.05.02-.12.05-.17.07Z"/>
|
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
|
||||||
|
<use href="#frontBubble" fill="url(#front)"/>
|
||||||
|
<g fill="#fff">
|
||||||
|
<circle cx="58" cy="62" r="3.6"/>
|
||||||
|
<circle cx="68" cy="62" r="3.6"/>
|
||||||
|
<circle cx="78" cy="62" r="3.6"/>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -1,14 +1,20 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 123.08 100.14">
|
<!-- Monochrome silhouette of mark.svg for macOS menu-bar templates (alpha only). -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
<defs>
|
<defs>
|
||||||
<style>
|
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
|
||||||
.cls-1 {
|
<mask id="gap">
|
||||||
fill: #1d1d1b;
|
<rect width="100" height="100" fill="#fff"/>
|
||||||
}
|
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
|
||||||
</style>
|
</mask>
|
||||||
|
<mask id="dots">
|
||||||
|
<rect width="100" height="100" fill="#fff"/>
|
||||||
|
<circle cx="58" cy="62" r="3.6" fill="#000"/>
|
||||||
|
<circle cx="68" cy="62" r="3.6" fill="#000"/>
|
||||||
|
<circle cx="78" cy="62" r="3.6" fill="#000"/>
|
||||||
|
</mask>
|
||||||
</defs>
|
</defs>
|
||||||
<g id="Ebene_1-2" data-name="Ebene 1">
|
<path mask="url(#gap)" fill="#1d1d1b"
|
||||||
<path class="cls-1" d="M91.64,53.93c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
|
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
|
||||||
<path class="cls-1" d="M93.21,98.04c-1.58.67-3.48,1.14-5.16,1.36-7.86,1.04-15.77.76-23.61.52-3.06-.04-7.8-.42-10.92-.56-2.12-.13-4.23-.56-6.23-1.28-4.01-1.34-7.48-4.13-10.26-7.27-3.66-3.85-11.08-11.56-14.75-15.4-2.49-2.54-4.81-5.23-7.56-7.52-4.08-3.48-7.59-7.49-11.21-11.42-3.78-3.58-4.71-9.08-1.77-13.49.6-.98,1.66-1.88,2.47-2.69,6.89-6.67,19.79-19.17,26.81-25.96,0,0,7.66-7.42,7.66-7.42,1.47-1.45,3.08-2.88,4.89-3.91C46.83,1.04,50.61,0,54.4,0c.89-.03,5.73.13,6.73.12,0,0,10.66.21,10.66.21,7.02.15,25.23.49,32.04.65,8.24.22,15.88,5.97,18.3,13.83,1.26,3.64.92,7.57.85,11.31,0,0-.85,42.64-.85,42.64-.31,13.92,1.41,31-17.86,30.33-.78,0-1.57-.04-2.33-.23-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26,3.35-.48,6.76-.1,9.95-1.53,6.81-2.78,8.04-10.78,7.88-17.39-.14-6.81-.88-45.63-1.05-53.3-.12-3.34.17-6.68-1.03-9.81-1.03-2.88-3-5.41-5.51-7.15-2.48-1.76-5.49-2.67-8.48-2.67,0,0-10.6.19-10.6.19-9.89.2-27.61.55-37.32.74-3.9-.11-7.81,1.33-10.51,4.18-.22.33-7.8,8.1-8.26,8.65-4.86,5.11-17.37,18.21-22.08,23.15-.73.81-4.11,4.27-4.82,5.05,0,0,0,.01,0,.01-.01,0,.01.02,0,.01.02.02.08.07.09.03.02-.02,0-.01.02-.03,0,0,.81.83.81.83,3.71,3.84,7.52,7.57,10.75,11.84,3.79,4.86,10.17,11.13,14.34,15.78,0,0,7.27,7.8,7.27,7.8,1.85,2.11,4.03,4.24,6.79,5.09,2.44.99,6.67.88,9.28,1.08,3.39.17,7.13.31,10.53.37,5.86.03,11.79.22,17.4-1.24,0,0,.9-.31.9-.31.35-.2.82-.44,1.2-.58,6.01-2.4,9.65,6.26,3.7,8.87h0Z"/>
|
<use href="#frontBubble" fill="#1d1d1b" mask="url(#dots)"/>
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -1,28 +1,33 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 123.07 100.13">
|
<!--
|
||||||
|
Resenhacord mark: two speech bubbles in conversation, the front one talking.
|
||||||
|
Square viewBox so every raster target (tray, favicon, PWA) scales without
|
||||||
|
letterboxing. The gap between the bubbles is cut with a mask rather than
|
||||||
|
painted, so the mark stays legible on any background and the macOS template
|
||||||
|
(alpha-only) variant keeps the same silhouette.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
<defs>
|
<defs>
|
||||||
<style>
|
<linearGradient id="back" x1="10" y1="12" x2="74" y2="74" gradientUnits="userSpaceOnUse">
|
||||||
.cls-1 {
|
<stop offset="0" stop-color="#c4b5fd"/>
|
||||||
fill: url(#Unbenannter_Verlauf_515);
|
<stop offset="1" stop-color="#7c6cf6"/>
|
||||||
}
|
|
||||||
|
|
||||||
.cls-2 {
|
|
||||||
fill: url(#Unbenannter_Verlauf_547);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<linearGradient id="Unbenannter_Verlauf_515" data-name="Unbenannter Verlauf 515" x1="52.91" y1="56.35" x2="108.94" y2="56.35" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop offset="0" stop-color="#fff"/>
|
|
||||||
<stop offset=".95" stop-color="#9a86b8"/>
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="Unbenannter_Verlauf_547" data-name="Unbenannter Verlauf 547" x1="0" y1="50.06" x2="126.19" y2="50.06" gradientUnits="userSpaceOnUse">
|
<linearGradient id="front" x1="42" y1="42" x2="94" y2="94" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0" stop-color="#fff"/>
|
<stop offset="0" stop-color="#fda4af"/>
|
||||||
<stop offset=".95" stop-color="#9a86b8"/>
|
<stop offset="1" stop-color="#f472b6"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
|
||||||
|
<mask id="gap">
|
||||||
|
<rect width="100" height="100" fill="#fff"/>
|
||||||
|
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
|
||||||
|
</mask>
|
||||||
</defs>
|
</defs>
|
||||||
<g id="Ebene_1-2" data-name="Ebene 1">
|
<path mask="url(#gap)" fill="url(#back)"
|
||||||
<g>
|
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
|
||||||
<path class="cls-1" d="M91.64,53.93c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
|
<use href="#frontBubble" fill="url(#front)"/>
|
||||||
<path class="cls-2" d="M93.21,98.04c-.64.25-1.29.48-1.93.7l-1.67.39c-.57.14-1.04.17-1.57.26-.51.09-1.02.16-1.51.21-.98.11-1.95.2-2.9.25-.96.07-1.88.12-2.82.16-3.72.14-7.36.14-10.98.07-1.81-.03-3.61-.1-5.41-.16l-2.67-.11c-.83-.03-1.76-.08-2.69-.13l-5.55-.32-.63-.04-.81-.09c-.54-.08-1.08-.16-1.62-.27-1.07-.22-2.13-.5-3.17-.88-2.07-.74-4.05-1.78-5.83-3.11-.9-.66-1.74-1.39-2.53-2.18l-1.91-1.98-7.37-7.7-7.37-7.7c-2.49-2.54-4.81-5.23-7.56-7.52-2.75-2.29-5.15-4.91-7.6-7.49-1.23-1.29-2.4-2.62-3.61-3.93l-.45-.49-.22-.25-.34-.38c-.59-.67-1.07-1.43-1.47-2.23-.77-1.61-1.13-3.41-.99-5.18.11-1.76.72-3.49,1.7-4.96.26-.35.51-.72.81-1.04l.45-.48.25-.25.96-.93,3.83-3.71,7.66-7.42,15.32-14.84,7.66-7.42.99-.95c.4-.38.8-.74,1.23-1.09.85-.69,1.73-1.33,2.67-1.87,1.87-1.11,3.91-1.92,6.02-2.42,1.06-.24,2.13-.4,3.2-.49.54-.04,1.08-.06,1.62-.06h.73s.67.02.67.02l5.33.11,10.66.21,21.32.42,10.72.23c4.1.16,8.16,1.58,11.38,4.07.83.6,1.56,1.31,2.29,2.01.67.76,1.36,1.51,1.93,2.35,1.16,1.65,2.12,3.46,2.7,5.4.59,1.92.97,3.93.94,5.93,0,1.87-.06,3.58-.09,5.38l-.85,42.64-.22,10.68c-.12,3.6-.47,7.31-1.88,10.77-.69,1.72-1.64,3.4-2.98,4.74-1.32,1.36-2.95,2.38-4.68,3.05-1.75.64-3.58.92-5.39,1.06-.9.02-1.82.04-2.71.02-.44-.01-.89,0-1.33-.06-.22-.02-.44-.06-.67-.1l-.33-.07c-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26l.33-.07c.22-.04.44-.08.67-.1.44-.05.89-.04,1.33-.06.89-.02,1.75-.07,2.62-.12,1.72-.2,3.42-.54,5-1.18,1.55-.67,2.95-1.64,4.06-2.88,1.13-1.22,1.92-2.74,2.48-4.33,1.16-3.19,1.37-6.71,1.34-10.18l-.2-10.64-.85-42.65c-.04-1.76-.05-3.61-.12-5.28-.04-1.56-.4-3.08-.9-4.52-.5-1.46-1.29-2.79-2.21-4.01-.45-.62-1-1.16-1.52-1.71-.58-.5-1.14-1.02-1.78-1.43-2.48-1.76-5.49-2.67-8.48-2.67l-10.6.19-21.32.42-10.66.21-5.33.11h-.67c-.23.02-.45.02-.6.03-.35,0-.7.04-1.04.08-.69.09-1.38.22-2.05.4-1.34.38-2.61.95-3.77,1.7-.58.37-1.12.8-1.64,1.25-.26.23-.5.47-.74.71l-.9.94-7.36,7.72-14.72,15.43-7.36,7.72-3.68,3.86-.92.96-.22.23s0,.01,0,.01c-.01,0,.01.02,0,.01.02.02.02.02.05.04,0,.01.05,0,.04,0,.02-.02,0-.01.02-.03l.81.83c1.24,1.28,2.5,2.53,3.71,3.82,2.43,2.59,4.91,5.14,7.04,8.02,2.13,2.88,4.69,5.35,7.08,7.98l7.27,7.8,7.27,7.8,1.76,1.89c.46.48.95.92,1.48,1.32,1.06.8,2.27,1.43,3.54,1.88.64.23,1.3.4,1.97.54.33.07.67.12,1.01.17l.51.06c.33.03.46.03.7.04l5.09.27c.84.05,1.69.1,2.63.12l2.65.1c1.76.06,3.51.12,5.25.15,3.48.06,6.96.07,10.34-.17,1.68-.1,3.36-.28,4.9-.57.4-.06.77-.14,1.12-.23.35-.09.76-.16,1.04-.27l.9-.31c.18-.11.36-.22.54-.3l.65-.29c2.43-1.07,5.26.03,6.33,2.46,1.07,2.43-.03,5.26-2.46,6.33-.05.02-.12.05-.17.07Z"/>
|
<g fill="#fff">
|
||||||
</g>
|
<circle cx="58" cy="62" r="3.6"/>
|
||||||
|
<circle cx="68" cy="62" r="3.6"/>
|
||||||
|
<circle cx="78" cy="62" r="3.6"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -21,11 +21,29 @@ código e os commits seguem em inglês, como o resto do repositório.
|
|||||||
| Spotify por OAuth (atividade com capa e progresso) | `75316b08` | Token só no servidor; `state` assinado; atividades por fonte para não brigar com o detector do Electron |
|
| Spotify por OAuth (atividade com capa e progresso) | `75316b08` | Token só no servidor; `state` assinado; atividades por fonte para não brigar com o detector do Electron |
|
||||||
| Menu do próprio nome | `f5451e1b` | Perfil, status e copiar ID. Sem Clips/trocar conta — não existem aqui |
|
| Menu do próprio nome | `f5451e1b` | Perfil, status e copiar ID. Sem Clips/trocar conta — não existem aqui |
|
||||||
| Cronômetro da call | `f5451e1b` | `startedAt` do servidor; zera sozinho porque sala vazia é destruída |
|
| Cronômetro da call | `f5451e1b` | `startedAt` do servidor; zera sozinho porque sala vazia é destruída |
|
||||||
|
| Correções do Spotify (sincronia, sumiço, barra) | `c899253e` | Pausa virou estado; horário do servidor no `ready` corrige relógio |
|
||||||
|
| Soundboard no app desktop | `c899253e` | `window.prompt` não existe no Electron; campo inline no lugar |
|
||||||
|
| Áudio do sistema sem eco | `d525bbb8` `5b848432` | Módulo nativo com isolamento por processo + ganho e preset de música |
|
||||||
|
| Instaladores publicados pelo Gitea | `ce6bba05` | GitHub compila, Gitea distribui; atualização automática ligada |
|
||||||
|
| Fixar mensagens no canal | `4ceb5cd6` | Estado na própria mensagem; painel no cabeçalho; limite de 50 |
|
||||||
|
| Busca: filtros embutidos e tradução | `50a8f12c` | Os filtros já existiam; faltava `de:fulano` e descoberta |
|
||||||
|
| Emojis e figurinhas do servidor | `7d003021` | `:nome:` no markdown sem plugin; figurinha como mensagem inteira |
|
||||||
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
|
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
|
||||||
|
| Rebrand: **Resenhacord** + logo nova | (ver git log) | `productName` mudou, `appId` **não** — assim o NSIS troca a instalação antiga no lugar. `userData` migra `Backspace` → `Resenhacord` no primeiro boot (senão todo mundo cairia da sessão). Logo: duas bolhas de conversa, roxo → rosa; fonte em `assets/brand/*.svg`, PNGs e ícones gerados por `pnpm gen-icons` |
|
||||||
|
| Aviso "X fixou uma mensagem" | (ver git log) | Tabela `messages` ganhou `type` (migração 0018); a rota de pin posta uma mensagem de sistema. Dois links: pular pra mensagem e abrir o painel de fixadas |
|
||||||
|
| Menção `@everyone` | (ver git log) | Reusa o token `<@id>`; permissão `MENTION_EVERYONE` nova, concedida ao `@everyone` por padrão e retroativa via backfill no boot. Servidor neutraliza o token de quem não tem a permissão, no envio **e** na edição |
|
||||||
|
| Renomear canal (nome e tópico) | (ver git log) | Rota `PATCH /api/channels/:id` já existia; faltava interface. Normalização em `shared/constants` para servidor e web usarem a mesma regra |
|
||||||
|
| Volume da tela em 50% por padrão | (ver git log) | `DEFAULT_STREAM_VOLUME`; o publicador reforça o áudio do sistema, então o padrão anterior chegava alto demais |
|
||||||
|
| Sons 50% mais baixos | (ver git log) | Só `SFX_BASE_VOLUME` (0.8 → 0.4); os `.ogg` não foram regerados |
|
||||||
|
| Atualização do desktop verificada com mais frequência | (ver git log) | Era só na abertura e de 4 em 4 horas; agora a cada 30 min e ao focar a janela, com trava de 5 min |
|
||||||
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
||||||
|
|
||||||
## Já existia no código (verificado, não construir de novo)
|
## Já existia no código (verificado, não construir de novo)
|
||||||
|
|
||||||
|
- **Busca com filtros** — servidor (`q`, `from`, `has`, `before`, `after`),
|
||||||
|
client de API e painel no `SearchPopover` já existiam completos. Só faltava
|
||||||
|
tradução, sintaxe embutida (`de:fulano`) e descoberta.
|
||||||
|
|
||||||
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
|
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
|
||||||
escalonados em 0/150/300ms.
|
escalonados em 0/150/300ms.
|
||||||
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
|
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
|
||||||
@@ -47,12 +65,33 @@ Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
|
|||||||
| # | Feature | Tamanho | Observação técnica |
|
| # | Feature | Tamanho | Observação técnica |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
|
||||||
|
## Identidade do perfil (entregue 2026-09-02, pedido: "completo e FODA")
|
||||||
|
|
||||||
|
Uma coluna JSON (`users.profile_style`, migração 0019) normalizada no servidor
|
||||||
|
por `shared/profile.ts`. Aba **Perfil** nas configurações com prévia ao vivo
|
||||||
|
(`ProfilePanel` renderiza o mesmo `ProfileCard` que o popout).
|
||||||
|
|
||||||
|
| Peça | Onde aparece | Detalhe |
|
||||||
|
|---|---|---|
|
||||||
|
| Estilo do nome — 10 fontes × 9 efeitos × 2 cores | popout, perfil completo | Fontes OFL em `public/fonts`; efeitos são CSS (`.name-fx-*`) |
|
||||||
|
| Decoração do avatar — 10 | popout, perfil, editor | SVG inline em `DecoratedAvatar`, sem imagens |
|
||||||
|
| Efeito do perfil — 9 | banner do popout e do perfil | Partículas CSS com posições semeadas (`ProfileEffectLayer`) |
|
||||||
|
| Nameplate — 10 + personalizado | lista de membros | `.nameplate-*` |
|
||||||
|
| Tema (2 cores) | fundo do cartão e banner sem imagem | |
|
||||||
|
| Pronomes, tag (emoji + 2–4 letras) | ao lado do nome; tag também na lista de membros | |
|
||||||
|
| Links (até 5) | popout e aba Sobre | Ícone detectado pelo host; só https |
|
||||||
|
| Quadro: jogo favorito, rotação (5), quero jogar (10), citação | aba Quadro do perfil; tira "Coleção" no popout | Busca na Steam via `/api/games/search` (proxy, cache 10 min); capas só de CDN da Steam |
|
||||||
|
| Emblemas derivados | popout e perfil | admin, dono, fundador (1º mês), veterano (1 ano), OG (6 meses), estiloso — nada é armazenado |
|
||||||
|
|
||||||
|
Fora do escopo desta entrega, candidatos a seguir: perfil por servidor
|
||||||
|
(apelido/avatar por servidor), emblemas concedidos pelo admin, molduras
|
||||||
|
animadas por upload, status com expiração.
|
||||||
|
|
||||||
## Pendente — ideias aprovadas
|
## Pendente — ideias aprovadas
|
||||||
|
|
||||||
| Feature | Tamanho | Observação técnica |
|
| Feature | Tamanho | Observação técnica |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
|
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
|
||||||
| Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem |
|
|
||||||
|
|
||||||
## Regra de idioma (a partir de 2026-08-31)
|
## Regra de idioma (a partir de 2026-08-31)
|
||||||
|
|
||||||
@@ -72,22 +111,52 @@ fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
|
|||||||
- Configurações → Privacidade
|
- Configurações → Privacidade
|
||||||
- Configurações → Conexões (nasceu bilíngue)
|
- Configurações → Conexões (nasceu bilíngue)
|
||||||
- Seletor de GIF
|
- Seletor de GIF
|
||||||
|
- Chat: composer e mensagens (placeholder, anexos, responder, reações)
|
||||||
|
- Barra lateral: servidores, canais e menus de contexto
|
||||||
|
- Lista de membros (grupos por cargo, carregamento)
|
||||||
|
- Busca (rótulos, filtros e sintaxe embutida)
|
||||||
- Registro de auditoria e Estatísticas (nasceram bilíngues)
|
- Registro de auditoria e Estatísticas (nasceram bilíngues)
|
||||||
|
- Configurações do canal (visão geral, privacidade, exclusão e rótulos de permissão)
|
||||||
|
- Amigos: abas, listas, pedidos, aba de adicionar e cartões de descoberta
|
||||||
|
- Modal de convite (incluindo os motivos de falha por convite)
|
||||||
|
- Lista de membros da DM em grupo (remover e transferir propriedade)
|
||||||
|
- Configurações → Área de trabalho (início automático, atualizações, instância)
|
||||||
|
- Configurações → Voz: dispositivo de saída (completa a aba, junto com a entrada)
|
||||||
|
- Configurações → Atalhos (incluindo os nomes das ações e o aviso de conflito)
|
||||||
|
- Configurações → Conta (perfil, senha, conta desligada da origem, zona de perigo)
|
||||||
|
- Explorar servidores (cartões, entrar, pedir para entrar)
|
||||||
|
- Configurações → Voz: vídeo (prévia, permissões, escolha de câmera) — fecha a aba
|
||||||
|
- Criar canal, categoria e servidor
|
||||||
|
- DMs em grupo (nova DM, adicionar amigos, configurações do grupo, sair)
|
||||||
|
- Entrar num servidor (descoberta, código de convite, conectar e login federado)
|
||||||
|
- Excluir conta, transferir propriedade e a tela de erro geral
|
||||||
|
- Configurações do servidor → Visão geral (identidade, ícone, banner, zona de perigo)
|
||||||
|
- Configurações do servidor → Membros e banimentos
|
||||||
|
- Configurações do servidor → Cargos (incluindo a tabela de permissões)
|
||||||
|
- Configurações da categoria (renomear, privacidade, exceções, excluir)
|
||||||
|
- Configurações da instância → Geral (nome, descoberta, busca de GIFs)
|
||||||
|
- Mensagens de sistema do chat e a prévia delas na barra lateral
|
||||||
|
- Login, registro e a página de convite
|
||||||
|
- Voz: controles, informações de conexão, folha de entrada no celular, aba Voz
|
||||||
|
- Telas do celular (`Mobile*.tsx`)
|
||||||
|
- Navegação das configurações (servidor, usuário e instância) + Descoberta e
|
||||||
|
pedidos de entrada
|
||||||
|
|
||||||
|
Os rótulos de permissão viraram um conjunto único (`perm.*`), usado pelo painel
|
||||||
|
de cargos e pelos editores de exceção de canal e de categoria — antes havia
|
||||||
|
duas cópias da mesma lista.
|
||||||
|
|
||||||
### Fila sugerida de tradução
|
### Fila sugerida de tradução
|
||||||
|
|
||||||
Mensagens e composer · lista de membros · servidores e canais · amigos e DMs ·
|
Configurações → Instância: registro, federação, transmissão, armazenamento e
|
||||||
modais de convite · configurações restantes · telas de erro
|
usuários (os cinco painéis grandes, só o dono vê) · `ConnectedInstances` ·
|
||||||
|
`UserProfileModal` · `MainContent` e `ChannelSidebar` · mensagens de erro
|
||||||
|
vindas de stores e hooks (`instanceStore`, `useLiveKit`, `transferStore`)
|
||||||
|
|
||||||
## Correções pendentes
|
## Correções pendentes
|
||||||
|
|
||||||
| Problema | Causa provável | Correção |
|
| Problema | Causa provável | Correção |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Enviar som ao soundboard não funciona no app desktop** (funciona no navegador) | `SoundboardPopover` pede o nome do som com `window.prompt`, que o **Electron não implementa** — não abre nada e devolve vazio, então o fluxo aborta em silêncio, sem erro. `window.prompt` aparece em exatamente um lugar no projeto: esse. O resto do código já o evitava | Trocar por um campo de texto dentro do próprio popover (ou um modal reutilizável). Some o prompt e passa a funcionar igual nos dois. **Vale criar o modal de entrada genérico**, já que não existe nenhum e outras features vão precisar |
|
|
||||||
| **Eco absurdo ao compartilhar tela com som** (app desktop) | O `loopback` do Electron captura a mistura de saída do sistema **inteiro**, que inclui o próprio Backspace tocando a voz dos outros. Essa voz volta para eles dentro da transmissão, com atraso. Não é eco acústico — é digital, então **fone não resolve**. As constraints do web (`restrictOwnAudio`) nunca chegam: o handler ignora o `_request` e monta o stream a partir do enum. É também por isso que `shareAudio` já vinha desligado por padrão no app | **Implementado (2026-09-01), não testado.** Trocado por `electron-native-screenshare`: captura com isolamento por processo — só a janela compartilhada (include) ou tudo menos este app (exclude). O PCM vai do processo principal ao renderer por IPC e vira faixa publicada no LiveKit. **Exige gerar instalador novo e reinstalar**, e só pode ser confirmado numa máquina Windows |
|
|
||||||
| **Bloco do Spotify dessincronizado** (mostra a faixa errada por um tempo) | Soma de duas esperas: a consulta roda a cada **20s** (`useSpotifyActivity`) e o envio pelo WebSocket ainda passa por um **debounce de 5s** no `activityStore`. Na pior hipótese os outros veem a música anterior por ~25s | Consultar de novo perto do fim da faixa (a duração é conhecida) em vez de só por intervalo fixo, e encurtar o debounce para esta fonte |
|
|
||||||
| **Bloco do Spotify some sozinho** | Três caminhos apagam a atividade inteira: faixa **pausada** (`is_playing: false` devolve `null`), o **vão entre faixas** (o Spotify responde `204`) e qualquer falha transitória. Some e volta = a piscada que você viu | Manter a última faixa conhecida por alguns segundos antes de apagar, e enviar um estado *pausado* explícito em vez de sumir com o bloco |
|
|
||||||
| **Barra de progresso errada / andando pausada** | Duas causas independentes: (1) o progresso é derivado de carimbos calculados com o relógio do **servidor** e desenhado contra o relógio de **quem olha** — se os relógios divergem, a barra fica deslocada; (2) a barra continua avançando localmente depois que a pessoa pausa, até a próxima consulta | Enviar o horário do servidor junto no payload para o cliente corrigir a diferença, e congelar a barra quando o estado for pausado |
|
|
||||||
|
|
||||||
|
|
||||||
## App desktop — já existia (verificado 2026-08-31)
|
## App desktop — já existia (verificado 2026-08-31)
|
||||||
@@ -112,8 +181,6 @@ Escolhidas pelo dono, inspiradas no Discord. **Nenhuma iniciada.**
|
|||||||
|
|
||||||
| Feature | Tamanho | Observação técnica |
|
| Feature | Tamanho | Observação técnica |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Fixar mensagens no canal** | Pequena | Tabela de fixadas por canal + rota de listagem; permissão natural é `MANAGE_MESSAGES` |
|
|
||||||
| **Busca com filtros** | Média | `SearchPopover` já existe; falta filtrar por autor, canal e presença de anexo — e índice no banco, senão fica lento quando o histórico crescer |
|
|
||||||
| **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora |
|
| **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora |
|
||||||
| **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros |
|
| **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros |
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "backspace",
|
"name": "backspace",
|
||||||
"version": "1.1.0",
|
"version": "2.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Open, self-hosted communication platform \u2014 text, voice, video, and federation",
|
"description": "Open, self-hosted communication platform \u2014 text, voice, video, and federation",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 955 B After Width: | Height: | Size: 712 B |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -1,5 +1,5 @@
|
|||||||
appId: com.backspace.desktop
|
appId: com.backspace.desktop
|
||||||
productName: Backspace
|
productName: Resenhacord
|
||||||
artifactName: "${productName}-${version}-${arch}.${ext}"
|
artifactName: "${productName}-${version}-${arch}.${ext}"
|
||||||
directories:
|
directories:
|
||||||
output: dist-electron
|
output: dist-electron
|
||||||
@@ -36,7 +36,7 @@ publish:
|
|||||||
- provider: generic
|
- provider: generic
|
||||||
url: https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/
|
url: https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/
|
||||||
protocols:
|
protocols:
|
||||||
- name: Backspace
|
- name: Resenhacord
|
||||||
schemes:
|
schemes:
|
||||||
- backspace
|
- backspace
|
||||||
mac:
|
mac:
|
||||||
@@ -58,5 +58,15 @@ linux:
|
|||||||
- deb
|
- deb
|
||||||
category: Network
|
category: Network
|
||||||
nsis:
|
nsis:
|
||||||
oneClick: false
|
# One-click, per-user install (%LOCALAPPDATA%), the way Discord ships.
|
||||||
allowToChangeInstallationDirectory: true
|
#
|
||||||
|
# This is what makes an update silent: a per-user install needs no elevation,
|
||||||
|
# so electron-updater can run the installer with /S and no UAC prompt breaks
|
||||||
|
# the flow. The assisted installer we had before (oneClick: false) showed its
|
||||||
|
# wizard on every update and offered an all-users install, which would then
|
||||||
|
# demand elevation on each subsequent update.
|
||||||
|
#
|
||||||
|
# The trade is that a fresh install no longer asks where to put the app.
|
||||||
|
oneClick: true
|
||||||
|
perMachine: false
|
||||||
|
allowToChangeInstallationDirectory: false
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@backspace/desktop",
|
"name": "@backspace/desktop",
|
||||||
"version": "1.1.0",
|
"version": "2.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"description": "Backspace",
|
"description": "Resenhacord",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Jannis Braun",
|
"name": "Jannis Braun",
|
||||||
"email": "backspace@backspace.chat"
|
"email": "backspace@backspace.chat"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Backspace — Connect</title>
|
<title>Resenhacord — Conectar</title>
|
||||||
<style>
|
<style>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
@@ -270,7 +270,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Welcome to Backspace</h1>
|
<h1>Bem-vindo ao Resenhacord</h1>
|
||||||
<p>Connect to your instance</p>
|
<p>Connect to your instance</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -360,7 +360,7 @@
|
|||||||
// Update header copy to reflect the "switch" intent rather than the
|
// Update header copy to reflect the "switch" intent rather than the
|
||||||
// first-run "welcome" framing.
|
// first-run "welcome" framing.
|
||||||
document.querySelector('.header h1').textContent = 'Switch instance';
|
document.querySelector('.header h1').textContent = 'Switch instance';
|
||||||
document.querySelector('.header p').textContent = 'Connect to a different Backspace instance, or cancel to stay.';
|
document.querySelector('.header p').textContent = 'Conecte-se a outra instância, ou cancele para ficar nesta.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
init();
|
init();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Backspace — Recovery</title>
|
<title>Resenhacord — Recuperação</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Backspace couldn't load.</h1>
|
<h1>O Resenhacord não conseguiu abrir.</h1>
|
||||||
<p class="hint" id="hint">Loading…</p>
|
<p class="hint" id="hint">Loading…</p>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
<pre id="diagnostic">loading…</pre>
|
<pre id="diagnostic">loading…</pre>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<button class="action subtle" id="btn-quit">Quit Backspace</button>
|
<button class="action subtle" id="btn-quit">Fechar o Resenhacord</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,7 +270,7 @@
|
|||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
if (!window.backspace) {
|
if (!window.backspace) {
|
||||||
$('hint').textContent = 'Recovery bridge unavailable. Please quit and reinstall Backspace.';
|
$('hint').textContent = 'A ponte de recuperação não respondeu. Feche e reinstale o Resenhacord.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 983 B After Width: | Height: | Size: 746 B |
|
Before Width: | Height: | Size: 703 B After Width: | Height: | Size: 474 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 836 B |
@@ -36,28 +36,36 @@ import {
|
|||||||
buildTrayMenuTemplate,
|
buildTrayMenuTemplate,
|
||||||
buildAppMenuTemplate,
|
buildAppMenuTemplate,
|
||||||
type RecoveryState,
|
type RecoveryState,
|
||||||
|
RELEASES_URL,
|
||||||
} from './recovery';
|
} from './recovery';
|
||||||
import { migrateUserData } from './userDataMigration';
|
import { migrateUserData } from './userDataMigration';
|
||||||
|
|
||||||
// Override Electron's package.json-derived app name so userData lives at
|
// Override Electron's package.json-derived app name so userData lives at
|
||||||
// "<appData>/Backspace" instead of leaking the monorepo's "@backspace/desktop"
|
// "<appData>/Resenhacord" instead of leaking the monorepo's "@backspace/desktop"
|
||||||
// package name. Must run before any app.getPath('userData') consumer.
|
// package name. Must run before any app.getPath('userData') consumer.
|
||||||
app.setName('Backspace');
|
app.setName('Resenhacord');
|
||||||
|
|
||||||
// One-time migration from the historical scoped path. After the move the old
|
// One-time migrations, oldest first, so an install that skipped a release
|
||||||
// folder is gone, so subsequent launches hit the old-missing no-op branch.
|
// still walks the whole chain: "@backspace/desktop" → "Backspace" →
|
||||||
|
// "Resenhacord". Each step is a no-op once its source folder is gone, and a
|
||||||
|
// step whose target already has data leaves it alone (`new-populated`), so a
|
||||||
|
// user who already ran the renamed build is never overwritten by stale files.
|
||||||
|
// Without the second step the rename would have logged everyone out: the
|
||||||
|
// instance URL and session live under userData.
|
||||||
{
|
{
|
||||||
const appDataDir = app.getPath('appData');
|
const appDataDir = app.getPath('appData');
|
||||||
const oldParent = path.join(appDataDir, '@backspace');
|
const legacyParent = path.join(appDataDir, '@backspace');
|
||||||
const result = migrateUserData({
|
const steps: { oldDir: string; newDir: string; oldParent: string }[] = [
|
||||||
oldDir: path.join(oldParent, 'desktop'),
|
{ oldDir: path.join(legacyParent, 'desktop'), newDir: path.join(appDataDir, 'Backspace'), oldParent: legacyParent },
|
||||||
newDir: path.join(appDataDir, 'Backspace'),
|
{ oldDir: path.join(appDataDir, 'Backspace'), newDir: path.join(appDataDir, 'Resenhacord'), oldParent: appDataDir },
|
||||||
oldParent,
|
];
|
||||||
});
|
for (const step of steps) {
|
||||||
if (result.kind === 'migrated') {
|
const result = migrateUserData(step);
|
||||||
console.log(`[userData] migrated ${result.from} → ${result.to}`);
|
if (result.kind === 'migrated') {
|
||||||
} else if (result.kind === 'failed') {
|
console.log(`[userData] migrated ${result.from} → ${result.to}`);
|
||||||
console.error('[userData] migration failed:', result.error);
|
} else if (result.kind === 'failed') {
|
||||||
|
console.error('[userData] migration failed:', result.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +249,7 @@ function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean):
|
|||||||
enabled: openAtLogin,
|
enabled: openAtLogin,
|
||||||
path: process.execPath,
|
path: process.execPath,
|
||||||
args: startMinimized ? ['--hidden'] : [],
|
args: startMinimized ? ['--hidden'] : [],
|
||||||
name: 'Backspace',
|
name: 'Resenhacord',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Linux: setLoginItemSettings creates ~/.config/autostart/<name>.desktop.
|
// Linux: setLoginItemSettings creates ~/.config/autostart/<name>.desktop.
|
||||||
@@ -437,7 +445,7 @@ function createWindow(): void {
|
|||||||
: {}),
|
: {}),
|
||||||
minWidth: 940,
|
minWidth: 940,
|
||||||
minHeight: 500,
|
minHeight: 500,
|
||||||
title: 'Backspace',
|
title: 'Resenhacord',
|
||||||
icon: path.join(__dirname, '..', 'build', 'icon.png'),
|
icon: path.join(__dirname, '..', 'build', 'icon.png'),
|
||||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
|
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
|
||||||
...(process.platform !== 'darwin' ? {
|
...(process.platform !== 'darwin' ? {
|
||||||
@@ -570,7 +578,7 @@ function createTray(): void {
|
|||||||
const icon = loadTrayIcon();
|
const icon = loadTrayIcon();
|
||||||
tray = new Tray(icon);
|
tray = new Tray(icon);
|
||||||
|
|
||||||
tray.setToolTip('Backspace');
|
tray.setToolTip('Resenhacord');
|
||||||
// Context menu is set by the recoveryStore subscriber in app.whenReady,
|
// Context menu is set by the recoveryStore subscriber in app.whenReady,
|
||||||
// which keeps the menu in sync with update/recovery state.
|
// which keeps the menu in sync with update/recovery state.
|
||||||
|
|
||||||
@@ -588,7 +596,9 @@ function createTray(): void {
|
|||||||
|
|
||||||
function showNotification(title: string, body: string, onClick?: () => void): void {
|
function showNotification(title: string, body: string, onClick?: () => void): void {
|
||||||
if (!Notification.isSupported()) return;
|
if (!Notification.isSupported()) return;
|
||||||
const notification = new Notification({ title, body, silent: false });
|
// silent: o som do sistema é o que mais incomoda numa notificação de chat, e
|
||||||
|
// o app toca o seu próprio efeito — que combina com os demais sons dele.
|
||||||
|
const notification = new Notification({ title, body, silent: true });
|
||||||
notification.on('click', onClick ?? (() => {
|
notification.on('click', onClick ?? (() => {
|
||||||
mainWindow?.show();
|
mainWindow?.show();
|
||||||
mainWindow?.focus();
|
mainWindow?.focus();
|
||||||
@@ -672,12 +682,25 @@ function registerIpcHandlers(): void {
|
|||||||
ipcMain.on('install-update', () => {
|
ipcMain.on('install-update', () => {
|
||||||
try {
|
try {
|
||||||
const { autoUpdater } = require('electron-updater');
|
const { autoUpdater } = require('electron-updater');
|
||||||
autoUpdater.quitAndInstall();
|
quitAndInstallSilently(autoUpdater);
|
||||||
} catch {
|
} catch {
|
||||||
// Auto-updater not available
|
// Auto-updater not available
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The `update-downloaded` push is a one-shot: it reaches whatever page is
|
||||||
|
// loaded at that instant, and nothing else. It is lost when the window is in
|
||||||
|
// the tray (destroyed), when the check lands before the web app has mounted
|
||||||
|
// its listener, and on every reload. Since `downloaded` also latches off
|
||||||
|
// further polls, the banner could then never appear at all — which is why
|
||||||
|
// pressing "check for updates" by hand looked like the only thing that
|
||||||
|
// worked. The renderer now asks for the current state on mount instead of
|
||||||
|
// relying solely on catching the event live.
|
||||||
|
ipcMain.handle('get-update-status', () => {
|
||||||
|
const s = recoveryStore.get();
|
||||||
|
return { state: s.updateState, version: s.updateVersion };
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.on('check-for-updates', () => {
|
ipcMain.on('check-for-updates', () => {
|
||||||
try {
|
try {
|
||||||
const { autoUpdater } = require('electron-updater');
|
const { autoUpdater } = require('electron-updater');
|
||||||
@@ -708,7 +731,7 @@ function registerIpcHandlers(): void {
|
|||||||
// executableWillLaunchAtLogin to honour Task Manager's StartupApproved state.
|
// executableWillLaunchAtLogin to honour Task Manager's StartupApproved state.
|
||||||
const osState = app.getLoginItemSettings({ path: process.execPath });
|
const osState = app.getLoginItemSettings({ path: process.execPath });
|
||||||
const ownEntry = osState.launchItems?.find(
|
const ownEntry = osState.launchItems?.find(
|
||||||
(item) => item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
|
(item) => item.name === 'Resenhacord' || item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
|
||||||
);
|
);
|
||||||
// When the Run entry exists, its args are the source of truth for startMinimized.
|
// When the Run entry exists, its args are the source of truth for startMinimized.
|
||||||
// When the entry is absent (autostart is off), there is no OS state to read, so we
|
// When the entry is absent (autostart is off), there is no OS state to read, so we
|
||||||
@@ -743,7 +766,7 @@ function registerIpcHandlers(): void {
|
|||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
const osState = app.getLoginItemSettings({ path: process.execPath });
|
const osState = app.getLoginItemSettings({ path: process.execPath });
|
||||||
const ownEntry = osState.launchItems?.find(
|
const ownEntry = osState.launchItems?.find(
|
||||||
(item) => item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
|
(item) => item.name === 'Resenhacord' || item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
|
||||||
);
|
);
|
||||||
currentOpenAtLogin = osState.executableWillLaunchAtLogin ?? false;
|
currentOpenAtLogin = osState.executableWillLaunchAtLogin ?? false;
|
||||||
// See `get-auto-launch-settings`: when the entry is absent, fall back to disk
|
// See `get-auto-launch-settings`: when the entry is absent, fall back to disk
|
||||||
@@ -802,6 +825,22 @@ function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
// ─── Auto-Update ────────────────────────────────────────────────────────────
|
// ─── Auto-Update ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs a staged update with no installer UI and relaunches the app.
|
||||||
|
*
|
||||||
|
* `quitAndInstall()` defaults to (isSilent=false, isForceRunAfter=false): on
|
||||||
|
* Windows that runs the NSIS installer with its full wizard, so "restart to
|
||||||
|
* update" meant clicking through a setup program. Silent mode passes /S and
|
||||||
|
* reuses the recorded install location; isForceRunAfter brings the app back up
|
||||||
|
* afterwards, which the wizard would otherwise leave to a checkbox.
|
||||||
|
*
|
||||||
|
* Both flags are inert on macOS and Linux, where the update is applied by
|
||||||
|
* swapping files — no installer runs either way.
|
||||||
|
*/
|
||||||
|
function quitAndInstallSilently(au: { quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void }): void {
|
||||||
|
au.quitAndInstall(true, true);
|
||||||
|
}
|
||||||
|
|
||||||
function initAutoUpdater(): void {
|
function initAutoUpdater(): void {
|
||||||
try {
|
try {
|
||||||
const { autoUpdater } = require('electron-updater');
|
const { autoUpdater } = require('electron-updater');
|
||||||
@@ -812,7 +851,14 @@ function initAutoUpdater(): void {
|
|||||||
|
|
||||||
let updateConfirmed = false;
|
let updateConfirmed = false;
|
||||||
|
|
||||||
|
// Cadence state. `lastCheckAt` is stamped from the event rather than from
|
||||||
|
// the callers, so a manual "check for updates" from the settings panel
|
||||||
|
// counts too and a focus arriving right after it doesn't check twice.
|
||||||
|
let lastCheckAt = 0;
|
||||||
|
let downloaded = false;
|
||||||
|
|
||||||
autoUpdater.on('checking-for-update', () => {
|
autoUpdater.on('checking-for-update', () => {
|
||||||
|
lastCheckAt = Date.now();
|
||||||
recoveryStore.update({ updateState: 'checking', lastCheckResult: null });
|
recoveryStore.update({ updateState: 'checking', lastCheckResult: null });
|
||||||
updateConfirmed = false;
|
updateConfirmed = false;
|
||||||
});
|
});
|
||||||
@@ -834,6 +880,7 @@ function initAutoUpdater(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
autoUpdater.on('update-downloaded', (info: { version: string }) => {
|
autoUpdater.on('update-downloaded', (info: { version: string }) => {
|
||||||
|
downloaded = true;
|
||||||
const version = info.version.slice(0, 32);
|
const version = info.version.slice(0, 32);
|
||||||
recoveryStore.update({ updateState: 'downloaded', updateVersion: version });
|
recoveryStore.update({ updateState: 'downloaded', updateVersion: version });
|
||||||
mainWindow?.webContents.send('update-downloaded', { version });
|
mainWindow?.webContents.send('update-downloaded', { version });
|
||||||
@@ -843,9 +890,9 @@ function initAutoUpdater(): void {
|
|||||||
// (recovery mode) is visible — no need to also fire a native toast.
|
// (recovery mode) is visible — no need to also fire a native toast.
|
||||||
if (!mainWindow?.isFocused()) {
|
if (!mainWindow?.isFocused()) {
|
||||||
showNotification(
|
showNotification(
|
||||||
'Backspace update ready',
|
'Resenhacord update ready',
|
||||||
`Click to restart and install version ${version}.`,
|
`Click to restart and install version ${version}.`,
|
||||||
() => autoUpdater.quitAndInstall(),
|
() => quitAndInstallSilently(autoUpdater),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -868,22 +915,33 @@ function initAutoUpdater(): void {
|
|||||||
if (updateConfirmed) {
|
if (updateConfirmed) {
|
||||||
mainWindow?.webContents.send('update-error', {
|
mainWindow?.webContents.send('update-error', {
|
||||||
message,
|
message,
|
||||||
releaseUrl: 'https://github.com/TheZwiss/backspace/releases/latest',
|
releaseUrl: RELEASES_URL,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial check with 10s delay (existing behavior)
|
// A release published while the app sits open used to go unnoticed until
|
||||||
setTimeout(() => {
|
// the next poll — the startup check had long since run, and four hours is
|
||||||
updateConfirmed = false;
|
// a long time to look at a stale version. Poll more often, and also check
|
||||||
autoUpdater.checkForUpdates().catch(() => {});
|
// when the user comes back to the window, which is exactly when they would
|
||||||
}, 10_000);
|
// notice an update anyway.
|
||||||
|
const POLL_INTERVAL_MS = 30 * 60 * 1000;
|
||||||
|
const MIN_GAP_MS = 5 * 60 * 1000; // keeps alt-tabbing off the feed
|
||||||
|
|
||||||
// Periodic check every 4 hours (existing behavior)
|
const maybeCheck = (): void => {
|
||||||
setInterval(() => {
|
if (downloaded) return; // already staged; nothing left to look for
|
||||||
|
if (Date.now() - lastCheckAt < MIN_GAP_MS) return;
|
||||||
updateConfirmed = false;
|
updateConfirmed = false;
|
||||||
autoUpdater.checkForUpdates().catch(() => {});
|
autoUpdater.checkForUpdates().catch(() => {});
|
||||||
}, 4 * 60 * 60 * 1000);
|
};
|
||||||
|
|
||||||
|
setTimeout(maybeCheck, 10_000);
|
||||||
|
setInterval(maybeCheck, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
// App-level rather than mainWindow-level: the window is destroyed and
|
||||||
|
// recreated when closed to tray and reopened, and a listener bound to the
|
||||||
|
// old instance would go quiet after the first close.
|
||||||
|
app.on('browser-window-focus', maybeCheck);
|
||||||
} catch {
|
} catch {
|
||||||
// Auto-updater unavailable — recovery surface still functions, just
|
// Auto-updater unavailable — recovery surface still functions, just
|
||||||
// without update affordances. autoUpdaterRef stays null; recovery action
|
// without update affordances. autoUpdaterRef stays null; recovery action
|
||||||
@@ -1083,7 +1141,7 @@ if (!gotTheLock) {
|
|||||||
|
|
||||||
// AGPL-3.0 § 13: native About panel advertises the version + source repo.
|
// AGPL-3.0 § 13: native About panel advertises the version + source repo.
|
||||||
app.setAboutPanelOptions({
|
app.setAboutPanelOptions({
|
||||||
applicationName: 'Backspace',
|
applicationName: 'Resenhacord',
|
||||||
applicationVersion: app.getVersion(),
|
applicationVersion: app.getVersion(),
|
||||||
copyright: `AGPL-3.0-only · Source: ${UPSTREAM_SOURCE_URL}`,
|
copyright: `AGPL-3.0-only · Source: ${UPSTREAM_SOURCE_URL}`,
|
||||||
website: UPSTREAM_SOURCE_URL,
|
website: UPSTREAM_SOURCE_URL,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('backspace', {
|
|||||||
checkForUpdates: () => {
|
checkForUpdates: () => {
|
||||||
ipcRenderer.send('check-for-updates');
|
ipcRenderer.send('check-for-updates');
|
||||||
},
|
},
|
||||||
|
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
|
||||||
getVersion: () => ipcRenderer.invoke('get-app-version'),
|
getVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
|
|
||||||
// Window focus
|
// Window focus
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ describe('buildTrayMenuTemplate', () => {
|
|||||||
it('includes Show/Hide/Change Instance/Quit base items', () => {
|
it('includes Show/Hide/Change Instance/Quit base items', () => {
|
||||||
const items = buildTrayMenuTemplate(defaultState());
|
const items = buildTrayMenuTemplate(defaultState());
|
||||||
const labels = items.map((i) => i.label);
|
const labels = items.map((i) => i.label);
|
||||||
expect(labels).toContain('Show Backspace');
|
expect(labels).toContain('Mostrar o Resenhacord');
|
||||||
expect(labels).toContain('Hide');
|
expect(labels).toContain('Hide');
|
||||||
expect(labels).toContain('Change Instance');
|
expect(labels).toContain('Change Instance');
|
||||||
expect(labels).toContain('Quit');
|
expect(labels).toContain('Quit');
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ export type UpdateState =
|
|||||||
| 'downloaded'
|
| 'downloaded'
|
||||||
| 'error';
|
| 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a manual download lands when auto-update fails.
|
||||||
|
*
|
||||||
|
* The upstream GitHub repository this used to point at is private for this
|
||||||
|
* fork, so the link 404'd for everyone who followed it. Releases are on the
|
||||||
|
* fork's own Gitea, which is also where the updater feed lives.
|
||||||
|
*/
|
||||||
|
export const RELEASES_URL = 'https://git.resenha.website/devsyncwrld/backspace/releases';
|
||||||
|
|
||||||
export interface RecoveryState {
|
export interface RecoveryState {
|
||||||
mode: 'normal' | 'recovery';
|
mode: 'normal' | 'recovery';
|
||||||
reason: { code: RecoveryReasonCode; detail: string } | null;
|
reason: { code: RecoveryReasonCode; detail: string } | null;
|
||||||
@@ -122,7 +131,7 @@ export function buildTrayMenuTemplate(
|
|||||||
actions?: Partial<MenuActions>,
|
actions?: Partial<MenuActions>,
|
||||||
): MenuItemConstructorOptions[] {
|
): MenuItemConstructorOptions[] {
|
||||||
const items: MenuItemConstructorOptions[] = [
|
const items: MenuItemConstructorOptions[] = [
|
||||||
{ label: 'Show Backspace', click: actions?.onShow },
|
{ label: 'Mostrar o Resenhacord', click: actions?.onShow },
|
||||||
{ label: 'Hide', click: actions?.onHide },
|
{ label: 'Hide', click: actions?.onHide },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
checkForUpdatesItem(state, () => actions?.onCheckForUpdates?.()),
|
checkForUpdatesItem(state, () => actions?.onCheckForUpdates?.()),
|
||||||
@@ -419,7 +428,8 @@ export function handleRecoveryAction(action: RecoveryAction): void {
|
|||||||
// Direct quitAndInstall — does NOT rely on autoInstallOnAppQuit.
|
// Direct quitAndInstall — does NOT rely on autoInstallOnAppQuit.
|
||||||
// Force-kill-fix: if user reaches recovery and clicks here, install
|
// Force-kill-fix: if user reaches recovery and clicks here, install
|
||||||
// happens cleanly even if the on-quit hook would otherwise be bypassed.
|
// happens cleanly even if the on-quit hook would otherwise be bypassed.
|
||||||
autoUpdaterRef?.quitAndInstall();
|
// Silent + relaunch, matching the in-app banner: no installer wizard.
|
||||||
|
autoUpdaterRef?.quitAndInstall(true, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'change-instance': {
|
case 'change-instance': {
|
||||||
@@ -440,7 +450,7 @@ export function handleRecoveryAction(action: RecoveryAction): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'open-releases': {
|
case 'open-releases': {
|
||||||
shell.openExternal('https://github.com/TheZwiss/backspace/releases/latest');
|
shell.openExternal(RELEASES_URL);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'quit': {
|
case 'quit': {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Gerados por `cap sync` a cada build — versioná-los só criaria diffs ruidosos
|
||||||
|
# e o risco de o APK sair com uma cópia velha.
|
||||||
|
android/app/src/main/assets/public/
|
||||||
|
android/app/src/main/assets/capacitor.config.json
|
||||||
|
android/app/src/main/assets/capacitor.plugins.json
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "com.resenhacord.app"
|
||||||
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.resenhacord.app"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
// Mantidos em sincronia com a versao do monorepo. O versionCode e o
|
||||||
|
// numero que o Android usa para decidir se um APK e mais novo, e ele
|
||||||
|
// precisa ser inteiro e sempre crescente: 2.1.0 -> 20100.
|
||||||
|
versionCode 20100
|
||||||
|
versionName "2.1.0"
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir{
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProperty('postBuildExtras')) {
|
||||||
|
postBuildExtras()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void useAppContext() throws Exception {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
|
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
O tipo "microphone" tem de bater com o que o servico passa para o
|
||||||
|
startForeground e com a permissao FOREGROUND_SERVICE_MICROPHONE. A
|
||||||
|
partir do Android 14 os tres precisam concordar, senao o sistema
|
||||||
|
derruba o app na hora de entrar na call.
|
||||||
|
-->
|
||||||
|
<service
|
||||||
|
android:name=".VoiceForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="microphone" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Voz e video. A WebView nao pede nada ao sistema sozinha: quando a pagina
|
||||||
|
chama getUserMedia, o bridge do Capacitor intercepta o pedido e traduz
|
||||||
|
para estas permissoes nativas. Sem declara-las aqui, a chamada falha com
|
||||||
|
NotAllowedError e o microfone simplesmente nao liga.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<!-- Trocar entre alto-falante e fone durante a chamada. -->
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|
||||||
|
<!-- Android 13+ nao mostra notificacao nenhuma sem esta permissao. -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Chamada com a tela apagada. O servico em primeiro plano impede o sistema
|
||||||
|
de congelar o processo; o wake lock impede a CPU de dormir. Sem os dois, a
|
||||||
|
WebView para de rodar o JavaScript e a call cai.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
O aparelho precisa ter microfone; camera e opcional para nao excluir
|
||||||
|
tablets sem camera frontal da lista de aparelhos compativeis.
|
||||||
|
-->
|
||||||
|
<uses-feature android:name="android.hardware.microphone" android:required="true" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.resenhacord.app;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {
|
||||||
|
|
||||||
|
private static final int REQ_NOTIFICATIONS = 100;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
|
// Plugins que moram no proprio app nao sao descobertos sozinhos, e o
|
||||||
|
// registro tem de acontecer antes do super.onCreate — depois disso a
|
||||||
|
// ponte ja foi montada e o plugin nao aparece para o JavaScript.
|
||||||
|
registerPlugin(VoicePlugin.class);
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
requestNotificationPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A partir do Android 13 a notificacao da chamada nao aparece sem esta
|
||||||
|
* permissao. O servico continua rodando de qualquer forma — a chamada nao
|
||||||
|
* cai se o usuario recusar —, mas ele fica sem nada visivel na barra, o que
|
||||||
|
* so parece defeito. Por isso perguntamos, e seguimos com a recusa.
|
||||||
|
*/
|
||||||
|
private void requestNotificationPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return;
|
||||||
|
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
== PackageManager.PERMISSION_GRANTED) return;
|
||||||
|
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQ_NOTIFICATIONS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.resenhacord.app;
|
||||||
|
|
||||||
|
import android.app.Notification;
|
||||||
|
import android.app.NotificationChannel;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.app.Service;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.ServiceInfo;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.os.PowerManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mantem o app vivo enquanto ha uma chamada em andamento.
|
||||||
|
*
|
||||||
|
* Sem isto, trancar a tela derruba a call. O Android congela processos em
|
||||||
|
* segundo plano — e uma WebView congelada para de rodar o JavaScript que
|
||||||
|
* mantem a conexao do LiveKit de pe. Um servico em primeiro plano e a unica
|
||||||
|
* coisa que o sistema aceita como "este app precisa continuar rodando", e ele
|
||||||
|
* exige a notificacao permanente que aparece junto: e o preco combinado, o
|
||||||
|
* usuario sempre ve que algo esta ativo.
|
||||||
|
*
|
||||||
|
* O wake lock parcial e separado e igualmente necessario: o servico impede o
|
||||||
|
* congelamento do processo, mas nao impede a CPU de dormir.
|
||||||
|
*/
|
||||||
|
public class VoiceForegroundService extends Service {
|
||||||
|
|
||||||
|
public static final String ACTION_START = "com.resenhacord.app.VOICE_START";
|
||||||
|
public static final String ACTION_STOP = "com.resenhacord.app.VOICE_STOP";
|
||||||
|
public static final String ACTION_HANGUP = "com.resenhacord.app.VOICE_HANGUP";
|
||||||
|
public static final String EXTRA_CHANNEL = "channel";
|
||||||
|
|
||||||
|
private static final String CHANNEL_ID = "voice_call";
|
||||||
|
private static final int NOTIFICATION_ID = 1;
|
||||||
|
|
||||||
|
private PowerManager.WakeLock wakeLock;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IBinder onBind(Intent intent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
|
String action = intent != null ? intent.getAction() : null;
|
||||||
|
|
||||||
|
if (ACTION_STOP.equals(action)) {
|
||||||
|
stop();
|
||||||
|
return START_NOT_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ACTION_HANGUP.equals(action)) {
|
||||||
|
// O botao da notificacao nao desliga a chamada sozinho: quem sabe
|
||||||
|
// como sair da sala e o app web. Avisamos o JavaScript e deixamos
|
||||||
|
// ele encerrar; o servico para quando o JS confirmar a saida.
|
||||||
|
VoicePlugin.emitHangup();
|
||||||
|
return START_NOT_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
String channel = intent != null ? intent.getStringExtra(EXTRA_CHANNEL) : null;
|
||||||
|
startForegroundWithNotification(channel);
|
||||||
|
acquireWakeLock();
|
||||||
|
return START_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startForegroundWithNotification(String channelName) {
|
||||||
|
createNotificationChannel();
|
||||||
|
|
||||||
|
Intent open = new Intent(this, MainActivity.class);
|
||||||
|
open.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||||
|
PendingIntent openPending = PendingIntent.getActivity(
|
||||||
|
this, 0, open, PendingIntent.FLAG_IMMUTABLE);
|
||||||
|
|
||||||
|
Intent hangup = new Intent(this, VoiceForegroundService.class);
|
||||||
|
hangup.setAction(ACTION_HANGUP);
|
||||||
|
PendingIntent hangupPending = PendingIntent.getService(
|
||||||
|
this, 1, hangup, PendingIntent.FLAG_IMMUTABLE);
|
||||||
|
|
||||||
|
String text = channelName != null && !channelName.isEmpty()
|
||||||
|
? channelName
|
||||||
|
: getString(R.string.voice_notification_text);
|
||||||
|
|
||||||
|
Notification notification = new Notification.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.voice_notification_title))
|
||||||
|
.setContentText(text)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_voice)
|
||||||
|
.setContentIntent(openPending)
|
||||||
|
.addAction(new Notification.Action.Builder(
|
||||||
|
null, getString(R.string.voice_notification_hangup), hangupPending).build())
|
||||||
|
.setOngoing(true)
|
||||||
|
.setUsesChronometer(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
// O tipo e obrigatorio a partir do Android 14: sem ele o sistema
|
||||||
|
// recusa o servico e derruba o app com uma excecao.
|
||||||
|
startForeground(NOTIFICATION_ID, notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE);
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||||
|
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||||
|
if (manager.getNotificationChannel(CHANNEL_ID) != null) return;
|
||||||
|
|
||||||
|
// Importancia baixa de proposito: a notificacao existe porque o sistema
|
||||||
|
// exige, nao para chamar atencao. Som ou vibracao aqui tocariam a cada
|
||||||
|
// entrada em call, por cima da propria chamada.
|
||||||
|
NotificationChannel channel = new NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
getString(R.string.voice_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW);
|
||||||
|
channel.setDescription(getString(R.string.voice_channel_description));
|
||||||
|
channel.setShowBadge(false);
|
||||||
|
manager.createNotificationChannel(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void acquireWakeLock() {
|
||||||
|
if (wakeLock != null && wakeLock.isHeld()) return;
|
||||||
|
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
|
||||||
|
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Resenhacord::voice");
|
||||||
|
// Sem timeout infinito: se o app morrer de um jeito que nao passe pelo
|
||||||
|
// stop(), o sistema solta o lock sozinho depois de duas horas em vez de
|
||||||
|
// segurar a CPU para sempre.
|
||||||
|
wakeLock.acquire(2 * 60 * 60 * 1000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void releaseWakeLock() {
|
||||||
|
if (wakeLock != null && wakeLock.isHeld()) {
|
||||||
|
wakeLock.release();
|
||||||
|
}
|
||||||
|
wakeLock = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stop() {
|
||||||
|
releaseWakeLock();
|
||||||
|
stopForeground(true);
|
||||||
|
stopSelf();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
releaseWakeLock();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.resenhacord.app;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Build;
|
||||||
|
|
||||||
|
import com.getcapacitor.JSObject;
|
||||||
|
import com.getcapacitor.Plugin;
|
||||||
|
import com.getcapacitor.PluginCall;
|
||||||
|
import com.getcapacitor.PluginMethod;
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
|
|
||||||
|
import java.lang.ref.WeakReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ponte entre o app web e o servico de chamada.
|
||||||
|
*
|
||||||
|
* O app web e quem sabe quando ha uma call: ele fala com o LiveKit. O lado
|
||||||
|
* nativo e quem sabe pedir ao Android para nao congelar o processo. Este plugin
|
||||||
|
* so liga as duas pontas.
|
||||||
|
*/
|
||||||
|
@CapacitorPlugin(name = "VoiceForeground")
|
||||||
|
public class VoicePlugin extends Plugin {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referencia fraca de proposito: o servico precisa avisar o JavaScript
|
||||||
|
* quando alguem toca em "Sair" na notificacao, mas ele nao pode ser o
|
||||||
|
* motivo de a instancia do plugin (e a Activity presa a ela) continuar
|
||||||
|
* viva depois de destruida.
|
||||||
|
*/
|
||||||
|
private static WeakReference<VoicePlugin> instance = new WeakReference<>(null);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void load() {
|
||||||
|
instance = new WeakReference<>(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void start(PluginCall call) {
|
||||||
|
Intent intent = new Intent(getContext(), VoiceForegroundService.class);
|
||||||
|
intent.setAction(VoiceForegroundService.ACTION_START);
|
||||||
|
intent.putExtra(VoiceForegroundService.EXTRA_CHANNEL, call.getString("channel"));
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
getContext().startForegroundService(intent);
|
||||||
|
} else {
|
||||||
|
getContext().startService(intent);
|
||||||
|
}
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void stop(PluginCall call) {
|
||||||
|
Intent intent = new Intent(getContext(), VoiceForegroundService.class);
|
||||||
|
intent.setAction(VoiceForegroundService.ACTION_STOP);
|
||||||
|
getContext().startService(intent);
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chamado pelo servico quando o botao "Sair" da notificacao e tocado. */
|
||||||
|
static void emitHangup() {
|
||||||
|
VoicePlugin plugin = instance.get();
|
||||||
|
if (plugin != null) {
|
||||||
|
plugin.notifyListeners("hangup", new JSObject());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="78.5885"
|
||||||
|
android:endY="90.9159"
|
||||||
|
android:startX="48.7653"
|
||||||
|
android:startY="61.0927"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#26A69A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Icone pequeno da notificacao. O Android descarta a cor e usa so o formato
|
||||||
|
(a partir do Lollipop ele e pintado de branco sobre a silhueta), entao aqui
|
||||||
|
vale desenho solido e simples — um microfone.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFF">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3z"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Tela de abertura como camadas em vez das splash.png que o Capacitor gera para
|
||||||
|
cada densidade e orientacao. Duas razoes: a cor de fundo fica exata em
|
||||||
|
qualquer aparelho (as PNGs padrao sao claras e piscavam branco antes da
|
||||||
|
WebView pintar), e a marca escala sozinha sem manter doze arquivos.
|
||||||
|
-->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@color/brand_background" />
|
||||||
|
<item
|
||||||
|
android:drawable="@drawable/brand_mark"
|
||||||
|
android:gravity="center"
|
||||||
|
android:width="128dp"
|
||||||
|
android:height="128dp" />
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- theme_color / background_color do manifest do PWA, para a abertura
|
||||||
|
nativa e a WebView comecarem na mesma cor. -->
|
||||||
|
<color name="brand_background">#0B0B10</color>
|
||||||
|
<color name="colorPrimary">#0B0B10</color>
|
||||||
|
<color name="colorPrimaryDark">#0B0B10</color>
|
||||||
|
<color name="colorAccent">#A78BFA</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- A mesma base escura da marca; o icone adaptativo recorta o primeiro
|
||||||
|
plano sobre ela em qualquer formato que o launcher use. -->
|
||||||
|
<color name="ic_launcher_background">#0B0B10</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Resenhacord</string>
|
||||||
|
<string name="title_activity_main">Resenhacord</string>
|
||||||
|
<string name="package_name">com.resenhacord.app</string>
|
||||||
|
<string name="custom_url_scheme">com.resenhacord.app</string>
|
||||||
|
<string name="voice_channel_name">Chamada em andamento</string>
|
||||||
|
<string name="voice_channel_description">Mantem a chamada de voz ativa com o app em segundo plano</string>
|
||||||
|
<string name="voice_notification_title">Resenhacord</string>
|
||||||
|
<string name="voice_notification_text">Em call</string>
|
||||||
|
<string name="voice_notification_hangup">Sair</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:background">@null</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
public class ExampleUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void addition_isCorrect() throws Exception {
|
||||||
|
assertEquals(4, 2 + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:8.7.2'
|
||||||
|
classpath 'com.google.gms:google-services:4.4.2'
|
||||||
|
|
||||||
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
// in the individual module build.gradle files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "variables.gradle"
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
include ':capacitor-android'
|
||||||
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx1536m
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||||
|
' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
include ':app'
|
||||||
|
include ':capacitor-cordova-android-plugins'
|
||||||
|
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||||
|
|
||||||
|
apply from: 'capacitor.settings.gradle'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
ext {
|
||||||
|
minSdkVersion = 23
|
||||||
|
compileSdkVersion = 35
|
||||||
|
targetSdkVersion = 35
|
||||||
|
androidxActivityVersion = '1.9.2'
|
||||||
|
androidxAppCompatVersion = '1.7.0'
|
||||||
|
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||||
|
androidxCoreVersion = '1.15.0'
|
||||||
|
androidxFragmentVersion = '1.8.4'
|
||||||
|
coreSplashScreenVersion = '1.0.1'
|
||||||
|
androidxWebkitVersion = '1.12.1'
|
||||||
|
junitVersion = '4.13.2'
|
||||||
|
androidxJunitVersion = '1.2.1'
|
||||||
|
androidxEspressoCoreVersion = '3.6.1'
|
||||||
|
cordovaAndroidVersion = '10.1.1'
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* O APK e uma casca fina: a WebView carrega a instancia pela rede em vez de
|
||||||
|
* servir uma copia do app web embutida no pacote.
|
||||||
|
*
|
||||||
|
* Duas razoes, nesta ordem:
|
||||||
|
*
|
||||||
|
* 1. Atualizacao. O site muda a cada deploy. Com os arquivos embutidos, cada
|
||||||
|
* mudanca no app web exigiria republicar e reinstalar o APK a mao — nao ha
|
||||||
|
* updater automatico no Android como o electron-updater faz no desktop.
|
||||||
|
* Pela rede, o telefone recebe a mesma versao que o navegador, na hora.
|
||||||
|
*
|
||||||
|
* 2. Origem. O cliente fala com /api e /ws na mesma origem. Embutido, a pagina
|
||||||
|
* rodaria em https://localhost e toda chamada viraria cross-origin, o que
|
||||||
|
* obrigaria a abrir CORS no servidor para uma origem que qualquer app pode
|
||||||
|
* reivindicar.
|
||||||
|
*
|
||||||
|
* O preco e nao funcionar offline, o que para um app de chat em tempo real nao
|
||||||
|
* e perda: sem servidor nao ha o que mostrar. A pagina em public/ so aparece se
|
||||||
|
* a WebView nao alcancar o servidor.
|
||||||
|
*/
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'com.resenhacord.app',
|
||||||
|
appName: 'Resenhacord',
|
||||||
|
webDir: 'public',
|
||||||
|
android: {
|
||||||
|
// A captura de audio e video da WebView e concedida pelo bridge do
|
||||||
|
// Capacitor, que traduz o pedido do getUserMedia para a permissao nativa.
|
||||||
|
allowMixedContent: false,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
url: 'https://chat.resenha.website',
|
||||||
|
// Sem cleartext: a instancia e servida por HTTPS (Caddy), e o WebRTC do
|
||||||
|
// LiveKit exige contexto seguro de qualquer forma.
|
||||||
|
cleartext: false,
|
||||||
|
androidScheme: 'https',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@backspace/mobile",
|
||||||
|
"version": "2.1.0",
|
||||||
|
"private": true,
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"description": "Resenhacord para Android",
|
||||||
|
"scripts": {
|
||||||
|
"sync": "cap sync android",
|
||||||
|
"open": "cap open android",
|
||||||
|
"build:debug": "cd android && ./gradlew assembleDebug",
|
||||||
|
"build:release": "cd android && ./gradlew assembleRelease"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@capacitor/android": "^7.0.0",
|
||||||
|
"@capacitor/core": "^7.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@capacitor/cli": "^7.0.0",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Resenhacord</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
body {
|
||||||
|
margin: 0; min-height: 100dvh;
|
||||||
|
display: grid; place-items: center; padding: 24px;
|
||||||
|
background: #0b0b10; color: #e7e7ee;
|
||||||
|
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h1 { font-size: 19px; margin: 0 0 8px; }
|
||||||
|
p { margin: 0; color: #9a9aab; max-width: 30ch; }
|
||||||
|
</style>
|
||||||
|
<h1>Sem conexão</h1>
|
||||||
|
<p>Não deu pra falar com o servidor. Confere a internet e abre de novo.</p>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE `messages` ADD `pinned_at` integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE `messages` ADD `pinned_by` text;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_messages_channel_pinned` ON `messages` (`channel_id`,`pinned_at`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_messages_channel_user_created` ON `messages` (`channel_id`,`user_id`,`created_at`);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
CREATE TABLE `space_emojis` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`space_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`filename` text NOT NULL,
|
||||||
|
`uploader_id` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `space_stickers` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`space_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`filename` text NOT NULL,
|
||||||
|
`uploader_id` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `messages` ADD `sticker_id` text;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_space_emojis_space` ON `space_emojis` (`space_id`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `idx_space_emojis_space_name` ON `space_emojis` (`space_id`,`name`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_space_stickers_space` ON `space_stickers` (`space_id`);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `messages` ADD `type` text DEFAULT 'user' NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `users` ADD `profile_style` text;
|
||||||
@@ -113,6 +113,34 @@
|
|||||||
"when": 1788194631751,
|
"when": 1788194631751,
|
||||||
"tag": "0015_young_human_fly",
|
"tag": "0015_young_human_fly",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788295165743,
|
||||||
|
"tag": "0016_lyrical_freak",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788295936825,
|
||||||
|
"tag": "0017_purple_wildside",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788401450986,
|
||||||
|
"tag": "0018_eminent_kid_colt",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788402724322,
|
||||||
|
"tag": "0019_glamorous_crusher_hogan",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|||||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import * as schema from './schema.js';
|
import * as schema from './schema.js';
|
||||||
import { ensureDefaults, backfillOneOnOneDmMembership } from './migrate.js';
|
import { ensureDefaults, backfillOneOnOneDmMembership, grantMentionEveryoneToLegacyRoles } from './migrate.js';
|
||||||
import { setWorkerId } from '../utils/snowflake.js';
|
import { setWorkerId } from '../utils/snowflake.js';
|
||||||
import { createSnapshot } from '../utils/backup.js';
|
import { createSnapshot } from '../utils/backup.js';
|
||||||
import { hasPendingMigrations } from './pendingMigrations.js';
|
import { hasPendingMigrations } from './pendingMigrations.js';
|
||||||
@@ -53,6 +53,7 @@ export function initDatabase() {
|
|||||||
// Recover pre-fix broken 1-on-1 DM threads (deleted partner's membership row
|
// Recover pre-fix broken 1-on-1 DM threads (deleted partner's membership row
|
||||||
// lost before the tombstone fix). Idempotent — safe no-op on every later boot.
|
// lost before the tombstone fix). Idempotent — safe no-op on every later boot.
|
||||||
backfillOneOnOneDmMembership(sqlite);
|
backfillOneOnOneDmMembership(sqlite);
|
||||||
|
grantMentionEveryoneToLegacyRoles(sqlite);
|
||||||
|
|
||||||
// Initialize Snowflake worker ID from persisted value
|
// Initialize Snowflake worker ID from persisted value
|
||||||
const settings = sqlite.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number } | undefined;
|
const settings = sqlite.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number } | undefined;
|
||||||
|
|||||||
@@ -87,3 +87,44 @@ export function backfillOneOnOneDmMembership(db: Database.Database): void {
|
|||||||
run();
|
run();
|
||||||
if (restored > 0) console.log(`[backfill] restored ${restored} deleted-partner DM membership row(s)`);
|
if (restored > 0) console.log(`[backfill] restored ${restored} deleted-partner DM membership row(s)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grant MENTION_EVERYONE on @everyone roles that predate the bit.
|
||||||
|
*
|
||||||
|
* `DEFAULT_EVERYONE_PERMISSIONS` only applies at space creation, so spaces
|
||||||
|
* created before the bit existed carry a stored value without it — and would
|
||||||
|
* silently have @everyone mentions stripped for every member. This ORs the bit
|
||||||
|
* in once.
|
||||||
|
*
|
||||||
|
* Idempotent by construction: the OR is a no-op when the bit is already set,
|
||||||
|
* and the WHERE clause skips those rows anyway. Deliberately scoped to the
|
||||||
|
* @everyone role (`id = space_id`), so an owner who later denies the
|
||||||
|
* permission is not overruled on the next boot — their denial lives on a
|
||||||
|
* different row.
|
||||||
|
*/
|
||||||
|
export function grantMentionEveryoneToLegacyRoles(db: Database.Database): void {
|
||||||
|
const BIT = 1n << 15n;
|
||||||
|
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT id, permissions FROM roles WHERE id = space_id`
|
||||||
|
).all() as { id: string; permissions: string | null }[];
|
||||||
|
|
||||||
|
const update = db.prepare('UPDATE roles SET permissions = ? WHERE id = ?');
|
||||||
|
let changed = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
let current: bigint;
|
||||||
|
try {
|
||||||
|
current = BigInt(row.permissions ?? '0');
|
||||||
|
} catch {
|
||||||
|
continue; // unparseable value — leave it for a human, do not guess
|
||||||
|
}
|
||||||
|
if ((current & BIT) !== 0n) continue;
|
||||||
|
update.run((current | BIT).toString(), row.id);
|
||||||
|
changed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed > 0) {
|
||||||
|
console.log(`[defaults] Granted MENTION_EVERYONE to ${changed} @everyone role(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export const users = sqliteTable('users', {
|
|||||||
profileUpdatedAt: integer('profile_updated_at'),
|
profileUpdatedAt: integer('profile_updated_at'),
|
||||||
passwordChangedAt: integer('password_changed_at'),
|
passwordChangedAt: integer('password_changed_at'),
|
||||||
showActivity: integer('show_activity').notNull().default(1),
|
showActivity: integer('show_activity').notNull().default(1),
|
||||||
|
// JSON ProfileStyle (shared/profile.ts), normalised on write. One column,
|
||||||
|
// not twelve: every field is optional, nothing is queried by value, and the
|
||||||
|
// shape keeps growing. Null means defaults.
|
||||||
|
profileStyle: text('profile_style'),
|
||||||
federationRegistryUpdatedAt: integer('federation_registry_updated_at').default(0),
|
federationRegistryUpdatedAt: integer('federation_registry_updated_at').default(0),
|
||||||
federationHealPending: integer('federation_heal_pending').default(0),
|
federationHealPending: integer('federation_heal_pending').default(0),
|
||||||
federationHomeOrphaned: integer('federation_home_orphaned').default(0),
|
federationHomeOrphaned: integer('federation_home_orphaned').default(0),
|
||||||
@@ -81,6 +85,18 @@ export const messages = sqliteTable('messages', {
|
|||||||
replyToId: text('reply_to_id'),
|
replyToId: text('reply_to_id'),
|
||||||
content: text('content'),
|
content: text('content'),
|
||||||
editedAt: integer('edited_at'),
|
editedAt: integer('edited_at'),
|
||||||
|
// Pinned state lives on the message rather than in a join table: a message is
|
||||||
|
// pinned in exactly one channel — its own — so a separate table would only
|
||||||
|
// add a join to every pin lookup.
|
||||||
|
pinnedAt: integer('pinned_at'),
|
||||||
|
pinnedBy: text('pinned_by'),
|
||||||
|
// Figurinha enviada como mensagem. `ON DELETE set null` de propósito: apagar
|
||||||
|
// a figurinha do servidor não pode apagar o histórico de quem a usou.
|
||||||
|
stickerId: text('sticker_id'),
|
||||||
|
// 'user' | 'system'. Space channels gained system messages with the pin
|
||||||
|
// notice; DM channels have carried them since group DMs. Same values, same
|
||||||
|
// renderer on the client.
|
||||||
|
type: text('type').notNull().default('user'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
replyToFk: foreignKey({
|
replyToFk: foreignKey({
|
||||||
@@ -89,6 +105,10 @@ export const messages = sqliteTable('messages', {
|
|||||||
}).onDelete('set null'),
|
}).onDelete('set null'),
|
||||||
channelIdx: index('idx_messages_channel_id').on(table.channelId),
|
channelIdx: index('idx_messages_channel_id').on(table.channelId),
|
||||||
userIdx: index('idx_messages_user_id').on(table.userId),
|
userIdx: index('idx_messages_user_id').on(table.userId),
|
||||||
|
// Listing a channel's pins, newest first, without scanning its history.
|
||||||
|
pinnedIdx: index('idx_messages_channel_pinned').on(table.channelId, table.pinnedAt),
|
||||||
|
// Filtered search: author within a channel, ordered by recency.
|
||||||
|
searchIdx: index('idx_messages_channel_user_created').on(table.channelId, table.userId, table.createdAt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const attachments = sqliteTable('attachments', {
|
export const attachments = sqliteTable('attachments', {
|
||||||
@@ -645,3 +665,37 @@ export const soundboardSounds = sqliteTable('soundboard_sounds', {
|
|||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
spaceIdx: index('idx_soundboard_space').on(table.spaceId),
|
spaceIdx: index('idx_soundboard_space').on(table.spaceId),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emojis próprios de um espaço, referenciados por `:nome:` nas mensagens.
|
||||||
|
*
|
||||||
|
* O nome é único por espaço: `:trollface:` tem de resolver para uma imagem só,
|
||||||
|
* senão o render vira loteria. Espaços diferentes podem repetir o nome.
|
||||||
|
*/
|
||||||
|
export const spaceEmojis = sqliteTable('space_emojis', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
filename: text('filename').notNull(),
|
||||||
|
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
spaceIdx: index('idx_space_emojis_space').on(table.spaceId),
|
||||||
|
nameUnique: uniqueIndex('idx_space_emojis_space_name').on(table.spaceId, table.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figurinhas do espaço. Tabela separada dos emojis de propósito: emoji entra
|
||||||
|
* no meio do texto e figurinha ocupa a mensagem inteira — tamanhos, limites e
|
||||||
|
* caminho de render são diferentes.
|
||||||
|
*/
|
||||||
|
export const spaceStickers = sqliteTable('space_stickers', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
filename: text('filename').notNull(),
|
||||||
|
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
spaceIdx: index('idx_space_stickers_space').on(table.spaceId),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { spotifyRoutes } from './routes/spotify.js';
|
|||||||
import { auditRoutes } from './routes/audit.js';
|
import { auditRoutes } from './routes/audit.js';
|
||||||
import { statsRoutes } from './routes/stats.js';
|
import { statsRoutes } from './routes/stats.js';
|
||||||
import { soundboardRoutes } from './routes/soundboard.js';
|
import { soundboardRoutes } from './routes/soundboard.js';
|
||||||
|
import { expressionRoutes } from './routes/expressions.js';
|
||||||
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
|
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
|
||||||
import { socialRoutes } from './routes/social.js';
|
import { socialRoutes } from './routes/social.js';
|
||||||
import { settingsRoutes } from './routes/settings.js';
|
import { settingsRoutes } from './routes/settings.js';
|
||||||
@@ -30,6 +31,7 @@ import { searchRoutes } from './routes/search.js';
|
|||||||
import { adminRoutes } from './routes/admin.js';
|
import { adminRoutes } from './routes/admin.js';
|
||||||
import { gifRoutes } from './routes/gif.js';
|
import { gifRoutes } from './routes/gif.js';
|
||||||
import { federationRoutes } from './routes/federation.js';
|
import { federationRoutes } from './routes/federation.js';
|
||||||
|
import { gameRoutes } from './routes/games.js';
|
||||||
import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js';
|
import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js';
|
||||||
import { startBackupWorker, stopBackupWorker } from './utils/backupWorker.js';
|
import { startBackupWorker, stopBackupWorker } from './utils/backupWorker.js';
|
||||||
import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures.
|
import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures.
|
||||||
@@ -139,6 +141,7 @@ async function main(): Promise<void> {
|
|||||||
await app.register(auditRoutes);
|
await app.register(auditRoutes);
|
||||||
await app.register(statsRoutes);
|
await app.register(statsRoutes);
|
||||||
await app.register(soundboardRoutes);
|
await app.register(soundboardRoutes);
|
||||||
|
await app.register(expressionRoutes);
|
||||||
await app.register(socialRoutes);
|
await app.register(socialRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(utilRoutes);
|
await app.register(utilRoutes);
|
||||||
@@ -148,6 +151,7 @@ async function main(): Promise<void> {
|
|||||||
await app.register(searchRoutes);
|
await app.register(searchRoutes);
|
||||||
await app.register(adminRoutes);
|
await app.register(adminRoutes);
|
||||||
await app.register(gifRoutes);
|
await app.register(gifRoutes);
|
||||||
|
await app.register(gameRoutes);
|
||||||
await app.register(federationRoutes);
|
await app.register(federationRoutes);
|
||||||
await app.register(registerWebSocket);
|
await app.register(registerWebSocket);
|
||||||
|
|
||||||
|
|||||||