feat(keyboard): harden clipboard command and add What's New sheet

Stabilize clipboard long-press prepare/resume across paste alerts and cold start, add an in-app release-notes sheet with remote bilingual HTML, localize typing input settings, and bump build to 55.
This commit is contained in:
Rocky
2026-08-08 00:20:42 +08:00
parent f197b68573
commit 9da32b81e9
45 changed files with 3670 additions and 422 deletions
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env bash
# Cold-start stress on iOS Simulator.
#
# Usage:
# ./Scripts/cold-start-stress.sh [COUNT=100] [DEVICE_NAME=iPhone 17]
#
# Method: terminate → simctl launch (host cold start that auto-arms PiP).
# Keyboard URL wake (osgkeyboard://startflow) is optional once scheme is approved.
#
# Limitations (documented in summary):
# - Simulator VideoCall PiP is typically `unsupported` — not a device signal.
# - Keyboard extension process (KVC.init) is not spawned here.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
COUNT="${1:-100}"
DEVICE_NAME="${2:-iPhone 17}"
BUNDLE="com.osgkeyboard.ios"
OUT_DIR="${ROOT}/.tmp/cold-start-stress-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$OUT_DIR"
REPORT="$OUT_DIR/report.jsonl"
SUMMARY="$OUT_DIR/summary.txt"
LOG_FILE="$OUT_DIR/unified.log"
echo "==> Out: $OUT_DIR"
echo "==> Count: $COUNT Device: '$DEVICE_NAME'"
# Exact device name match (avoid "iPhone 17" → "iPhone 17 Pro")
UDID="$(xcrun simctl list devices available | awk -F '[()]' -v n="$DEVICE_NAME" '
{
line=$0
# strip leading spaces
sub(/^[[:space:]]+/, "", line)
name=line
sub(/ \(.*/, "", name)
if (name == n && line ~ /Booted/) { print $2; exit }
}
')"
if [[ -z "$UDID" ]]; then
UDID="$(xcrun simctl list devices available | awk -F '[()]' -v n="$DEVICE_NAME" '
{
line=$0
sub(/^[[:space:]]+/, "", line)
name=line
sub(/ \(.*/, "", name)
if (name == n && line !~ /unavailable/) { print $2; exit }
}
')"
fi
if [[ -z "$UDID" ]]; then
echo "error: exact device '$DEVICE_NAME' not found" >&2
xcrun simctl list devices available | grep -i iphone | head -40 >&2 || true
exit 1
fi
echo "==> UDID: $UDID"
echo "==> Booting simulator"
xcrun simctl boot "$UDID" 2>/dev/null || true
xcrun simctl bootstatus "$UDID" -b
# Prefer signed cold-start DerivedData build, else any existing Debug-iphonesimulator app
APP=""
if [[ -d "$ROOT/.derivedData-cold-start/Build/Products/Debug-iphonesimulator/OSGKeyboard.app" ]]; then
APP="$ROOT/.derivedData-cold-start/Build/Products/Debug-iphonesimulator/OSGKeyboard.app"
else
APP="$(ls -d "$HOME"/Library/Developer/Xcode/DerivedData/OSGKeyboard-*/Build/Products/Debug-iphonesimulator/OSGKeyboard.app 2>/dev/null | head -1 || true)"
fi
if [[ -z "$APP" || ! -d "$APP" ]]; then
echo "error: no built OSGKeyboard.app — build for simulator first" >&2
exit 1
fi
echo "==> Installing $APP"
xcrun simctl install "$UDID" "$APP" >/dev/null
xcrun simctl privacy "$UDID" grant microphone "$BUNDLE" 2>/dev/null || true
seed_onboarding() {
echo "==> Seeding onboarding (App Group + Keychain path)"
xcrun simctl launch "$UDID" "$BUNDLE" >/dev/null || true
sleep 2
xcrun simctl terminate "$UDID" "$BUNDLE" 2>/dev/null || true
sleep 0.5
local group_container prefs
group_container="$(xcrun simctl get_app_container "$UDID" "$BUNDLE" group.com.osgkeyboard.shared 2>/dev/null || true)"
if [[ -z "$group_container" || ! -d "$group_container" ]]; then
echo "warning: App Group container missing" >&2
return 0
fi
prefs="$group_container/Library/Preferences/group.com.osgkeyboard.shared.plist"
mkdir -p "$(dirname "$prefs")"
if [[ -f "$prefs" ]]; then
/usr/libexec/PlistBuddy -c "Set :config.hasCompletedOnboarding true" "$prefs" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :config.hasCompletedOnboarding bool true" "$prefs" 2>/dev/null || true
else
/usr/libexec/PlistBuddy -c "Add :config.hasCompletedOnboarding bool true" "$prefs" 2>/dev/null || true
fi
echo "==> Seeded hasCompletedOnboarding=$(/usr/libexec/PlistBuddy -c 'Print :config.hasCompletedOnboarding' "$prefs" 2>/dev/null || echo missing)"
# One launch so ProviderConfig mirrors App Group → Keychain
xcrun simctl launch "$UDID" "$BUNDLE" >/dev/null || true
sleep 2
xcrun simctl terminate "$UDID" "$BUNDLE" 2>/dev/null || true
sleep 0.4
}
seed_onboarding
: >"$LOG_FILE"
xcrun simctl spawn "$UDID" log stream \
--style compact \
--level debug \
--predicate 'process == "OSGKeyboard"' \
>"$LOG_FILE" 2>&1 &
LOG_PID=$!
trap 'kill $LOG_PID 2>/dev/null || true' EXIT
sleep 1
logfile_window() {
local offset="$1"
local start=$((offset + 1))
tail -c +"$start" "$LOG_FILE" 2>/dev/null || true
}
# Returns: host|pip
# host: success|fail|unknown
# pip: success|unsupported|fail|permissions|onboarding|unknown
logfile_classify() {
local chunk="$1"
local host="unknown" pip="unknown"
# Use here-string (not pipe) to avoid SIGPIPE under `set -o pipefail`.
if grep -Eq "OSGKeyboardApp\.init" <<<"$chunk"; then
host="success"
fi
if grep -Eq "MainAppRoot\.onAppear skip Flow.*onboarding incomplete" <<<"$chunk"; then
pip="onboarding"
echo "$host|$pip"
return
fi
if grep -Eq "activateOnForeground aborted reason=permissions|startSessionAsync\.blocked.*permissions" <<<"$chunk"; then
pip="permissions"
echo "$host|$pip"
return
fi
if grep -Eq "Flow session started \(PiP keep-alive\)|low-profile PiP active|startSessionAsync\.ready" <<<"$chunk"; then
pip="success"
elif grep -Eq "failure=unsupported|PiP keep-alive failed to start: unsupported" <<<"$chunk"; then
pip="unsupported"
elif grep -Eq "PiP keep-alive failed to start|PiP startAndWait failed|startSessionAsync\.failed.*pipUnavailable" <<<"$chunk"; then
pip="fail"
elif grep -Eq "activateOnForeground|startSession\.request.*autoPiP|startSessionAsync\.begin" <<<"$chunk"; then
pip="seen_no_result"
fi
echo "$host|$pip"
}
HOST_OK=0
HOST_FAIL=0
PIP_SUCCESS=0
PIP_UNSUPPORTED=0
PIP_FAIL=0
PIP_PERMISSIONS=0
PIP_ONBOARDING=0
PIP_OTHER=0
echo "==> Running $COUNT cold starts (terminate → launch)"
for i in $(seq 1 "$COUNT"); do
xcrun simctl terminate "$UDID" "$BUNDLE" 2>/dev/null || true
sleep 0.35
OFFSET=$(wc -c <"$LOG_FILE" | tr -d ' ')
START_TS=$(date +%s)
if ! xcrun simctl launch "$UDID" "$BUNDLE" >/dev/null 2>"$OUT_DIR/launch-$i.err"; then
printf '{"i":%d,"host":"fail","pip":"unknown","elapsed_s":0,"note":"launch_failed"}\n' "$i" >>"$REPORT"
printf "[%3d/%d] host=fail pip=unknown\n" "$i" "$COUNT"
HOST_FAIL=$((HOST_FAIL + 1))
PIP_OTHER=$((PIP_OTHER + 1))
continue
fi
RESULT="unknown|unknown"
for _ in $(seq 1 50); do # ~15s
sleep 0.3
CHUNK="$(logfile_window "$OFFSET")"
RESULT="$(logfile_classify "$CHUNK")"
PIP="${RESULT##*|}"
if [[ "$PIP" == "success" || "$PIP" == "unsupported" || "$PIP" == "fail" || "$PIP" == "permissions" || "$PIP" == "onboarding" ]]; then
break
fi
done
ELAPSED=$(( $(date +%s) - START_TS ))
HOST="${RESULT%%|*}"
PIP="${RESULT##*|}"
case "$HOST" in
success) HOST_OK=$((HOST_OK + 1)) ;;
*) HOST_FAIL=$((HOST_FAIL + 1)) ;;
esac
case "$PIP" in
success) PIP_SUCCESS=$((PIP_SUCCESS + 1)) ;;
unsupported) PIP_UNSUPPORTED=$((PIP_UNSUPPORTED + 1)) ;;
fail) PIP_FAIL=$((PIP_FAIL + 1)) ;;
permissions) PIP_PERMISSIONS=$((PIP_PERMISSIONS + 1)) ;;
onboarding) PIP_ONBOARDING=$((PIP_ONBOARDING + 1)) ;;
*) PIP_OTHER=$((PIP_OTHER + 1)) ;;
esac
printf '{"i":%d,"host":"%s","pip":"%s","elapsed_s":%d}\n' "$i" "$HOST" "$PIP" "$ELAPSED" >>"$REPORT"
printf "[%3d/%d] host=%-8s pip=%-12s %2ds\n" "$i" "$COUNT" "$HOST" "$PIP" "$ELAPSED"
done
{
echo "Cold-start stress summary"
echo "device=$DEVICE_NAME udid=$UDID count=$COUNT"
echo "method=terminate + simctl launch (host cold start / autoPiP path)"
echo
echo "Host cold start (OSGKeyboardApp.init observed):"
echo " success=$HOST_OK fail=$HOST_FAIL"
echo " fail_rate=$(python3 -c "print(f'{$HOST_FAIL/$COUNT*100:.1f}%')")"
echo
echo "PiP keep-alive outcome:"
echo " success=$PIP_SUCCESS"
echo " unsupported=$PIP_UNSUPPORTED (expected on Simulator)"
echo " fail_other=$PIP_FAIL"
echo " permissions=$PIP_PERMISSIONS"
echo " onboarding=$PIP_ONBOARDING"
echo " unknown/other=$PIP_OTHER"
echo " non-unsupported fail_rate=$(python3 -c "print(f'{($PIP_FAIL+$PIP_PERMISSIONS+$PIP_ONBOARDING+$PIP_OTHER)/$COUNT*100:.1f}%')")"
echo
echo "Artifacts: $OUT_DIR"
echo "Caveats:"
echo " - Simulator PiP is typically 'unsupported'; device PiP is the release gate."
echo " - Keyboard extension KVC.init / openHostApp is not covered (needs physical keyboard enablement)."
echo " - Host wake path covered = cold launch of the process the keyboard would open."
} | tee "$SUMMARY"
kill "$LOG_PID" 2>/dev/null || true
trap - EXIT
echo "==> Done"
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Compose a Chinese App Store preview (iPhone 6.7\") for OSGKeyboard.
Uses real Simulator / marketing UI stills + ffmpeg Ken Burns + ASS titles.
Output: docs/assets/app-preview/zh/OSGKeyboard-preview-6.7-zh.mp4
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "docs" / "assets" / "app-preview" / "zh"
WORK = ROOT / ".tmp" / "app-preview-zh" / "compose"
W, H = 1290, 2796 # App Store Connect 6.7"
FPS = 30
HOME_SRC = ROOT / ".tmp" / "app-preview-zh" / "shot-home-dark.png"
KB_IDLE = ROOT / "docs" / "assets" / "screenshots" / "zh" / "dark" / "iphone-keyboard_idle.png"
KB_REC = ROOT / "docs" / "assets" / "screenshots" / "zh" / "dark" / "iphone-keyboard_recording.png"
ICON = ROOT / "docs" / "assets" / "app-icon.png"
# Prefer a clean Chinese UI font available on this machine.
FONT_CANDIDATES = [
Path("/Users/rocky/Library/Fonts/OPPO Sans 4.0.ttf"),
Path("/System/Library/Fonts/STHeiti Medium.ttc"),
Path("/System/Library/Fonts/Supplemental/Songti.ttc"),
]
def pick_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
for path in FONT_CANDIDATES:
if not path.exists():
continue
try:
return ImageFont.truetype(str(path), size=size, index=0)
except OSError:
continue
return ImageFont.load_default()
def cover(img: Image.Image) -> Image.Image:
"""Scale to cover WxH and center-crop."""
src = img.convert("RGB")
scale = max(W / src.width, H / src.height)
nw, nh = int(src.width * scale), int(src.height * scale)
resized = src.resize((nw, nh), Image.Resampling.LANCZOS)
left = (nw - W) // 2
top = (nh - H) // 2
return resized.crop((left, top, left + W, top + H))
def patch_home_warning(src: Path) -> Image.Image:
"""Hide Simulator-only Flow warning banner for marketing stills."""
im = Image.open(src).convert("RGB")
# Banner sits under the three status dots (~y 520580 @ 1320×2868).
sample = im.getpixel((im.width // 2, 500))
draw = ImageDraw.Draw(im)
draw.rounded_rectangle((48, 518, im.width - 48, 586), radius=28, fill=sample)
return cover(im)
def title_card(lines: list[str], subtitle: str | None = None, with_icon: bool = False) -> Image.Image:
im = Image.new("RGB", (W, H), (12, 12, 14))
# Soft brand glow
glow = Image.new("RGB", (W, H), (12, 12, 14))
gdraw = ImageDraw.Draw(glow)
cx, cy = W // 2, int(H * 0.38)
for r, alpha in ((520, 28), (360, 40), (220, 55)):
color = (18 + alpha // 4, 40 + alpha // 3, 28 + alpha // 5)
gdraw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=color)
im = Image.blend(im, glow, 0.55)
draw = ImageDraw.Draw(im)
y = int(H * 0.34)
if with_icon and ICON.exists():
icon = Image.open(ICON).convert("RGBA").resize((220, 220), Image.Resampling.LANCZOS)
# Rounded mask
mask = Image.new("L", (220, 220), 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, 219, 219), radius=48, fill=255)
im.paste(icon, ((W - 220) // 2, y - 280), mask)
y = int(H * 0.42)
title_font = pick_font(96 if len(lines) == 1 else 84)
sub_font = pick_font(44)
for line in lines:
bbox = draw.textbbox((0, 0), line, font=title_font)
tw = bbox[2] - bbox[0]
draw.text(((W - tw) // 2, y), line, fill=(245, 246, 248), font=title_font)
y += 120
if subtitle:
bbox = draw.textbbox((0, 0), subtitle, font=sub_font)
tw = bbox[2] - bbox[0]
draw.text(((W - tw) // 2, y + 24), subtitle, fill=(140, 160, 145), font=sub_font)
return im
def overlay_caption(base: Image.Image, text: str) -> Image.Image:
"""Bottom gradient + caption for ad-style supers."""
im = base.copy().convert("RGBA")
shade = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(shade)
for i in range(420):
a = int(190 * (i / 419))
y = H - 420 + i
d.line([(0, y), (W, y)], fill=(0, 0, 0, a))
im = Image.alpha_composite(im, shade)
draw = ImageDraw.Draw(im)
font = pick_font(64)
bbox = draw.textbbox((0, 0), text, font=font)
tw = bbox[2] - bbox[0]
draw.text(((W - tw) // 2, H - 260), text, fill=(255, 255, 255, 255), font=font)
return im.convert("RGB")
def write_ass(path: Path) -> None:
# Kept for optional future hardsubs; primary captions are burned into stills.
path.write_text(
"""[Script Info]
ScriptType: v4.00+
PlayResX: 1290
PlayResY: 2796
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Heiti SC,64,&H00FFFFFF,&H000000FF,&H64000000,&H80000000,0,0,0,0,100,100,0,0,1,0,0,2,60,60,120,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
""",
encoding="utf-8",
)
def run(cmd: list[str]) -> None:
print("+", " ".join(cmd[:8]), "..." if len(cmd) > 8 else "")
subprocess.run(cmd, check=True)
def main() -> int:
if not HOME_SRC.exists():
print(f"missing home still: {HOME_SRC}", file=sys.stderr)
return 1
WORK.mkdir(parents=True, exist_ok=True)
OUT_DIR.mkdir(parents=True, exist_ok=True)
home = patch_home_warning(HOME_SRC)
kb_idle = cover(Image.open(KB_IDLE))
kb_rec = cover(Image.open(KB_REC))
scenes = {
"01_open": title_card(["开口即文字"], with_icon=True),
"02_hook": overlay_caption(home, "说,就好了"),
"03_voice": overlay_caption(kb_rec, "说完就能用"),
"04_type": overlay_caption(kb_idle, "打字也在行"),
"05_privacy": overlay_caption(home, "默认不上传录音"),
"06_end": title_card(["OSGKeyboard"], "开口即文字", with_icon=True),
}
for name, img in scenes.items():
img.save(WORK / f"{name}.png", optimize=True)
# Durations (seconds) — total ~28s
timeline = [
("01_open", 2.2),
("02_hook", 3.2),
("03_voice", 7.0),
("04_type", 6.0),
("05_privacy", 5.0),
("06_end", 4.0),
]
# Build concat list with zoompan Ken Burns per still.
parts: list[Path] = []
for idx, (name, dur) in enumerate(timeline):
src = WORK / f"{name}.png"
part = WORK / f"part-{idx:02d}.mp4"
frames = max(1, int(dur * FPS))
# Gentle zoom: alternate direction for rhythm
if idx % 2 == 0:
z = f"min(zoom+0.00045,1.08)"
x = "iw/2-(iw/zoom/2)"
y = "ih/2-(ih/zoom/2)"
else:
z = f"if(eq(on,1),1.08,max(zoom-0.00045,1.0))"
x = "iw/2-(iw/zoom/2)"
y = "ih/2-(ih/zoom/2)"
vf = (
f"scale={W}:{H}:force_original_aspect_ratio=increase,"
f"crop={W}:{H},"
f"zoompan=z='{z}':x='{x}':y='{y}':d={frames}:s={W}x{H}:fps={FPS},"
f"setsar=1,format=yuv420p"
)
run(
[
"ffmpeg",
"-y",
"-loop",
"1",
"-i",
str(src),
"-vf",
vf,
"-t",
f"{dur:.2f}",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-profile:v",
"high",
"-level",
"4.0",
"-crf",
"18",
"-r",
str(FPS),
str(part),
]
)
parts.append(part)
concat_list = WORK / "concat.txt"
concat_list.write_text("".join(f"file '{p}'\n" for p in parts), encoding="utf-8")
silent = WORK / "preview-silent.mp4"
run(
[
"ffmpeg",
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(concat_list),
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
"-r",
str(FPS),
str(silent),
]
)
# Soft generated bed (no third-party music / no copyright risk).
final = OUT_DIR / "OSGKeyboard-preview-6.7-zh.mp4"
run(
[
"ffmpeg",
"-y",
"-i",
str(silent),
"-f",
"lavfi",
"-i",
"sine=frequency=196:sample_rate=44100,volume=0.035,afade=t=in:st=0:d=1.2,afade=t=out:st=25:d=2.5",
"-f",
"lavfi",
"-i",
"sine=frequency=293.66:sample_rate=44100,volume=0.02,afade=t=in:st=0:d=1.5,afade=t=out:st=25:d=2.5",
"-filter_complex",
"[1:a][2:a]amix=inputs=2:duration=first:dropout_transition=2[a]",
"-map",
"0:v",
"-map",
"[a]",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest",
"-movflags",
"+faststart",
str(final),
]
)
# Probe
probe = subprocess.check_output(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,duration",
"-show_entries",
"format=duration,size",
"-of",
"default=noprint_wrappers=1",
str(final),
],
text=True,
)
print(probe)
print(f"Wrote {final}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env bash
# Physical-device PiP / host-wake stress using `devicectl --console` logs.
#
# Usage:
# ./Scripts/device-pip-stress.sh [UDID] [COUNT=50]
#
# Suites:
# cold — terminate-existing launch (force-quit / cold start)
# bgfg — open Safari (background) then relaunch OSG (foreground restore)
# hold — start PiP, wait 20s in session, relaunch to verify still recoverable
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
UDID="${1:-00008130-001C249C0E52001C}"
COUNT="${2:-50}"
BUNDLE="com.osgkeyboard.ios"
SAFARI="com.apple.mobilesafari"
OUT_DIR="${ROOT}/.tmp/device-pip-stress-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$OUT_DIR"
REPORT="$OUT_DIR/report.jsonl"
SUMMARY="$OUT_DIR/summary.txt"
APP="$ROOT/.derivedData-device-stress/Build/Products/Debug-iphoneos/OSGKeyboard.app"
echo "==> Out: $OUT_DIR"
echo "==> Device: $UDID count/suite: $COUNT"
if [[ ! -d "$APP" ]]; then
echo "error: missing $APP — build for device first" >&2
exit 1
fi
echo "==> Ensuring install"
xcrun devicectl device install app --device "$UDID" "$APP" --timeout 180 >/dev/null
# Capture one console launch (timeout seconds). Prints raw console to stdout file.
console_launch() {
local outfile="$1"
local secs="${2:-14}"
local extra_flags="${3:-}" # e.g. --terminate-existing
# shellcheck disable=SC2086
timeout "$secs" xcrun devicectl device process launch \
--device "$UDID" \
--console \
$extra_flags \
"$BUNDLE" >"$outfile" 2>&1 || true
}
classify_file() {
local f="$1"
local host="unknown" pip="unknown" mic="unknown"
if grep -Eq "OSGKeyboardApp\.init|MainAppRoot\.onAppear|activateOnForeground" "$f"; then
host="success"
elif grep -Eq "Launched application with com\.osgkeyboard\.ios" "$f"; then
host="launch_only"
fi
if grep -Eq "onboarding incomplete" "$f"; then
pip="onboarding"
elif grep -Eq "aborted reason=permissions|blocked.*permissions" "$f"; then
pip="permissions"
elif grep -Eq "Flow session started \(PiP keep-alive\)|low-profile PiP active|startSessionAsync\.ready" "$f"; then
pip="success"
elif grep -Eq "failure=unsupported|failed to start: unsupported" "$f"; then
pip="unsupported"
elif grep -Eq "PiP keep-alive failed to start|startSessionAsync\.failed.*pipUnavailable|startAndWait failed" "$f"; then
pip="fail"
elif grep -Eq "PiP start attempt failed" "$f"; then
# Retry path — only fail if we never saw success above
pip="retry_then_unknown"
elif grep -Eq "activateOnForeground|autoPiP|startSessionAsync\.begin" "$f"; then
pip="seen_no_result"
fi
# Mic keep-alive contract: idle releases mic after PiP proves
if grep -Eq "mic released between utterances|released audio session and frame pump" "$f"; then
mic="released_ok"
elif grep -Eq "PiP audio session ready" "$f"; then
mic="armed"
else
mic="unknown"
fi
# Promote retry_then_unknown if success markers appeared (grep order already handled)
if [[ "$pip" == "retry_then_unknown" ]]; then
if grep -Eq "low-profile PiP active|Flow session started \(PiP keep-alive\)" "$f"; then
pip="success"
else
pip="fail"
fi
fi
echo "$host|$pip|$mic"
}
run_suite() {
local suite="$1"
local i outfile result host pip mic start_ts elapsed flags
echo "==> Suite: $suite × $COUNT"
for i in $(seq 1 "$COUNT"); do
outfile="$OUT_DIR/${suite}-$i.console.log"
start_ts=$(date +%s)
flags=""
case "$suite" in
cold)
flags="--terminate-existing"
console_launch "$outfile" 14 "$flags"
;;
bgfg)
# Ensure app running with PiP first
console_launch "$OUT_DIR/${suite}-$i.prep.log" 12 "--terminate-existing" >/dev/null || true
# Background by opening Safari
xcrun devicectl device process launch --device "$UDID" "$SAFARI" >/dev/null 2>&1 || true
sleep 3
# Resume OSG without terminate
console_launch "$outfile" 12 ""
;;
hold)
console_launch "$OUT_DIR/${suite}-$i.prep.log" 12 "--terminate-existing" >/dev/null || true
# Leave session alive ~20s (PiP should keep host)
sleep 20
# Background briefly then resume
xcrun devicectl device process launch --device "$UDID" "$SAFARI" >/dev/null 2>&1 || true
sleep 2
console_launch "$outfile" 12 ""
;;
esac
elapsed=$(( $(date +%s) - start_ts ))
result="$(classify_file "$outfile")"
host="${result%%|*}"
rest="${result#*|}"
pip="${rest%%|*}"
mic="${rest##*|}"
printf '{"suite":"%s","i":%d,"host":"%s","pip":"%s","mic":"%s","elapsed_s":%d}\n' \
"$suite" "$i" "$host" "$pip" "$mic" "$elapsed" >>"$REPORT"
printf "[%s %3d/%d] host=%-11s pip=%-12s mic=%-12s %2ds\n" \
"$suite" "$i" "$COUNT" "$host" "$pip" "$mic" "$elapsed"
done
}
: >"$REPORT"
run_suite cold
run_suite bgfg
run_suite hold
python3 - "$REPORT" "$SUMMARY" "$UDID" "$COUNT" "$OUT_DIR" <<'PY'
import json, collections, sys
from pathlib import Path
report, summary, udid, count, out = sys.argv[1:6]
rows = [json.loads(l) for l in Path(report).read_text().splitlines() if l.strip()]
by = collections.defaultdict(list)
for r in rows:
by[r["suite"]].append(r)
lines = [
"Device PiP / mic keep-alive stress summary",
f"device=Rocky 15 PM udid={udid} count_per_suite={count}",
f"total_rows={len(rows)}",
"",
]
for suite in ("cold", "bgfg", "hold"):
rs = by.get(suite, [])
n = max(len(rs), 1)
host_ok = sum(1 for r in rs if r["host"] in ("success", "launch_only"))
pip_c = collections.Counter(r["pip"] for r in rs)
mic_c = collections.Counter(r["mic"] for r in rs)
pip_ok = pip_c.get("success", 0)
lines += [
f"[{suite}]",
f" host_ok={host_ok}/{len(rs)} ({host_ok/n*100:.1f}%)",
f" pip_success={pip_ok}/{len(rs)} ({pip_ok/n*100:.1f}%) breakdown={dict(pip_c)}",
f" mic={dict(mic_c)}",
"",
]
lines += [
f"Artifacts: {out}",
"Notes:",
" - cold uses --terminate-existing (force-quit recovery).",
" - bgfg backgrounds via Safari then resumes.",
" - hold keeps session ~20s then Safari background + resume.",
" - Keyboard-extension mic tap is not automated.",
]
text = "\n".join(lines) + "\n"
Path(summary).write_text(text)
print(text)
PY
echo "==> Done"