diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index dc31da5..8299c3e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -18,7 +18,7 @@ jobs: - platform: macos-latest runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Install Linux deps if: matrix.platform == 'ubuntu-22.04' @@ -27,12 +27,21 @@ jobs: sudo apt install -y libwebkit2gtk-4.1-dev build-essential curl wget file \ libxdo-dev libdbus-1-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev libhunspell-dev patchelf - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - - uses: swatinem/rust-cache@v2 + - uses: swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 with: workspaces: "./src-tauri -> target" + - name: cargo fmt + if: matrix.platform == 'ubuntu-22.04' + working-directory: src-tauri + run: cargo fmt --check + + - name: cargo clippy + working-directory: src-tauri + run: cargo clippy --locked -- -D warnings + - name: cargo build (compile check) working-directory: src-tauri run: cargo build --locked @@ -45,7 +54,7 @@ jobs: working-directory: src-tauri run: cargo test --locked - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 20 @@ -60,3 +69,9 @@ jobs: # and shows up only as a dead button on macOS, so it needs a test. - name: settings-ui dialog tests run: node settings-ui/dialog.test.mjs + + - name: settings-ui language tests (every UI string has an English entry) + run: node settings-ui/i18n.test.mjs + + - name: notification tests + run: node src-tauri/resources/notifications.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35c6983..5c4d33f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: outputs: tag: ${{ steps.tag.outputs.tag }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Resolve the release tag id: tag env: @@ -59,7 +59,7 @@ jobs: - platform: macos-latest runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Install Linux deps if: matrix.platform == 'ubuntu-22.04' @@ -69,9 +69,9 @@ jobs: libxdo-dev libdbus-1-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev libhunspell-dev patchelf \ desktop-file-utils - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - - uses: swatinem/rust-cache@v2 + - uses: swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 with: workspaces: "./src-tauri -> target" @@ -88,7 +88,7 @@ jobs: echo "WHATRUST_NOTES_EOF" } >> "$GITHUB_OUTPUT" - - uses: tauri-apps/tauri-action@v0 + - uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/smoke-windows.yml b/.github/workflows/smoke-windows.yml index d3d7b1d..cb70d40 100644 --- a/.github/workflows/smoke-windows.yml +++ b/.github/workflows/smoke-windows.yml @@ -109,7 +109,7 @@ jobs: shell: pwsh run: Get-Content "$env:LOCALAPPDATA\whatRust\logs\whatrust.log" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: smoke-windows-screenshots diff --git a/.gitignore b/.gitignore index 79adbbd..81b07a3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,7 @@ dist/ /builddir/ /repo/ /*.flatpak + +# UI validation artifacts +/.playwright-cli/ +/output/ diff --git a/docs/windows-usabilidade.md b/docs/windows-usabilidade.md new file mode 100644 index 0000000..4340e87 --- /dev/null +++ b/docs/windows-usabilidade.md @@ -0,0 +1,128 @@ +# Windows: interface integrada e notificações + +Esta implementação usa Tauri 2.11.2 fixado e suas APIs experimentais de múltiplas visualizações. macOS e Linux conservam o fluxo de uma janela por conta. + +## Estrutura + +- `sessions.rs`: uma WebView viva por conta, janela principal, destaque/reintegração, painel local e acesso de recuperação pelo menu da janela. +- `integration.rs` e `resources/integration.js`: contrato mínimo por origem/visualização, dados de exibição, montagem sem classes geradas, Shadow DOM e detector conservador. A criação e o gerenciamento de visualizações só ocorrem no Rust. +- `notify.rs`: políticas, correlação por identidade, recibos de envio, falhas síncronas/assíncronas e histórico limitado a 100 eventos sem conteúdo de mensagens. + +O painel de configurações é uma visualização local com armazenamento e permissões próprios. WhatsApp recebe apenas comandos de relato, exibição e abertura do painel. `Shadow DOM` isola CSS; não é fronteira de segurança. + +## Compatibilidade + +A conta histórica `default` continua com o armazenamento padrão. As demais preservam o mesmo diretório por ID. Destacar usa reparent da visualização existente, preservando sua sessão e estado em memória. Configurações antigas recebem as três preferências novas ativadas, mantendo `notifications` como controle global. + +A preferência de conta inicial continua independente do ID histórico `default`. A ordem só aceita uma permutação completa das contas. Remover uma conta exige confirmação no painel e não permite remover a última. + +## Notificações + +No Windows, WebView2 é a rota principal. A ponte JavaScript assume quando essa rota está indisponível ou recusa a criação. O detector visual só complementa eventos com identificadores de mensagens recebidas reconhecidos no DOM montado da conversa atual. Não lê texto de mensagens nem estruturas internas do React. Identificadores transmitidos pelo detector são hashes em memória. + +Histórico, troca de conversa, remetente próprio e reconstruções sem referência de continuidade não geram avisos adicionais. O silenciamento exige prova explícita. Sem essa prova, o reforço permanece silencioso. Em layouts compatíveis, abrir o menu da conversa permite reconhecer a ação explícita de silenciar/reativar; esse reconhecimento fica isolado no adaptador. + +Uma confirmação de envio ao Windows não prova que houve banner. Não perturbe e as permissões do sistema continuam sendo respeitados. O diagnóstico distingue envio pendente, aceitação, falha, supressão, clique e encerramento. Conteúdo não é escrito no histórico nem nos logs. + +## Verificações + +```powershell +cargo clippy --locked --all-targets -- -D warnings +cargo test --locked +node src-tauri/resources/bridge.test.mjs +node --test src-tauri/resources/notifications.test.mjs +node settings-ui/comboToAccelerator.test.mjs +node settings-ui/dialog.test.mjs +node settings-ui/i18n.test.mjs +.\scripts\test-adapter.ps1 +.\scripts\test-ui.ps1 +.\scripts\test-shell.ps1 +``` + +Os comandos Cargo são executados em `src-tauri`; os demais, na raiz do checkout. O teste do adaptador usa Playwright CLI com página e dados sintéticos e não acessa conversas reais. + +`WHATRUST_SMOKE=1` é aceito somente por uma compilação debug. Ele usa o identificador isolado `com.karem.whatrust.smoke`, duas contas sintéticas, log separado e um caminho de relatório definido em `WHATRUST_SMOKE_REPORT`. O teste verifica o WebView2 real, movimentação da mesma visualização, origem do painel, recusa de IPC remoto, redimensionamento, bloqueio e submissão de notificação. Ele não está disponível na compilação release. + +## Entrega e limites + +O instalador NSIS é em português, para o usuário atual, com menu Iniciar e executável GUI sem console. A versão desta entrega é `0.6.9`, acima da versão 0.6.8 anteriormente instalada. + +Os testes automatizados não substituem receber mensagens reais no aplicativo instalado, conferir conversas silenciadas em cada layout suportado, testar chamadas/microfone e confirmar arquivos arrastados/baixados com suas contas. Quando faltam sinais confiáveis, o detector adicional permanece conservador. Esses cenários devem constar como pendentes no recibo de entrega quando não executados. + +## Idioma + +O português do Brasil é o padrão. Em Configurações > Aparência > Idioma é possível trocar para English; a troca vale na hora e fica em `settings.json` (`language`). O texto-fonte do código é em português e `src-tauri/src/i18n.rs` (Rust) e `settings-ui/i18n.js` (páginas locais) trazem a tabela em inglês. Textos sem tradução aparecem em português. Os testes `cargo test` e `node settings-ui/i18n.test.mjs` falham quando um texto novo fica sem entrada em inglês. As páginas do instalador NSIS continuam em português. + +## Recuperação + +O PR #26 e sua branch foram preservados. Antes das mudanças foi criado um bundle Git completo fora do checkout. A versão anterior do instalador permanece na pasta de entrega. Para voltar à interface anterior, reinstale a versão anterior sem selecionar exclusão de dados do aplicativo. A implementação não migra os diretórios de sessão para outro formato. + +## Correção da inicialização instalada (local.2) + +O layout não altera menus nativos durante eventos de redimensionamento: `SetMenu` dispara `WM_SIZE` e o bloqueio interno de menu do Tauri pode ser acessado novamente. A visibilidade do menu muda somente quando muda a integração/conta ativa, antes de calcular o layout. + +As contas Windows agora nascem como WebViews filhas, sem uma janela temporária. Isso evita a lógica de redimensionamento automático de uma WebviewWindow movida. Os limites usam pixels físicos da área cliente atual. O shell de recuperação ocupa somente sua própria faixa, fica oculto quando os controles integrados estão disponíveis e usa a janela completa apenas quando não existe visualização ativa. + +Na primeira execução, o perfil `default` abre a página oficial de vinculação do WhatsApp; um perfil cadastrado não significa uma sessão autenticada. Uma configuração explicitamente vazia recebe um perfil inicial, sem apagar nem modificar outros diretórios. QR Code e login por telefone permanecem no WhatsApp. Não se lê estado privado de autenticação. + +A regressão nativa alterna integração/recuperação via IPC real, confere o tamanho físico até o rodapé, redimensiona repetidamente e abre o painel antes de vincular contas. Os cenários usam perfis isolados sem login e não desconectam contas do usuário. + +## Controles e estilo (local.3) + +A inspeção do WhatsApp real encontrou uma barra de navegação vertical implementada como `header` com 64 px de largura antes do cabeçalho da lista. O adaptador agora verifica geometria, relação com `#side`, orientação horizontal e espaço livre. Cabeçalhos aninhados usam a linha interna de ações. Os botões próprios têm 40 px e SVGs de 24 px; não encolhem nem alteram CSS do WhatsApp. Se não couberem, usa-se o acesso nativo de recuperação. `ResizeObserver` acompanha somente a lateral relevante e a observação de tema fica limitada à raiz e ao corpo. + +O menu de contas abre próximo ao botão, respeita os limites da janela e fecha por Esc ou clique externo. O tema claro/escuro resolvido pelo adaptador é transmitido apenas como um valor validado (`light`/`dark`) e aplicado à visualização local quando ela abre. Isso alinha painel, menus, diálogos e controles mesmo quando WhatsApp e Windows usam temas diferentes. A identidade do whatRust permanece indicada no painel. + +As regressões incluem navegação vertical antes do cabeçalho correto, cabeçalhos aninhados, ações originais ocupando a largura, telas estreitas, recuperação e mudança de tema. O recebimento real de mensagens continua dependendo de validação pelo usuário. + +## Barra de recuperação (local.4) + +O shell tem uma faixa de 48 px com seletor de contas e configurações. Raiz e corpo não geram rolagem; o texto da tela completa fica oculto na faixa compacta. Seus controles e cores seguem os mesmos tamanhos e temas do WhatsApp. A rolagem das conversas e do painel de configurações não é modificada. + +## Largura do seletor (local.5) + +O seletor compacto acompanha a largura do nome selecionado até 320 px (ou 65% da janela). O conteúdo excedente usa reticências e o nome completo fica na dica e na opção acessível. Um elemento de medição com a mesma fonte evita que opções maiores afastem a seta de nomes curtos. A atualização local recebe o ID da conta ativa, mantendo nome e janela sincronizados. + +## Menus, troca de contas e bloqueio (local.5) + +Menus locais removem o `inset` padrão do popover, medem sua altura real e escolhem acima/abaixo do botão conforme o espaço. Acompanham rolagem e redimensionamento e fecham quando o botão sai do painel visível. Restauração de foco não rola o painel nem toma foco de outro menu/diálogo. Diálogos altos e avisos de download têm limites próprios; nomes extensos não ampliam o painel. + +A ativação de conta é uma operação única na fila da interface: seleciona a identidade, oculta as demais visualizações, mostra a conta, aplica os limites físicos e publica o mesmo ID ao seletor. O tamanho final é aplicado depois de mostrar a visualização. Atualizações pendentes do seletor usam o ID mais recente. + +A tela de senha usa SVG de cadeado, paleta neutra, botão verde e textos em português. A revisão também inclui diálogos de senha, menus nativos e avisos de download/anexo. Bloqueio e ativação compartilham a fila da interface. O desbloqueio sempre retorna do callback IPC antes de destruir o próprio WebView2; destruí-lo dentro do callback podia travar a reintegração. O painel verifica novamente o estado de bloqueio antes de revelar sua janela. + +A redefinição exige confirmação e grava um marcador fora da pasta dos perfis. A limpeza só ocorre na próxima inicialização, antes de criar WebViews. Falhas mantêm o marcador e abortam a abertura dos perfis; a redefinição não declara sucesso ignorando arquivos em uso. Os testes destrutivos usam somente diretórios temporários sintéticos. + +A regressão nativa usa senha sintética para validar rejeição de senha incorreta, troca de senha, bloqueio de janelas principal/destacada, recusa de desbloqueio pela página remota, biometria desativada, restauração da conta pendente, bloqueio ao fechar e desativação com senha atual. O teste visual cobre temas claro/escuro, teclado e textos em português. Autenticação real pelo Windows Hello requer interação com o dispositivo e permanece pendente; não é simulada como sucesso. + +## Notificação com conversa aberta e janela minimizada (local.6) + +O plugin genérico de notificações do Tauri substituía `window.Notification` por uma função que chamava `plugin:notification|notify`. A ponte capturava essa função como se fosse o construtor nativo e delegava para ela; a recusa assíncrona da ACL não acionava a alternativa. No Windows, o aplicativo não registra mais esse plugin, pois o serviço de `notify.rs` já controla permissões, bloqueio, conta e cliques. As permissões da página remota permanecem restritas. macOS/Linux conservam o plugin existente. + +A ponte pede também a permissão real do navegador. Sem ela, usa diretamente o comando próprio; erros assíncronos do construtor nativo acionam a alternativa. O estado de primeiro plano exclui explicitamente janelas minimizadas. O teste nativo agora cria uma notificação pela API JavaScript real dentro do WhatsApp/WebView2, minimizado e oculto na bandeja, e exige recibo da rota WebView2. Uma chamada direta de teste ao Rust não é evidência suficiente para essa integração. + +A API persistente `ServiceWorkerRegistration.showNotification()` chamada pela página usa diretamente o comando próprio: o contrato de `ICoreWebView2_24.NotificationReceived` cobre notificações não persistentes, portanto uma Promise persistente resolvida não prova que esse evento ocorreu. Referência: https://learn.microsoft.com/en-us/microsoft-edge/webview2/reference/win32/icorewebview2_24 . A permissão real continua sendo solicitada para o navegador, inclusive para os contextos de worker. + +As WebViews filhas também precisam de visibilidade própria: minimizar a janela nativa deixava `document.hidden === false` mesmo sem foco. O layout agora oculta controladores quando a janela está minimizada, na bandeja ou bloqueada e restaura apenas a conta/painel selecionado ao reaparecer. A minimização preserva o último tamanho útil. Há regressões para `document.hidden` e retorno à visibilidade, além do envio da notificação. + +## Bloqueio silencioso, abertura pelo instalador e travamentos (local.7) + +“Bloquear ao ocultar” agora protege as contas e permanece na bandeja, sem criar nem focar a tela de senha. Abrir pelo ícone, atalho, menu ou outra execução apresenta o desbloqueio. Bloqueio manual mantém a apresentação imediata. Solicitações antigas de apresentação são invalidadas quando a intenção mais recente é ocultar. + +Ao bloquear, o serviço avança uma geração de eventos, cancela os avisos pendentes e remove os avisos anteriores do Windows somente deste aplicativo. A operação do sistema ocorre em workers; o loop gráfico não espera pela limpeza. O envio revalida bloqueio e geração imediatamente antes/depois de chamar o Windows. Temporizadores e conversões de ícone preservam a geração de detecção para não reaparecer após um desbloqueio rápido. A preferência global de notificações e o respeito ao silenciamento continuam valendo. + +O evento CloseRequested de uma notificação do WebView2 não executa mais remoção de histórico WinRT de forma síncrona. ReportShown/Clicked/Closed são adiados para fora do callback original; nenhum método COM é chamado com a coleção de notificações emprestada. A visibilidade dos filhos só é alterada quando muda, reduzindo eventos e reentrância. Consultas de Windows Hello e os IPCs de notificações/layout são executados fora do callback gráfico. Essas alterações tratam caminhos capazes de bloquear a UI; o AppHang registrado pelo Windows confirma a ocorrência, mas não fornece a pilha necessária para atribuir todos os congelamentos a uma única causa. + +O arquivo de diagnóstico anterior é preservado em `whatrust.previous.log`. Um pulso verifica a resposta da thread gráfica; atraso superior a 5 s registra o estágio técnico atual e o tempo de recuperação, sem conteúdo de conversas. Não encerra nem reinicia o processo automaticamente. O teste nativo provoca um atraso finito em um perfil isolado para validar esse diagnóstico. + +A página final NSIS e `/R` passam `--show`. Esse argumento sobrepõe somente a preferência de iniciar oculto; não dispensa a senha quando há bloqueio ao iniciar. Autostart com `--minimized` continua oculto. O template está fixado no Tauri CLI 2.12.3, com origem, hash e licenças em `src-tauri/windows`; apenas as duas chamadas de execução são modificadas. + +A regressão nativa inclui 12 ciclos de criar/encerrar avisos enquanto troca conta, abre/fecha painel e altera não lidas; chegada bloqueada; remoção de histórico Windows; reabertura exigindo senha; não repetição de eventos atrasados; e início explícito visível com preferência persistida de segundo plano. Todos os testes usam contas e arquivos sintéticos. + +Os testes unitários desativam o arquivo de log instalado. As regressões nativas continuam usando seu próprio relatório/log isolado, evitando misturar arquivos sintéticos com evidência do aplicativo real. + +## Layout deferido (local.8) + +Um novo atraso real foi registrado em `account layout`. O runtime do Tauri despacha eventos com o mutex de listeners da janela adquirido; aplicar visibilidade/tamanho no callback de foco/redimensionamento permitia reentrada no mesmo caminho nativo. Esses callbacks agora apenas enfileiram um layout por janela; o fechamento também agenda suas mutações após liberar o callback. Limites físicos idênticos não são reaplicados. Mostrar uma visualização ou mover entre janelas invalida a geometria para reaplicar seu tamanho final uma vez. Essa correção é adicional às alterações de notificações da local.7. + +A regressão nativa também reproduziu o travamento ao reintegrar uma visualização com layout pendente. O dispatcher Wry segura seu mutex de ID de janela enquanto aguarda a mensagem de reparent; um layout anterior na fila tentava esse mesmo mutex, bloqueando a UI antes de processar a mudança. O reparent agora ocorre na própria tarefa de UI, com a visualização previamente oculta, sem adquirir o mutex no worker que aguardará a fila. Painel e contas compartilham esse caminho. diff --git a/install.ps1 b/install.ps1 index 01457db..539f7c5 100644 --- a/install.ps1 +++ b/install.ps1 @@ -25,6 +25,19 @@ $out = Join-Path $env:TEMP $asset.name Write-Host "Downloading $($asset.name)..." Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $out -UseBasicParsing +# GitHub publishes a SHA-256 digest for each release asset; refuse a mismatch. +$expected = if ($asset.digest -match '^sha256:(?[0-9a-fA-F]{64})$') { $Matches.h } else { $null } +$actual = (Get-FileHash -Algorithm SHA256 -Path $out).Hash +if ($expected) { + if ($actual -ne $expected) { + Remove-Item -Force $out + throw "Checksum mismatch for $($asset.name): expected $expected, got $actual. Aborting." + } + Write-Host "SHA-256 verified: $actual" +} else { + Write-Warning "GitHub published no digest for this asset; SHA-256 is $actual. Compare it with the release notes before trusting the installer." +} + Write-Host "Launching installer..." if ($asset.name -match '\.msi$') { Start-Process msiexec.exe -ArgumentList "/i `"$out`"" -Wait diff --git a/scripts/adapter-check.template.cjs b/scripts/adapter-check.template.cjs new file mode 100644 index 0000000..4025b0e --- /dev/null +++ b/scripts/adapter-check.template.cjs @@ -0,0 +1,75 @@ +async (page) => { + const checks=[]; + function check(name,pass){if(!pass)throw Error(name);checks.push(name);} + await page.clock.install(); + await page.addInitScript(() => { + window.__WHATRUST_WINDOWS__=true;window.calls=[]; + window.__TAURI__={core:{invoke:async(cmd,args)=>{window.calls.push({cmd,args});if(cmd==='integration_state')return {accounts:[{id:'default',name:'Matheus',unread:2}],current_id:'default',native_notifications:true};return null;}}}; + }); + await page.route('https://web.whatsapp.com/**',route=>route.fulfill({contentType:'text/html',body:'

WhatsApp

Histórico
'})); + await page.goto('https://web.whatsapp.com/'); + await page.addScriptTag({content:__ADAPTER_SOURCE__}); + await page.clock.runFor(3000); + const signals=()=>page.evaluate(()=>window.calls.filter(c=>c.cmd==='report_message_signal').map(c=>c.args.signal)); + check('initial_history_is_silent',(await signals()).length===0); + async function append(id){await page.evaluate(id=>{const r=document.createElement('div');r.dataset.id=id;r.textContent='Mesmo texto';document.getElementById('messages').appendChild(r);},id);await page.clock.runFor(300);await page.waitForTimeout(20);} + await append('false_chatA_new1');await append('false_chatA_new2'); + let s=await signals();check('identical_text_distinct_messages',s.length===2&&s[0].id!==s[1].id); + check('identifier_is_hashed',s.every(x=>/^[a-f0-9]{64}$/.test(x.id))); + check('explicit_unmuted_state',s.every(x=>x.muted===false)); + await append('true_chatA_outgoing');check('outgoing_is_silent',(await signals()).length===2); + await page.evaluate(()=>document.querySelector('#main header').dataset.muted='true');await append('false_chatA_muted'); + check('muted_state_reported',(await signals()).at(-1).muted===true); + await page.evaluate(()=>document.querySelector('#main header').removeAttribute('data-muted'));await append('false_chatA_unknown'); + check('unknown_mute_state_reported',(await signals()).at(-1).muted===null); + let count=(await signals()).length; + await page.evaluate(()=>{const r=document.createElement('div');r.dataset.id='false_chatA_older';document.getElementById('messages').prepend(r);});await page.clock.runFor(300); + check('scroll_history_is_silent',(await signals()).length===count); + await page.evaluate(()=>document.getElementById('messages').innerHTML='
Outra conversa
');await page.clock.runFor(300); + check('conversation_change_is_silent',(await signals()).length===count); + await page.getByRole('button',{name:'Configurações do whatRust'}).click(); + check('settings_uses_limited_opener',await page.evaluate(()=>window.calls.some(c=>c.cmd==='open_app_panel'))); + await page.evaluate(()=>document.querySelector('#app>header').remove());await page.clock.runFor(300); + check('missing_header_activates_recovery',await page.evaluate(()=>window.calls.some(c=>c.cmd==='report_integration'&&c.args.mounted===false))); + await page.evaluate(()=>{const h=document.createElement('header');h.innerHTML='

WhatsApp

';document.getElementById('app').prepend(h);});await page.clock.runFor(300); + check('header_replacement_remounts_once',await page.locator('#__whatrust_controls').count()===1); + await page.getByRole('button',{name:'Trocar conta: Matheus · 2 conversas não lidas',exact:true}).click(); + await page.evaluate(()=>window.__whatrustState({accounts:[{id:'default',name:'Matheus',unread:3}],current_id:'default',native_notifications:true})); + check('live_unread_update_preserves_menu_focus',await page.locator('#__whatrust_controls').evaluate(host=>host.shadowRoot.activeElement?.dataset.item==='default')); + await page.keyboard.press('Escape'); + check('escape_restores_focus',await page.locator('#__whatrust_controls').evaluate(host=>host.shadowRoot.activeElement?.title==='Conta: Matheus — Trocar conta')); + for(const width of [560,900,1920]){await page.setViewportSize({width,height:800});const box=await page.locator('#__whatrust_controls').boundingBox();check('header_fits_'+width,box.x>=0&&box.x+box.width<=width);} + // Regression from the real WhatsApp DOM: the 64px navigation rail is + // itself a header, before two nested list headers and a full-width action row. + await page.evaluate(()=>{ + document.body.innerHTML=`

WhatsApp

Conversa
`; + });await page.clock.runFor(300); + check('rejects_vertical_navigation_header',await page.locator('#actions>#__whatrust_controls').count()===1); + for(const width of [560,900,1920]){ + await page.setViewportSize({width,height:800});await page.waitForTimeout(30);await page.clock.runFor(300); + const fit=await page.locator('#__whatrust_controls').evaluate(host=>{ + const r=host.getBoundingClientRect(),header=host.closest('header').getBoundingClientRect(); + const buttons=Array.from(host.shadowRoot.querySelectorAll('.bar>button')).map(e=>e.getBoundingClientRect()); + const native=Array.from(document.querySelectorAll('#native button')).map(e=>e.getBoundingClientRect()); + return r.left>=header.left&&r.right<=header.right+1&&r.bottom<=header.bottom+1&&buttons.every(b=>b.width>=40&&b.height>=40)&&native.every(b=>b.right<=r.left||b.left>=r.right); + });if(!fit)throw Error('header geometry '+JSON.stringify(await page.locator('#__whatrust_controls').evaluate(h=>({host:h.getBoundingClientRect().toJSON(),head:h.closest('header').getBoundingClientRect().toJSON(),buttons:Array.from(h.shadowRoot.querySelectorAll('.bar>button')).map(e=>e.getBoundingClientRect().toJSON()),native:Array.from(document.querySelectorAll('#native button')).map(e=>e.getBoundingClientRect().toJSON())}))));check('realistic_header_controls_fit_'+width,fit); + } + await page.evaluate(()=>document.getElementById('list').style.width='180px');await page.waitForTimeout(40);await page.clock.runFor(300); + check('unsafe_narrow_header_uses_recovery',await page.locator('#__whatrust_controls').count()===0&&await page.evaluate(()=>window.calls.filter(c=>c.cmd==='report_integration').at(-1).args.mounted===false)); + await page.evaluate(()=>document.getElementById('list').style.width='');await page.waitForTimeout(40);await page.clock.runFor(300); + check('safe_width_restores_controls_once',await page.locator('#actions>#__whatrust_controls').count()===1); + await page.evaluate(()=>{window.__mountMutations=0;window.__probe=new MutationObserver(rs=>{for(const r of rs)for(const n of [...r.addedNodes,...r.removedNodes])if(n.id==='__whatrust_controls')window.__mountMutations++;});window.__probe.observe(document.getElementById('app'),{subtree:true,childList:true});document.getElementById('list').style.width='280px';}); + await page.waitForTimeout(40);await page.clock.runFor(1600); + check('unsafe_header_does_not_retry_own_mutations',await page.evaluate(()=>window.__mountMutations<=4)); + await page.evaluate(()=>{window.__probe.disconnect();document.getElementById('list').style.width='';});await page.waitForTimeout(40);await page.clock.runFor(300); + check('reports_whatsapp_dark_theme',await page.evaluate(()=>window.calls.filter(c=>c.cmd==='report_integration').at(-1).args.theme==='dark')); + await page.evaluate(()=>document.body.style.color='rgb(10,10,10)');await page.clock.runFor(300); + check('reports_whatsapp_light_theme_change',await page.evaluate(()=>window.calls.filter(c=>c.cmd==='report_integration').at(-1).args.theme==='light')); + return {passed:true,checks}; +} \ No newline at end of file diff --git a/scripts/shell-check.template.cjs b/scripts/shell-check.template.cjs new file mode 100644 index 0000000..012fe33 --- /dev/null +++ b/scripts/shell-check.template.cjs @@ -0,0 +1,40 @@ +async(page)=>{ + const checks=[];const check=(n,p)=>{if(!p)throw Error(n);checks.push(n)}; + await page.addInitScript(()=>{window.calls=[];window.__TAURI__={core:{invoke:async(cmd,args)=>{window.calls.push({cmd,args});return cmd==='list_accounts'?[{id:'default',name:'Matheus',unread:2},{id:'acct-1',name:'VS Prestadora de Serviços',unread:0}]:null;}}};}); + await page.emulateMedia({colorScheme:'dark'}); + await page.goto(__SHELL_URL__); + for(const width of [480,560,1920]){ + await page.setViewportSize({width,height:48}); + check('compact_shell_no_scroll_'+width,await page.evaluate(()=>document.documentElement.scrollHeight<=innerHeight&&document.documentElement.scrollWidth<=innerWidth&&getComputedStyle(document.querySelector('main')).display==='none')); + check('toolbar_controls_fit_'+width,await page.evaluate(()=>Array.from(document.querySelectorAll('button,select')).every(e=>{const r=e.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight;}))); + } + await page.selectOption('#accounts','acct-1'); + check('account_switch_preserved',await page.evaluate(()=>window.calls.some(c=>c.cmd==='open_account'&&c.args.id==='acct-1'))); + await page.getByRole('button',{name:'Configurações do whatRust',exact:true}).click(); + check('settings_access_preserved',await page.evaluate(()=>window.calls.some(c=>c.cmd==='open_app_panel'))); + await page.setViewportSize({width:1000,height:800}); + check('full_window_empty_state',await page.getByRole('heading',{name:'Escolha uma conta para continuar'}).isVisible()); + check('neutral_dark_background',await page.evaluate(()=>getComputedStyle(document.body).backgroundColor==='rgb(22, 23, 23)')); + await page.setViewportSize({width:1280,height:48}); + + await page.evaluate(()=>window.refresh('default')); + check('arrow_close_to_short_name',await page.evaluate(()=>{const m=document.getElementById('account-size'),r=document.createRange();r.selectNodeContents(m);const arrow=document.querySelector('.chevron').getBoundingClientRect();return Math.abs(arrow.left-r.getBoundingClientRect().right-8)<2;})); + await page.screenshot({path:'output/playwright/shell-short-local5.png'}); + const longName='Conta empresarial com um nome muito longo para conferir as reticências no seletor'; + await page.evaluate(name=>{const s=document.getElementById('accounts');s.options[1].textContent=name;s.value='acct-1';s.dispatchEvent(new Event('change'));},longName); + const longWidth=await page.locator('#accounts').evaluate(e=>e.getBoundingClientRect().width); + check('long_name_capped_with_ellipsis',await page.locator('#accounts').evaluate(e=>getComputedStyle(e).textOverflow==='ellipsis'&&e.getBoundingClientRect().width<=320&&document.getElementById('account-size').scrollWidth>e.clientWidth)); + check('full_name_available_in_tooltip',await page.locator('#accounts').getAttribute('title')===longName); + await page.screenshot({path:'output/playwright/shell-long-local5.png'}); + await page.evaluate(()=>window.refresh('acct-1')); + check('selected_account_tracks_active_window',await page.locator('#accounts').inputValue()==='acct-1'); + await page.evaluate(()=>window.refresh('default')); + check('short_name_shrinks_after_long_name',await page.locator('#accounts').evaluate(e=>e.getBoundingClientRect().width){window.__TAURI__.core.invoke=(cmd)=>cmd==='list_accounts'?new Promise(resolve=>window.__releaseList=resolve):Promise.resolve();}); + await page.reload(); + await page.evaluate(async()=>{window.refresh('default');window.refresh('acct-1');window.__releaseList([{id:'default',name:'Matheus'},{id:'acct-1',name:'Empresa'}]);}); + await page.waitForFunction(()=>document.getElementById('accounts').options.length===2); + check('delayed_refresh_keeps_latest_active_account',await page.locator('#accounts').inputValue()==='acct-1'); + return {passed:true,checks}; +} diff --git a/scripts/test-adapter.ps1 b/scripts/test-adapter.ps1 new file mode 100644 index 0000000..a7fd394 --- /dev/null +++ b/scripts/test-adapter.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference='Stop' +$taskRoot=Split-Path -Parent $PSScriptRoot +$taskOutput=Join-Path $taskRoot 'output\playwright' +New-Item -ItemType Directory -Path $taskOutput -Force | Out-Null +$taskSource=Get-Content -LiteralPath (Join-Path $taskRoot 'src-tauri\resources\integration.js') -Raw -Encoding UTF8 +$taskTemplate=Get-Content -LiteralPath (Join-Path $PSScriptRoot 'adapter-check.template.cjs') -Raw -Encoding UTF8 +$taskCode=$taskTemplate.Replace('__ADAPTER_SOURCE__',(ConvertTo-Json -InputObject ([string]$taskSource) -Compress)) +$taskFile=Join-Path $taskOutput 'adapter-check.cjs' +[IO.File]::WriteAllText($taskFile,$taskCode,[Text.UTF8Encoding]::new($false)) +npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-adapter open about:blank | Out-Null +$taskResult=npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-adapter run-code --filename $taskFile +$taskResult | Set-Content -LiteralPath (Join-Path $taskOutput 'adapter-result.log') -Encoding UTF8 +$taskResult | Select-Object -First 3 +npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-adapter close | Out-Null +if (($taskResult -join "`n") -match '### Error') { exit 1 } diff --git a/scripts/test-shell.ps1 b/scripts/test-shell.ps1 new file mode 100644 index 0000000..6d476af --- /dev/null +++ b/scripts/test-shell.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference='Stop' +$taskRoot=Split-Path -Parent $PSScriptRoot +$taskOutput=Join-Path $taskRoot 'output\playwright' +New-Item -ItemType Directory -Path $taskOutput -Force | Out-Null +$taskTemplate=Get-Content -LiteralPath (Join-Path $PSScriptRoot 'shell-check.template.cjs') -Raw -Encoding UTF8 +$taskUrl=([uri](Join-Path $taskRoot 'settings-ui\shell.html')).AbsoluteUri +$taskCode=$taskTemplate.Replace('__SHELL_URL__',($taskUrl | ConvertTo-Json -Compress)) +$taskFile=Join-Path $taskOutput 'shell-check.cjs' +[IO.File]::WriteAllText($taskFile,$taskCode,[Text.UTF8Encoding]::new($false)) +npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-shell open about:blank | Out-Null +$taskResult=npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-shell run-code --filename $taskFile +$taskResult | Set-Content -LiteralPath (Join-Path $taskOutput 'shell-result.log') -Encoding UTF8 +$taskResult | Select-Object -First 3 +npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-shell close | Out-Null +if (($taskResult -join "`n") -match '### Error') { exit 1 } diff --git a/scripts/test-ui.ps1 b/scripts/test-ui.ps1 new file mode 100644 index 0000000..6954d54 --- /dev/null +++ b/scripts/test-ui.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference='Stop' +$taskRoot=Split-Path -Parent $PSScriptRoot +$taskOutput=Join-Path $taskRoot 'output\playwright' +New-Item -ItemType Directory -Path $taskOutput -Force | Out-Null +$taskTemplate=Get-Content -LiteralPath (Join-Path $PSScriptRoot 'ui-audit.template.cjs') -Raw -Encoding UTF8 +$taskUrl=([uri](Join-Path $taskRoot 'settings-ui\index.html')).AbsoluteUri +$taskCode=$taskTemplate.Replace('__SETTINGS_URL__',($taskUrl | ConvertTo-Json -Compress)) +$taskFile=Join-Path $taskOutput 'ui-audit.cjs' +[IO.File]::WriteAllText($taskFile,$taskCode,[Text.UTF8Encoding]::new($false)) +npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-ui-audit open about:blank | Out-Null +$taskResult=npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-ui-audit run-code --filename $taskFile +$taskResult | Set-Content -LiteralPath (Join-Path $taskOutput 'ui-audit-result.log') -Encoding UTF8 +$taskResult | Select-Object -First 3 +npx.cmd --yes --package @playwright/cli playwright-cli -s=whatrust-ui-audit close | Out-Null +if (($taskResult -join "`n") -match '### Error') { exit 1 } diff --git a/scripts/ui-audit.template.cjs b/scripts/ui-audit.template.cjs new file mode 100644 index 0000000..e989fa3 --- /dev/null +++ b/scripts/ui-audit.template.cjs @@ -0,0 +1,70 @@ +async(page)=>{ + const checks=[];const check=(name,ok)=>{if(!ok)throw Error(name);checks.push(name);}; + await page.addInitScript(()=>{ + window.__settings={close_to_tray:true,start_minimized:false,autostart:false,hotkey_enabled:true,hotkey:'CmdOrCtrl+Shift+W',notifications:true,notify_when_focused:true,notify_active_chat:true,respect_muted:true,zoom:1,menu_bar_icon:true}; + window.__accounts=[{id:'default',name:'Matheus',order:0,unread:2,is_default:true,detached:false},{id:'acct-1',name:'Empresa '+('NomeMuitoLongo'.repeat(18)),order:1,unread:0,is_default:false,detached:false}]; + window.__events=[];window.__TAURI__={core:{invoke:async(cmd,args)=>{window.__events.push({cmd,args});if(cmd==='list_accounts')return window.__accounts;if(cmd==='get_settings')return window.__settings;if(cmd==='set_settings'){window.__settings=args.settings;return null;}if(cmd==='get_lock_status')return {enabled:false,biometric_available:false};if(cmd==='notification_diagnostics')return {version:'test',accounts:window.__accounts,adapters:{},native:{},recent:[]};if(cmd==='unlock_password')return false;if(cmd==='unlock_biometric')return false;if(cmd==='test_notification')return 'Teste solicitado';return null;}}}; + }); + const url=__SETTINGS_URL__; + await page.goto(url); + const names=['Contas','Notificações','Aparência','Segurança','Inicialização','Atalhos','Diagnóstico']; + for(const scheme of ['light','dark'])for(const [width,height] of [[560,480],[900,700],[1920,1000],[2560,1300]]){ + await page.emulateMedia({colorScheme:scheme});await page.setViewportSize({width,height}); + for(const name of names){await page.getByRole('link',{name,exact:true}).click();check('panel_'+scheme+'_'+width+'_'+name,await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth&&Array.from(document.querySelectorAll('.prefs input,.prefs select,.prefs button')).filter(e=>e.getClientRects().length).every(e=>{const r=e.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1;})));} + await page.getByRole('link',{name:'Contas',exact:true}).click(); + const trigger=page.getByRole('button',{name:'Opções de Matheus',exact:true}); + await trigger.hover(); + await page.waitForTimeout(160); + check('account_menu_hover_'+scheme+'_'+width,await trigger.evaluate(e=>getComputedStyle(e).backgroundColor!=='rgba(0, 0, 0, 0)')); + await trigger.click(); + await page.waitForFunction(()=>document.querySelector('[aria-label="Opções de Matheus"]').getAttribute('aria-expanded')==='true'); + check('account_menu_open_feedback_'+scheme+'_'+width,await trigger.evaluate(e=>getComputedStyle(e).backgroundColor!=='rgba(0, 0, 0, 0)')); + check('menu_anchored_'+scheme+'_'+width,await page.evaluate(()=>{const b=document.querySelector('[aria-label="Opções de Matheus"]').getBoundingClientRect(),m=document.getElementById('account-actions').getBoundingClientRect();const gap=Math.min(Math.abs(m.top-b.bottom-6),Math.abs(b.top-m.bottom-6));return m.left>=12&&m.right<=innerWidth-12+1&&m.top>=12&&m.bottom<=innerHeight-12+1&&Math.abs(m.right-b.right)<=1&&gap<=1;})); + await page.keyboard.press('End');check('menu_keyboard_'+scheme+'_'+width,await page.evaluate(()=>document.activeElement===document.querySelector('#account-actions button:last-child'))); + await page.keyboard.press('Escape');check('menu_focus_restore_'+scheme+'_'+width,await trigger.evaluate(e=>document.activeElement===e)); + } + await page.setViewportSize({width:900,height:600});await page.getByRole('link',{name:'Contas',exact:true}).click();await page.getByRole('button',{name:'Opções de Matheus',exact:true}).click(); + await page.getByRole('menuitem',{name:'Renomear',exact:true}).click(); + check('rename_dialog_focus_not_stolen',await page.getByRole('dialog').locator('input').evaluate(e=>document.activeElement===e)); + await page.keyboard.press('Escape'); + for(const [width,height] of [[560,480],[900,480]]){ + await page.setViewportSize({width,height}); + await page.evaluate(()=>{window.__auditDialog=Dlg.confirm('Conteúdo '+('teste'.repeat(200)),{title:'Confirmação '+('nome'.repeat(40)),okLabel:'Confirmar operação'});}); + check('long_dialog_within_'+width,await page.getByRole('dialog').evaluate(e=>{const r=e.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight&&e.scrollWidth<=e.clientWidth;})); + await page.keyboard.press('Escape'); + } + await page.setViewportSize({width:900,height:600});await page.getByRole('link',{name:'Contas',exact:true}).click();await page.getByRole('button',{name:'Opções de Matheus',exact:true}).click(); + await page.keyboard.press('Escape');await page.locator('.account-more').last().click(); + check('changing_menu_keeps_focus_in_new_menu',await page.evaluate(()=>document.getElementById('account-actions').contains(document.activeElement))); + await page.setViewportSize({width:560,height:480});await page.waitForTimeout(50); + check('menu_follows_resize',await page.evaluate(()=>{const b=document.querySelector('.account-more:last-child').getBoundingClientRect(),m=document.getElementById('account-actions').getBoundingClientRect();return m.left>=12&&m.right<=innerWidth-11&&m.top>=12&&m.bottom<=innerHeight-11;})); + await page.keyboard.press('Escape'); + await page.evaluate(()=>{const f=document.createElement('div');f.id='audit-filler';f.style.height='1200px';document.getElementById('sec-contas').appendChild(f);}); + await page.getByRole('button',{name:'Opções de Matheus',exact:true}).click(); + await page.evaluate(()=>document.querySelector('.prefs').scrollTo({top:900,behavior:'instant'}));await page.waitForFunction(()=>document.getElementById('account-actions').dataset.open==='false'); + check('menu_closes_when_anchor_scrolls_away',await page.evaluate(()=>document.getElementById('account-actions').dataset.open==='false'&&document.querySelector('.prefs').scrollTop>500)); + await page.evaluate(()=>{document.getElementById('audit-filler').remove();document.querySelector('.prefs').scrollTop=0;window.__nativeShow=HTMLElement.prototype.showPopover;HTMLElement.prototype.showPopover=undefined;}); + await page.getByRole('button',{name:'Opções de Matheus',exact:true}).click(); + check('menu_fallback_anchored',await page.evaluate(()=>{const b=document.querySelector('[aria-label="Opções de Matheus"]').getBoundingClientRect(),m=document.getElementById('account-actions').getBoundingClientRect();return Math.abs(m.right-b.right)<=1&&Math.min(Math.abs(m.top-b.bottom-6),Math.abs(b.top-m.bottom-6))<=1;})); + await page.keyboard.press('Escape');await page.evaluate(()=>HTMLElement.prototype.showPopover=window.__nativeShow); + await page.evaluate(()=>{window.__oneButtonDialog=Dlg.alert('Teste de foco');});await page.keyboard.press('Tab'); + check('single_button_dialog_traps_focus',await page.getByRole('dialog').evaluate(e=>e.contains(document.activeElement)));await page.keyboard.press('Escape'); + await page.goto(new URL('lock.html',url).href); + for(const [width,height] of [[560,480],[900,700]]){await page.setViewportSize({width,height});check('lock_controls_'+width,await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth&&Array.from(document.querySelectorAll('button,input')).filter(e=>e.getClientRects().length).every(e=>{const r=e.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight;})));} + for(const scheme of ['light','dark'])for(const [width,height] of [[320,480],[420,540]]){ + await page.emulateMedia({colorScheme:scheme});await page.setViewportSize({width,height}); + check('lock_layout_'+scheme+'_'+width,await page.locator('.lock-card').evaluate(e=>{const r=e.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight;})); + check('lock_vector_icon_'+scheme+'_'+width,await page.locator('.lock-glyph svg').count()===1&&!await page.locator('.lock-glyph').textContent()); + await page.screenshot({path:'output/playwright/lock-'+scheme+'-'+width+'-local5.png'}); + } + await page.getByRole('button',{name:'Desbloquear',exact:true}).click(); + check('empty_password_portuguese',await page.locator('#error').textContent()==='Digite sua senha.'); + await page.getByLabel('Senha',{exact:true}).fill('incorreta');await page.keyboard.press('Enter'); + await page.waitForFunction(()=>document.getElementById('error').textContent.includes('Senha incorreta')); + check('wrong_password_feedback_and_focus',await page.locator('#password').evaluate(e=>!e.value&&document.activeElement===e&&!document.getElementById('unlock').disabled)); + await page.getByRole('link',{name:'Esqueceu a senha? Redefinir…'}).click(); + check('reset_confirmation_portuguese',await page.getByRole('dialog').textContent().then(t=>t.includes('Redefinir o whatRust?')&&t.includes('todas as contas')&&t.includes('Cancelar'))); + await page.getByRole('button',{name:'Cancelar',exact:true}).click(); + check('cancel_reset_has_no_side_effect',await page.evaluate(()=>!window.__events.some(e=>e.cmd==='reset_app_lock'))); + return {passed:true,count:checks.length,checks}; +} diff --git a/settings-ui/dialog.css b/settings-ui/dialog.css index b941f22..46653a6 100644 --- a/settings-ui/dialog.css +++ b/settings-ui/dialog.css @@ -8,14 +8,14 @@ } .dlg-backdrop[hidden] { display: none; } .dlg { - width: 100%; max-width: 340px; padding: 20px; + box-sizing:border-box;width:100%;max-width:340px;max-height:calc(100vh - 48px);overflow-y:auto;overflow-wrap:anywhere;padding:20px; border-radius: 12px; box-shadow: 0 14px 44px #0007; - background: #fff; color: #111b21; + background: var(--card,#fff); color: var(--fg,#0a0a0a); font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 14px; text-align: left; } @media (prefers-color-scheme: dark) { - .dlg { background: #202c33; color: #e9edef; } + .dlg { background: var(--card,#232626); color: var(--fg,#fafafa); } } .dlg h2 { font-size: 15px; font-weight: 600; margin: 0 0 8px; } .dlg-message { margin: 0; line-height: 1.45; white-space: pre-line; } @@ -27,11 +27,11 @@ .dlg-input:focus { outline: 2px solid #25d36688; outline-offset: 1px; } .dlg-error { color: #f15c6d; font-size: 13px; margin: 8px 0 0; } .dlg-error[hidden] { display: none; } -.dlg-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } +.dlg-actions { display:flex;flex-wrap:wrap; justify-content: flex-end; gap: 8px; margin-top: 18px; } .dlg-actions button { padding: 8px 16px; font-size: 14px; font-weight: 600; - border: 0; border-radius: 8px; cursor: pointer; - background: #25d366; color: #042f1a; + border: 0; border-radius: 20px; cursor: pointer; + background: var(--green,#21c063); color: var(--on-green,#0a0a0a); } .dlg-actions button.dlg-secondary { background: transparent; color: inherit; border: 1px solid #8886; } .dlg-actions button.dlg-danger { background: #e5484d; color: #fff; } diff --git a/settings-ui/dialog.js b/settings-ui/dialog.js index 60bdc0a..6fd2fc7 100644 --- a/settings-ui/dialog.js +++ b/settings-ui/dialog.js @@ -44,6 +44,7 @@ // Keep focus inside the dialog: it is modal in intent, and a Tab that // escapes it lands on controls the dialog is covering. var f = open.focusable; + if (f.length === 1) { e.preventDefault(); f[0].focus(); return; } if (f.length < 2) return; var first = f[0]; var last = f[f.length - 1]; @@ -69,6 +70,8 @@ if (opts.title) { var h = el("h2", null, opts.title); + h.id = "dlg-title"; + box.setAttribute("aria-labelledby", "dlg-title"); box.appendChild(h); } if (message) box.appendChild(el("p", "dlg-message", message)); @@ -90,7 +93,7 @@ var actions = el("div", "dlg-actions"); var cancel = null; if (kind !== "alert") { - cancel = el("button", "dlg-secondary", opts.cancelLabel || "Cancel"); + cancel = el("button", "dlg-secondary", opts.cancelLabel || "Cancelar"); cancel.type = "button"; actions.appendChild(cancel); } @@ -108,7 +111,7 @@ // Never trim a password: leading or trailing whitespace is part of it. var v = opts.password ? input.value : input.value.trim(); if (opts.required !== false && !v) { - error.textContent = opts.requiredMessage || "This cannot be empty."; + error.textContent = opts.requiredMessage || "Este campo não pode ficar vazio."; error.hidden = false; input.focus(); return; diff --git a/settings-ui/i18n.js b/settings-ui/i18n.js new file mode 100644 index 0000000..27088c4 --- /dev/null +++ b/settings-ui/i18n.js @@ -0,0 +1,299 @@ +"use strict"; +// Interface language for the local pages (settings, lock screen, shell). +// +// Portuguese (Brazil) is the source language: the HTML and scripts are written +// in Portuguese and EN maps each Portuguese string to its English text. With +// English selected, text nodes and a few attributes whose (whitespace-collapsed) +// text is a key are rewritten, including nodes added later by scripts, so code +// that does `el.textContent = "Salvar"` needs no change. Strings with variable +// parts go through t("Abrir {0}", [name]). A string without an entry is shown +// as written. The start language is injected by Rust as window.__WR_LANG; the +// settings page switches it live with I18n.setLang(). +(function (root) { + var EN = { + // titles + "whatRust — Configurações": "whatRust — Settings", + "whatRust — Bloqueado": "whatRust — Locked", + // header and navigation + "Configurações": "Settings", + "Voltar ao WhatsApp": "Back to WhatsApp", + "Contas": "Accounts", + "Notificações": "Notifications", + "Aparência": "Appearance", + "Segurança": "Security", + "Inicialização": "Startup", + "Atalhos": "Shortcuts", + "Diagnóstico": "Diagnostics", + // accounts + "Conta ao iniciar": "Account on startup", + "Nome da nova conta": "New account name", + "Adicionar": "Add", + "Múltiplas contas exigem macOS 14 ou superior.": "Multiple accounts require macOS 14 or later.", + "Aguarde…": "Please wait…", + "Ações de {0}": "Actions for {0}", + "Abrir conta": "Open account", + "Reintegrar à janela": "Return to the main window", + "Abrir em janela separada": "Open in a separate window", + "Usar ao iniciar": "Use on startup", + "Renomear": "Rename", + "Renomear conta": "Rename account", + "Informe um nome.": "Enter a name.", + "Mover para cima": "Move up", + "Mover para baixo": "Move down", + "Remover conta": "Remove account", + "A sessão local desta conta será apagada. Para usá-la novamente, você precisará ler o QR code.": "This account's local session will be deleted. To use it again, you will need to scan the QR code.", + "Remover \"{0}\"?": "Remove \"{0}\"?", + "Remover": "Remove", + "Abrir {0}": "Open {0}", + "Em janela separada": "In a separate window", + "{0} conversas não lidas": "{0} unread chats", + "Opções de {0}": "Options for {0}", + "Não foi possível carregar as contas: {0}": "Could not load the accounts: {0}", + // notifications + "Receber notificações": "Receive notifications", + "Ativa os avisos de mensagens do whatRust.": "Turns on whatRust message alerts.", + "Avisar com o aplicativo aberto": "Notify while the app is open", + "Receber avisos mesmo com a janela em primeiro plano.": "Get alerts even when the window is in the foreground.", + "Avisar na conversa que estou lendo": "Notify in the chat I am reading", + "Também avisar mensagens novas na conversa aberta.": "Also alert for new messages in the open chat.", + "Respeitar conversas silenciadas": "Respect muted chats", + "Quando o silenciamento não puder ser confirmado, o detector adicional permanece silencioso.": "When muting cannot be confirmed, the additional detector stays silent.", + "O detector adicional só usa mensagens novas identificáveis. Conversas silenciadas, histórico e estados desconhecidos não geram avisos adicionais.": "The additional detector only uses identifiable new messages. Muted chats, history and unknown states do not produce additional alerts.", + // behavior + "Comportamento": "Behavior", + "Continuar em segundo plano ao fechar": "Keep running in the background when closed", + "Mantém suas contas conectadas ao fechar a janela": "Keeps your accounts connected when the window is closed", + "Iniciar minimizado": "Start minimized", + "Iniciar sem abrir a janela principal": "Start without opening the main window", + "Iniciar com o sistema": "Start with the system", + "Mostrar ícone na barra de menus": "Show icon in the menu bar", + "Quando desativado, clique no ícone do Dock para abrir o whatRust": "When off, click the Dock icon to open whatRust", + "Iniciar minimizado com o Windows": "Start minimized with Windows", + "Vale apenas ao entrar no Windows. Ao abrir pelo atalho, a janela sempre aparece.": "Applies only at Windows sign-in. When opened from a shortcut, the window always appears.", + // appearance and language + "Tamanho da interface": "Interface size", + "Ajusta o tamanho do WhatsApp sem alterar as outras contas.": "Adjusts the size of WhatsApp without changing the other accounts.", + "Muito pequeno": "Very small", + "Pequeno": "Small", + "Padrão": "Default", + "Grande": "Large", + "Somente na barra de menus": "Menu bar only", + "Sem ícone no Dock nem janela principal — todas as contas continuam conectadas para notificações e cada uma abre como painel pela bandeja": "No Dock icon or main window — every account stays connected for notifications, and each opens as its own panel from the tray", + "O ícone da bandeja é o único ponto de entrada no modo somente barra de menus, por isso permanece visível": "The tray icon is the only entry point in menu-bar-only mode, so it stays visible", + "Requer macOS 14 (Sonoma) ou superior — este Mac não consegue manter um painel oculto conectado": "Requires macOS 14 (Sonoma) or later — this Mac cannot keep a hidden panel connected", + "Idioma": "Language", + "Idioma da interface do whatRust.": "Language of the whatRust interface.", + // shortcuts + "Atalho para mostrar ou ocultar": "Shortcut to show or hide", + "Uma combinação de teclas para mostrar ou ocultar o aplicativo": "A key combination to show or hide the app", + "Atalho": "Shortcut", + "Gravar": "Record", + "No Linux/Wayland, configure": "On Linux/Wayland, set", + "nos atalhos do sistema.": "in the system shortcuts.", + "Pressione as teclas… (Esc cancela)": "Press the keys… (Esc cancels)", + "Tecla não suportada. Tente outra.": "Unsupported key. Try another.", + "Inclua Ctrl, Alt ou Shift.": "Include Ctrl, Alt or Shift.", + // security + "Senha": "Password", + "Pelo menos 6 caracteres": "At least 6 characters", + "Pelo menos 6 caracteres.": "At least 6 characters.", + "Confirmar": "Confirm", + "Ativar bloqueio": "Turn on lock", + "Usar biometria": "Use biometrics", + "Usar {0}": "Use {0}", + "Bloquear ao iniciar": "Lock on launch", + "Bloquear ao ocultar": "Lock when hidden", + "Bloquear por inatividade": "Lock when idle", + "Minutos; 0 desativa": "Minutes; 0 turns it off", + "Bloquear agora": "Lock now", + "Alterar senha…": "Change password…", + "Desativar…": "Turn off…", + "O bloqueio protege o acesso às janelas do whatRust. Ele": "The lock protects access to the whatRust windows. It", + "não": "does not", + "criptografa os dados no disco.": "encrypt the data on disk.", + "Não foi possível ativar o bloqueio": "Could not turn on the lock", + "Não foi possível bloquear": "Could not lock", + "Senha atual": "Current password", + "Continuar": "Continue", + "Informe sua senha atual.": "Enter your current password.", + "Nova senha": "New password", + "Alterar": "Change", + "Informe uma nova senha.": "Enter a new password.", + "Sua senha de bloqueio foi alterada.": "Your lock password was changed.", + "Senha alterada": "Password changed", + "Não foi possível alterar a senha": "Could not change the password", + "Informe sua senha atual para desativar o bloqueio.": "Enter your current password to turn off the lock.", + "Desativar bloqueio": "Turn off lock", + "Desativar": "Turn off", + "Não foi possível desativar o bloqueio": "Could not turn off the lock", + "Não foi possível alterar a biometria": "Could not change biometrics", + "Não foi possível salvar": "Could not save", + // diagnostics + "Diagnóstico de notificações": "Notification diagnostics", + "Verificar avisos do Windows": "Check Windows alerts", + "O teste confirma o envio; confira se o Windows exibe o banner.": "The test confirms sending; check that Windows shows the banner.", + "Testar": "Test", + "Permissões e Não perturbe": "Permissions and Do Not Disturb", + "Abrir no Windows": "Open in Windows", + "Atividade desta sessão": "Activity this session", + "Somente horário, conta e resultado. Não armazena o texto das mensagens.": "Time, account and result only. Message text is not stored.", + "Aguardando envio": "Waiting to send", + "Enviada ao Windows; exibição não confirmada": "Sent to Windows; display not confirmed", + "Falha no envio": "Send failed", + "Não enviada": "Not sent", + "Aviso aberto": "Alert opened", + "Aviso encerrado": "Alert dismissed", + "Aplicativo bloqueado": "App locked", + "Notificações desativadas": "Notifications off", + "Avisos em primeiro plano desativados": "Foreground alerts off", + "Conversa ativa": "Active chat", + "Conversa silenciada": "Muted chat", + "Silenciamento não confirmado": "Mute state not confirmed", + "Windows recusou o envio": "Windows rejected the send", + "Captura nativa ativa": "Native capture active", + "Captura nativa indisponível; ponte alternativa": "Native capture unavailable; fallback bridge", + "Controles integrados": "Integrated controls", + "Acesso pelo menu da janela": "Access from the window menu", + "Detector adicional disponível quando houver sinais confiáveis": "Additional detector available when reliable signals exist", + "Aguardando conversa compatível": "Waiting for a compatible chat", + "Nenhum aviso registrado": "No alerts recorded", + "Os próximos eventos aparecerão aqui durante esta execução.": "Upcoming events will appear here during this run.", + // footer and panel + "Salvar": "Save", + "Alterações salvas ✓": "Changes saved ✓", + "Alterações salvas": "Changes saved", + "Salvo com observação: {0}": "Saved with a note: {0}", + "Voltar sem salvar?": "Go back without saving?", + "As alterações ainda não foram salvas.": "Your changes have not been saved.", + "Voltar": "Back", + // shell + "Controles do whatRust": "whatRust controls", + "Configurações do whatRust": "whatRust settings", + "Trocar conta do WhatsApp": "Switch WhatsApp account", + "Escolha uma conta para continuar": "Choose an account to continue", + "Use o seletor acima para abrir um WhatsApp ou as configurações para gerenciar suas contas.": "Use the selector above to open a WhatsApp, or settings to manage your accounts.", + // lock screen + "Digite sua senha para acessar suas contas do WhatsApp.": "Enter your password to access your WhatsApp accounts.", + "Digite sua senha": "Enter your password", + "Digite sua senha.": "Enter your password.", + "Desbloquear": "Unlock", + "Esqueceu a senha? Redefinir…": "Forgot your password? Reset…", + "Senha incorreta. Tente novamente.": "Wrong password. Try again.", + "Não foi possível desbloquear. Tente novamente.": "Could not unlock. Try again.", + "Não foi possível confirmar a biometria. Digite sua senha.": "Could not confirm biometrics. Enter your password.", + "Muitas tentativas. Tente de novo em {0}s.": "Too many attempts. Try again in {0}s.", + "Isso desconecta todas as contas deste aplicativo e remove o bloqueio. Você precisará vincular o WhatsApp novamente pelo QR Code. Suas conversas continuam no celular.": "This signs out every account in this app and removes the lock. You will need to link WhatsApp again with the QR code. Your chats stay on your phone.", + "Redefinir o whatRust?": "Reset whatRust?", + "Redefinir": "Reset", + "Não foi possível redefinir o aplicativo. Tente novamente.": "Could not reset the app. Try again.", + // dialogs + "Cancelar": "Cancel", + "Este campo não pode ficar vazio.": "This field cannot be empty." + }; + + var lang = root.__WR_LANG === "en" ? "en" : "pt-BR"; + + function norm(s) { return String(s).replace(/\s+/g, " ").trim(); } + function has(k) { return Object.prototype.hasOwnProperty.call(EN, k); } + function fill(text, args) { + if (!args) return text; + for (var i = 0; i < args.length; i++) text = text.split("{" + i + "}").join(String(args[i])); + return text; + } + // Translate a source string (exact key) and fill {0}, {1}... + function t(s, args) { + var key = norm(s); + var out = lang === "en" && has(key) ? EN[key] : s; + return fill(out, args); + } + + var api = { + EN: EN, + norm: norm, + t: t, + lang: function () { return lang; }, + locale: function () { return lang === "en" ? "en-US" : "pt-BR"; }, + setLang: function () {}, + }; + root.I18n = api; + root.t = t; + if (typeof module !== "undefined" && module.exports) module.exports = api; + if (typeof document === "undefined" || !document.documentElement) return; + + // ---- DOM translation ------------------------------------------------- + var ATTRS = ["aria-label", "title", "placeholder"]; + var text = new WeakMap(); // text node -> { src, out } + var attrs = new WeakMap(); // element -> { name: { src, out } } + var SKIP = { SCRIPT: 1, STYLE: 1, CODE: 1 }; + + function translateText(node) { + var rec = text.get(node); + if (!rec || node.nodeValue !== rec.out) rec = { src: node.nodeValue, out: node.nodeValue }; + var key = norm(rec.src), out = rec.src; + if (lang === "en" && has(key)) { + var m = /^(\s*)[\s\S]*?(\s*)$/.exec(rec.src); + out = m[1] + EN[key] + m[2]; + } + rec.out = out; + text.set(node, rec); + if (node.nodeValue !== out) node.nodeValue = out; + } + + function translateAttrs(el) { + var recs = attrs.get(el) || {}; + ATTRS.forEach(function (name) { + if (!el.hasAttribute || !el.hasAttribute(name)) return; + var value = el.getAttribute(name), rec = recs[name]; + if (!rec || value !== rec.out) rec = { src: value, out: value }; + var key = norm(rec.src), out = lang === "en" && has(key) ? EN[key] : rec.src; + rec.out = out; + recs[name] = rec; + if (value !== out) el.setAttribute(name, out); + }); + attrs.set(el, recs); + } + + function walk(node) { + if (node.nodeType === 3) { translateText(node); return; } + if (node.nodeType !== 1 || SKIP[node.nodeName]) return; + translateAttrs(node); + for (var c = node.firstChild; c; c = c.nextSibling) walk(c); + } + + var observer; + function translateAll() { + if (observer) observer.disconnect(); + walk(document.documentElement); + observe(); + } + + function observe() { + if (!observer) { + observer = new MutationObserver(function (records) { + observer.disconnect(); + records.forEach(function (r) { + if (r.type === "characterData") { translateText(r.target); } + else if (r.type === "attributes") { translateAttrs(r.target); } + else { r.addedNodes.forEach(function (n) { walk(n); }); } + }); + observe(); + }); + } + observer.observe(document.documentElement, { + subtree: true, childList: true, characterData: true, + attributes: true, attributeFilter: ATTRS, + }); + } + + api.setLang = function (next) { + lang = next === "en" ? "en" : "pt-BR"; + root.__WR_LANG = lang; + document.documentElement.lang = lang; + translateAll(); + root.dispatchEvent(new Event("i18n")); + }; + + function start() { document.documentElement.lang = lang; translateAll(); } + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); + else start(); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/settings-ui/i18n.test.mjs b/settings-ui/i18n.test.mjs new file mode 100644 index 0000000..f98488a --- /dev/null +++ b/settings-ui/i18n.test.mjs @@ -0,0 +1,92 @@ +// Run: node settings-ui/i18n.test.mjs +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import assert from "node:assert/strict"; + +const require = createRequire(import.meta.url); +const I18n = require("./i18n.js"); +const { EN, norm, t } = I18n; +const dir = new URL(".", import.meta.url); +const read = (f) => readFileSync(new URL(f, dir), "utf8"); + +let failures = 0; +function check(name, fn) { + try { fn(); console.log(" ok " + name); } + catch (e) { failures++; console.log(" FAIL " + name + "\n " + e.message.split("\n").join("\n ")); } +} + +// Text that is the same in both languages, or a proper name. +const SAME = new Set(["whatRust", "WhatsApp", "OK", "Português (Brasil)", "English", "⇄", "⋮", "⚙", + "CmdOrCtrl+Shift+W", "whatrust --toggle", "Esc"]); + +function visibleTexts(html) { + html = html.replace(//g, "").replace(//g, "") + .replace(//g, "").replace(/[\s\S]*?<\/code>/g, ""); + const out = []; + for (const m of html.matchAll(/>([^<>]+) { + assert.equal(t("Salvar"), "Salvar"); // default language is pt-BR + assert.equal(t("Abrir {0}", ["Ana"]), "Abrir Ana"); +}); + +check("placeholders match between Portuguese and English", () => { + for (const [pt, en] of Object.entries(EN)) { + for (let i = 0; i < 4; i++) { + assert.equal(pt.includes(`{${i}}`), en.includes(`{${i}}`), `placeholder {${i}} in "${pt}"`); + } + } +}); + +check("every static string in the HTML pages has an English entry", () => { + const missing = []; + for (const f of ["index.html", "lock.html", "shell.html"]) { + for (const s of visibleTexts(read(f))) { + if (!(s in EN) && !SAME.has(s)) missing.push(`${f}: ${s}`); + } + } + assert.deepEqual(missing, []); +}); + +check("every t(\"…\") literal in the scripts has an English entry", () => { + const missing = []; + for (const f of ["main.js", "panel.js", "lock.js", "shell.js", "dialog.js", "hotkey.js"]) { + for (const m of read(f).matchAll(/\bt\(\s*(["'])((?:\\.|(?!\1).)*)\1/g)) { + const s = norm(m[2].replace(/\\(.)/g, "$1")); + if (!(s in EN)) missing.push(`${f}: ${s}`); + } + } + assert.deepEqual(missing, []); +}); + +check("strings assigned to the page by the scripts have an English entry", () => { + // textContent = "…" / Dlg.*("…", {title: "…", okLabel: "…"}) literals. + const missing = []; + const re = /(?:textContent\s*=\s*|title\s*:\s*|okLabel\s*:\s*|requiredMessage\s*:\s*|Dlg\.(?:alert|confirm|prompt)\(\s*)(["'])((?:\\.|(?!\1).)*[A-Za-zÀ-ú](?:\\.|(?!\1).)*)\1/g; + for (const f of ["main.js", "lock.js", "panel.js"]) { + for (const m of read(f).matchAll(re)) { + const s = norm(m[2].replace(/\\(.)/g, "$1")); + if (!/[À-ú]|\s/.test(s) && !(s in EN)) { + // single-word ASCII strings (ids, "Mac") are not UI text unless in EN already + continue; + } + if (!(s in EN) && !SAME.has(s)) missing.push(`${f}: ${s}`); + } + } + assert.deepEqual(missing, []); +}); + +check("both language options are offered", () => { + const html = read("index.html"); + assert.match(html, /