Skip to content

Commit d5352bc

Browse files
Fix Clear chat (#6) and sign APK in CI (#9)
Squash-merges #10. Closes #6 and #9. Pre-PR codex review surfaced 4 findings, all addressed before push. Post-PR codex re-validation surfaced 1 narrower finding (identity- switch state leak in the per-thread menu), addressed in a follow-up commit. Final codex pass: CLEAN. CI: backend + frontend both pass.
1 parent 5d315ac commit d5352bc

2 files changed

Lines changed: 131 additions & 23 deletions

File tree

.github/workflows/release-android.yml

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -144,18 +144,65 @@ jobs:
144144
- name: Build APK (release, all ABIs)
145145
run: cargo tauri android build --apk
146146

147-
- name: Locate built APKs
147+
- name: Sign APKs with Android debug keystore
148+
# `cargo tauri android build --apk` produces unsigned release
149+
# APKs, which Android refuses to install
150+
# (INSTALL_PARSE_FAILED_NO_CERTIFICATES). Sign with the debug
151+
# keystore so sideloads at least install. Same alpha policy as
152+
# the unsigned macOS / Windows builds: install with friction,
153+
# not Play Store-trusted. Promote to a real signing keystore
154+
# via secrets when one is wired up.
155+
shell: bash
156+
run: |
157+
set -euo pipefail
158+
DEBUG_KS="$RUNNER_TEMP/debug.keystore"
159+
# Generate the debug keystore on the fly so we don't depend on
160+
# a runner-provided ~/.android setup. Credentials are the
161+
# well-known public Android debug values.
162+
keytool -genkeypair -v -keystore "$DEBUG_KS" -storepass android \
163+
-alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 \
164+
-validity 10000 -dname "CN=Android Debug,O=Android,C=US"
165+
166+
# apksigner ships in build-tools; setup-android pins one but
167+
# doesn't put it on PATH, so resolve it explicitly. Guard
168+
# the build-tools dir so a `find` over a missing path can't
169+
# blow up the script before our targeted error fires.
170+
if [ ! -d "$ANDROID_HOME/build-tools" ]; then
171+
echo "::error::ANDROID_HOME/build-tools is missing — setup-android did not provision build-tools"
172+
exit 1
173+
fi
174+
APKSIGNER="$(find "$ANDROID_HOME/build-tools" -name apksigner -type f 2>/dev/null | sort -V | tail -n1 || true)"
175+
if [ -z "$APKSIGNER" ] || [ ! -x "$APKSIGNER" ]; then
176+
echo "::error::apksigner not found in $ANDROID_HOME/build-tools"
177+
exit 1
178+
fi
179+
echo "Using $APKSIGNER"
180+
181+
shopt -s globstar nullglob
182+
for unsigned in src-tauri/gen/android/app/build/outputs/apk/**/release/*-unsigned.apk; do
183+
signed="${unsigned%-unsigned.apk}-debug-signed.apk"
184+
"$APKSIGNER" sign --ks "$DEBUG_KS" --ks-pass pass:android \
185+
--key-pass pass:android --ks-key-alias androiddebugkey \
186+
--out "$signed" "$unsigned"
187+
"$APKSIGNER" verify "$signed"
188+
echo "Signed: $signed"
189+
# Drop the unsigned source so the upload step can't pick it
190+
# up by mistake.
191+
rm -f "$unsigned"
192+
done
193+
194+
- name: Locate signed APKs
148195
id: apk
149196
shell: bash
150197
run: |
151198
set -euo pipefail
152199
shopt -s globstar nullglob
153-
# Tauri 2 emits per-ABI APKs under
154-
# src-tauri/gen/android/app/build/outputs/apk/<abi>/release/
155-
# We capture the universal one if present, else all per-ABI.
156-
mapfile -t found < <(ls src-tauri/gen/android/app/build/outputs/apk/**/release/*.apk 2>/dev/null || true)
200+
# Expand directly into an array — `nullglob` makes a
201+
# non-matching glob expand to zero args, which would have
202+
# made `ls`-piped-into-array list the cwd and silently pass.
203+
found=(src-tauri/gen/android/app/build/outputs/apk/**/release/*-debug-signed.apk)
157204
if [ "${#found[@]}" -eq 0 ]; then
158-
echo "::error::no APKs produced"
205+
echo "::error::no signed APKs produced"
159206
ls -R src-tauri/gen/android/app/build/outputs || true
160207
exit 1
161208
fi
@@ -170,4 +217,4 @@ jobs:
170217
tag_name: ${{ inputs.tag }}
171218
fail_on_unmatched_files: false
172219
files: |
173-
src-tauri/gen/android/app/build/outputs/apk/**/release/*.apk
220+
src-tauri/gen/android/app/build/outputs/apk/**/release/*-debug-signed.apk

src/routes/+page.svelte

Lines changed: 77 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@
4848
let emojiOpen = $state<boolean>(false);
4949
let threadMenuOpen = $state<boolean>(false);
5050
let clearing = $state<boolean>(false);
51+
// Inline confirm step. The Tauri webview silently returned `false`
52+
// from `window.confirm`, so the click chain never reached the actual
53+
// clear path. Use UI state for a two-click flow that works in every
54+
// webview.
55+
let confirmingClear = $state<boolean>(false);
56+
let confirmClearBtn: HTMLButtonElement | undefined = $state();
5157
5258
// Fast-poll cadence while a thread is focused. SDK pulls are global,
5359
// so this just shortens latency for whichever chat the user is
@@ -70,18 +76,28 @@
7076
7177
function toggleThreadMenu() {
7278
threadMenuOpen = !threadMenuOpen;
79+
// Reset the confirm step whenever the menu opens, so it always
80+
// starts at "Clear chat" not the half-armed "Confirm" view.
81+
if (threadMenuOpen) confirmingClear = false;
7382
}
7483
75-
async function clearChat() {
84+
function requestClearChat() {
85+
confirmingClear = true;
86+
// Move focus onto the destructive button so the next Enter / Space
87+
// confirms (or Escape via the menu cancels). Without this, focus
88+
// stays on the now-removed "Clear chat" button and falls back to
89+
// the document.
90+
tick().then(() => confirmClearBtn?.focus());
91+
}
92+
93+
function cancelClearChat() {
94+
confirmingClear = false;
95+
}
96+
97+
async function confirmClearChat() {
98+
confirmingClear = false;
7699
threadMenuOpen = false;
77100
if (!activeConversation) return;
78-
const label = activeConversation.label;
79-
if (
80-
typeof window !== "undefined" &&
81-
!window.confirm(`Clear all messages with ${label}?`)
82-
) {
83-
return;
84-
}
85101
clearing = true;
86102
try {
87103
const incomingIds = activeConversation.messages
@@ -214,6 +230,11 @@
214230
activeKey = key;
215231
replyTo = null;
216232
sendError = "";
233+
// Don't carry per-thread overflow state across a switch — a
234+
// half-armed "Yes, clear" from chat A would otherwise act on
235+
// chat B once it became active.
236+
threadMenuOpen = false;
237+
confirmingClear = false;
217238
await tick();
218239
if (composerEl) composerEl.focus();
219240
// Mark-read is handled by the $effect below so messages that
@@ -234,9 +255,22 @@
234255
}
235256
});
236257
258+
// Identity switch / lock from anywhere (header pill, /identities
259+
// page) drops the transient per-thread menu state. Without this, a
260+
// half-armed "Yes, clear" arming under identity A could carry into
261+
// identity B and act on B's data if the new identity happens to
262+
// share the same activeKey (same contact username).
263+
$effect(() => {
264+
void $activeIdentity;
265+
threadMenuOpen = false;
266+
confirmingClear = false;
267+
});
268+
237269
function closeConversation() {
238270
activeKey = null;
239271
replyTo = null;
272+
threadMenuOpen = false;
273+
confirmingClear = false;
240274
}
241275
242276
async function send() {
@@ -535,14 +569,35 @@
535569
>⋯</button>
536570
{#if threadMenuOpen}
537571
<div class="thread-menu" role="menu">
538-
<button
539-
type="button"
540-
class="thread-menu-item danger-text"
541-
onclick={clearChat}
542-
disabled={clearing || activeConversation.messages.length === 0}
543-
>
544-
{clearing ? "Clearing…" : "Clear chat"}
545-
</button>
572+
{#if !confirmingClear}
573+
<button
574+
type="button"
575+
class="thread-menu-item danger-text"
576+
onclick={requestClearChat}
577+
disabled={clearing || activeConversation.messages.length === 0}
578+
>
579+
{clearing ? "Clearing…" : "Clear chat"}
580+
</button>
581+
{:else}
582+
<p class="thread-menu-prompt">Clear all messages with this contact?</p>
583+
<button
584+
bind:this={confirmClearBtn}
585+
type="button"
586+
class="thread-menu-item danger-text"
587+
onclick={confirmClearChat}
588+
disabled={clearing}
589+
>
590+
{clearing ? "Clearing…" : "Yes, clear"}
591+
</button>
592+
<button
593+
type="button"
594+
class="thread-menu-item"
595+
onclick={cancelClearChat}
596+
disabled={clearing}
597+
>
598+
Cancel
599+
</button>
600+
{/if}
546601
</div>
547602
{/if}
548603
</div>
@@ -900,6 +955,12 @@
900955
padding: 0.5em 0.7em;
901956
border-radius: 6px;
902957
}
958+
.thread-menu-prompt {
959+
margin: 0 0 0.4rem;
960+
padding: 0.4em 0.7em 0;
961+
font-size: 12px;
962+
color: var(--muted-strong);
963+
}
903964
.thread-menu-item:hover:not(:disabled) {
904965
background: var(--accent-softer);
905966
border-color: var(--accent);

0 commit comments

Comments
 (0)