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:
Executable
+243
@@ -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"
|
||||
@@ -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 520–580 @ 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())
|
||||
Executable
+193
@@ -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"
|
||||
Reference in New Issue
Block a user