diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7d27aaf..deaa2d4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,30 +1,80 @@ -## Description + + +## Summary + + + +- Replace this bullet with a summary of your change. +- Explain *what* changed and *why* it matters to users or contributors. Fixes # (issue) -## Type of Change +## Validation + + + +- [ ] `gofmt -w CLI GUI` (or `gofmt -l CLI GUI` reports no files) +- [ ] `cd CLI && go vet ./... && go test -v -race ./...` +- [ ] `cd GUI && go vet ./... && go test -v -race ./...` +- [ ] Manually tested on the affected platform(s) (describe below): -Please delete options that are not relevant. + -- [ ] Bug fix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) -- [ ] Documentation update -- [ ] CI/CD or Repository chore +## Breaking Changes -## Checklist + -- [ ] My code follows the style guidelines of this project (run `gofmt -w .`) -- [ ] I have performed a self-review of my own code -- [ ] I have commented my code, particularly in hard-to-understand areas -- [ ] I have made corresponding changes to the documentation -- [ ] My changes generate no new warnings or console errors -- [ ] I have added tests that prove my fix is effective or that my feature works -- [ ] New and existing unit tests pass locally with my changes -- [ ] Any dependent changes have been merged and published in downstream modules +None. + +## Notes + + + + + +## Type of Change -## Screenshots / Interactive GIFs + -*Please provide screenshots, pictures, or screen recordings demonstrating the changes, especially if they affect the GUI or the website.* +- [ ] **MAJOR** β€” Breaking change (existing behavior/API changes; users may need to migrate) +- [ ] **MINOR** β€” New feature (backward-compatible; adds functionality) +- [ ] **PATCH** β€” Bug fix, chore, docs, CI, or other maintenance change diff --git a/.github/scripts/build_release_body.py b/.github/scripts/build_release_body.py new file mode 100755 index 0000000..9088f7d --- /dev/null +++ b/.github/scripts/build_release_body.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Build the GitHub Release body for a given tag (used by the publish job). + +Priority order (highest to lowest): + + 1. `.release-body.md` if present and non-empty. Written by prepare_release.py + in the same workflow run. (Because this job runs on a fresh checkout at + the tag, the file will NOT exist when re-running against an already- + published tag; we still check so a future single-run release can use it.) + 2. The body embedded in the annotated tag message between the + `---RELEASE-BODY-START---` and `---RELEASE-BODY-END---` markers. This is + the primary path for both the normal flow and for re-runs, because the + release body is always stamped into the tag object itself. + 3. The matching `## [X.Y.Z]` entry in `CHANGELOG.md`. + 4. A minimal fallback body. +""" + +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path + +REPO_ROOT = Path.cwd() +PRECOMPUTED = REPO_ROOT / ".release-body.md" +OUT = REPO_ROOT / "release-body.md" +CHANGELOG = REPO_ROOT / "CHANGELOG.md" +BODY_START = "---RELEASE-BODY-START---" +BODY_END = "---RELEASE-BODY-END---" + + +def git(*args: str) -> str: + r = subprocess.run(["git", *args], capture_output=True, text=True) + return r.stdout + + +def extract_from_tag(tag: str) -> str: + tag_msg = git("tag", "-l", "--format=%(contents)", tag) + if BODY_START in tag_msg and BODY_END in tag_msg: + start = tag_msg.index(BODY_START) + len(BODY_START) + end = tag_msg.index(BODY_END, start) + return tag_msg[start:end].strip() + "\n" + return "" + + +def extract_changelog_entry(ver: str) -> str: + if not CHANGELOG.exists(): + return "" + text = CHANGELOG.read_text(encoding="utf-8") + pat = re.compile( + rf"^##\s*\[\s*{re.escape(ver)}\s*\].*?(?=^##\s*\[|\Z)", + flags=re.MULTILINE | re.DOTALL, + ) + m = pat.search(text) + if not m: + return "" + entry = m.group(0).rstrip() + lines = entry.splitlines() + return "\n".join(lines[1:]).strip() + + +def main() -> int: + ref = os.environ.get("GITHUB_REF", "") + tag = os.environ.get("TAG", "").strip() + if not tag and ref.startswith("refs/tags/"): + tag = ref[len("refs/tags/"):] + ver = tag.lstrip("v") + + body = "" + if PRECOMPUTED.exists() and PRECOMPUTED.stat().st_size > 0: + body = PRECOMPUTED.read_text(encoding="utf-8") + print(f"Using precomputed release body for {tag} ({len(body)} chars).") + elif tag: + body = extract_from_tag(tag) + if body: + print(f"Using release body from annotated tag message ({len(body)} chars).") + if not body and ver: + entry = extract_changelog_entry(ver) + if entry: + body = f"## ADBPureFlow {tag}\n\n{entry}\n" + print(f"Using CHANGELOG entry for {tag} ({len(body)} chars).") + if not body: + body = ( + f"## ADBPureFlow {tag}\n\n" + f"Release {tag} of ADBPureFlow.\n\n" + f"See [CHANGELOG.md](./CHANGELOG.md) for details.\n" + ) + print(f"Using minimal fallback body for {tag}.") + + OUT.write_text(body if body.endswith("\n") else body + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/prepare_release.py b/.github/scripts/prepare_release.py new file mode 100755 index 0000000..9b498db --- /dev/null +++ b/.github/scripts/prepare_release.py @@ -0,0 +1,631 @@ +#!/usr/bin/env python3 +"""Prepare a new release when a PR is merged into `main`. + +This script is invoked from the `version-bump` job of `.github/workflows/release.yml` +and is responsible for: + + 1. Detecting whether the current push is an automated release commit (in + which case we should NOT produce a new version β€” this closes the loop + that would otherwise be caused by pushing the CHANGELOG update). + 2. Finding the merge commit on `main` corresponding to the just-merged PR, + and fetching that PR's title, body, labels, and number via the GitHub + REST API. + 3. Determining the next semantic version based on PR labels / body content + (major = breaking change, minor = feature, patch = fix/maintenance), with + a sensible patch-level fallback. + 4. Parsing the PR body to extract the `## Summary`, `## Validation`, + `## Breaking Changes`, and `## Notes` sections (case-insensitive heading + matching, tolerant of alternate punctuation). + 5. Prepending a new entry to `CHANGELOG.md` without overwriting history. + 6. Committing the updated CHANGELOG with a message containing the magic + marker `[release skip]` (detected in step 1), creating an annotated + Git tag `vX.Y.Z`, and writing outputs (`tag`, `version`, `body_file`, + `changelog_updated`) for downstream jobs. + +The script is intentionally dependency-free (uses only the Python standard +library) to keep the release pipeline deterministic and free of third-party +automation. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +from datetime import date, datetime, timezone +from pathlib import Path + + +REPO = os.environ.get("GITHUB_REPOSITORY", "flessan/AdbPureFlow") +GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") +HEAD_SHA = os.environ.get("HEAD_SHA", "HEAD") +API = "https://api.github.com" +RELEASE_SKIP_MARKER = "[release skip]" +CHANGELOG_PATH = Path("CHANGELOG.md") +BODY_OUT_PATH = Path(".release-body.md") + +# --------------------------------------------------------------------------- +# GitHub helpers +# --------------------------------------------------------------------------- + +def gh_request(method: str, path: str, data: dict | None = None) -> dict | list | None: + url = f"{API}{path}" + body = json.dumps(data).encode() if data is not None else None + req = urllib.request.Request(url, data=body, method=method) + req.add_header("Authorization", f"Bearer {GITHUB_TOKEN}") + req.add_header("Accept", "application/vnd.github+json") + req.add_header("X-GitHub-Api-Version", "2022-11-28") + if body is not None: + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req) as resp: + raw = resp.read() + return json.loads(raw) if raw else None + except urllib.error.HTTPError as exc: + print(f"::error::{method} {path} -> HTTP {exc.code}: {exc.read().decode(errors='replace')}", + file=sys.stderr) + raise + + +# --------------------------------------------------------------------------- +# Git helpers +# --------------------------------------------------------------------------- + +def git(*args: str, check: bool = True) -> str: + result = subprocess.run(["git", *args], capture_output=True, text=True) + if check and result.returncode != 0: + raise RuntimeError(f"git {' '.join(args)} failed: {result.stderr.strip()}") + return result.stdout.strip() + + +def latest_version_tag() -> tuple[str, tuple[int, int, int]]: + """Return (tag_str, (major, minor, patch)) of the highest v* tag reachable from HEAD. + + If no tag exists, scan CHANGELOG.md for the last [X.Y.Z] heading to + preserve continuity with existing history (the repository originally + recorded versions manually in CHANGELOG rather than via tags). + """ + tags = git("tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--sort=-v:refname") + tag_list = [t for t in tags.splitlines() if t] + for tag in tag_list: + m = re.match(r"^v(\d+)\.(\d+)\.(\d+)$", tag) + if m: + return tag, (int(m.group(1)), int(m.group(2)), int(m.group(3))) + + # Fallback: scan CHANGELOG for the last [X.Y.Z] entry. + if CHANGELOG_PATH.exists(): + text = CHANGELOG_PATH.read_text(encoding="utf-8") + for m in re.finditer(r"^##\s*\[(\d+)\.(\d+)\.(\d+)\]", text, flags=re.MULTILINE): + major, minor, patch = (int(x) for x in m.groups()) + # Synthesize a virtual tag reference β€” we do NOT create the tag. + return f"v{major}.{minor}.{patch}", (major, minor, patch) + + # Ultimate fallback: start at 0.1.0 so the very first automated release is + # v0.1.0 (which is appropriate for a project that hasn't yet tagged). + return "", (0, 0, 0) + + +def existing_release_tag(tag: str) -> bool: + """Return True if a Git tag already exists for `tag` (idempotency check).""" + result = subprocess.run(["git", "rev-parse", "--verify", tag], + capture_output=True, text=True) + return result.returncode == 0 + + +def find_merged_pr_number() -> int | None: + """Walk back from HEAD_SHA until we find a merge commit for a PR into main. + + A GitHub PR merge commit message has one of these forms: + Merge pull request #NNN from + (#NNN) (squash merge) + """ + # Look at up to the last 20 commits β€” merges into main should be at HEAD. + log = git("log", "--pretty=%H%n%s%n%b%n---END---", "-n", "20", HEAD_SHA) + blocks = log.split("---END---") + for block in blocks: + block = block.strip() + if not block: + continue + lines = block.splitlines() + # first line after the sha is the subject; but our format is sha on its + # own line, then subject line, then body. + sha_line = lines[0] + subject = lines[1] if len(lines) > 1 else "" + body = "\n".join(lines[2:]) if len(lines) > 2 else "" + + # Skip automated release commits. + if RELEASE_SKIP_MARKER in subject or RELEASE_SKIP_MARKER in body: + continue + + # Merge commit form: "Merge pull request #NNN ..." + m = re.search(r"Merge pull request #(\d+)", subject) + if m: + print(f"::debug::Found merge-commit PR #{m.group(1)} at {sha_line}") + return int(m.group(1)) + + # Squash-merge form: " (#NNN)" at the end of the subject. + m = re.search(r"\(#(\d+)\)\s*$", subject) + if m: + print(f"::debug::Found squash-merge PR #{m.group(1)} at {sha_line}") + return int(m.group(1)) + + return None + + +# --------------------------------------------------------------------------- +# PR body parsing +# --------------------------------------------------------------------------- + +SECTION_ALIASES = { + "summary": "Summary", + "description": "Summary", # legacy PR template heading + "validation": "Validation", + "testing": "Validation", + "tests": "Validation", + "breaking changes": "Breaking Changes", + "breaking change": "Breaking Changes", + "notes": "Notes", + "additional notes": "Notes", + "separator": "_Separator", # template `<!-- SEPARATOR -->` placeholder + "type of change": "_Type", # checklist β€” drop from release notes + "checklist": "_Checklist", + "screenshots": "_Screenshots", + "screenshots / interactive gifs": "_Screenshots", +} + +HEADING_RE = re.compile(r"^(#{1,6})\s*(.+?)\s*$") +# HTML comments (including multi-line), stripped so PR template guidance is +# not emitted into release notes/changelogs. +HTML_COMMENT_RE = re.compile(r"<!--.*?-->", flags=re.DOTALL) + + +def parse_sections(body: str) -> dict[str, str]: + """Parse a Markdown body into a dict keyed by canonical section name. + + Sections start with an ATX heading (`##`, `###`, ...). We treat all heading + levels equally and look them up case-insensitively via SECTION_ALIASES. + Content before the first heading is collected under "Summary" as a + fallback. HTML comments (`<!-- ... -->`) are stripped so explanatory text + from the PR template doesn't leak into release notes. + """ + # Strip HTML comments first (works across line breaks). + body = HTML_COMMENT_RE.sub("", body) + + sections: dict[str, str] = {} + current_key = "Summary" + current_buf: list[str] = [] + + def flush(): + content = "\n".join(current_buf).strip() + # Collapse runs of >2 blank lines and trim filler lines that the PR + # template leaves behind (e.g. bare "None." placeholders are kept; + # pure whitespace is not). + if content: + # If the key is prefixed with '_' it's a throwaway section. + if not current_key.startswith("_"): + sections.setdefault(current_key, content) + current_buf.clear() + + for line in body.splitlines(): + m = HEADING_RE.match(line) + if m: + flush() + raw_heading = m.group(2).strip().lower() + key = SECTION_ALIASES.get(raw_heading, raw_heading.title() if raw_heading else "") + current_key = key + continue + current_buf.append(line) + + flush() + return sections + + +# --------------------------------------------------------------------------- +# Semver bump logic +# --------------------------------------------------------------------------- + +LABEL_BUMP_MAJOR = {"breaking", "breaking-change", "breaking change", "major"} +LABEL_BUMP_MINOR = {"feature", "enhancement", "minor", "new-feature", "feature request"} +LABEL_BUMP_PATCH = {"bug", "bugfix", "fix", "patch", "maintenance", "chore", "ci", "docs", "documentation"} + + +def determine_bump(pr: dict) -> str: + """Return 'major', 'minor', or 'patch' based on the PR's labels and body. + + Labels take precedence over body keywords. If nothing indicates a bump + level, default to 'patch'. + """ + labels = {lbl["name"].lower() for lbl in pr.get("labels", [])} + + body_text = (pr.get("body") or "") + body_lower = body_text.lower() + + # Explicit release-type markers in body, e.g. `Release-As: v5.1.0` or + # `Semver: major`. These take precedence over everything except a + # "breaking" label. + m = re.search(r"^release-as:\s*v?\d+\.\d+\.\d+\s*$", body_lower, flags=re.MULTILINE) + if m: + return "explicit" + m = re.search(r"^semver:\s*(major|minor|patch)\s*$", body_lower, flags=re.MULTILINE) + if m: + return m.group(1) + + # Parse the body once into sections so we can scope checkboxes to the + # `Type of Change` area rather than matching "[x] ... breaking" in the + # Validation checklist. + sections = parse_sections(body_text) + type_section_parts = [sections.get(k, "") for k in ( + "_Type", "Type Of Change", "Type of Change", "Type", + )] + # Also scan the raw body between a "## Type of Change" heading and the + # next heading (in case an alias didn't match). + in_toc = False + toc_lines: list[str] = [] + for line in body_text.splitlines(): + hm = HEADING_RE.match(line) + if hm: + if in_toc: + break + if hm.group(2).strip().lower() in {"type of change", "type"}: + in_toc = True + continue + if in_toc: + toc_lines.append(line) + type_section_text = "\n".join(type_section_parts + toc_lines).lower() + + checked = {option for option in ("major", "minor", "patch") + if re.search(rf"-\s*\[\s*x\s*\].{{0,80}}{option}", type_section_text, + flags=re.IGNORECASE)} + + # Conventional-Commits-style `BREAKING CHANGE:` footer anywhere in body. + if "breaking change:" in body_lower: + return "major" + + if labels & LABEL_BUMP_MAJOR or "major" in checked: + return "major" + if labels & LABEL_BUMP_MINOR or "minor" in checked: + return "minor" + if labels & LABEL_BUMP_PATCH or "patch" in checked: + return "patch" + # Default: safe, conservative patch bump. + return "patch" + + +def next_version(current: tuple[int, int, int], bump: str) -> tuple[int, int, int]: + major, minor, patch = current + if bump == "major": + return (major + 1, 0, 0) + if bump == "minor": + return (major, minor + 1, 0) + # patch / explicit-as-patch / fallback + return (major, minor, patch + 1) + + +def explicit_version(body: str) -> tuple[int, int, int] | None: + m = re.search(r"^release-as:\s*v?(\d+)\.(\d+)\.(\d+)\s*$", + (body or ""), flags=re.MULTILINE | re.IGNORECASE) + if m: + return (int(m.group(1)), int(m.group(2)), int(m.group(3))) + return None + + +# --------------------------------------------------------------------------- +# Changelog +# --------------------------------------------------------------------------- + +_PLACEHOLDER_LINES = { + "none.", "none", "n/a", "na", "-", + "- replace this bullet with a summary of your change.", +} + + +def _has_user_content(text: str | None) -> bool: + """Return True if a section contains meaningful user-provided content, + as opposed to being empty or containing only the PR template boilerplate. + """ + if not text: + return False + stripped = text.strip() + if not stripped: + return False + # Treat pure placeholder ("None.") as empty for release-note purposes. + if stripped.lower() in _PLACEHOLDER_LINES: + return False + # If every non-empty line is a template reminder bullet, treat as empty. + lines = [ln.strip() for ln in stripped.splitlines() if ln.strip()] + if not lines: + return False + return any(ln.lower() not in _PLACEHOLDER_LINES and not ln.startswith("<!--") + for ln in lines) + + +def build_changelog_entry(version: str, pr: dict, sections: dict[str, str]) -> str: + today = date.today().isoformat() + pr_num = pr["number"] + title = (pr.get("title") or "").strip() + author = (pr.get("user") or {}).get("login", "unknown") + + lines = [f"## [{version[1:]}] - {today}", ""] + if title: + lines.append(f"### {title}") + lines.append("") + + if "Summary" in sections: + lines.append(sections["Summary"].rstrip()) + lines.append("") + + if _has_user_content(sections.get("Breaking Changes")): + lines.append("### ⚠️ Breaking Changes") + lines.append("") + lines.append(sections["Breaking Changes"].rstrip()) + lines.append("") + + if _has_user_content(sections.get("Validation")): + lines.append("### Validation") + lines.append("") + lines.append(sections["Validation"].rstrip()) + lines.append("") + + if _has_user_content(sections.get("Notes")): + lines.append("### Notes") + lines.append("") + lines.append(sections["Notes"].rstrip()) + lines.append("") + + lines.append(f"**Pull Request:** [#{pr_num}]({pr.get('html_url', '')}) " + f"by @{author}") + lines.append("") + return "\n".join(lines) + + +def prepend_changelog(entry: str) -> None: + entry = entry.rstrip() + "\n\n---\n\n" + if CHANGELOG_PATH.exists(): + existing = CHANGELOG_PATH.read_text(encoding="utf-8") + # Find the first `## [` heading after the header and insert before it, + # preserving the introductory "All notable changes..." paragraph and + # any `---` separator that precedes the entries. + m = re.search(r"^##\s*\[\d+\.\d+\.\d+\]", existing, flags=re.MULTILINE) + if m: + # Back up over a preceding separator line + blank lines so the new + # entry's own separator cleanly replaces it (no duplicated "---"). + insert_at = m.start() + prefix = existing[:insert_at].rstrip() + # If the prefix ends with "---", keep it that way and just append; + # otherwise ensure a blank line between header and new entry. + if prefix.endswith("---"): + new_text = prefix + "\n\n" + entry + existing[insert_at:].lstrip() + else: + new_text = existing[:insert_at] + entry + existing[insert_at:] + else: + new_text = existing.rstrip() + "\n\n" + entry + else: + new_text = ( + "# Changelog\n\n" + "All notable changes to this project will be documented in this file.\n\n" + "The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\n" + "and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n" + "---\n\n" + + entry + ) + CHANGELOG_PATH.write_text(new_text, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Release body (GitHub release notes) +# --------------------------------------------------------------------------- + +def _embed_body_in_tag(tag: str, pr: dict, sections: dict[str, str], version: str, + create: bool = False) -> None: + """Embed the rendered release body in the annotated tag message between + two markers, so the publish job can recover it even on a fresh checkout. + + If `create` is True the tag is freshly created with `git tag -a`; otherwise + an existing tag is replaced (via `git tag -a -f`) so that a re-run can + update the body. + """ + release_body = build_release_body(pr, sections, version) + pr_number = pr["number"] + tag_msg_parts = [ + f"Release generated from PR #{pr_number}: {pr.get('title','').strip()}", + "", + "---RELEASE-BODY-START---", + release_body.rstrip(), + "---RELEASE-BODY-END---", + ] + tag_msg = "\n".join(tag_msg_parts) + "\n" + tag_msg_file = Path(".git") / "TAG_MESSAGE" + tag_msg_file.write_text(tag_msg, encoding="utf-8") + try: + args = ["git", "tag"] + args += ["-a"] + if not create: + args += ["-f"] + args += [tag, "-F", str(tag_msg_file), "--cleanup=whitespace"] + subprocess.run(args, check=True) + finally: + try: + tag_msg_file.unlink() + except OSError: + pass + + +def build_release_body(pr: dict, sections: dict[str, str], version: str) -> str: + pr_num = pr["number"] + title = (pr.get("title") or "").strip() + author = (pr.get("user") or {}).get("login", "unknown") + url = pr.get("html_url", "") + + out: list[str] = [] + out.append(f"## ADBPureFlow {version}") + out.append("") + if title: + out.append(f"**{title}**") + out.append("") + if _has_user_content(sections.get("Summary")): + out.append(sections["Summary"].rstrip()) + out.append("") + if _has_user_content(sections.get("Breaking Changes")): + out.append("### ⚠️ Breaking Changes") + out.append("") + out.append(sections["Breaking Changes"].rstrip()) + out.append("") + if _has_user_content(sections.get("Validation")): + out.append("### Validation") + out.append("") + out.append(sections["Validation"].rstrip()) + out.append("") + if _has_user_content(sections.get("Notes")): + out.append("### Additional Notes") + out.append("") + out.append(sections["Notes"].rstrip()) + out.append("") + out.append("---") + out.append("") + out.append(f"- Merged via Pull Request: [#{pr_num}]({url})") + out.append(f"- Author: @{author}") + out.append(f"- Released on: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}") + out.append("") + return "\n".join(out) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def set_output(key: str, value: str) -> None: + """Write a step output via $GITHUB_OUTPUT using the heredoc delimiter + syntax so multi-line values (e.g. the body_file path is just a single line + here, but we use the safe form anyway) are handled correctly. + """ + value = value or "" + out_file = os.environ.get("GITHUB_OUTPUT") + if out_file: + delim = f"gho_{key}_{os.urandom(6).hex()}" + with open(out_file, "a", encoding="utf-8") as f: + f.write(f"{key}<<{delim}\n{value}\n{delim}\n") + # Log for visibility in the Action run log. + print(f"::notice::output {key}={value!r}") + + +def main() -> int: + # --- Guard: skip automated release commits (close the loop) -------------- + head_subject = git("log", "-1", "--pretty=%s", HEAD_SHA) + head_body = git("log", "-1", "--pretty=%b", HEAD_SHA) + if RELEASE_SKIP_MARKER in head_subject or RELEASE_SKIP_MARKER in head_body: + print("::notice::HEAD is an automated release commit; nothing to do.") + set_output("changelog_updated", "false") + set_output("tag", "") + set_output("version", "") + set_output("skip_build", "true") + return 0 + + # --- Optional: bail out early if the caller already supplied a tag ------- + # (set via the MANUAL_TAG environment variable from workflow_dispatch). + manual_tag = os.environ.get("MANUAL_TAG", "").strip() + if manual_tag: + if not manual_tag.startswith("v"): + print(f"::error::Manual tag '{manual_tag}' must start with 'v'.", file=sys.stderr) + set_output("skip_build", "true") + return 1 + if not existing_release_tag(manual_tag): + print(f"::error::Manual tag '{manual_tag}' does not exist.", file=sys.stderr) + set_output("skip_build", "true") + return 1 + print(f"::notice::Re-publishing existing tag {manual_tag} (manual dispatch).") + set_output("tag", manual_tag) + set_output("version", manual_tag) + set_output("skip_build", "false") + set_output("changelog_updated", "false") + return 0 + + # --- Find the merged PR -------------------------------------------------- + pr_number = find_merged_pr_number() + if pr_number is None: + print("::notice::No merged PR detected at HEAD; skipping release. " + "(This typically happens for direct pushes to main.)") + set_output("changelog_updated", "false") + set_output("tag", "") + set_output("version", "") + set_output("skip_build", "true") + return 0 + + print(f"Preparing release for PR #{pr_number} ...") + pr = gh_request("GET", f"/repos/{REPO}/pulls/{pr_number}") + if not isinstance(pr, dict): + print(f"::error::Unexpected PR response for #{pr_number}: {pr!r}", + file=sys.stderr) + return 1 + + body_text = pr.get("body") or "" + sections = parse_sections(body_text) + + # --- Determine next version --------------------------------------------- + _prev_tag, prev_version = latest_version_tag() + explicit = explicit_version(body_text) + if explicit is not None: + new_ver = explicit + bump_used = "explicit" + else: + bump = determine_bump(pr) + if bump == "explicit": + bump = "patch" + new_ver = next_version(prev_version, bump) + bump_used = bump + + tag = f"v{new_ver[0]}.{new_ver[1]}.{new_ver[2]}" + version = tag # tag is vX.Y.Z; version string is the same + + if existing_release_tag(tag): + print(f"::notice::Tag {tag} already exists; no new version will be created. " + "Building and publishing against the existing tag.") + # Still persist the release body so the publish job can pick it up. + BODY_OUT_PATH.write_text(build_release_body(pr, sections, version), encoding="utf-8") + # Embed (or re-embed) the release body in the annotated tag message so + # future re-runs / manual workflow_dispatch runs can find it. + _embed_body_in_tag(tag, pr, sections, version) + set_output("tag", tag) + set_output("version", version) + set_output("skip_build", "false") + set_output("changelog_updated", "false") + return 0 + + print(f" previous version: v{prev_version[0]}.{prev_version[1]}.{prev_version[2]}") + print(f" bump: {bump_used}") + print(f" next version: {tag}") + + # --- Update CHANGELOG.md ------------------------------------------------ + entry = build_changelog_entry(version, pr, sections) + prepend_changelog(entry) + print("CHANGELOG.md updated.") + + # --- Prepare the release body and persist it for the publish job ------- + release_body = build_release_body(pr, sections, version) + BODY_OUT_PATH.write_text(release_body, encoding="utf-8") + + # --- Commit and tag ----------------------------------------------------- + git("add", str(CHANGELOG_PATH)) + commit_msg = ( + f"chore(release): {tag}\n\n" + f"Automated changelog update for {tag} (PR #{pr_number}).\n\n" + f"{RELEASE_SKIP_MARKER}" + ) + subprocess.run(["git", "commit", "-m", commit_msg], check=True) + + _embed_body_in_tag(tag, pr, sections, version, create=True) + + set_output("tag", tag) + set_output("version", version) + set_output("skip_build", "false") + set_output("changelog_updated", "true") + + print(f"Prepared {tag} successfully.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/CLI/go.mod b/CLI/go.mod index e06f9a4..5607fe3 100644 --- a/CLI/go.mod +++ b/CLI/go.mod @@ -1,3 +1,7 @@ -module adbpureflow-cli +module github.com/flessan/AdbPureFlow/CLI go 1.21 + +require github.com/flessan/AdbPureFlow/internal v0.0.0 + +replace github.com/flessan/AdbPureFlow/internal => ../internal diff --git a/CLI/main.go b/CLI/main.go index d03dd0f..05bdc66 100644 --- a/CLI/main.go +++ b/CLI/main.go @@ -1,309 +1,749 @@ +// Command adbpureflow-cli is the interactive command-line interface for +// ADBPureFlow. It is a thin presentation layer over the shared `internal/adb` +// core, which also powers the Fyne GUI. +// +// Launch with no arguments to enter the interactive REPL. Pass a subcommand +// to run a single operation scriptably: +// +// adbpureflow-cli devices +// adbpureflow-cli apps [-u] [serial] +// adbpureflow-cli info <package> [serial] +// adbpureflow-cli install <path-to.apk> [serial] +// adbpureflow-cli launch <package> [serial] +// adbpureflow-cli stop <package> [serial] +// adbpureflow-cli uninstall <package> [serial] +// adbpureflow-cli mirror [serial] package main import ( - "archive/zip" "bufio" + "context" + "flag" "fmt" - "io" - "net/http" "os" - "os/exec" + "os/signal" "path/filepath" - "runtime" + "strconv" "strings" - "time" -) -var adbURLs = map[string]string{ - "windows": "https://dl.google.com/android/repository/platform-tools-latest-windows.zip", - "darwin": "https://dl.google.com/android/repository/platform-tools-latest-darwin.zip", - "linux": "https://dl.google.com/android/repository/platform-tools-latest-linux.zip", -} + "github.com/flessan/AdbPureFlow/internal/adb" +) -const engineDir = "adb_engine" +var version = "dev" func main() { - reader := bufio.NewReader(os.Stdin) - fmt.Println("=================================================================") - fmt.Println(" ADBPureFlow CLI Pro v5.0 ") - fmt.Println(" Automated Android APK Lifecycle Companion ") - fmt.Println("=================================================================") + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() - // 1. Setup ADB - adbPath := setupADB() - if adbPath == "" { - fmt.Println("\n[!] Gagal menyiapkan ADB. Cek koneksi internet.") - fmt.Println("[!] Failed to set up ADB. Check your internet connection or path.") - tungguEnter(reader) - return + flag.Usage = usage + if len(os.Args) > 1 { + os.Exit(runCLI(ctx, os.Args[1:])) } + runREPL(ctx) +} - fmt.Printf("\n[*] ADB Engine active: %s\n", adbPath) +func usage() { + fmt.Fprintf(os.Stderr, "ADBPureFlow CLI %s\n\n", version) + fmt.Fprintf(os.Stderr, "Usage:\n") + fmt.Fprintf(os.Stderr, " %s interactive REPL\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s devices\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s apps [-u] [serial]\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s info <package> [serial]\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s install <path-to.apk> [serial]\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s launch <package> [serial]\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s stop <package> [serial]\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s uninstall <package> [serial]\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s mirror [serial]\n", os.Args[0]) +} - // 2. Input APK - fmt.Print("\n[1] Tarik file APK ke sini & Tekan Enter:\n Drag the APK file here & Press Enter: ") - apkPathRaw, err := reader.ReadString('\n') - if err != nil { - fmt.Printf("[!] Error reading input: %v\n", err) - return - } - apkPath := strings.TrimSpace(strings.Trim(apkPathRaw, "\"\r\n'")) - if apkPath == "" { - fmt.Println("[!] Path kosong. Membalkan operasi.\n[!] Empty path. Cancelling operation.") - tungguEnter(reader) - return - } +// --------------------------------------------------------------------------- +// Interactive REPL +// --------------------------------------------------------------------------- - // Verify APK file exists - if _, err := os.Stat(apkPath); os.IsNotExist(err) { - fmt.Printf("[!] File APK tidak ditemukan di path: %s\n[!] APK file not found at: %s\n", apkPath, apkPath) - tungguEnter(reader) - return - } +func runREPL(ctx context.Context) { + m := mustInitManager() + r := bufio.NewReader(os.Stdin) - // 3. Scan Sebelum Install - fmt.Println("\n[2] Memindai daftar aplikasi di HP...\n Scanning list of applications on your device...") - beforeList := getPackageList(adbPath) - - // 4. Install - fmt.Println("\n[3] Memasang aplikasi ke HP...\n Installing the application to your device...") - installCmd := exec.Command(adbPath, "install", "-r", "-d", apkPath) - installCmd.Stdout = os.Stdout - installCmd.Stderr = os.Stderr - if err := installCmd.Run(); err != nil { - fmt.Printf("[!] Error saat instalasi: %v\n[!] Installation error: %v\n", err, err) + printBanner() + fmt.Println(" Type `help` for a list of commands, `exit` to quit.\n") + + var selected *adb.Device + var lastPackages []adb.Package + prompt := func() string { + if selected == nil { + return "adbpureflow> " + } + return fmt.Sprintf("adbpureflow@%s> ", shortSerial(selected.Serial)) } - // 5. Scan Sesudah & Identifikasi - fmt.Println("\n[4] Mencari ID aplikasi baru...\n Searching for new application ID...") - afterList := getPackageList(adbPath) - packageName := findNewPackage(beforeList, afterList) - - if packageName == "" { - fmt.Println("\n[!] Tidak ada ID baru terdeteksi. Aplikasi mungkin sudah ada atau gagal terpasang.") - fmt.Println("[!] No new ID found. The application may already exist or failed to install.") - fmt.Print("Ketik ID manual (contoh: com.example.app) atau Tekan Enter untuk batal: \nType the manual ID (example: com.example.app) or press Enter to cancel: ") - manual, _ := reader.ReadString('\n') - packageName = strings.TrimSpace(manual) - if packageName == "" { + for { + fmt.Print(prompt()) + line, err := r.ReadString('\n') + if err != nil { + fmt.Println() return } - } + args := tokenize(strings.TrimSpace(line)) + if len(args) == 0 { + continue + } + cmd := strings.ToLower(args[0]) - fmt.Printf("\nSUCCESS! Terdeteksi / Detected: %s\n", packageName) + switch cmd { + case "help", "?", "h": + printHelp() - // 6. Auto-Launch - fmt.Println("\n[5] Menunggu sistem... membuka aplikasi otomatis...") - fmt.Println(" Waiting for system... launching the application automatically...") - time.Sleep(1500 * time.Millisecond) // Wait 1.5 seconds for device to be ready + case "exit", "quit", "q": + fmt.Println("Goodbye.") + return - launchCmd := exec.Command(adbPath, "shell", "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1") - if err := launchCmd.Run(); err != nil { - fmt.Printf("[!] Gagal meluncurkan aplikasi otomatis: %v\n[!] Failed to auto-launch application: %v\n", err, err) - } + case "devices", "ls": + devs, err := m.RefreshDevices(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + if len(devs) == 0 { + fmt.Println("(no devices connected)") + continue + } + for i, d := range devs { + marker := " " + if selected != nil && d.Serial == selected.Serial { + marker = "*" + } + fmt.Printf(" %s [%d] %-13s %s\n", marker, i+1, d.State, d.DisplayName()) + } + lastPackages = nil - // 7. Menu Konfirmasi & Verifikasi Uninstall - fmt.Println("\n=================================================================") - fmt.Println(" APLIKASI AKTIF DI PERANGKAT ") - fmt.Println("=================================================================") - fmt.Print("Hapus aplikasi sekarang? (y/n) [Default: n]: \nDelete application now? (y/n) [Default: n]: ") - - pilihan, _ := reader.ReadString('\n') - pilihanClean := strings.ToLower(strings.TrimSpace(pilihan)) - if pilihanClean == "y" || pilihanClean == "yes" { - fmt.Printf("\n-----> Menghapus %s...\n", packageName) - uninstallCmd := exec.Command(adbPath, "uninstall", packageName) - uninstallCmd.Stdout = os.Stdout - uninstallCmd.Stderr = os.Stderr - uninstallCmd.Run() - - // Verifikasi Akhir - if isPackageStillExists(adbPath, packageName) { - fmt.Println("[!] ERROR: Aplikasi gagal dihapus! Coba hapus manual via HP.") - fmt.Println("[!] ERROR: Application failed to delete! Try deleting it manually from your device.") - } else { - fmt.Println("[OK] Konfirmasi: Aplikasi telah benar-benar terhapus.") - fmt.Println("[OK] Confirmation: The application has been completely deleted.") - } - } else { - fmt.Println("\n-----> Aplikasi dibiarkan terpasang.\n-----> Application left installed.") - } + case "use", "select": + dev, err := pickDevice(ctx, m, args[1:], r) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + selected = &dev + info := m.Client.InspectDevice(ctx, dev) + fmt.Printf("selected %s", dev.DisplayName()) + if info.Model != "" || info.AndroidVer != "" { + fmt.Printf(" (%s Android %s, SDK %s)", info.Model, info.AndroidVer, info.SDK) + } + fmt.Println() + lastPackages = nil + + case "apps", "list", "packages": + dev, err := ensureSelected(ctx, m, selected, r) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + selected = &dev + userOnly := false + var filter string + for _, a := range args[1:] { + switch a { + case "-u", "--user": + userOnly = true + default: + if !strings.HasPrefix(a, "-") { + filter = a + } + } + } + pkgs, err := m.ListPackages(ctx, dev.Serial, userOnly) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + lastPackages = pkgs + printPackages(pkgs, filter) + + case "search": + dev, err := ensureSelected(ctx, m, selected, r) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + selected = &dev + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: search <query>") + continue + } + pkgs, err := m.ListPackages(ctx, dev.Serial, false) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + lastPackages = pkgs + printPackages(pkgs, strings.Join(args[1:], " ")) + + case "info", "show", "details": + dev, err := ensureSelected(ctx, m, selected, r) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + selected = &dev + pkg, err := pickPackageWithIndex(args[1:], lastPackages, r, true) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + info, err := m.PackageInfo(ctx, dev.Serial, pkg) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + printPackageInfo(info) - tungguEnter(reader) + case "refresh": + if _, err := m.RefreshDevices(ctx); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + lastPackages = nil + fmt.Println("refreshed.") + + case "install": + dev, err := ensureSelected(ctx, m, selected, r) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + selected = &dev + apkPath := "" + if len(args) >= 2 { + apkPath = strings.Trim(args[1], "\"'") + } else { + fmt.Print("path to APK: ") + raw, _ := r.ReadString('\n') + apkPath = strings.Trim(strings.TrimSpace(raw), "\"'") + } + fmt.Printf("installing %s ...\n", apkPath) + if _, err := m.InstallAPK(ctx, dev.Serial, apkPath); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + fmt.Println("install succeeded.") + lastPackages = nil + + case "launch": + dev, err := ensureSelected(ctx, m, selected, r) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + selected = &dev + pkg, err := pickPackageWithIndex(args[1:], lastPackages, r, false) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + fmt.Printf("launching %s ...\n", pkg) + if err := m.LaunchApp(ctx, dev.Serial, pkg); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + fmt.Println("launched.") + + case "stop", "force-stop": + dev, err := ensureSelected(ctx, m, selected, r) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + selected = &dev + pkg, err := pickPackageWithIndex(args[1:], lastPackages, r, false) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + fmt.Printf("force-stopping %s ...\n", pkg) + if err := m.ForceStopApp(ctx, dev.Serial, pkg); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + fmt.Println("stopped.") + + case "uninstall", "rm": + dev, err := ensureSelected(ctx, m, selected, r) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + selected = &dev + pkg, err := pickPackageWithIndex(args[1:], lastPackages, r, false) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + fmt.Printf("uninstall %s? type YES to confirm: ", pkg) + confirm, _ := r.ReadString('\n') + if strings.TrimSpace(strings.ToUpper(confirm)) != "YES" { + fmt.Println("aborted.") + continue + } + if err := m.UninstallApp(ctx, dev.Serial, pkg, false); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + fmt.Println("uninstalled.") + lastPackages = nil + + case "mirror", "scrcpy": + dev, err := ensureSelected(ctx, m, selected, r) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + selected = &dev + fmt.Println("launching scrcpy ...") + cmd, err := m.Scrcpy.StartMirror(ctx, dev.Serial, "ADBPureFlow-Mirror") + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + continue + } + fmt.Printf("scrcpy started (pid %d).\n", cmd.Process.Pid) + + case "version", "-v", "--version": + fmt.Println("ADBPureFlow CLI", version) + + default: + fmt.Fprintf(os.Stderr, "unknown command %q (try `help`)\n", cmd) + } + } } -// --- FUNGSI HELPERS --- +// --------------------------------------------------------------------------- +// Non-interactive subcommands +// --------------------------------------------------------------------------- -func getPackageList(adbPath string) map[string]bool { - list := make(map[string]bool) - out, err := exec.Command(adbPath, "shell", "pm", "list", "packages", "-3").Output() - if err != nil { - // Try without -3 fallback - out, err = exec.Command(adbPath, "shell", "pm", "list", "packages").Output() +func runCLI(ctx context.Context, args []string) int { + m := mustInitManager() + cmd := strings.ToLower(args[0]) + + requireSerial := func(positional []string) (string, error) { + devs, err := m.RefreshDevices(ctx) if err != nil { - return list + return "", err } - } - lines := strings.Split(string(out), "\n") - for _, line := range lines { - name := strings.TrimSpace(strings.Replace(line, "package:", "", 1)) - if name != "" { - list[name] = true + var online []adb.Device + for _, d := range devs { + if d.State == adb.StateDevice { + online = append(online, d) + } + } + for _, p := range positional { + for _, d := range online { + if d.Serial == p { + return d.Serial, nil + } + } } + if len(online) == 1 { + return online[0].Serial, nil + } + if len(online) == 0 { + return "", fmt.Errorf("no online devices; connect one or specify a serial") + } + return "", fmt.Errorf("multiple devices online; specify a serial: %s", + strings.Join(serials(online), ", ")) } - return list -} -func findNewPackage(before, after map[string]bool) string { - for pkg := range after { - if !before[pkg] { - return pkg + switch cmd { + case "devices": + devs, err := m.RefreshDevices(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + for _, d := range devs { + fmt.Printf("%s\t%s\t%s\n", d.Serial, d.State, d.DisplayName()) + } + return 0 + + case "version": + fmt.Println(version) + return 0 + + case "apps": + userOnly := false + var positional []string + for _, a := range args[1:] { + if a == "-u" || a == "--user" { + userOnly = true + } else { + positional = append(positional, a) + } + } + serial, err := requireSerial(positional) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + pkgs, err := m.ListPackages(ctx, serial, userOnly) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + for _, p := range pkgs { + fmt.Printf("%s\t%s\t%s\t%s\n", p.Kind, p.Name, formatVersion(p), p.DisplayTitle()) + } + return 0 + + case "info": + if len(args) < 2 { + usage() + return 2 + } + pkgName := args[1] + serial, err := requireSerial(args[2:]) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + info, err := m.PackageInfo(ctx, serial, pkgName) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + printPackageInfo(info) + return 0 + + case "install": + if len(args) < 2 { + usage() + return 2 + } + serial, err := requireSerial(args[2:]) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if _, err := m.InstallAPK(ctx, serial, args[1]); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + fmt.Println("install OK") + return 0 + + case "launch": + if len(args) < 2 { + usage() + return 2 + } + serial, err := requireSerial(args[2:]) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 } + if err := m.LaunchApp(ctx, serial, args[1]); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + return 0 + + case "stop": + if len(args) < 2 { + usage() + return 2 + } + serial, err := requireSerial(args[2:]) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if err := m.ForceStopApp(ctx, serial, args[1]); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + return 0 + + case "uninstall": + if len(args) < 2 { + usage() + return 2 + } + serial, err := requireSerial(args[2:]) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if err := m.UninstallApp(ctx, serial, args[1], false); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + fmt.Println("uninstalled", args[1]) + return 0 + + case "mirror": + serial, err := requireSerial(args[1:]) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + cmd, err := m.Scrcpy.StartMirror(ctx, serial, "ADBPureFlow-Mirror") + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + fmt.Printf("scrcpy started (pid %d)\n", cmd.Process.Pid) + _ = cmd.Wait() + return 0 + + case "help", "-h", "--help": + usage() + return 0 + + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n", cmd) + usage() + return 2 } - return "" } -func isPackageStillExists(adbPath, pkgName string) bool { - out, err := exec.Command(adbPath, "shell", "pm", "list", "packages", pkgName).Output() +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func mustInitManager() *adb.Manager { + dataDir := "" + if exe, err := os.Executable(); err == nil { + dataDir = filepath.Dir(exe) + } + m, err := adb.NewManager(dataDir, true) if err != nil { - return false + fmt.Fprintln(os.Stderr, "failed to initialize ADB:", err) + fmt.Fprintln(os.Stderr, "Install adb or allow this binary to download platform-tools.") + os.Exit(1) } - return strings.Contains(string(out), "package:"+pkgName) + return m } -func setupADB() string { - base, err := os.Getwd() - if err != nil { - base = "." - } - adbName := "adb" - if runtime.GOOS == "windows" { - adbName = "adb.exe" - } +func printBanner() { + fmt.Println("=================================================================") + fmt.Printf(" ADBPureFlow CLI %s \n", version) + fmt.Println(" Automated Android APK Lifecycle Companion ") + fmt.Println("=================================================================") +} - adbFile := filepath.Join(base, engineDir, "platform-tools", adbName) - if _, err := os.Stat(adbFile); err == nil { - return adbFile - } +func printHelp() { + fmt.Println(`Commands: + devices | ls list connected devices + use <n|serial> select a device for subsequent commands + apps [-u] [query] list installed packages (-u = user only) + search <query> search apps by name / package + info [<pkg>|n] show detailed info for an application + refresh re-scan connected devices + install <path> install an APK onto the selected device + launch [<pkg>|n] launch an app (number = from last apps/search) + stop [<pkg>|n] force-stop an app + uninstall [<pkg>|n] uninstall an app (prompts for confirmation) + mirror launch scrcpy screen mirror + version print CLI version + help show this help + exit quit`) +} - // Fallback to checking system PATH - if path, err := exec.LookPath(adbName); err == nil { - return path +func tokenize(line string) []string { + var ( + out []string + cur strings.Builder + inQ bool + ) + for _, r := range line { + switch { + case r == '"': + inQ = !inQ + case (r == ' ' || r == '\t') && !inQ: + if cur.Len() > 0 { + out = append(out, cur.String()) + cur.Reset() + } + default: + cur.WriteRune(r) + } } - - url, ok := adbURLs[runtime.GOOS] - if !ok { - fmt.Printf("[!] Sistem operasi %s tidak didukung untuk pengunduhan otomatis.\n", runtime.GOOS) - fmt.Printf("[!] OS %s not supported for auto-download.\n", runtime.GOOS) - return "" + if cur.Len() > 0 { + out = append(out, cur.String()) } + return out +} - fmt.Printf("[*] Engine ADB tidak ditemukan. Mendownload untuk %s...\n", runtime.GOOS) - fmt.Println("[*] ADB Engine not found. Downloading...") - - resp, err := http.Get(url) +func pickDevice(ctx context.Context, m *adb.Manager, args []string, r *bufio.Reader) (adb.Device, error) { + devs, err := m.RefreshDevices(ctx) if err != nil { - fmt.Printf("[!] Download gagal: %v\n", err) - return "" + return adb.Device{}, err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - fmt.Printf("[!] HTTP status error: %s\n", resp.Status) - return "" + var online []adb.Device + for _, d := range devs { + if d.State == adb.StateDevice { + online = append(online, d) + } } - - zipName := "adb.zip" - f, err := os.Create(zipName) - if err != nil { - fmt.Printf("[!] Gagal membuat file zip temp: %v\n", err) - return "" + if len(online) == 0 { + return adb.Device{}, fmt.Errorf("no online devices") } - - _, err = io.Copy(f, resp.Body) - f.Close() - if err != nil { - fmt.Printf("[!] Gagal mendownload content: %v\n", err) - os.Remove(zipName) - return "" + if len(args) >= 1 { + s := strings.TrimSpace(args[0]) + if n, err := strconv.Atoi(s); err == nil && n >= 1 && n <= len(online) { + return online[n-1], nil + } + for _, d := range online { + if d.Serial == s { + return d, nil + } + } + return adb.Device{}, fmt.Errorf("no such device: %s", s) } - - fmt.Println("[*] Mengekstrak platform-tools...") - err = unzip(zipName, engineDir) - os.Remove(zipName) - if err != nil { - fmt.Printf("[!] Gagal mengekstrak zip: %v\n", err) - return "" + if len(online) == 1 { + return online[0], nil } - - // Set permissions for macOS/Linux - if runtime.GOOS != "windows" { - _ = os.Chmod(adbFile, 0755) + for i, d := range online { + fmt.Printf(" [%d] %s\n", i+1, d.DisplayName()) } - - if _, err := os.Stat(adbFile); err == nil { - return adbFile + fmt.Print("select device (number): ") + raw, _ := r.ReadString('\n') + n, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || n < 1 || n > len(online) { + return adb.Device{}, fmt.Errorf("invalid selection") } - return "" + return online[n-1], nil } -func unzip(src, dest string) error { - r, err := zip.OpenReader(src) - if err != nil { - return err +func ensureSelected(ctx context.Context, m *adb.Manager, cur *adb.Device, r *bufio.Reader) (adb.Device, error) { + if cur != nil { + devs, err := m.RefreshDevices(ctx) + if err == nil { + for _, d := range devs { + if d.Serial == cur.Serial && d.State == adb.StateDevice { + return d, nil + } + } + } } - defer r.Close() + return pickDevice(ctx, m, nil, r) +} - destAbs, err := filepath.Abs(dest) - if err != nil { - return err +// pickPackageWithIndex resolves a package from CLI args, accepting either an +// explicit package name or a numeric index into the most recent listing +// (when allowIndex is true). When no argument is given and a reader is +// available, it prompts interactively. +func pickPackageWithIndex(args []string, lastPackages []adb.Package, r *bufio.Reader, allowIndex bool) (string, error) { + if len(args) >= 1 { + s := strings.TrimSpace(args[0]) + if s == "" { + return "", fmt.Errorf("no package specified") + } + if allowIndex { + if n, err := strconv.Atoi(s); err == nil && n >= 1 && n <= len(lastPackages) { + return lastPackages[n-1].Name, nil + } + } + return s, nil } - - for _, f := range r.File { - fpath := filepath.Join(dest, f.Name) - - // Prevent Zip Slip vulnerability - fpathAbs, err := filepath.Abs(fpath) - if err != nil { - return err + if allowIndex && r != nil && len(lastPackages) > 0 { + fmt.Println(" (from last listing)") + for i, p := range lastPackages { + fmt.Printf(" [%d] %s %s\n", i+1, p.DisplayTitle(), p.Name) + } + fmt.Print("select app (number or package name): ") + raw, _ := r.ReadString('\n') + s := strings.TrimSpace(raw) + if n, err := strconv.Atoi(s); err == nil && n >= 1 && n <= len(lastPackages) { + return lastPackages[n-1].Name, nil } - if !strings.HasPrefix(fpathAbs, destAbs+string(filepath.Separator)) && fpathAbs != destAbs { - return fmt.Errorf("illegal file path in zip: %s", f.Name) + if s != "" { + return s, nil } + return "", fmt.Errorf("no package specified") + } + return "", fmt.Errorf("no package specified") +} - if f.FileInfo().IsDir() { - if err := os.MkdirAll(fpath, 0755); err != nil { - return err - } +func printPackages(pkgs []adb.Package, filter string) { + filter = strings.ToLower(filter) + count := 0 + for _, p := range pkgs { + label := p.DisplayTitle() + line := fmt.Sprintf("%-16s %-16s %-40s %s", p.Kind, formatVersion(p), label, p.Name) + if filter != "" && !strings.Contains(strings.ToLower(line), filter) { continue } + fmt.Println(line) + count++ + } + if count == 0 { + fmt.Println("(no packages match)") + } +} - if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil { - return err +// printPackageInfo renders a structured human-readable block of metadata. +func printPackageInfo(p *adb.Package) { + fmt.Println() + fmt.Printf(" %s\n", p.DisplayTitle()) + fmt.Printf(" %s\n", p.Name) + fmt.Println(strings.Repeat("-", 60)) + rows := []struct{ k, v string }{ + {"Type", p.Kind.String()}, + {"Version", dashIfEmpty(p.VersionSummary())}, + {"Enabled", yesNo(p.Enabled)}, + {"Installer", dashIfEmpty(p.Installer)}, + {"APK path", dashIfEmpty(p.Path)}, + {"UID", dashIfEmpty(i64toa(int64(p.UID)))}, + {"Target SDK", dashIfEmpty(i64toa(int64(p.TargetSdk)))}, + {"Min SDK", dashIfEmpty(i64toa(int64(p.MinSdk)))}, + {"First installed", adb.FormatMillis(p.FirstInstall)}, + {"Last updated", adb.FormatMillis(p.LastUpdate)}, + } + for _, r := range rows { + fmt.Printf(" %-16s %s\n", r.k+":", r.v) + } + if len(p.SplitCodePaths) > 0 { + fmt.Printf(" %-16s\n", "Split APKs:") + for _, s := range p.SplitCodePaths { + fmt.Printf(" %s\n", s) } + } + fmt.Println() +} - out, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) - if err != nil { - return err - } +func formatVersion(p adb.Package) string { + return p.VersionSummary() +} - rc, err := f.Open() - if err != nil { - out.Close() - return err - } +func shortSerial(s string) string { + if len(s) <= 12 { + return s + } + return s[:8] + "…" +} - _, err = io.Copy(out, rc) - out.Close() - rc.Close() - if err != nil { - return err - } +func serials(ds []adb.Device) []string { + out := make([]string, 0, len(ds)) + for _, d := range ds { + out = append(out, d.Serial) + } + return out +} + +func dashIfEmpty(s string) string { + if s == "" || s == "-1" { + return "β€”" } - return nil + return s } -func tungguEnter(r *bufio.Reader) { - fmt.Println("\nTekan Enter untuk keluar...\nPress Enter to exit...") - _, _ = r.ReadString('\n') +func yesNo(b bool) string { + if b { + return "Yes" + } + return "No" +} + +func i64toa(v int64) string { + if v <= 0 { + return "" + } + return strconv.FormatInt(v, 10) } diff --git a/CLI/main_test.go b/CLI/main_test.go index 2029e56..9c0e342 100644 --- a/CLI/main_test.go +++ b/CLI/main_test.go @@ -1,37 +1,168 @@ package main import ( + "bytes" + "io" + "os" + "strings" "testing" + + "github.com/flessan/AdbPureFlow/internal/adb" ) -func TestFindNewPackage(t *testing.T) { - before := map[string]bool{ - "com.android.settings": true, - "com.google.android": true, +// Core ADB behavior (device parsing, package parsing, etc.) lives in +// internal/adb and is tested there. These tests cover CLI-specific helpers +// that don't require a real adb binary or Android device. + +func TestTokenize(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"launch com.example.app", []string{"launch", "com.example.app"}}, + {`install "C:\My Apps\foo.apk"`, []string{"install", `C:\My Apps\foo.apk`}}, + {"", nil}, + {" apps -u ", []string{"apps", "-u"}}, } + for _, c := range cases { + got := tokenize(c.in) + if len(got) != len(c.want) { + t.Errorf("tokenize(%q) = %v, want %v", c.in, got, c.want) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("tokenize(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i]) + } + } + } +} - after := map[string]bool{ - "com.android.settings": true, - "com.google.android": true, - "com.example.newapp": true, +func TestShortSerial(t *testing.T) { + if s := shortSerial("ABC123"); s != "ABC123" { + t.Errorf("shortSerial short = %q", s) } + got := shortSerial("ABCDEFGHIJKLMNOP") + if !strings.HasPrefix(got, "ABCDEFGH") || !strings.Contains(got, "…") { + t.Errorf("shortSerial long = %q", got) + } +} - pkg := findNewPackage(before, after) - if pkg != "com.example.newapp" { - t.Errorf("Expected com.example.newapp, got %s", pkg) +// formatVersion is a small presenter in CLI/main.go. It must not panic and +// should produce a stable string for both empty and populated inputs. +func TestFormatVersion(t *testing.T) { + cases := []struct { + p adb.Package + want string + }{ + {adb.Package{}, ""}, + {adb.Package{VersionCode: 12}, "(12)"}, + {adb.Package{VersionName: "1.2.3", VersionCode: 42}, "1.2.3 (42)"}, + } + for _, c := range cases { + got := formatVersion(c.p) + if got != c.want { + t.Errorf("formatVersion(%+v) = %q, want %q", c.p, got, c.want) + } } } -func TestFindNewPackageNoChange(t *testing.T) { - before := map[string]bool{ - "com.android.settings": true, +func TestDashIfEmpty(t *testing.T) { + cases := map[string]string{ + "": "β€”", + "-1": "β€”", + "0": "0", + "foo": "foo", } - after := map[string]bool{ - "com.android.settings": true, + for in, want := range cases { + if got := dashIfEmpty(in); got != want { + t.Errorf("dashIfEmpty(%q) = %q, want %q", in, got, want) + } } +} + +func TestYesNo(t *testing.T) { + if yesNo(true) != "Yes" { + t.Errorf("yesNo(true) = %q", yesNo(true)) + } + if yesNo(false) != "No" { + t.Errorf("yesNo(false) = %q", yesNo(false)) + } +} - pkg := findNewPackage(before, after) - if pkg != "" { - t.Errorf("Expected empty package, got %s", pkg) +func TestI64toa(t *testing.T) { + if i64toa(0) != "" { + t.Errorf("i64toa(0) = %q", i64toa(0)) + } + if i64toa(-5) != "" { + t.Errorf("i64toa(-5) = %q", i64toa(-5)) } + if i64toa(42) != "42" { + t.Errorf("i64toa(42) = %q", i64toa(42)) + } +} + +// captureStdout temporarily redirects os.Stdout while calling fn and +// returns the captured output as a string. +func captureStdout(fn func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + fn() + w.Close() + os.Stdout = old + var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String() +} + +func TestPrintPackageInfo(t *testing.T) { + p := &adb.Package{ + Name: "com.example.app", + Label: "Example", + VersionName: "1.2.3", + VersionCode: 42, + Installer: "com.android.vending", + Kind: adb.KindUser, + Path: "/data/app/~~x/com.example.app-y==", + FirstInstall: 1704067200000, // 2024-01-01 UTC + LastUpdate: 1704067200000, + UID: 10123, + MinSdk: 24, + TargetSdk: 34, + Enabled: true, + } + out := captureStdout(func() { printPackageInfo(p) }) + for _, want := range []string{ + "Example", + "com.example.app", + "1.2.3 (42)", + "com.android.vending", + "/data/app/", + "10123", + "34", + "24", + "user", + } { + if !strings.Contains(out, want) { + t.Errorf("printPackageInfo output missing %q; got:\n%s", want, out) + } + } + + // Empty label falls back to package name in the title. + p2 := &adb.Package{Name: "com.example.nolabel"} + out2 := captureStdout(func() { printPackageInfo(p2) }) + if !strings.Contains(out2, "com.example.nolabel") { + t.Errorf("fallback title should include package name, got:\n%s", out2) + } + // Missing metadata is rendered as "β€”". + if !strings.Contains(out2, "β€”") { + t.Errorf("expected em-dash for missing fields, got:\n%s", out2) + } +} + +// TestUsageDoesNotPanic ensures the usage function renders without +// erroring when called directly. +func TestUsageDoesNotPanic(t *testing.T) { + captureStdout(func() { usage() }) } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df54636..c0908e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,11 +25,21 @@ Feature requests are always welcome! Open an issue with: ### 3. Submitting Pull Requests - Fork the repository. -- Create a new branch from `main` (e.g., `feature/awesome-new-tool` or `fix/connection-bug`). +- Create a new branch from `main` (development branches for Arena sessions use the `arena/*` namespace; personal branches can use any descriptive name, e.g. `feature/awesome-new-tool` or `fix/connection-bug`). - Write meaningful, descriptive commit messages. -- Ensure all tests pass. +- Ensure all tests pass locally. - Write tests for any new logic introduced. -- Submit a pull request describing the changes and why they are valuable. +- Fill in the pull request template sections (`## Summary`, `## Validation`, + `## Breaking Changes`, `## Notes`) β€” the content you write here is used to + build the changelog and release notes automatically when your PR is merged. +- Select the semantic versioning impact of your change (MAJOR/MINOR/PATCH) + using the checkbox in the PR template, or apply one of the labels + `breaking`, `feature`, `bug`, `chore`, `ci`, `docs` to the PR. If nothing + is selected, the release defaults to a PATCH bump. You may also add a + `Release-As: vX.Y.Z` line in the PR body to force a specific version. +- Submit the pull request. CI (lint, vet, tests, multi-platform build) will + run automatically. Releases are produced **only** when a PR is merged into + `main`; branch builds never create tags or publish artifacts. --- diff --git a/GUI/app.go b/GUI/app.go index 77b1b6b..07ec95f 100644 --- a/GUI/app.go +++ b/GUI/app.go @@ -1,473 +1,12 @@ package main -import ( - "archive/tar" - "archive/zip" - "bytes" - "compress/gzip" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" -) +// This file was originally the GUI's ad-hoc "App" type that bundled ADB +// helpers (getADBPath, runCommand, parsePackages, etc). All of that logic +// has moved to the shared `internal/adb` package, which is used by both the +// GUI and CLI. Only presentation-layer code (widget wiring, event handlers, +// layout) lives in this directory now. +// +// The package is kept as `package main` so `go build ./GUI` still produces +// a working executable. See internal/adb/{adb,devices,packages,scrcpy,manager}.go +// for the device/app management implementation. -type App struct{} - -func NewApp() *App { - return &App{} -} - -// Scrcpy release download mapping for version 3.3.4 -var downloadURLs = map[string]string{ - "windows-amd64": "https://github.com/Genymobile/scrcpy/releases/download/v3.3.4/scrcpy-win64-v3.3.4.zip", - "windows-386": "https://github.com/Genymobile/scrcpy/releases/download/v3.3.4/scrcpy-win32-v3.3.4.zip", - "windows-arm64": "https://github.com/Genymobile/scrcpy/releases/download/v3.3.4/scrcpy-win64-v3.3.4.zip", - "linux-amd64": "https://github.com/Genymobile/scrcpy/releases/download/v3.3.4/scrcpy-linux-x86_64-v3.3.4.tar.gz", - "darwin-amd64": "https://github.com/Genymobile/scrcpy/releases/download/v3.3.4/scrcpy-macos-x86_64-v3.3.4.tar.gz", - "darwin-arm64": "https://github.com/Genymobile/scrcpy/releases/download/v3.3.4/scrcpy-macos-aarch64-v3.3.4.tar.gz", -} - -const scrcpyFolder = "scrcpy_core" - -// --- HELPER FUNCTIONS --- - -var adbURLs = map[string]string{ - "windows": "https://dl.google.com/android/repository/platform-tools-latest-windows.zip", - "darwin": "https://dl.google.com/android/repository/platform-tools-latest-darwin.zip", - "linux": "https://dl.google.com/android/repository/platform-tools-latest-linux.zip", -} - -const adbFolder = "adb_engine" - -func getADBPath() string { - adbName := "adb" - - if runtime.GOOS == "windows" { - adbName = "adb.exe" - } - - base, err := os.Getwd() - if err != nil { - base = "." - } - - localADB := filepath.Join( - base, - adbFolder, - "platform-tools", - adbName, - ) - - if _, err := os.Stat(localADB); err == nil { - return localADB - } - - // check system adb - if path, err := exec.LookPath(adbName); err == nil { - return path - } - - // auto download - url, ok := adbURLs[runtime.GOOS] - if !ok { - return adbName - } - - fmt.Println("Downloading ADB engine...") - - resp, err := http.Get(url) - if err != nil { - return adbName - } - defer resp.Body.Close() - - tmp := filepath.Join(base, "adb.zip") - - f, err := os.Create(tmp) - if err != nil { - return adbName - } - - io.Copy(f, resp.Body) - f.Close() - - err = unzip(tmp, adbFolder) - os.Remove(tmp) - - if err != nil { - return adbName - } - - if _, err := os.Stat(localADB); err == nil { - return localADB - } - - return adbName -} - -// Unified Command Runner -func runCommand(name string, args ...string) (string, error) { - cmd := exec.Command(name, args...) - var out bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &stderr - - err := cmd.Run() - if err != nil { - return strings.TrimSpace(stderr.String()), err - } - return strings.TrimSpace(out.String()), nil -} - -// --- ADB FEATURES --- - -func (a *App) GetDetailedDevices() ([]string, error) { - adb := getADBPath() - out, err := runCommand(adb, "devices", "-l") - if err != nil { - return nil, fmt.Errorf("ADB Path: %s | Error: %v", adb, err) - } - - var devices []string - lines := strings.Split(out, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "List of") { - continue - } - - parts := strings.Fields(line) - if len(parts) >= 1 { - serial := parts[0] - model := "Unknown Device" - - for _, p := range parts { - if strings.HasPrefix(p, "model:") { - model = strings.TrimPrefix(p, "model:") - model = strings.ReplaceAll(model, "_", " ") - break - } - } - devices = append(devices, fmt.Sprintf("%s (%s)", model, serial)) - } - } - return devices, nil -} - -func (a *App) Uninstall(pkg, serial string) (string, error) { - adb := getADBPath() - args := []string{"-s", serial, "uninstall", pkg} - return runCommand(adb, args...) -} - -func (a *App) RunADBPure(apkPath, serial string) string { - adb := getADBPath() - - cmdArgs := func(baseArgs ...string) []string { - if serial != "" { - return append([]string{"-s", serial}, baseArgs...) - } - return baseArgs - } - - // Fetch installed packages beforehand - beforeOut, _ := runCommand(adb, cmdArgs("shell", "pm", "list", "packages", "-3")...) - beforePackages := parsePackages(beforeOut) - - installArgs := cmdArgs("install", "-r", "-d", apkPath) - installCmd := exec.Command(adb, installArgs...) - installOut, err := installCmd.CombinedOutput() - resultStr := string(installOut) - - if err != nil || !strings.Contains(resultStr, "Success") { - return fmt.Sprintf("Install Failed: %s", strings.TrimSpace(resultStr)) - } - - // Fetch installed packages after - afterOut, _ := runCommand(adb, cmdArgs("shell", "pm", "list", "packages", "-3")...) - afterPackages := parsePackages(afterOut) - - newPackageID := "" - for pkg := range afterPackages { - if !beforePackages[pkg] { - newPackageID = pkg - break - } - } - - if newPackageID != "" { - _, _ = runCommand(adb, cmdArgs("shell", "monkey", "-p", newPackageID, "-c", "android.intent.category.LAUNCHER", "1")...) - return fmt.Sprintf("Success! Installed & Launched: %s", newPackageID) - } - - return "Install Success, but Package ID detection failed." -} - -func parsePackages(output string) map[string]bool { - pkgs := make(map[string]bool) - lines := strings.Split(output, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "package:") { - id := strings.TrimPrefix(line, "package:") - pkgs[id] = true - } - } - return pkgs -} - -// --- SCRCPY FEATURES --- - -func (a *App) StartScrcpy(serial string, logFunc func(string)) error { - exe, err := os.Executable() - - baseDir := "." - - if err == nil { - baseDir = filepath.Dir(exe) - } - if err != nil { - baseDir = "." - } - targetDir := filepath.Join(baseDir, scrcpyFolder) - - // Ensure scrcpy Folder Root Exists - if err := os.MkdirAll(targetDir, 0755); err != nil { - return fmt.Errorf("failed to create target folder: %v", err) - } - - // 1. Find or Download - scrcpyPath := findScrcpyFolder(targetDir) - if scrcpyPath == "" { - platformKey := fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH) - url, ok := downloadURLs[platformKey] - if !ok { - return fmt.Errorf("platform %s not supported for auto-download", platformKey) - } - - logFunc(fmt.Sprintf("Downloading Scrcpy for %s...", platformKey)) - err := downloadAndSetup(url, runtime.GOOS, targetDir, logFunc) - if err != nil { - return fmt.Errorf("download failed: %v", err) - } - scrcpyPath = findScrcpyFolder(targetDir) - } - - if scrcpyPath == "" { - return fmt.Errorf("scrcpy binary folder not found after setup") - } - - // 2. Execute - logFunc(fmt.Sprintf("Launching Mirror for %s...", serial)) - - execName := "scrcpy" - if runtime.GOOS == "windows" { - execName = "scrcpy.exe" - } - - fullPath := filepath.Join(scrcpyPath, execName) - - // Set executable permissions for Unix-like systems - if runtime.GOOS != "windows" { - _ = os.Chmod(fullPath, 0755) - _ = os.Chmod(filepath.Join(scrcpyPath, "adb"), 0755) - } - - // Run in background - cmd := exec.Command(fullPath, "-s", serial, "--always-on-top", "--window-title", "ADBPureFlow-Mirror") - cmd.Dir = scrcpyPath // Set working directory - - return cmd.Start() -} - -func findScrcpyFolder(root string) string { - files, err := os.ReadDir(root) - if err != nil { - return "" - } - - execName := "scrcpy" - if runtime.GOOS == "windows" { - execName = "scrcpy.exe" - } - - for _, f := range files { - if f.IsDir() && (strings.Contains(f.Name(), "scrcpy-") || f.Name() == "bin") { - // Check if executable exists inside - subPath := filepath.Join(root, f.Name()) - if _, err := os.Stat(filepath.Join(subPath, execName)); err == nil { - return subPath - } - } - } - - // Fallback check if scrcpy is directly in the folder - if _, err := os.Stat(filepath.Join(root, execName)); err == nil { - return root - } - - return "" -} - -func downloadAndSetup(url string, osType string, destRoot string, logFunc func(string)) error { - ext := ".tar.gz" - if osType == "windows" { - ext = ".zip" - } - tempFile := filepath.Join(destRoot, "download_temp"+ext) - - logFunc("Connecting to GitHub...") - resp, err := http.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("received bad status code: %s", resp.Status) - } - - f, err := os.Create(tempFile) - if err != nil { - return fmt.Errorf("failed to create temporary file: %v", err) - } - - _, err = io.Copy(f, resp.Body) - f.Close() - if err != nil { - os.Remove(tempFile) - return fmt.Errorf("failed to save download: %v", err) - } - - logFunc("Extracting engine components...") - if osType == "windows" { - err = unzip(tempFile, destRoot) - } else { - err = untar(tempFile, destRoot) - } - os.Remove(tempFile) - return err -} - -func unzip(src, dest string) error { - r, err := zip.OpenReader(src) - if err != nil { - return err - } - defer r.Close() - - destAbs, err := filepath.Abs(dest) - if err != nil { - return err - } - - for _, f := range r.File { - fpath := filepath.Join(dest, f.Name) - - // Prevent Zip Slip vulnerability - fpathAbs, err := filepath.Abs(fpath) - if err != nil { - return err - } - if !strings.HasPrefix(fpathAbs, destAbs+string(filepath.Separator)) && fpathAbs != destAbs { - return fmt.Errorf("illegal file path in zip: %s", f.Name) - } - - if f.FileInfo().IsDir() { - if err := os.MkdirAll(fpath, 0755); err != nil { - return err - } - continue - } - - if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil { - return err - } - - out, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) - if err != nil { - return err - } - - rc, err := f.Open() - if err != nil { - out.Close() - return err - } - - _, err = io.Copy(out, rc) - out.Close() - rc.Close() - if err != nil { - return err - } - } - return nil -} - -func untar(src, dest string) error { - f, err := os.Open(src) - if err != nil { - return err - } - defer f.Close() - - gzr, err := gzip.NewReader(f) - if err != nil { - return err - } - defer gzr.Close() - tr := tar.NewReader(gzr) - - destAbs, err := filepath.Abs(dest) - if err != nil { - return err - } - - for { - header, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return err - } - - target := filepath.Join(dest, header.Name) - - // Prevent Tar Slip vulnerability - targetAbs, err := filepath.Abs(target) - if err != nil { - return err - } - if !strings.HasPrefix(targetAbs, destAbs+string(filepath.Separator)) && targetAbs != destAbs { - return fmt.Errorf("illegal file path in tar: %s", header.Name) - } - - switch header.Typeflag { - case tar.TypeDir: - if err := os.MkdirAll(target, 0755); err != nil { - return err - } - case tar.TypeReg: - if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return err - } - outFile, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode)) - if err != nil { - return err - } - _, err = io.Copy(outFile, tr) - outFile.Close() - if err != nil { - return err - } - } - } - return nil -} diff --git a/GUI/app_test.go b/GUI/app_test.go index b21b535..728ab76 100644 --- a/GUI/app_test.go +++ b/GUI/app_test.go @@ -1,63 +1,16 @@ package main import ( - "os" - "path/filepath" "testing" ) -func TestParsePackages(t *testing.T) { - output := `package:com.android.settings -package:com.google.android.youtube -package:com.thio.adbpureflow -` - res := parsePackages(output) - if len(res) != 3 { - t.Errorf("Expected 3 packages, got %d", len(res)) - } - if !res["com.android.settings"] { - t.Errorf("Expected com.android.settings to be found") - } - if !res["com.thio.adbpureflow"] { - t.Errorf("Expected com.thio.adbpureflow to be found") - } -} - -func TestParsePackagesEmpty(t *testing.T) { - output := "" - res := parsePackages(output) - if len(res) != 0 { - t.Errorf("Expected 0 packages, got %d", len(res)) - } -} - -func TestFindScrcpyFolder(t *testing.T) { - tempDir, err := os.MkdirTemp("", "scrcpy_test_*") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - // Create simulated scrcpy subdirectory - scrcpySubDir := filepath.Join(tempDir, "scrcpy-win64-v3.3.4") - if err := os.MkdirAll(scrcpySubDir, 0755); err != nil { - t.Fatal(err) - } - - // Create simulated scrcpy binary inside - execName := "scrcpy" - if os.PathSeparator == '\\' { - // Windows - execName = "scrcpy.exe" - } - - simulatedBinary := filepath.Join(scrcpySubDir, execName) - if err := os.WriteFile(simulatedBinary, []byte("dummy binary contents"), 0755); err != nil { - t.Fatal(err) - } - - foundDir := findScrcpyFolder(tempDir) - if foundDir != scrcpySubDir { - t.Errorf("Expected to find %s, got %s", scrcpySubDir, foundDir) - } +// The previous GUI tests exercised helpers (parsePackages, findScrcpyFolder) +// that have been moved into the shared `internal/adb` package along with +// their test coverage. This file now acts as a placeholder so `go test ./GUI` +// continues to pass without requiring a real device or an adb binary at +// test time β€” see internal/adb/*_test.go for the behavioral tests. +func TestGUIAppStub(t *testing.T) { + // Nothing to assert: the GUI is a presentation layer that can only be + // exercised interactively or with a display; unit tests for core ADB + // behavior live in internal/adb. } diff --git a/GUI/go.mod b/GUI/go.mod index 4b2a67c..60cfa93 100644 --- a/GUI/go.mod +++ b/GUI/go.mod @@ -2,7 +2,12 @@ module github.com/flessan/AdbPureFlow/GUI go 1.21 -require fyne.io/fyne/v2 v2.7.3 +require ( + fyne.io/fyne/v2 v2.7.3 + github.com/flessan/AdbPureFlow/internal v0.0.0 +) + +replace github.com/flessan/AdbPureFlow/internal => ../internal require ( fyne.io/systray v1.12.0 // indirect @@ -24,12 +29,12 @@ require ( github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/kr/text v0.2.0 // indirect - github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nfnt/resize v0.0.0-2018022111625-83c6a9932646 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rymdport/portal v0.4.2 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect - github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + github.com/srwiley/rasterx v0.0.0-20220731165023-2ab79fcdd4ef // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/yuin/goldmark v1.7.8 // indirect golang.org/x/image v0.24.0 // indirect diff --git a/GUI/main.go b/GUI/main.go index 43db7d7..dba1ff1 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -1,256 +1,789 @@ +// Command adbpureflow-gui is the Fyne-based desktop GUI for ADBPureFlow. +// +// Architecture note: this file is a *presentation layer* only. All ADB +// operations (device discovery, package listing, install/launch/stop/ +// uninstall, scrcpy) live in the shared `internal/adb` package, which is +// also used by the CLI. package main import ( + "context" "fmt" + "os" "path/filepath" + "sort" "strings" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" + + "github.com/flessan/AdbPureFlow/internal/adb" ) +// version is stamped at build time via -ldflags="-X main.version=vX.Y.Z". +var version = "dev" + func main() { - // 1. App Initialization - myApp := app.NewWithID("com.thio.adbpureflow") - myApp.Settings().SetTheme(theme.DarkTheme()) - myWindow := myApp.NewWindow("ADBPureFlow Pro v5.0 (Mirror Edition)") - myWindow.Resize(fyne.NewSize(750, 600)) - - // Backend Logic - adbLogic := NewApp() - - // 2. UI Components - title := widget.NewLabelWithStyle("ADBPureFlow Pro", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}) - subtitle := widget.NewLabelWithStyle("Universal ADB Installer, Manager & Mirror", fyne.TextAlignCenter, fyne.TextStyle{Italic: true}) - - // Status Bar - statusLabel := widget.NewLabel("Status: Ready") - progress := widget.NewProgressBarInfinite() - progress.Hide() - - // Log Area - logArea := widget.NewMultiLineEntry() - logArea.SetPlaceHolder("System Logs:\n1. Connect your device.\n2. Drag & Drop APK or Click Open.\n3. Use Mirror button to view screen.") - logArea.Wrapping = fyne.TextWrapBreak - logArea.Disable() - - // --- DEFINE HELPERS FIRST --- - - // Thread-safe Logger - appendLog := func(msg string) { - fyne.Do(func() { - timestamp := time.Now().Format("15:04:05") - currentText := logArea.Text - logArea.SetText(fmt.Sprintf("%s [%s] %s\n", currentText, timestamp, msg)) - }) + a := app.NewWithID("com.thio.adbpureflow") + a.Settings().SetTheme(theme.DarkTheme()) + w := a.NewWindow(fmt.Sprintf("ADBPureFlow %s", version)) + w.Resize(fyne.NewSize(960, 640)) + + ui := newUI(a, w) + w.SetContent(ui.build()) + w.CenterOnScreen() + + go ui.refreshDevices() + + w.ShowAndRun() +} + +// ui bundles GUI state. +type ui struct { + app fyne.App + window fyne.Window + mgr *adb.Manager + ctx context.Context + cancel context.CancelFunc + + // Header widgets. + statusLbl *widget.Label + deviceSelect *widget.Select + refreshDevBtn *widget.Button + searchEntry *widget.Entry + + // List view. + appList *widget.List + logArea *widget.Label + logScroll *container.Scroll + + // Detail view. + detailName *canvas.Text + detailPkg *widget.Label + detailForm *widget.Form + detailScroll *container.Scroll + backBtn *widget.Button + + // Center stack swaps between list and detail panels. + center *fyne.Container + + // Footer actions (context-sensitive). + refreshBtn *widget.Button + installBtn *widget.Button + launchBtn *widget.Button + stopBtn *widget.Button + uninstallBtn *widget.Button + detailsBtn *widget.Button + mirrorBtn *widget.Button + actionBar *fyne.Container + + // State. + devices []adb.Device + apps []adb.Package // filtered list view + allApps []adb.Package // unfiltered + selectedDevice *adb.Device + selectedApp *adb.Package + detailApp *adb.Package + detailMode bool + logLines []string +} + +func newUI(_ fyne.App, w fyne.Window) *ui { + ctx, cancel := context.WithCancel(context.Background()) + u := &ui{window: w, ctx: ctx, cancel: cancel} + + dataDir := "" + if exe, err := os.Executable(); err == nil { + dataDir = filepath.Dir(exe) } + mgr, err := adb.NewManager(dataDir, true) + if err != nil { + dialog.ShowError(fmt.Errorf("failed to initialize ADB: %w", err), w) + } + u.mgr = mgr + return u +} + +func (u *ui) build() fyne.CanvasObject { + u.statusLbl = widget.NewLabel("Status: initializing…") - // Thread-safe Status Updater - updateStatus := func(status string, loading bool) { - fyne.Do(func() { - statusLabel.SetText("Status: " + status) - if loading { - progress.Show() - } else { - progress.Hide() + // ---- Device selector --------------------------------------------------- + u.deviceSelect = widget.NewSelect([]string{"(no devices)"}, func(s string) { + u.onDeviceSelected(s) + }) + u.deviceSelect.PlaceHolder = "Select a device…" + u.refreshDevBtn = widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { go u.refreshDevices() }) + deviceRow := container.NewBorder(nil, nil, widget.NewLabel("Target device: "), u.refreshDevBtn, u.deviceSelect) + + // ---- Search ------------------------------------------------------------ + u.searchEntry = widget.NewEntry() + u.searchEntry.SetPlaceHolder("Search installed applications…") + u.searchEntry.OnChanged = func(s string) { u.applyFilter(s) } + + // ---- App list ---------------------------------------------------------- + u.appList = widget.NewList( + func() int { return len(u.apps) }, + func() fyne.CanvasObject { return newAppListItem() }, + func(id widget.ListItemID, obj fyne.CanvasObject) { + if id < 0 || id >= len(u.apps) { + return } + it := obj.(*appListItem) + it.set(u.apps[id]) + isSel := u.selectedApp != nil && u.selectedApp.Name == u.apps[id].Name + it.setSelected(isSel) + }, + ) + u.appList.OnSelected = func(id widget.ListItemID) { + if id < 0 || id >= len(u.apps) { + return + } + p := u.apps[id] + u.selectedApp = &p + u.updateActionState() + u.appList.RefreshItem(id) + } + + // ---- Log --------------------------------------------------------------- + u.logArea = widget.NewLabel("") + u.logArea.Wrapping = fyne.TextWrapWord + u.logScroll = container.NewVScroll(u.logArea) + u.logScroll.SetMinSize(fyne.NewSize(0, 120)) + u.log("Ready.") + + // ---- Action buttons ---------------------------------------------------- + u.refreshBtn = widget.NewButtonWithIcon("Refresh", theme.ViewRefreshIcon(), func() { go u.refreshApps() }) + u.installBtn = widget.NewButtonWithIcon("Install APK…", theme.UploadIcon(), func() { u.onInstall() }) + u.launchBtn = widget.NewButtonWithIcon("Launch", theme.MediaPlayIcon(), func() { go u.onLaunch() }) + u.stopBtn = widget.NewButtonWithIcon("Force Stop", theme.MediaStopIcon(), func() { go u.onStop() }) + u.uninstallBtn = widget.NewButtonWithIcon("Uninstall", theme.DeleteIcon(), func() { u.onUninstall() }) + u.detailsBtn = widget.NewButtonWithIcon("Details", theme.InfoIcon(), func() { u.openDetail() }) + u.mirrorBtn = widget.NewButtonWithIcon("Mirror", theme.ComputerIcon(), func() { go u.onMirror() }) + + u.actionBar = container.NewHBox( + u.refreshBtn, + widget.NewSeparator(), + u.installBtn, + u.launchBtn, + u.stopBtn, + u.uninstallBtn, + widget.NewSeparator(), + u.detailsBtn, + u.mirrorBtn, + ) + + // ---- Detail view widgets (created once, populated on demand) ----------- + u.detailName = canvas.NewText("", theme.ForegroundColor()) + u.detailName.TextSize = 22 + u.detailName.TextStyle = fyne.TextStyle{Bold: true} + u.detailPkg = widget.NewLabel("") + u.detailPkg.TextStyle = fyne.TextStyle{Monospace: true} + u.detailPkg.Wrapping = fyne.TextWrapWord + u.detailForm = widget.NewForm() + u.detailScroll = container.NewVScroll(u.detailForm) + u.backBtn = widget.NewButtonWithIcon("← Back to applications", theme.NavigateBackIcon(), func() { u.showList() }) + + // ---- Layout ------------------------------------------------------------ + header := container.NewVBox( + heading("ADBPureFlow"), + subheading("Simple Android device & application management"), + widget.NewSeparator(), + deviceRow, + widget.NewSeparator(), + ) + footer := container.NewVBox( + widget.NewSeparator(), + u.actionBar, + widget.NewSeparator(), + container.NewBorder(nil, nil, u.statusLbl, nil, nil), + ) + + u.center = container.NewStack(u.buildListPanel()) + + u.updateActionState() + return container.NewBorder(header, footer, nil, nil, u.center) +} + +// --------------------------------------------------------------------------- +// Panels +// --------------------------------------------------------------------------- + +func (u *ui) buildListPanel() fyne.CanvasObject { + header := container.NewBorder(nil, nil, widget.NewLabel("Applications"), nil, u.searchEntry) + body := container.NewHSplit(u.appList, u.logScroll) + body.SetOffset(0.7) + return container.NewBorder(header, nil, nil, nil, body) +} + +func (u *ui) buildDetailPanel() fyne.CanvasObject { + p := u.detailApp + if p == nil { + return u.buildListPanel() + } + u.detailName.Text = p.DisplayTitle() + u.detailName.Color = theme.ForegroundColor() + u.detailName.Refresh() + u.detailPkg.SetText(p.Name) + + items := u.detailItems(p) + u.detailForm.Items = items + u.detailForm.Refresh() + + // Detail-only actions in the panel body (footer still shows global set + // but these are conveniently placed next to the metadata). + dLaunch := widget.NewButtonWithIcon("Launch", theme.MediaPlayIcon(), func() { go u.onLaunch() }) + dStop := widget.NewButtonWithIcon("Force Stop", theme.MediaStopIcon(), func() { go u.onStop() }) + dUninstall := widget.NewButtonWithIcon("Uninstall", theme.DeleteIcon(), func() { u.onUninstall() }) + detailActions := container.NewHBox(dLaunch, dStop, dUninstall, layout.NewSpacer()) + + head := container.NewVBox( + u.backBtn, + widget.NewSeparator(), + u.detailName, + u.detailPkg, + widget.NewSeparator(), + ) + foot := container.NewVBox(widget.NewSeparator(), detailActions) + u.detailScroll = container.NewVScroll(u.detailForm) + return container.NewBorder(head, foot, nil, nil, u.detailScroll) +} + +func (u *ui) detailItems(p *adb.Package) []*widget.FormItem { + ver := p.VersionSummary() + if ver == "" { + ver = "β€”" + } + enabled := "Yes" + if !p.Enabled { + enabled = "No" + } + installer := p.Installer + if installer == "" { + installer = "β€”" + } + apkPath := p.Path + if apkPath == "" { + apkPath = "β€”" + } + uid := "β€”" + if p.UID > 0 { + uid = fmt.Sprintf("%d", p.UID) + } + targetSdk := "β€”" + if p.TargetSdk > 0 { + targetSdk = fmt.Sprintf("%d", p.TargetSdk) + } + minSdk := "β€”" + if p.MinSdk > 0 { + minSdk = fmt.Sprintf("%d", p.MinSdk) + } + first := adb.FormatMillis(p.FirstInstall) + last := adb.FormatMillis(p.LastUpdate) + splits := "β€”" + if len(p.SplitCodePaths) > 0 { + splits = strings.Join(p.SplitCodePaths, "\n") + } + return []*widget.FormItem{ + widget.NewFormItem("Type", widget.NewLabel(p.Kind.String())), + widget.NewFormItem("Version", widget.NewLabel(ver)), + widget.NewFormItem("Enabled", widget.NewLabel(enabled)), + widget.NewFormItem("Installer", widget.NewLabelWithStyle(installer, fyne.TextAlignLeading, fyne.TextStyle{Monospace: installer != "β€”"})), + widget.NewFormItem("APK path", widget.NewLabelWithStyle(apkPath, fyne.TextAlignLeading, fyne.TextStyle{Monospace: true})), + widget.NewFormItem("Split APKs", widget.NewLabelWithStyle(splits, fyne.TextAlignLeading, fyne.TextStyle{Monospace: splits != "β€”"})), + widget.NewFormItem("UID", widget.NewLabel(uid)), + widget.NewFormItem("Target SDK", widget.NewLabel(targetSdk)), + widget.NewFormItem("Min SDK", widget.NewLabel(minSdk)), + widget.NewFormItem("First installed", widget.NewLabel(first)), + widget.NewFormItem("Last updated", widget.NewLabel(last)), + } +} + +func (u *ui) showList() { + u.detailMode = false + u.detailApp = nil + u.center.Objects = []fyne.CanvasObject{u.buildListPanel()} + u.center.Refresh() + u.updateActionState() +} + +func (u *ui) openDetail() { + if u.selectedApp == nil { + dialog.ShowInformation("No application selected", "Select an application from the list to view details.", u.window) + return + } + u.detailMode = true + u.detailApp = u.selectedApp + u.center.Objects = []fyne.CanvasObject{u.buildDetailPanel()} + u.center.Refresh() + u.updateActionState() +} + +func (u *ui) refreshDetail() { + if u.detailMode && u.detailApp != nil { + u.center.Objects = []fyne.CanvasObject{u.buildDetailPanel()} + u.center.Refresh() + } +} + +// --------------------------------------------------------------------------- +// Event handlers +// --------------------------------------------------------------------------- + +func (u *ui) refreshDevices() { + if u.mgr == nil { + u.setStatus("ADB not initialized", false) + return + } + u.setStatus("Scanning for devices…", true) + u.log("scanning for devices…") + + devs, err := u.mgr.RefreshDevices(u.ctx) + if err != nil { + u.doUI(func() { + u.setStatus("device scan failed", false) + dialog.ShowError(err, u.window) }) + u.logf("error: %v", err) + return } - // Device Selector - deviceSelect := widget.NewSelect([]string{"No Device"}, func(s string) { - if s == "No Device" { - updateStatus("Waiting for device...", false) - } else { - updateStatus("Target -> "+s, false) + u.doUI(func() { + u.devices = devs + options := make([]string, 0, len(devs)) + for _, d := range devs { + options = append(options, d.DisplayName()+" ["+string(d.State)+"]") } - }) - deviceSelect.PlaceHolder = "Select Device..." - - // Process APK Logic - processAPK := func(path string) { - cleanPath := filepath.FromSlash(path) - // Fix specific Fyne Windows URI issue - if len(cleanPath) > 2 && cleanPath[0] == '\\' && cleanPath[2] == ':' { - cleanPath = cleanPath[1:] - } - - selectedDevice := deviceSelect.Selected - if selectedDevice == "" || selectedDevice == "No Device" { - dialog.ShowError(fmt.Errorf("no device selected"), myWindow) - appendLog("Error: No device selected") + if len(options) == 0 { + options = []string{"(no devices)"} + u.deviceSelect.SetOptions(options) + u.deviceSelect.SetSelectedIndex(0) + u.selectedDevice = nil + u.apps = nil + u.allApps = nil + u.selectedApp = nil + u.appList.Refresh() + u.setStatus("no devices connected", false) + u.log("no devices found.") + u.updateActionState() + if u.detailMode { + u.showList() + } return } + u.deviceSelect.SetOptions(options) + if u.selectedDevice == nil { + for i, d := range devs { + if d.State == adb.StateDevice { + u.deviceSelect.SetSelectedIndex(i) + u.onDeviceSelected(options[i]) + break + } + } + } + u.setStatus(fmt.Sprintf("found %d device(s)", len(devs)), false) + u.logf("found %d device(s).", len(devs)) + }) +} - parts := strings.Split(selectedDevice, "(") - if len(parts) < 2 { +func (u *ui) onDeviceSelected(label string) { + if label == "" || label == "(no devices)" { + u.selectedDevice = nil + u.apps = nil + u.allApps = nil + u.selectedApp = nil + u.appList.Refresh() + u.updateActionState() + return + } + for i := range u.devices { + d := &u.devices[i] + if strings.HasPrefix(label, strings.SplitN(d.DisplayName(), " (", 2)[0]) && strings.Contains(label, d.Serial) { + if d.State != adb.StateDevice { + u.setStatus(fmt.Sprintf("device %s is %s", d.Serial, d.State), false) + u.logf("device %s is in state %s (not usable)", d.Serial, d.State) + u.selectedDevice = nil + u.updateActionState() + dialog.ShowInformation("Device not ready", + fmt.Sprintf("Device %q is in state %q. Please authorize/connect it and refresh.", d.Serial, d.State), + u.window) + return + } + u.selectedDevice = d + _ = u.mgr.Client.InspectDevice(u.ctx, *d) + u.setStatus("target: "+d.DisplayName(), false) + u.logf("selected %s", d.DisplayName()) + go u.refreshApps() + u.updateActionState() return } - serial := strings.TrimSuffix(parts[len(parts)-1], ")") - - fileName := filepath.Base(cleanPath) - appendLog(fmt.Sprintf("Processing: %s", fileName)) - updateStatus("Installing to "+selectedDevice, true) + } +} - go func() { - result := adbLogic.RunADBPure(cleanPath, serial) - appendLog(result) - updateStatus("Finished", false) - }() +func (u *ui) refreshApps() { + if u.selectedDevice == nil { + return + } + u.setStatus("loading applications…", true) + u.logf("listing apps on %s…", u.selectedDevice.Serial) + + pkgs, err := u.mgr.ListPackages(u.ctx, u.selectedDevice.Serial, false) + if err != nil { + u.doUI(func() { + u.setStatus("failed to list apps", false) + dialog.ShowError(err, u.window) + }) + u.logf("error listing apps: %v", err) + return } - // Refresh Device List Logic - refreshDevices := func() { - go func() { - appendLog("Scanning for devices...") - devices, err := adbLogic.GetDetailedDevices() - if err != nil { - appendLog("Error: " + err.Error()) - return + u.doUI(func() { + u.allApps = pkgs + u.applyFilter(u.searchEntry.Text) + u.setStatus(fmt.Sprintf("%s β€” %d apps", u.selectedDevice.DisplayName(), len(pkgs)), false) + u.logf("loaded %d apps.", len(pkgs)) + // If we're in detail mode and the detail app still exists, refresh + // its entry; otherwise fall back to list. + if u.detailMode && u.detailApp != nil { + for i := range pkgs { + if pkgs[i].Name == u.detailApp.Name { + u.detailApp = &pkgs[i] + u.selectedApp = u.detailApp + u.refreshDetail() + return + } } + u.showList() + } + }) +} - fyne.Do(func() { - if len(devices) == 0 { - deviceSelect.Options = []string{"No Device"} - deviceSelect.SetSelectedIndex(0) - appendLog("No devices found.") - } else { - deviceSelect.Options = devices - deviceSelect.SetSelectedIndex(0) - appendLog(fmt.Sprintf("Found %d device(s).", len(devices))) - } - }) - }() +func (u *ui) applyFilter(q string) { + if u.allApps == nil { + u.apps = nil + if u.appList != nil { + u.appList.Refresh() + } + u.updateActionState() + return + } + q = strings.ToLower(strings.TrimSpace(q)) + u.apps = u.apps[:0] + if q == "" { + u.apps = append(u.apps, u.allApps...) + } else { + for _, p := range u.allApps { + label := strings.ToLower(p.Label) + name := strings.ToLower(p.Name) + if strings.Contains(name, q) || strings.Contains(label, q) { + u.apps = append(u.apps, p) + } + } + } + sort.SliceStable(u.apps, func(i, j int) bool { + if u.apps[i].Kind != u.apps[j].Kind { + return u.apps[i].Kind < u.apps[j].Kind + } + return strings.ToLower(u.apps[i].DisplayTitle()) < strings.ToLower(u.apps[j].DisplayTitle()) + }) + if u.appList != nil { + u.appList.Refresh() } + u.selectedApp = nil + u.updateActionState() +} - // Uninstall Logic - uninstallApp := func() { - selectedDevice := deviceSelect.Selected - if selectedDevice == "" || selectedDevice == "No Device" { - dialog.ShowError(fmt.Errorf("no device selected"), myWindow) +func (u *ui) onInstall() { + if u.selectedDevice == nil { + dialog.ShowInformation("No device", "Select a connected device first.", u.window) + return + } + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { return } - - entry := widget.NewEntry() - entry.SetPlaceHolder("com.example.app") - - dialog.ShowForm("Uninstall App", "Uninstall", "Cancel", - []*widget.FormItem{ - widget.NewFormItem("Package Name", entry), - }, - func(confirm bool) { - if !confirm || entry.Text == "" { + defer reader.Close() + path := reader.URI().Path() + if len(path) >= 3 && path[0] == '/' && path[2] == ':' { + path = path[1:] + } + path = filepath.FromSlash(path) + go func() { + u.setStatus("installing "+filepath.Base(path)+"…", true) + u.logf("installing %s …", path) + msg, err := u.mgr.InstallAPK(u.ctx, u.selectedDevice.Serial, path) + u.doUI(func() { + if err != nil { + u.setStatus("install failed", false) + dialog.ShowError(err, u.window) + u.logf("install error: %v", err) return } + u.setStatus("install OK", false) + u.log(msg) + u.refreshApps() + }) + }() + }, u.window) +} - parts := strings.Split(selectedDevice, "(") - serial := strings.TrimSuffix(parts[len(parts)-1], ")") +func (u *ui) currentApp() *adb.Package { + if u.detailMode && u.detailApp != nil { + return u.detailApp + } + return u.selectedApp +} - updateStatus("Uninstalling...", true) - go func() { - out, err := adbLogic.Uninstall(entry.Text, serial) - if err != nil { - appendLog("Uninstall Failed: " + out) - } else { - appendLog("Success: " + out) - } - updateStatus("Ready", false) - }() - }, myWindow) +func (u *ui) onLaunch() { + if u.selectedDevice == nil { + return + } + pkg := u.currentApp() + if pkg == nil { + return } + name := pkg.Name + title := pkg.DisplayTitle() + u.setStatus("launching "+title+"…", true) + u.logf("launching %s …", name) + if err := u.mgr.LaunchApp(u.ctx, u.selectedDevice.Serial, name); err != nil { + u.doUI(func() { + u.setStatus("launch failed", false) + dialog.ShowError(err, u.window) + }) + u.logf("launch error: %v", err) + return + } + u.doUI(func() { u.setStatus("launched "+title, false) }) + u.logf("launched %s.", name) +} - // NEW: Mirror Screen Logic (Scrcpy) - startMirror := func() { - selectedDevice := deviceSelect.Selected - if selectedDevice == "" || selectedDevice == "No Device" { - dialog.ShowError(fmt.Errorf("no device selected"), myWindow) +func (u *ui) onStop() { + if u.selectedDevice == nil { + return + } + pkg := u.currentApp() + if pkg == nil { + return + } + target := pkg.Name + title := pkg.DisplayTitle() + dialog.ShowConfirm("Force stop", fmt.Sprintf("Force-stop %q?", title), func(ok bool) { + if !ok { return } - - parts := strings.Split(selectedDevice, "(") - serial := strings.TrimSuffix(parts[len(parts)-1], ")") - - appendLog("Preparing Scrcpy Engine...") - updateStatus("Checking/Downloading Scrcpy...", true) - go func() { - // This function handles download if missing, then runs scrcpy - err := adbLogic.StartScrcpy(serial, func(msg string) { - appendLog(msg) - }) - - if err != nil { - appendLog("Mirror Error: " + err.Error()) - updateStatus("Mirror Failed", false) - } else { - appendLog("Scrcpy launched successfully.") - updateStatus("Mirroring Active", false) + u.setStatus("stopping "+title+"…", true) + if err := u.mgr.ForceStopApp(u.ctx, u.selectedDevice.Serial, target); err != nil { + u.doUI(func() { + dialog.ShowError(err, u.window) + u.setStatus("force-stop failed", false) + }) + u.logf("stop error: %v", err) + return } + u.doUI(func() { u.setStatus("stopped "+title, false) }) + u.logf("force-stopped %s.", target) }() - } + }, u.window) +} - // 3. Toolbar - toolbar := widget.NewToolbar( - widget.NewToolbarAction(theme.FileIcon(), func() { - dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { - if err != nil || reader == nil { +func (u *ui) onUninstall() { + if u.selectedDevice == nil { + return + } + pkg := u.currentApp() + if pkg == nil { + return + } + target := pkg.Name + title := pkg.DisplayTitle() + dialog.ShowConfirm("Uninstall", + fmt.Sprintf("Uninstall %q from %s? This cannot be undone.", title, u.selectedDevice.DisplayName()), + func(ok bool) { + if !ok { + return + } + go func() { + u.setStatus("uninstalling "+title+"…", true) + if err := u.mgr.UninstallApp(u.ctx, u.selectedDevice.Serial, target, false); err != nil { + u.doUI(func() { + dialog.ShowError(err, u.window) + u.setStatus("uninstall failed", false) + }) + u.logf("uninstall error: %v", err) return } - reader.Close() - processAPK(reader.URI().Path()) - }, myWindow) - }), - widget.NewToolbarSeparator(), - widget.NewToolbarAction(theme.ComputerIcon(), refreshDevices), - widget.NewToolbarAction(theme.DeleteIcon(), uninstallApp), - widget.NewToolbarSeparator(), - // NEW MIRROR BUTTON - widget.NewToolbarAction(theme.MediaPlayIcon(), startMirror), - widget.NewToolbarSpacer(), - widget.NewToolbarAction(theme.ViewRefreshIcon(), func() { logArea.SetText("") }), - ) - - // 4. Layout - topSection := container.NewVBox( - title, - subtitle, - widget.NewSeparator(), - container.NewBorder(nil, nil, widget.NewLabel("Target Device:"), nil, deviceSelect), - toolbar, - widget.NewSeparator(), - ) + u.doUI(func() { + wasDetail := u.detailMode + if wasDetail { + u.showList() + } + u.selectedApp = nil + u.setStatus("uninstalled "+title, false) + }) + u.logf("uninstalled %s.", target) + u.refreshApps() + }() + }, u.window) +} - bottomSection := container.NewVBox( - widget.NewSeparator(), - container.NewBorder(nil, nil, statusLabel, nil, progress), - ) +func (u *ui) onMirror() { + if u.selectedDevice == nil { + dialog.ShowInformation("No device", "Select a connected device first.", u.window) + return + } + u.setStatus("starting scrcpy…", true) + u.log("starting scrcpy mirror …") + cmd, err := u.mgr.Scrcpy.StartMirror(u.ctx, u.selectedDevice.Serial, "ADBPureFlow-Mirror") + if err != nil { + u.doUI(func() { + dialog.ShowError(err, u.window) + u.setStatus("mirror failed", false) + }) + u.logf("mirror error: %v", err) + return + } + u.doUI(func() { u.setStatus("mirror active", false) }) + u.logf("scrcpy started (pid %d).", cmd.Process.Pid) +} - mainContent := container.NewBorder( - topSection, - bottomSection, - nil, - nil, - logArea, - ) +// --------------------------------------------------------------------------- +// UI helpers +// --------------------------------------------------------------------------- + +func (u *ui) updateActionState() { + hasDevice := u.selectedDevice != nil + hasApp := u.currentApp() != nil + for _, b := range []*widget.Button{u.installBtn, u.mirrorBtn, u.refreshBtn} { + b.Enable() + if !hasDevice { + b.Disable() + } + } + if u.mgr == nil { + for _, b := range []*widget.Button{u.refreshBtn, u.installBtn, u.launchBtn, u.stopBtn, u.uninstallBtn, u.detailsBtn, u.mirrorBtn} { + b.Disable() + } + return + } + if !hasDevice { + u.refreshBtn.Enable() + } + setE := func(b *widget.Button, enabled bool) { + if enabled { + b.Enable() + } else { + b.Disable() + } + } + setE(u.launchBtn, hasApp) + setE(u.stopBtn, hasApp) + setE(u.uninstallBtn, hasApp) + setE(u.detailsBtn, hasApp && !u.detailMode) +} - // 5. Event Bindings - myWindow.SetContent(mainContent) +func (u *ui) setStatus(text string, loading bool) { + u.doUI(func() { + prefix := "Status: " + if loading { + prefix = "Status: ⟳ " + } + u.statusLbl.SetText(prefix + text) + }) +} - myWindow.SetOnDropped(func(pos fyne.Position, uris []fyne.URI) { - for _, uri := range uris { - processAPK(uri.Path()) +func (u *ui) log(s string) { + u.doUI(func() { + ts := time.Now().Format("15:04:05") + u.logLines = append(u.logLines, "["+ts+"] "+s) + if len(u.logLines) > 200 { + u.logLines = u.logLines[len(u.logLines)-200:] } + u.logArea.SetText(strings.Join(u.logLines, "\n")) + u.logScroll.ScrollToBottom() }) +} - myWindow.CenterOnScreen() +func (u *ui) logf(format string, args ...any) { u.log(fmt.Sprintf(format, args...)) } - // Auto-refresh devices on start - go refreshDevices() +func heading(text string) *canvas.Text { + t := canvas.NewText(text, theme.ForegroundColor()) + t.TextSize = 20 + t.TextStyle = fyne.TextStyle{Bold: true} + return t +} + +func subheading(text string) *canvas.Text { + t := canvas.NewText(text, theme.DisabledColor()) + t.TextSize = 12 + t.TextStyle = fyne.TextStyle{Italic: true} + return t +} + +func (u *ui) doUI(f func()) { f() } + +// --------------------------------------------------------------------------- +// App list item: a compact two-line row with label (bold primary), package +// name (monospace secondary), and version tag on the right. Implemented as +// a simple container (not a custom widget) to keep the rendering simple. +// --------------------------------------------------------------------------- + +type appListItem struct { + widget.BaseWidget + primary *widget.Label + secondary *widget.Label + meta *widget.Label + box *fyne.Container + selected bool +} + +func newAppListItem() *appListItem { + it := &appListItem{ + primary: widget.NewLabel(""), + secondary: widget.NewLabel(""), + meta: widget.NewLabel(""), + } + it.primary.TextStyle = fyne.TextStyle{Bold: true} + it.primary.Truncation = fyne.TextTruncateClip + it.secondary.TextStyle = fyne.TextStyle{Monospace: true} + it.secondary.Truncation = fyne.TextTruncateClip + it.meta.Alignment = fyne.TextAlignTrailing + it.box = container.NewBorder(nil, nil, nil, it.meta, + container.NewVBox(it.primary, it.secondary), + ) + it.ExtendBaseWidget(it) + return it +} + +func (it *appListItem) set(p adb.Package) { + it.primary.SetText(p.DisplayTitle()) + it.secondary.SetText(p.Name) + it.meta.SetText(p.VersionSummary()) +} + +func (it *appListItem) setSelected(s bool) { + if it.selected == s { + return + } + it.selected = s + if s { + it.primary.Importance = widget.HighImportance + it.secondary.Importance = widget.HighImportance + } else { + it.primary.Importance = widget.MediumImportance + it.secondary.Importance = widget.MediumImportance + } + it.primary.Refresh() + it.secondary.Refresh() +} - myWindow.ShowAndRun() +func (it *appListItem) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(container.NewPadded(it.box)) } diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..47c1b77 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,118 @@ +# Releasing ADBPureFlow + +Releases are produced **automatically** by the GitHub Actions workflow defined +in `.github/workflows/release.yml`. There are no manual release steps in the +usual case. This document describes how the automation works and how to +recover from failures. + +## Lifecycle + +1. **Development branch.** Every push to a development branch (including the + `arena/*` namespace used by Arena.ai sessions, feature branches, personal + forks, etc.) runs the read-only `CI` workflow: `gofmt` check, `go vet`, + unit tests, and a multi-platform build smoke test. These jobs **never** + publish artifacts, create Git tags, or create GitHub Releases β€” the CI + workflow is pinned to `contents: read`. + +2. **Pull request β†’ `main`.** When you open a PR targeting `main`, the same + `CI` workflow runs against the PR. Fill in the PR template sections: + - `## Summary` β€” human-readable description of the change (becomes the + **main body** of the GitHub Release). + - `## Validation` β€” how the change was tested (becomes the Validation + section of the release notes). + - `## Breaking Changes` β€” describe any breaking changes, or leave as + `None.` + - `## Notes` β€” optional additional context. + + Select the semantic version impact via the **MAJOR / MINOR / PATCH** + checkbox under `## Type of Change`, or apply one of the labels + `breaking`, `feature`/`enhancement`, `bug`/`fix`, `chore`, `ci`, `docs`, + `documentation`, `maintenance`. If nothing is selected, the release + defaults to a **PATCH** bump. You may also force a specific version by + adding `Release-As: vX.Y.Z` anywhere in the PR body, or + `Semver: major|minor|patch`. + +3. **Merge.** When the PR is merged into `main`, the `Release` workflow fires + on the push-to-main event. The `version-bump` job does the following + inside a single workflow run: + - Identifies the merged PR from the merge commit (both standard merge + commits and squash merges are recognized). + - Computes the next [SemVer](https://semver.org) based on the labels, + checkboxes, and `Release-As`/`Semver` directives. + - Extracts the `Summary`, `Validation`, `Breaking Changes`, and `Notes` + sections from the PR body. + - Prepends a new entry to `CHANGELOG.md` (history is always preserved). + - Commits the CHANGELOG update with the marker `[release skip]` in the + commit message so the automation won't try to produce another release + from the commit it just created. + - Creates an annotated tag `vX.Y.Z` with the rendered release notes + embedded between `---RELEASE-BODY-START---` and `---RELEASE-BODY-END---` + markers inside the tag message. + - Pushes the commit and tag to `origin/main`. GitHub does **not** re- + trigger workflows from token-pushed events (loop prevention), so the + build and publish continue in the **same** run. + +4. **Build + publish.** The `build` job checks out the new tag and compiles + the CLI and GUI for the existing platform matrix (Windows, Linux, macOS, + all amd64), stamping `main.version` via `-ldflags`. The `publish-release` + job then collates the binaries and creates (or edits) the GitHub Release, + attaching all artifacts and using the release notes prepared in step 3. + Artifact names keep the existing convention + `adbpureflow-{cli,gui}-{goos}-{goarch}[.exe]`. + +## Branch permissions + +| Branch / ref | Lint / Test / Build | Create tag | Publish Release | +|-----------------------------|:-------------------:|:----------:|:---------------:| +| `arena/*` and other branches | βœ… | ❌ | ❌ | +| Pull requests β†’ `main` | βœ… | ❌ | ❌ | +| Direct push to `main`* | βœ… (CI) | ❌ | ❌ | +| Release run on `main` (PR merge) | βœ… | βœ… | βœ… | +| `workflow_dispatch` (existing tag) | βœ… | ❌ | βœ… | + +> \* A direct push to `main` that is not a PR merge (no PR merge commit +> detected at HEAD) will **not** create a release; `version-bump` logs a +> notice and exits. Always land changes through a PR. + +## Safety and idempotency + +The pipeline is designed to be safe to rerun and free of release loops: + +- The CHANGELOG commit contains `[release skip]`, which the version-bump job + detects at HEAD and no-ops on β†’ no loop. +- If the tag already exists (e.g. from a partially-failed run), the + version-bump job skips the commit/tag steps and proceeds directly to + build + publish against the existing tag. +- `softprops/action-gh-release` edits an existing release in place, so + re-running `publish-release` overwrites the body and adds/replaces assets + rather than failing. +- Build is always performed against the tag (not against the branch tip), so + binaries reflect exactly the commit that was released. + +## Recovering from a failed release + +- If the `version-bump` job failed before pushing anything, simply re-run + the failed job from the Actions UI. +- If the CHANGELOG commit and/or tag was pushed but the build failed, re-run + the failed `build` job (it checks out the tag directly). You can also + re-run the entire `Release` workflow from the Actions UI on a completed + run, or trigger it manually from the **Actions β†’ Release β†’ Run workflow** + button, passing the existing tag in the `tag` input β€” this will rebuild + and re-publish binaries without bumping the version. +- If you need to roll back, delete the tag and the `chore(release)` commit + on `main` and revert the merge PR; then open a new PR with the fix and + merge it as usual. + +## Local verification + +You can exercise the release-notes parser locally without pushing anything: + +```bash +# Syntax check the helper scripts +python .github/scripts/prepare_release.py +python .github/scripts/build_release_body.py +``` + +The scripts depend only on the Python standard library β€” no third-party +packages are required, which keeps the release pipeline deterministic and +secure. diff --git a/go.work b/go.work new file mode 100644 index 0000000..10272b4 --- /dev/null +++ b/go.work @@ -0,0 +1,7 @@ +go 1.21 + +use ( + ./CLI + ./GUI + ./internal +) diff --git a/internal/adb/adb.go b/internal/adb/adb.go new file mode 100644 index 0000000..122da43 --- /dev/null +++ b/internal/adb/adb.go @@ -0,0 +1,396 @@ +// Package adb provides a thin, ergonomic wrapper around the Android Debug +// Bridge (`adb`) binary. It is the only place in ADBPureFlow that shells out +// to adb; every higher-level feature (device discovery, package management, +// install, launch, …) is built on top of Client so that GUI and CLI share one +// implementation. +// +// The package intentionally avoids global state: a Client carries the resolved +// adb path and a few configuration knobs, and every device-specific command +// accepts an explicit serial via `-s <serial>` so commands can never +// accidentally run against the wrong device. +package adb + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +// Client is a handle to a resolved adb binary. Construct with New or +// MustNew. A Client is safe for concurrent use from multiple goroutines +// as long as callers don't mutate its exported fields after construction. +type Client struct { + // Path is the absolute (or PATH-resolved) location of the adb binary. + Path string + + // Timeout is the default per-command timeout. Zero means no timeout (the + // caller is expected to supply a context). + Timeout time.Duration + + // PlatformToolsDir is the directory where platform-tools live; it is used + // as the download destination by EnsureServer. + PlatformToolsDir string + + // HTTPClient is used for auto-download; defaults to http.DefaultClient. + HTTPClient *http.Client +} + +// CommandError is returned for adb invocations that exit non-zero. It +// preserves stderr/stdout snippets and the exit code so callers can surface +// precise errors in the GUI/CLI. +type CommandError struct { + Args []string + Exit int + Stdout string + Stderr string + Err error // underlying error, e.g. executable not found +} + +func (e *CommandError) Error() string { + msg := strings.TrimSpace(e.Stderr) + if msg == "" { + msg = strings.TrimSpace(e.Stdout) + } + if msg == "" && e.Err != nil { + msg = e.Err.Error() + } + return fmt.Sprintf("adb %s: %s", strings.Join(e.Args, " "), msg) +} + +func (e *CommandError) Unwrap() error { return e.Err } + +// ErrNoADB is returned by New if the adb binary cannot be located and +// auto-download is not possible / disabled. +var ErrNoADB = errors.New("adb: executable not found on PATH or bundled platform-tools") + +// platformADBURL maps runtime.GOOS to the official Google platform-tools +// download URL. (These URLs redirect to the latest available build.) +var platformADBURL = map[string]string{ + "windows": "https://dl.google.com/android/repository/platform-tools-latest-windows.zip", + "darwin": "https://dl.google.com/android/repository/platform-tools-latest-darwin.zip", + "linux": "https://dl.google.com/android/repository/platform-tools-latest-linux.zip", +} + +// exeSuffix is ".exe" on Windows, "" elsewhere. +func exeSuffix() string { + if runtime.GOOS == "windows" { + return ".exe" + } + return "" +} + +// New returns a Client pointing at a usable adb binary. Resolution order: +// +// 1. The bundled `<dir>/platform-tools/adb[.exe]` (relative to the running +// executable or cwd), +// 2. An `adb` binary on the system PATH, +// 3. If `downloadIfMissing` is true, downloads the official platform-tools +// package into `dir` and uses that. +// +// dir is the directory under which platform-tools should be placed; it +// defaults to `<cwd>/adb_engine` if empty. +func New(dir string, downloadIfMissing bool) (*Client, error) { + if dir == "" { + if cwd, err := os.Getwd(); err == nil { + dir = filepath.Join(cwd, "adb_engine") + } else { + dir = "adb_engine" + } + } + + adbName := "adb" + exeSuffix() + + // 1. bundled copy + local := filepath.Join(dir, "platform-tools", adbName) + if st, err := os.Stat(local); err == nil && !st.IsDir() { + return &Client{Path: local, PlatformToolsDir: dir, Timeout: 30 * time.Second, HTTPClient: http.DefaultClient}, nil + } + + // 2. system PATH + if p, err := exec.LookPath("adb"); err == nil { + return &Client{Path: p, PlatformToolsDir: dir, Timeout: 30 * time.Second, HTTPClient: http.DefaultClient}, nil + } + + // 3. download + if !downloadIfMissing { + return nil, ErrNoADB + } + url, ok := platformADBURL[runtime.GOOS] + if !ok { + return nil, fmt.Errorf("%w: unsupported OS %s", ErrNoADB, runtime.GOOS) + } + if err := downloadAndExtract(url, dir, adbName); err != nil { + return nil, fmt.Errorf("adb: download failed: %w", err) + } + if runtime.GOOS != "windows" { + _ = os.Chmod(local, 0o755) + } + if _, err := os.Stat(local); err != nil { + return nil, fmt.Errorf("adb: download completed but binary missing at %s: %w", local, err) + } + return &Client{Path: local, PlatformToolsDir: dir, Timeout: 30 * time.Second, HTTPClient: http.DefaultClient}, nil +} + +// Command runs an adb command with the given arguments and returns its +// trimmed stdout. If the serial argument is non-empty it is prepended as +// `-s <serial>` so the command targets a specific device. +// +// Stderr is captured and returned via *CommandError on non-zero exit. +func (c *Client) Command(ctx context.Context, serial string, args ...string) (string, error) { + full := c.deviceArgs(serial, args) + return c.run(ctx, full) +} + +// CommandWithInput runs an adb command and pipes stdin to it. This is useful +// for commands like `exec-out` or interactive shell commands (though we +// generally prefer non-interactive shell invocations). +func (c *Client) CommandWithInput(ctx context.Context, serial string, stdin io.Reader, args ...string) (string, error) { + full := c.deviceArgs(serial, args) + cmd := exec.CommandContext(ctx, c.Path, full...) + cmd.Stdin = stdin + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + return "", c.mkErr(full, stdout.String(), stderr.String(), err) + } + return strings.TrimSpace(stdout.String()), nil +} + +// ServerCmd runs an `adb` host-side command that does NOT take a serial +// (e.g. `start-server`, `devices`, `kill-server`). +func (c *Client) ServerCmd(ctx context.Context, args ...string) (string, error) { + return c.run(ctx, args) +} + +func (c *Client) deviceArgs(serial string, args []string) []string { + if serial == "" { + return args + } + out := make([]string, 0, 2+len(args)) + out = append(out, "-s", serial) + out = append(out, args...) + return out +} + +func (c *Client) run(ctx context.Context, args []string) (string, error) { + if ctx == nil { + ctx = context.Background() + } + if c.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, c.Timeout) + defer cancel() + } + cmd := exec.CommandContext(ctx, c.Path, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + outStr := strings.TrimSpace(stdout.String()) + if err != nil { + return outStr, c.mkErr(args, stdout.String(), stderr.String(), err) + } + return outStr, nil +} + +func (c *Client) mkErr(args []string, stdout, stderr string, err error) error { + ce := &CommandError{ + Args: append([]string{}, args...), + Stdout: stdout, + Stderr: stderr, + Err: err, + } + var ee *exec.ExitError + if errors.As(err, &ee) { + ce.Exit = ee.ExitCode() + } + return ce +} + +// StartServer runs `adb start-server`. It is a no-op if the server is +// already running. +func (c *Client) StartServer(ctx context.Context) error { + _, err := c.ServerCmd(ctx, "start-server") + return err +} + +// Version returns the first line of `adb version` output. +func (c *Client) Version(ctx context.Context) (string, error) { + out, err := c.ServerCmd(ctx, "version") + if err != nil { + return "", err + } + if i := strings.IndexByte(out, '\n'); i > 0 { + return out[:i], nil + } + return out, nil +} + +// --------------------------------------------------------------------------- +// Auto-download of platform-tools (shared with the legacy CLI/GUI +// implementation, but moved into the shared core so both frontends benefit). +// --------------------------------------------------------------------------- + +func downloadAndExtract(url, destDir, adbName string) error { + httpc := http.DefaultClient + resp, err := httpc.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download returned %s", resp.Status) + } + tmp, err := os.CreateTemp("", "adb-platform-tools-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := io.Copy(tmp, resp.Body); err != nil { + tmp.Close() + return err + } + tmp.Close() + + destAbs, err := filepath.Abs(destDir) + if err != nil { + return err + } + lowerURL := strings.ToLower(url) + switch { + case strings.HasSuffix(lowerURL, ".zip"): + return safeUnzip(tmp.Name(), destAbs) + case strings.HasSuffix(lowerURL, ".tar.gz"), strings.HasSuffix(lowerURL, ".tgz"): + return safeUntar(tmp.Name(), destAbs) + default: + return fmt.Errorf("unknown archive format: %s", url) + } +} + +// SafeUnzip extracts a zip archive into dest with Zip Slip protection. +// Exported for use by the scrcpy downloader. +func SafeUnzip(src, dest string) error { return safeUnzip(src, dest) } + +func safeUnzip(src, dest string) error { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer r.Close() + destAbs, err := filepath.Abs(dest) + if err != nil { + return err + } + if err := os.MkdirAll(destAbs, 0o755); err != nil { + return err + } + for _, f := range r.File { + fp := filepath.Join(destAbs, f.Name) + fpAbs, err := filepath.Abs(fp) + if err != nil { + return err + } + if !strings.HasPrefix(fpAbs, destAbs+string(os.PathSeparator)) && fpAbs != destAbs { + return fmt.Errorf("zip slip: illegal path %q", f.Name) + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(fpAbs, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(fpAbs), 0o755); err != nil { + return err + } + out, err := os.OpenFile(fpAbs, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode().Perm()|0o600) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + out.Close() + return err + } + _, err = io.Copy(out, rc) + rc.Close() + out.Close() + if err != nil { + return err + } + } + return nil +} + +// SafeUntar extracts a .tar.gz archive into dest with Tar Slip protection. +func SafeUntar(src, dest string) error { return safeUntar(src, dest) } + +func safeUntar(src, dest string) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + tr := tar.NewReader(gz) + destAbs, err := filepath.Abs(dest) + if err != nil { + return err + } + if err := os.MkdirAll(destAbs, 0o755); err != nil { + return err + } + for { + hdr, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + fp := filepath.Join(destAbs, hdr.Name) + fpAbs, err := filepath.Abs(fp) + if err != nil { + return err + } + if !strings.HasPrefix(fpAbs, destAbs+string(os.PathSeparator)) && fpAbs != destAbs { + return fmt.Errorf("tar slip: illegal path %q", hdr.Name) + } + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(fpAbs, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(fpAbs), 0o755); err != nil { + return err + } + out, err := os.OpenFile(fpAbs, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(hdr.Mode)&0o755) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return err + } + out.Close() + } + } +} diff --git a/internal/adb/adb_test.go b/internal/adb/adb_test.go new file mode 100644 index 0000000..d34e49a --- /dev/null +++ b/internal/adb/adb_test.go @@ -0,0 +1,329 @@ +package adb + +import ( + "reflect" + "sort" + "strings" + "testing" +) + +func TestParseDevicesList(t *testing.T) { + out := `List of devices attached +emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:14 +ABCDEF123456 unauthorized usb:1-2 transport_id:11 +OFFLINE001 offline usb:1-3 + +` + devs := parseDevicesList(out) + if len(devs) != 3 { + t.Fatalf("want 3 devices, got %d: %+v", len(devs), devs) + } + want := []Device{ + {Serial: "emulator-5554", State: StateDevice, Product: "sdk_gphone64_arm64", Model: "sdk_gphone64_arm64", Device: "emu64a", TransportID: "14"}, + {Serial: "ABCDEF123456", State: StateUnauthorized, Device: "", TransportID: "11"}, + {Serial: "OFFLINE001", State: StateOffline, Device: "", TransportID: ""}, + } + for i := range want { + if devs[i].Serial != want[i].Serial { + t.Errorf("dev[%d] serial = %q, want %q", i, devs[i].Serial, want[i].Serial) + } + if devs[i].State != want[i].State { + t.Errorf("dev[%d] state = %q, want %q", i, devs[i].State, want[i].State) + } + if want[i].Model != "" && devs[i].Model != want[i].Model { + t.Errorf("dev[%d] model = %q, want %q", i, devs[i].Model, want[i].Model) + } + } + if name := devs[0].DisplayName(); name != "sdk gphone64 arm64 (emulator-5554)" { + t.Errorf("DisplayName() = %q, want underscores replaced with spaces", name) + } +} + +func TestParseDevicesListEmpty(t *testing.T) { + got := parseDevicesList("List of devices attached\n\n") + if len(got) != 0 { + t.Errorf("want 0 devices, got %d", len(got)) + } +} + +func TestParsePackagesListing(t *testing.T) { + out := `package:com.android.settings uid:1000 +package:com.example.app uid:10123 versionCode:42 +package:com.google.android.youtube uid:10086 versionCode:123 +` + pkgs := parsePackageListing(out) + if len(pkgs) != 3 { + t.Fatalf("want 3, got %d", len(pkgs)) + } + byName := map[string]Package{} + for _, p := range pkgs { + byName[p.Name] = p + } + if p, ok := byName["com.example.app"]; !ok || p.VersionCode != 42 { + t.Errorf("com.example.app versionCode got %+v, want 42", p) + } + if p, ok := byName["com.android.settings"]; !ok || p.VersionCode != -1 { + t.Errorf("com.android.settings versionCode default = %d, want -1", p.VersionCode) + } + if p, ok := byName["com.example.app"]; !ok || p.UID != 10123 { + t.Errorf("com.example.app UID got %+v, want 10123", p) + } +} + +func TestEnrichFromDumpsys(t *testing.T) { + dump := ` +Packages: + Package [com.example.app] (1234): + userId=10123 + pkg=Package{... com.example.app} + codePath=/data/app/~~xxx/com.example.app-abc== + splitCodePaths=[/data/app/~~xxx/com.example.app-abc==/split_config.arm64_v8a.apk, /data/app/~~xxx/com.example.app-abc==/split_config.en.apk] + versionName=1.2.3 + versionCode=42 minSdk=29 targetSdk=34 + firstInstallTime=2024-03-10 12:34:56 + lastUpdateTime=2024-04-01 09:00:00 + installerPackageName=com.android.vending + applicationLabel="Example App" + pkgFlags=[ SYSTEM HAS_CODE ALLOW_CLEAR_USER_DATA UPDATED_SYSTEM_APP ] + User 0: installed=true hidden=false suspended=false stopped=true notLaunched=false enabled=0 instant=false virtual=false + Package [com.example.user] (5678): + userId=10456 + codePath=/data/app/~~yyy/com.example.user-xyz== + versionName=2.0 + versionCode=10 + applicationLabel=0x7f010001 + pkgFlags=[ HAS_CODE ] + User 0: installed=true hidden=false suspended=false stopped=false notLaunched=false enabled=0 instant=false virtual=false + Package [com.example.disabled] (9999): + userId=12000 + codePath=/system/priv-app/Disabled + versionName=1.0 + versionCode=1 + pkgFlags=[ SYSTEM ] + User 0: installed=false hidden=false suspended=false stopped=true notLaunched=false enabled=3 instant=false virtual=false +` + enrichment := map[string]*Package{ + "com.example.app": {Name: "com.example.app", VersionCode: -1, UID: -1, Enabled: true, MinSdk: -1, TargetSdk: -1}, + "com.example.user": {Name: "com.example.user", VersionCode: -1, UID: -1, Enabled: true, MinSdk: -1, TargetSdk: -1}, + "com.example.disabled": {Name: "com.example.disabled", VersionCode: -1, UID: -1, Enabled: true, MinSdk: -1, TargetSdk: -1}, + } + enrichFromDumpsys(dump, enrichment) + + p := enrichment["com.example.app"] + if p.VersionName != "1.2.3" { + t.Errorf("com.example.app versionName = %q, want 1.2.3", p.VersionName) + } + if p.VersionCode != 42 { + t.Errorf("com.example.app versionCode = %d, want 42", p.VersionCode) + } + if p.Installer != "com.android.vending" { + t.Errorf("com.example.app installer = %q", p.Installer) + } + if p.Kind != KindSystemUpdated { + t.Errorf("com.example.app kind = %v, want system (updated)", p.Kind) + } + if p.Label != "Example App" { + t.Errorf("com.example.app label = %q, want Example App", p.Label) + } + if p.MinSdk != 29 || p.TargetSdk != 34 { + t.Errorf("com.example.app min/target = %d/%d, want 29/34", p.MinSdk, p.TargetSdk) + } + if len(p.SplitCodePaths) != 2 { + t.Errorf("com.example.app splits = %d (%v), want 2", len(p.SplitCodePaths), p.SplitCodePaths) + } + if p.UID != 10123 { + t.Errorf("com.example.app UID = %d, want 10123", p.UID) + } + u := enrichment["com.example.user"] + if u.Kind != KindUnknown { + t.Errorf("com.example.user kind = %v, want unknown", u.Kind) + } + if u.VersionCode != 10 { + t.Errorf("com.example.user versionCode = %d, want 10", u.VersionCode) + } + // Resource ID must NOT be used as a label. + if u.Label != "" { + t.Errorf("com.example.user label should be empty for resource id, got %q", u.Label) + } + d := enrichment["com.example.disabled"] + if d.Enabled { + t.Errorf("com.example.disabled Enabled should be false") + } + if p.FirstInstall == 0 { + t.Errorf("com.example.app firstInstall was 0, expected non-zero") + } +} + +func TestPackageSorting(t *testing.T) { + pkgs := []Package{ + {Name: "z.example", Kind: KindUser, Label: "Zeta"}, + {Name: "a.example", Kind: KindSystem, Label: "Alpha"}, + {Name: "m.example", Kind: KindUser, Label: "Mu"}, + } + sort.SliceStable(pkgs, func(i, j int) bool { + if pkgs[i].Kind != pkgs[j].Kind { + return pkgs[i].Kind < pkgs[j].Kind + } + return packageSortKey(&pkgs[i]) < packageSortKey(&pkgs[j]) + }) + want := []string{"m.example", "z.example", "a.example"} + got := []string{pkgs[0].Name, pkgs[1].Name, pkgs[2].Name} + if !reflect.DeepEqual(got, want) { + t.Errorf("sorted order = %v, want %v", got, want) + } +} + +func TestPackageSortKeyFallsBackToName(t *testing.T) { + p := &Package{Name: "com.example.no_label"} + if k := packageSortKey(p); k != "com.example.no_label" { + t.Errorf("sort key = %q, want package name", k) + } +} + +func TestParseOnePackageLine(t *testing.T) { + if got := parseOnePackageLine("package:com.example.app"); got != "com.example.app" { + t.Errorf("got %q", got) + } + if got := parseOnePackageLine(" junk line "); got != "" { + t.Errorf("expected empty, got %q", got) + } +} + +func TestDumpsysKV(t *testing.T) { + kv := dumpsysKV(" versionName=1.2.3") + if kv.key != "versionName" || kv.val != "1.2.3" { + t.Errorf("unexpected kv: %+v", kv) + } + kv = dumpsysKV(" pkgFlags=[ SYSTEM HAS_CODE ]") + if kv.key != "pkgFlags" || !strings.Contains(kv.val, "SYSTEM") { + t.Errorf("unexpected flag kv: %+v", kv) + } +} + +func TestParsePerPackageLabels(t *testing.T) { + out := `---ADBPURE_PKG:com.example.one--- +applicationLabel=One App +---ADBPURE_PKG:com.example.two--- +applicationLabel=0x7f010002 +label=Two App +---ADBPURE_PKG:com.example.three--- +application-label:'Three App' +---ADBPURE_PKG:com.example.four--- +nonLocalizedLabel=Four the App +---ADBPURE_PKG:com.example.five--- +applicationLabel=12345 +someOtherLine=true +---ADBPURE_PKG:com.example.six--- +(no label lines here) +` + enrich := map[string]*Package{ + "com.example.one": {Name: "com.example.one"}, + "com.example.two": {Name: "com.example.two"}, + "com.example.three": {Name: "com.example.three"}, + "com.example.four": {Name: "com.example.four"}, + "com.example.five": {Name: "com.example.five"}, + "com.example.six": {Name: "com.example.six"}, + } + parsePerPackageLabels(out, enrich) + cases := map[string]string{ + "com.example.one": "One App", + "com.example.two": "Two App", // resource id skipped, next wins + "com.example.three": "Three App", + "com.example.four": "Four the App", + "com.example.five": "", // 12345 is numeric -> skipped + "com.example.six": "", + } + for pkg, want := range cases { + if got := enrich[pkg].Label; got != want { + t.Errorf("%s label = %q, want %q", pkg, got, want) + } + } +} + +func TestLooksLikeResourceID(t *testing.T) { + cases := map[string]bool{ + "0x7f010001": true, + "@0x7f010001": true, + "Settings": false, + "": false, + "0xdeadbeef": true, + "0x": false, + "Hello World": false, + } + for in, want := range cases { + if got := looksLikeResourceID(in); got != want { + t.Errorf("looksLikeResourceID(%q) = %v, want %v", in, got, want) + } + } +} + +func TestLooksNumeric(t *testing.T) { + cases := map[string]bool{ + "12345": true, + "0x1a2b": true, + "1.5": true, + "-42": true, + "Settings": false, + "": false, + } + for in, want := range cases { + if got := looksNumeric(in); got != want { + t.Errorf("looksNumeric(%q) = %v, want %v", in, got, want) + } + } +} + +func TestParseBracketedList(t *testing.T) { + got := parseBracketedList("[/a/b.apk, /c/d.apk]") + want := []string{"/a/b.apk", "/c/d.apk"} + if !reflect.DeepEqual(got, want) { + t.Errorf("parseBracketedList = %v, want %v", got, want) + } + got = parseBracketedList("[]") + if len(got) != 0 { + t.Errorf("empty brackets should yield empty slice, got %v", got) + } + got = parseBracketedList("singleton") + if !reflect.DeepEqual(got, []string{"singleton"}) { + t.Errorf("non-bracketed should wrap as single elem, got %v", got) + } +} + +func TestVersionSummary(t *testing.T) { + p := Package{VersionName: "1.2.3", VersionCode: 42} + if got := p.VersionSummary(); got != "1.2.3 (42)" { + t.Errorf("VersionSummary() = %q", got) + } + p = Package{VersionCode: 7} + if got := p.VersionSummary(); got != "(7)" { + t.Errorf("VersionSummary() = %q", got) + } + p = Package{VersionName: "2.0"} + if got := p.VersionSummary(); got != "2.0" { + t.Errorf("VersionSummary() = %q", got) + } + p = Package{} + if got := p.VersionSummary(); got != "" { + t.Errorf("VersionSummary() = %q, want empty", got) + } +} + +func TestDisplayTitle(t *testing.T) { + p := Package{Name: "com.example.app", Label: "Example"} + if p.DisplayTitle() != "Example" { + t.Errorf("expected label, got %q", p.DisplayTitle()) + } + p = Package{Name: "com.example.app"} + if p.DisplayTitle() != "com.example.app" { + t.Errorf("expected name fallback, got %q", p.DisplayTitle()) + } +} + +func TestFormatMillis(t *testing.T) { + if got := FormatMillis(0); got != "β€”" { + t.Errorf("zero = %q", got) + } + if got := FormatMillis(1704067200000); !strings.Contains(got, "2024") { + t.Errorf("expected 2024 date, got %q", got) + } +} diff --git a/internal/adb/devices.go b/internal/adb/devices.go new file mode 100644 index 0000000..2cd904f --- /dev/null +++ b/internal/adb/devices.go @@ -0,0 +1,214 @@ +package adb + +import ( + "context" + "errors" + "fmt" + "strings" +) + +// DeviceState represents the state adb reports for a connected device. +type DeviceState string + +const ( + StateUnknown DeviceState = "unknown" + StateDevice DeviceState = "device" // online & ready + StateOffline DeviceState = "offline" + StateUnauthorized DeviceState = "unauthorized" // adb authorization pending + StateBootloader DeviceState = "bootloader" + StateRecovery DeviceState = "recovery" + StateConnecting DeviceState = "connecting" + StateNoPermissions DeviceState = "no permissions" +) + +// Device is a snapshot of a connected Android device as reported by +// `adb devices -l`. It is a value object β€” callers should not mutate it. +type Device struct { + Serial string + State DeviceState + Product string + Model string + Device string + TransportID string + Manufacturer string + // Info holds any additional key:value pairs from `adb devices -l` that + // we don't parse into named fields (future-compatibility). + Info map[string]string +} + +// DisplayName returns a human-friendly single-line label suitable for a +// device-select dropdown: "<Model> (<Serial>)". Unknown models fall back to +// the serial number. +func (d Device) DisplayName() string { + model := strings.TrimSpace(d.Model) + if model == "" { + model = strings.TrimSpace(d.Product) + } + if model == "" { + return d.Serial + } + return fmt.Sprintf("%s (%s)", strings.ReplaceAll(model, "_", " "), d.Serial) +} + +// StableID returns a stable identifier for the device β€” its serial. +func (d Device) StableID() string { return d.Serial } + +// ListDevices runs `adb devices -l` and parses the output into a slice of +// Device structs. Devices in `offline`, `unauthorized`, or other non-ready +// states are returned so that callers can surface appropriate UI; check +// Device.State to decide whether a device is usable. +func (c *Client) ListDevices(ctx context.Context) ([]Device, error) { + out, err := c.ServerCmd(ctx, "devices", "-l") + if err != nil { + return nil, fmt.Errorf("list devices: %w", err) + } + return parseDevicesList(out), nil +} + +// WaitForDevice blocks until the given serial reaches the "device" (online) +// state, or until ctx is canceled. It simply shells out to +// `adb -s <serial> wait-for-any-device`? We use `wait-for-device` which is +// per-serial when `-s` is passed. +func (c *Client) WaitForDevice(ctx context.Context, serial string) error { + if serial == "" { + return errors.New("adb: wait requires a serial") + } + _, err := c.Command(ctx, serial, "wait-for-device") + return err +} + +// GetProps fetches all system properties from the device via `getprop` and +// returns them as a map. Useful for extracting manufacturer, model, SDK +// level, etc. Returns a partial map plus error if getprop fails on a single +// device. +func (c *Client) GetProps(ctx context.Context, serial string) (map[string]string, error) { + out, err := c.Command(ctx, serial, "shell", "getprop") + if err != nil { + // Some older devices use `getprop` differently; try a single-line fallback + return nil, fmt.Errorf("getprop: %w", err) + } + props := make(map[string]string) + // adb shell getprop emits lines like `[ro.product.model]: [Pixel 7]` + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Strip surrounding brackets + // Format: [key]: [value] + // Be tolerant: strip the leading `[`, split on `]: [`, strip trailing `]` + if !strings.HasPrefix(line, "[") { + continue + } + sep := "]: [" + idx := strings.Index(line, sep) + if idx < 0 { + continue + } + key := strings.TrimPrefix(line[:idx], "[") + val := line[idx+len(sep):] + val = strings.TrimSuffix(val, "]") + props[key] = val + } + return props, nil +} + +// DeviceInfo is a higher-level, convenience view of a device assembled from +// both `adb devices -l` and `getprop` output. All fields are best-effort. +type DeviceInfo struct { + Device + Manufacturer string + Brand string + Model string + AndroidVer string + SDK string + BuildID string + SerialNo string // ro.serialno (when different from transport serial) +} + +// InspectDevice retrieves rich metadata for the given serial. Properties that +// can't be fetched (e.g. unauthorized device) are silently left empty so that +// list views can still show the Device snapshot. +func (c *Client) InspectDevice(ctx context.Context, d Device) DeviceInfo { + info := DeviceInfo{Device: d} + info.Model = d.Model + info.Manufacturer = d.Manufacturer + if d.State != StateDevice { + return info + } + props, err := c.GetProps(ctx, d.Serial) + if err != nil { + return info + } + if v := props["ro.product.manufacturer"]; v != "" { + info.Manufacturer = v + } + if v := props["ro.product.brand"]; v != "" { + info.Brand = v + } + if v := props["ro.product.model"]; v != "" { + info.Model = v + } + if v := props["ro.build.version.release"]; v != "" { + info.AndroidVer = v + } + if v := props["ro.build.version.sdk"]; v != "" { + info.SDK = v + } + if v := props["ro.build.display.id"]; v != "" { + info.BuildID = v + } + if v := props["ro.serialno"]; v != "" { + info.SerialNo = v + } + return info +} + +// parseDevicesList parses the output of `adb devices -l`. +// +// Example output: +// +// List of devices attached +// emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:14 +// XXXXXXXXXXX unauthorized usb:1-2 +func parseDevicesList(out string) []Device { + var devices []Device + for _, raw := range strings.Split(out, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + if strings.HasPrefix(strings.ToLower(line), "list of devices") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + d := Device{ + Serial: fields[0], + State: DeviceState(fields[1]), + Info: map[string]string{}, + } + for _, kv := range fields[2:] { + if i := strings.IndexByte(kv, ':'); i > 0 { + k := kv[:i] + v := kv[i+1:] + d.Info[k] = v + switch k { + case "product": + d.Product = v + case "model": + d.Model = v + case "device": + d.Device = v + case "transport_id": + d.TransportID = v + } + } + } + // usb:<path> entries don't have key:value; ignore. + devices = append(devices, d) + } + return devices +} diff --git a/internal/adb/devices_test.go b/internal/adb/devices_test.go new file mode 100644 index 0000000..6f400cc --- /dev/null +++ b/internal/adb/devices_test.go @@ -0,0 +1,16 @@ +package adb + +import "testing" + +// Device parser / display tests are in adb_test.go; this file is reserved +// for tests that require mocking the adb client (future integration tests) +// or heavier device logic. For now, keep a tiny build-warmup test so `go +// test ./...` exercises this file as code is added. +func TestDeviceStateConstants(t *testing.T) { + states := []DeviceState{StateDevice, StateOffline, StateUnauthorized, StateBootloader, StateRecovery} + for _, s := range states { + if s == "" { + t.Error("empty DeviceState const") + } + } +} diff --git a/internal/adb/manager.go b/internal/adb/manager.go new file mode 100644 index 0000000..57630d9 --- /dev/null +++ b/internal/adb/manager.go @@ -0,0 +1,169 @@ +package adb + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// Manager is the high-level faΓ§ade that the GUI and CLI both consume. It +// owns a Client, handles device selection, caches package lists, and +// exposes a simple API for install / launch / stop / uninstall. +type Manager struct { + Client *Client + Scrcpy *ScrcpyManager +} + +// NewManager returns a Manager that stores bundled binaries (platform-tools +// and scrcpy) under dataDir. If download is true, missing adb binaries are +// auto-fetched on construction. +func NewManager(dataDir string, download bool) (*Manager, error) { + if dataDir == "" { + if cwd, err := os.Getwd(); err == nil { + dataDir = cwd + } else { + dataDir = "." + } + } + adbDir := filepath.Join(dataDir, "adb_engine") + scrcpyDir := filepath.Join(dataDir, "scrcpy_core") + + client, err := New(adbDir, download) + if err != nil { + return nil, err + } + // Best-effort: start the adb server once so `devices` isn't slow on + // first call. We don't hard-fail if this errors (e.g. adb is present + // but broken; callers will see the error on the next command). + _ = client.StartServer(context.Background()) + + return &Manager{ + Client: client, + Scrcpy: NewScrcpyManager(client, scrcpyDir), + }, nil +} + +// RefreshDevices lists connected devices and returns them sorted (online +// first, then by display name). +func (m *Manager) RefreshDevices(ctx context.Context) ([]Device, error) { + devs, err := m.Client.ListDevices(ctx) + if err != nil { + return nil, err + } + sort.SliceStable(devs, func(i, j int) bool { + if devs[i].State == StateDevice && devs[j].State != StateDevice { + return true + } + if devs[j].State == StateDevice && devs[i].State != StateDevice { + return false + } + return strings.ToLower(devs[i].DisplayName()) < strings.ToLower(devs[j].DisplayName()) + }) + return devs, nil +} + +// FindDevice returns the Device whose serial matches, or an error. It is a +// tiny helper for validating user selection before running an operation. +func (m *Manager) FindDevice(ctx context.Context, serial string) (Device, error) { + devs, err := m.RefreshDevices(ctx) + if err != nil { + return Device{}, err + } + for _, d := range devs { + if d.Serial == serial { + if d.State != StateDevice { + return d, fmt.Errorf("device %s is in state %q (not ready)", serial, d.State) + } + return d, nil + } + } + return Device{}, fmt.Errorf("device %q not connected", serial) +} + +// ListPackages returns installed packages for the given serial. It delegates +// to Client.ListPackages with a userOnly flag (true = third-party apps +// only; false = all packages including system). +func (m *Manager) ListPackages(ctx context.Context, serial string, userOnly bool) ([]Package, error) { + if _, err := m.FindDevice(ctx, serial); err != nil { + return nil, err + } + return m.Client.ListPackages(ctx, serial, userOnly) +} + +// InstallAPK installs a local APK to the device. The localPath is resolved +// to an absolute path and stat'd before invoking adb. +func (m *Manager) InstallAPK(ctx context.Context, serial, localPath string) (string, error) { + if _, err := m.FindDevice(ctx, serial); err != nil { + return "", err + } + if err := m.Client.Install(ctx, serial, localPath); err != nil { + return "", err + } + abs, _ := filepath.Abs(localPath) + return fmt.Sprintf("Installed %s to %s", filepath.Base(abs), serial), nil +} + +// LaunchApp launches the package. The device must be online. +func (m *Manager) LaunchApp(ctx context.Context, serial, pkg string) error { + if _, err := m.FindDevice(ctx, serial); err != nil { + return err + } + return m.Client.Launch(ctx, serial, pkg) +} + +// ForceStopApp force-stops the package. +func (m *Manager) ForceStopApp(ctx context.Context, serial, pkg string) error { + if _, err := m.FindDevice(ctx, serial); err != nil { + return err + } + return m.Client.ForceStop(ctx, serial, pkg) +} + +// UninstallApp removes a package. If keepData is true, the -k flag is passed. +func (m *Manager) UninstallApp(ctx context.Context, serial, pkg string, keepData bool) error { + if _, err := m.FindDevice(ctx, serial); err != nil { + return err + } + return m.Client.Uninstall(ctx, serial, pkg, keepData) +} + +// ErrNotFound is returned when a package lookup fails. +var ErrNotFound = errors.New("package not found on device") + +// FindPackage returns metadata for a single package, or ErrNotFound. +func (m *Manager) FindPackage(ctx context.Context, serial, pkg string, userOnly bool) (*Package, error) { + pkgs, err := m.ListPackages(ctx, serial, userOnly) + if err != nil { + return nil, err + } + for i := range pkgs { + if strings.EqualFold(pkgs[i].Name, pkg) { + return &pkgs[i], nil + } + } + return nil, fmt.Errorf("%w: %s", ErrNotFound, pkg) +} + +// PackageInfo refreshes package metadata and returns a single package's +// record. It is a thin wrapper around ListPackages + linear lookup kept +// as a convenience for GUI/CLI detail views. +func (m *Manager) PackageInfo(ctx context.Context, serial, pkg string) (*Package, error) { + if _, err := m.FindDevice(ctx, serial); err != nil { + return nil, err + } + pkgs, err := m.Client.ListPackages(ctx, serial, false) + if err != nil { + return nil, err + } + lower := strings.ToLower(strings.TrimSpace(pkg)) + for i := range pkgs { + if strings.ToLower(pkgs[i].Name) == lower { + return &pkgs[i], nil + } + } + return nil, fmt.Errorf("%w: %s", ErrNotFound, pkg) +} diff --git a/internal/adb/packages.go b/internal/adb/packages.go new file mode 100644 index 0000000..5070fb6 --- /dev/null +++ b/internal/adb/packages.go @@ -0,0 +1,713 @@ +package adb + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +// PackageKind indicates whether a package is user-installed, a system app, +// or updated from a system app. +type PackageKind int + +const ( + KindUnknown PackageKind = iota + KindUser + KindSystem + KindSystemUpdated +) + +func (k PackageKind) String() string { + switch k { + case KindUser: + return "user" + case KindSystem: + return "system" + case KindSystemUpdated: + return "system (updated)" + default: + return "unknown" + } +} + +// Package is a lightweight description of an installed Android package. All +// string fields are best-effort: some devices / adb versions don't surface +// versionName/versionCode/installer/labels for system packages, so callers +// should gracefully handle empty values. +type Package struct { + Name string // package id, e.g. com.example.app + Label string // human-readable name when resolvable; "" otherwise + VersionName string // e.g. 1.2.3 + VersionCode int64 // -1 when unknown + Installer string // package that installed this (e.g. com.android.vending); "" when unknown + Kind PackageKind + // Path is the on-device APK base path (codePath). + Path string + // SplitCodePaths lists split APK paths when present (e.g. for App Bundles). + SplitCodePaths []string + // FirstInstall / LastUpdate are epoch-millis when available; 0 when unknown. + FirstInstall int64 + LastUpdate int64 + // TargetSdk / MinSdk are parsed from dumpsys when available; -1 when unknown. + TargetSdk int + MinSdk int + // Enabled reflects whether the package is enabled for the default user. + // True when unknown (we default up). + Enabled bool + // UID is the package's linux UID; -1 when unknown. + UID int +} + +// DisplayTitle returns a human-readable title preferring Label and falling +// back to Name. +func (p *Package) DisplayTitle() string { + if p.Label != "" { + return p.Label + } + return p.Name +} + +// VersionSummary returns a compact "versionName (versionCode)" string when +// available, or an empty string if no version metadata is known. +func (p *Package) VersionSummary() string { + switch { + case p.VersionName != "" && p.VersionCode > 0: + return fmt.Sprintf("%s (%d)", p.VersionName, p.VersionCode) + case p.VersionName != "": + return p.VersionName + case p.VersionCode > 0: + return fmt.Sprintf("(%d)", p.VersionCode) + default: + return "" + } +} + +// ListPackages retrieves the set of installed packages on the given device. +// When `userOnly` is true, only third-party (non-system) packages are +// returned. Best-effort label resolution is attempted so users see +// human-readable names. +func (c *Client) ListPackages(ctx context.Context, serial string, userOnly bool) ([]Package, error) { + var out string + var err error + if userOnly { + out, err = c.Command(ctx, serial, "shell", "pm", "list", "packages", "-3", "-U", "--show-versioncode") + } else { + out, err = c.Command(ctx, serial, "shell", "pm", "list", "packages", "-U", "--show-versioncode") + } + if err != nil { + // Some older devices don't support --show-versioncode / -U; fall back + // to plain `pm list packages`. + if userOnly { + out, err = c.Command(ctx, serial, "shell", "pm", "list", "packages", "-3") + } else { + out, err = c.Command(ctx, serial, "shell", "pm", "list", "packages") + } + if err != nil { + return nil, fmt.Errorf("list packages: %w", err) + } + } + + pkgs := parsePackageListing(out) + if len(pkgs) == 0 { + return pkgs, nil + } + + // Determine system vs user via a dedicated call. + systemSet := map[string]bool{} + if sysOut, err := c.Command(ctx, serial, "shell", "pm", "list", "packages", "-s"); err == nil { + for _, line := range strings.Split(sysOut, "\n") { + if name := parseOnePackageLine(line); name != "" { + systemSet[name] = true + } + } + } + + // Bulk dump of every package β€” much faster than one-per-package calls. + enrichment := make(map[string]*Package, len(pkgs)) + for i := range pkgs { + pkgs[i].Enabled = true + enrichment[pkgs[i].Name] = &pkgs[i] + } + if dumpsys, err := c.Command(ctx, serial, "shell", "dumpsys", "package", "packages"); err == nil { + enrichFromDumpsys(dumpsys, enrichment) + } + + // Resolve human-readable labels. We try two strategies: + // 1) Look for labels present directly in dumpsys (`applicationLabel=` + // values on older builds are sometimes pre-resolved strings). + // 2) Fall back to a batched `cmd package resolve-activity --brief` loop + // running in a single adb shell session. This yields launcher- + // activity labels for apps that declare a MAIN/LAUNCHER activity + // (the common case for apps users actually see). + c.resolveLabels(ctx, serial, enrichment) + + for i := range pkgs { + p := &pkgs[i] + switch { + case p.Kind == KindSystemUpdated: + // dumpsys wins. + case systemSet[p.Name]: + if p.Kind == KindUnknown { + p.Kind = KindSystem + } + default: + if p.Kind == KindUnknown { + p.Kind = KindUser + } + } + } + + // Sort: user packages first, then system, then by display title. + sort.SliceStable(pkgs, func(i, j int) bool { + if pkgs[i].Kind != pkgs[j].Kind { + return pkgs[i].Kind < pkgs[j].Kind + } + return packageSortKey(&pkgs[i]) < packageSortKey(&pkgs[j]) + }) + return pkgs, nil +} + +func packageSortKey(p *Package) string { + return strings.ToLower(p.DisplayTitle()) +} + +// parsePackageListing parses `pm list packages [-U] [--show-versioncode]` +// output and returns a slice of Package with Name/VersionCode/UID set. +func parsePackageListing(out string) []Package { + var pkgs []Package + // Output examples: + // package:com.example.app uid:10123 versionCode:42 + // package:com.example.app + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "package:") { + continue + } + rest := strings.TrimPrefix(line, "package:") + fields := strings.Fields(rest) + if len(fields) == 0 { + continue + } + pkg := Package{Name: fields[0], VersionCode: -1, UID: -1, Enabled: true, TargetSdk: -1, MinSdk: -1} + for _, f := range fields[1:] { + switch { + case strings.HasPrefix(f, "versionCode:"): + if n, err := strconv.ParseInt(strings.TrimPrefix(f, "versionCode:"), 10, 64); err == nil { + pkg.VersionCode = n + } + case strings.HasPrefix(f, "uid:"): + if n, err := strconv.Atoi(strings.TrimPrefix(f, "uid:")); err == nil { + pkg.UID = n + } + } + } + pkgs = append(pkgs, pkg) + } + return pkgs +} + +func parseOnePackageLine(line string) string { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "package:") { + return "" + } + rest := strings.TrimPrefix(line, "package:") + fields := strings.Fields(rest) + if len(fields) == 0 { + return "" + } + return fields[0] +} + +// enrichFromDumpsys parses the `dumpsys package packages` output and fills +// metadata fields for packages present in enrichment. It is deliberately +// forgiving about minor format differences across Android versions 8–15. +func enrichFromDumpsys(out string, enrichment map[string]*Package) { + var cur *Package + // Track whether we're inside a sub-block we don't care about (like + // "User 0:" installed lists). + for _, raw := range strings.Split(out, "\n") { + line := strings.TrimRight(raw, "\r") + trim := strings.TrimSpace(line) + + if m := pkgBlockRe.FindStringSubmatch(trim); m != nil { + name := m[1] + cur = enrichment[name] // may be nil if we didn't list this pkg + continue + } + if cur == nil { + continue + } + + if kv := dumpsysKV(trim); kv.key != "" { + switch kv.key { + case "versionName": + cur.VersionName = unquoteAndTrim(kv.val) + case "versionCode": + // "42 minSdk=29 targetSdk=34" or "42 (34) minSdk=29 targetSdk=34" + verPart, restVal := splitFirstSpace(kv.val) + if n, err := strconv.ParseInt(verPart, 10, 64); err == nil { + cur.VersionCode = n + } + // Parse minSdk=/targetSdk= from remainder. + for _, token := range strings.Fields(restVal) { + token = strings.Trim(token, "(),") + if strings.HasPrefix(token, "minSdk=") { + if n, err := strconv.Atoi(strings.TrimPrefix(token, "minSdk=")); err == nil { + cur.MinSdk = n + } + } else if strings.HasPrefix(token, "targetSdk=") { + if n, err := strconv.Atoi(strings.TrimPrefix(token, "targetSdk=")); err == nil { + cur.TargetSdk = n + } + } + } + case "codePath": + cur.Path = unquoteAndTrim(kv.val) + case "splitCodePaths": + // Sometimes form: "[split0.apk, split1.apk]" + cur.SplitCodePaths = parseBracketedList(kv.val) + case "installerPackageName": + cur.Installer = unquoteAndTrim(kv.val) + case "firstInstallTime": + cur.FirstInstall = parseEpochMillis(kv.val) + case "lastUpdateTime": + cur.LastUpdate = parseEpochMillis(kv.val) + case "pkgFlags", "privateFlags", "hiddenApiPolicy": + flags := kv.val + if strings.Contains(flags, "SYSTEM") && strings.Contains(flags, "UPDATED_SYSTEM_APP") { + cur.Kind = KindSystemUpdated + } else if strings.Contains(flags, "SYSTEM") && cur.Kind == KindUnknown { + cur.Kind = KindSystem + } + case "applicationLabel": + // On many Android builds this is a resource id (0x7f...), + // but on some (especially older / OEM builds, or when dumpsys + // resolved the label for us) it is a quoted string. Sniff it. + v := strings.TrimSpace(kv.val) + if s := unquoteAndTrim(v); s != "" && !looksLikeResourceID(s) { + cur.Label = s + } + case "enabledComponents", "disabledComponents": + // Not used yet; reserved. + case "uid": + // uid line appears inside the block as well (e.g. on older builds). + if n, err := strconv.Atoi(strings.TrimSpace(kv.val)); err == nil { + cur.UID = n + } + case "userid": + if n, err := strconv.Atoi(strings.TrimSpace(kv.val)); err == nil && cur.UID == -1 { + cur.UID = n + } + } + continue + } + + // "enabled=X" / "userId=1234" style lines appear without brackets. + // Some builds print "User 0: installed=true hidden=false suspended=false ..." + // within a package block β€” we use that to set Enabled=false when we + // see installed=false or stopped-but-enabled variants. + if strings.HasPrefix(trim, "User ") && strings.Contains(trim, "installed=") { + // Look at the default user entry only. Format varies; we just + // look for installed=false / enabled=false. + if strings.Contains(trim, "installed=false") || strings.Contains(trim, "enabled=false") { + if strings.HasPrefix(trim, "User 0:") || strings.Contains(trim, "installed=") { + // Don't flip off for secondary profiles; just track + // default user 0. + if strings.HasPrefix(trim, "User 0:") { + cur.Enabled = false + } + } + } + } + } +} + +// resolveLabels attempts to fill Label for packages where dumpsys did not +// provide a human-readable string. It uses a single batched `adb shell` +// invocation issuing `cmd package resolve-activity --brief -c LAUNCHER <pkg>` +// calls, then for any packages still missing a label it extracts the +// launcher activity's label via a secondary dumpsys lookup of +// "Activity Resolver Table" blocks. +// +// When everything fails, Label is left empty; callers fall back to Name. +func (c *Client) resolveLabels(ctx context.Context, serial string, enrichment map[string]*Package) { + // Build list of packages that still need a label. + var need []string + for name, p := range enrichment { + if p.Label == "" { + need = append(need, name) + } + } + if len(need) == 0 { + return + } + + // Batch resolve-activity calls. We use a shell "for" loop over arguments + // passed on stdin via `shell -x -s` so all lookups happen in one adb + // round-trip. cmd package resolve-activity --brief outputs: + // priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=true + // com.example/.MainActivity + // which only gives us the component name. To get the label we add `-d` + // to get a description table; instead we prefer querying the launcher + // activity via `dumpsys package <component>` but that's expensive. + // A simpler, widely-supported trick: `pm resolve-activity --brief` + // doesn't print labels, so we fall back to parsing "Activity Resolver + // Table" in our already-fetched dumpsys output β€” but we don't retain it. + // + // Instead, use this strategy: + // For each package without a label, shell out to `cmd package dump + // <pkg>` (or `dumpsys package <pkg>`) and pull any `android:labelRes=` + // reference AND the literal "application label=..." lines that some + // OEM builds emit. We batch these as a single shell command printing + // markers per package so we can associate the output cheaply. + // + // To avoid huge output (thousands of packages on real devices), we only + // attempt label resolution for the *first* 300 packages (user apps come + // first in our sort but this function runs before sorting, so we do our + // best β€” a cap prevents pathological cases). Batch size is small. + const cap = 300 + if len(need) > cap { + need = need[:cap] + } + + // Build a single shell script that prints separators and the dumpsys + // snippet for each package. Use `dumpsys package <pkg>` per-package β€” + // still expensive but bounded by cap, and avoids needing aapt. + var sb strings.Builder + sb.WriteString("for p in \"$@\"; do\n") + sb.WriteString(" echo \"---ADBPURE_PKG:$p---\"\n") + // `dumpsys package <pkg>` outputs the single-package block; we grep for + // just the label-ish lines to keep traffic small. We use `toybox grep` + // / `grep` where available; fall back to just piping the full output + // through `sed -n` for key lines. + sb.WriteString(" dumpsys package \"$p\" 2>/dev/null | ") + sb.WriteString("grep -E -m 5 '(applicationLabel=|application-label|labelRes=|android:label=)' || true\n") + sb.WriteString("done\n") + script := sb.String() + + // Build args list: sh -c <script> -- <pkg1> <pkg2> ... + // Use `shell sh -c <script> - <pkgs...>` to avoid argument quoting + // pitfalls. + args := []string{"shell", "sh", "-c", script, "-"} + args = append(args, need...) + + out, err := c.Command(ctx, serial, args...) + if err != nil { + // Give up silently β€” labels are best-effort. + return + } + parsePerPackageLabels(out, enrichment) + + // Second strategy for still-missing labels: attempt `cmd package + // resolve-activity` batched to discover launcher components, then for + // each component parse the label out of the global activity resolver + // table from a second call. Since we don't keep the full dumpsys + // output, we instead issue a tiny `dumpsys package <pkg>` looking + // for "Application" label lines like "labelRes=0x7f..." β€” those are + // still resource ids. The most portable adb-only trick that yields + // real strings for some apps is checking the activity-resolver output + // of `cmd package resolve-activity -c android.intent.category.LAUNCHER + // <pkg>` which on newer Android (13+) prints a `label=` line. + stillNeed := make([]string, 0, len(need)) + for _, n := range need { + if p, ok := enrichment[n]; ok && p.Label == "" { + stillNeed = append(stillNeed, n) + } + } + if len(stillNeed) == 0 { + return + } + var sb2 strings.Builder + sb2.WriteString("for p in \"$@\"; do\n") + sb2.WriteString(" echo \"---ADBPURE_PKG:$p---\"\n") + sb2.WriteString(" cmd package resolve-activity -c android.intent.category.LAUNCHER \"$p\" 2>/dev/null | ") + sb2.WriteString("grep -E -m 2 '(^label=|labelRes=|nonLocalizedLabel=)' || true\n") + sb2.WriteString("done\n") + args2 := []string{"shell", "sh", "-c", sb2.String(), "-"} + args2 = append(args2, stillNeed...) + out2, err := c.Command(ctx, serial, args2...) + if err == nil { + parsePerPackageLabels(out2, enrichment) + } +} + +// parsePerPackageLabels parses the output from the label-resolution shell +// script, which is delimited by `---ADBPURE_PKG:<name>---` markers. +func parsePerPackageLabels(out string, enrichment map[string]*Package) { + var cur *Package + for _, raw := range strings.Split(out, "\n") { + line := strings.TrimRight(raw, "\r") + if m := adbPkgMarkerRe.FindStringSubmatch(line); m != nil { + cur = enrichment[m[1]] + continue + } + if cur == nil || cur.Label != "" { + continue + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Match lines like: + // applicationLabel=Settings + // applicationLabel="My App" + // label=My App + // nonLocalizedLabel=My App + // application-label:'My App' + for _, re := range labelRes { + if m := re.FindStringSubmatch(line); m != nil { + v := unquoteAndTrim(m[1]) + if v != "" && !looksLikeResourceID(v) && !looksNumeric(v) { + cur.Label = v + break + } + } + } + } +} + +var ( + pkgBlockRe = regexp.MustCompile(`^Package \[([^\]]+)\]`) + adbPkgMarkerRe = regexp.MustCompile(`^---ADBPURE_PKG:(.+?)---\s*$`) + hexResourceRe = regexp.MustCompile(`^0x[0-9a-fA-F]+$`) + bracketedListRe = regexp.MustCompile(`^\[(.*)\]$`) +) + +// labelRes contains regex patterns for extracting label strings. Each must +// have exactly one capture group producing the candidate value. Leading +// and trailing quotes are stripped later by unquoteAndTrim, so the regexes +// are intentionally permissive. +var labelRes = []*regexp.Regexp{ + regexp.MustCompile(`^\s*applicationLabel=(.+)$`), + regexp.MustCompile(`^\s*nonLocalizedLabel=(.+)$`), + regexp.MustCompile(`^\s*label=(.+)$`), + regexp.MustCompile(`^\s*application-label:\s*(.+?)\s*$`), + regexp.MustCompile(`^\s*android:label=(.+)$`), +} + +// kv holds a parsed key=value pair. +type kvPair struct { + key string + val string +} + +// dumpsysKV parses a "key=value" style line from dumpsys. +func dumpsysKV(line string) kvPair { + i := strings.IndexByte(line, '=') + if i <= 0 { + return kvPair{} + } + key := strings.TrimSpace(line[:i]) + val := strings.TrimSpace(line[i+1:]) + return kvPair{key: key, val: val} +} + +// parseEpochMillis tries to convert adb's "2024-01-02 15:04:05" timestamps +// to milliseconds since epoch. ADB timestamps are in the device's local +// time, which we can't recover precisely; we parse as wall-clock UTC-ish. +func parseEpochMillis(s string) int64 { + s = strings.TrimSpace(s) + for _, layout := range []string{ + "2006-01-02 15:04:05", + "2006-01-02 15:04:05.000", + } { + if t, err := time.Parse(layout, s); err == nil { + return t.UnixMilli() + } + } + return 0 +} + +// FormatMillis formats an epoch-millis timestamp for display, or returns +// placeholder when zero. +func FormatMillis(ms int64) string { + if ms <= 0 { + return "β€”" + } + return time.UnixMilli(ms).Local().Format("2006-01-02 15:04:05") +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func splitFirstSpace(s string) (string, string) { + s = strings.TrimSpace(s) + i := strings.IndexByte(s, ' ') + if i < 0 { + return s, "" + } + return strings.TrimSpace(s[:i]), strings.TrimSpace(s[i+1:]) +} + +func unquoteAndTrim(s string) string { + s = strings.TrimSpace(s) + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + s = s[1 : len(s)-1] + } + } + return strings.TrimSpace(s) +} + +func looksLikeResourceID(s string) bool { + if len(s) == 0 || len(s) > 32 { + return false + } + if s[0] == '@' { + // e.g. @0x7f010001 + return hexResourceRe.MatchString(s[1:]) + } + return hexResourceRe.MatchString(s) +} + +func looksNumeric(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return false + } + for _, r := range s { + if !((r >= '0' && r <= '9') || r == '-' || r == '.' || r == 'x' || r == 'X' || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { + return false + } + } + // Treat pure integers/hex as "not a label" even if they parse. + _, err1 := strconv.ParseInt(s, 0, 64) + _, err2 := strconv.ParseFloat(s, 64) + return err1 == nil || err2 == nil +} + +func parseBracketedList(s string) []string { + s = strings.TrimSpace(s) + m := bracketedListRe.FindStringSubmatch(s) + if m == nil { + if s == "" { + return nil + } + return []string{unquoteAndTrim(s)} + } + inner := strings.TrimSpace(m[1]) + if inner == "" { + return nil + } + parts := strings.Split(inner, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + out = append(out, unquoteAndTrim(p)) + } + return out +} + +// --------------------------------------------------------------------------- +// App-level operations +// --------------------------------------------------------------------------- + +// Install installs an APK located at localPath to the device. +func (c *Client) Install(ctx context.Context, serial, localPath string) error { + if serial == "" { + return errors.New("adb: install requires a device serial") + } + abs, err := filepath.Abs(localPath) + if err != nil { + return fmt.Errorf("install: %w", err) + } + if _, err := os.Stat(abs); err != nil { + return fmt.Errorf("install: %w", err) + } + out, err := c.Command(ctx, serial, "install", "-r", "-d", "--streaming", abs) + if err != nil { + // `--streaming` was added in newer platform-tools; fall back without it. + out, err = c.Command(ctx, serial, "install", "-r", "-d", abs) + } + if err != nil { + return fmt.Errorf("install: %w", err) + } + if !strings.Contains(out, "Success") { + return fmt.Errorf("install: adb returned %q", out) + } + return nil +} + +// Uninstall removes a package from the device. When `keepData` is true, +// passes `-k` to keep the app's data/cache. +func (c *Client) Uninstall(ctx context.Context, serial, pkg string, keepData bool) error { + if serial == "" { + return errors.New("adb: uninstall requires a device serial") + } + if pkg == "" { + return errors.New("adb: uninstall: empty package name") + } + args := []string{"uninstall"} + if keepData { + args = append(args, "-k") + } + args = append(args, pkg) + out, err := c.Command(ctx, serial, args...) + if err != nil { + return fmt.Errorf("uninstall %s: %w", pkg, err) + } + if !strings.Contains(out, "Success") { + return fmt.Errorf("uninstall %s: %s", pkg, out) + } + return nil +} + +// Launch launches the default launcher activity for the given package. +func (c *Client) Launch(ctx context.Context, serial, pkg string) error { + if serial == "" { + return errors.New("adb: launch requires a device serial") + } + if pkg == "" { + return errors.New("adb: launch: empty package name") + } + out, err := c.Command(ctx, serial, "shell", "monkey", "-p", pkg, "-c", "android.intent.category.LAUNCHER", "1") + if err != nil { + return fmt.Errorf("launch %s: %w", pkg, err) + } + lower := strings.ToLower(out) + if strings.Contains(lower, "no activities found") || strings.Contains(lower, "aborted") { + return fmt.Errorf("launch %s: %s", pkg, strings.TrimSpace(out)) + } + return nil +} + +// ForceStop sends `am force-stop <pkg>`. +func (c *Client) ForceStop(ctx context.Context, serial, pkg string) error { + if serial == "" { + return errors.New("adb: force-stop requires a device serial") + } + if pkg == "" { + return errors.New("adb: force-stop: empty package name") + } + _, err := c.Command(ctx, serial, "shell", "am", "force-stop", pkg) + if err != nil { + return fmt.Errorf("force-stop %s: %w", pkg, err) + } + return nil +} + +// IsInstalled reports whether `pm list packages <pkg>` reports the package. +func (c *Client) IsInstalled(ctx context.Context, serial, pkg string) (bool, error) { + out, err := c.Command(ctx, serial, "shell", "pm", "list", "packages", pkg) + if err != nil { + return false, err + } + return strings.Contains(out, "package:"+pkg), nil +} + +// PackageExists is an alias for IsInstalled. +func (c *Client) PackageExists(ctx context.Context, serial, pkg string) (bool, error) { + return c.IsInstalled(ctx, serial, pkg) +} diff --git a/internal/adb/scrcpy.go b/internal/adb/scrcpy.go new file mode 100644 index 0000000..28b696c --- /dev/null +++ b/internal/adb/scrcpy.go @@ -0,0 +1,163 @@ +package adb + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// ScrcpyURL maps GOOS-GOARCH pairs to official scrcpy release binaries +// (v3.3.4, the same release the legacy GUI shipped with). +var ScrcpyURL = map[string]string{ + "windows-amd64": "https://github.com/Genymobile/scrcpy/releases/download/v3.3.4/scrcpy-win64-v3.3.4.zip", + "windows-386": "https://github.com/Genymobile/scrcpy/releases/download/v3.3.4/scrcpy-win32-v3.3.4.zip", + "windows-arm64": "https://github.com/Genymobile/scrcpy/releases/download/v3.3.4/scrcpy-win64-v3.3.4.zip", + "linux-amd64": "https://github.com/Genymobile/scrcpy/releases/download/v3.3.4/scrcpy-linux-x86_64-v3.3.4.tar.gz", + "darwin-amd64": "https://github.com/Genymobile/scrcpy/releases/download/v3.3.4/scrcpy-macos-x86_64-v3.3.4.tar.gz", + "darwin-arm64": "https://github.com/Genymobile/scrcpy/releases/download/v3.3.4/scrcpy-macos-aarch64-v3.3.4.tar.gz", +} + +// ScrcpyManager locates (and auto-downloads on first use) the scrcpy binary +// and launches mirror sessions for a specific device. +type ScrcpyManager struct { + client *Client + baseDir string + httpc *http.Client +} + +// NewScrcpyManager creates a ScrcpyManager that stores binaries under dir +// (defaults to <cwd>/scrcpy_core if empty). +func NewScrcpyManager(c *Client, dir string) *ScrcpyManager { + if dir == "" { + if cwd, err := os.Getwd(); err == nil { + dir = filepath.Join(cwd, "scrcpy_core") + } else { + dir = "scrcpy_core" + } + } + return &ScrcpyManager{client: c, baseDir: dir, httpc: http.DefaultClient} +} + +// BinaryPath returns the absolute path to the scrcpy executable, downloading +// it if necessary. +func (m *ScrcpyManager) BinaryPath(ctx context.Context) (string, error) { + if err := os.MkdirAll(m.baseDir, 0o755); err != nil { + return "", err + } + if p := m.findExisting(); p != "" { + return p, nil + } + key := fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH) + url, ok := ScrcpyURL[key] + if !ok { + return "", fmt.Errorf("scrcpy: no prebuilt binary for %s", key) + } + if err := m.download(ctx, url); err != nil { + return "", err + } + if p := m.findExisting(); p != "" { + return p, nil + } + return "", fmt.Errorf("scrcpy: binary not found after extraction") +} + +// StartMirror launches scrcpy for the given serial and returns without +// waiting for scrcpy to exit. The mirror runs in its own process group; the +// caller can cancel ctx to send SIGKILL (platforms vary). +func (m *ScrcpyManager) StartMirror(ctx context.Context, serial string, title string) (*exec.Cmd, error) { + if serial == "" { + return nil, fmt.Errorf("scrcpy: mirror requires a serial") + } + bin, err := m.BinaryPath(ctx) + if err != nil { + return nil, err + } + if title == "" { + title = "ADBPureFlow-Mirror" + } + args := []string{"-s", serial, "--always-on-top", "--window-title", title} + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Dir = filepath.Dir(bin) + // On POSIX, chmod the binary just in case the archive extraction didn't + // preserve the executable bit. + if runtime.GOOS != "windows" { + _ = os.Chmod(bin, 0o755) + } + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("scrcpy: %w", err) + } + return cmd, nil +} + +func (m *ScrcpyManager) exeName() string { + if runtime.GOOS == "windows" { + return "scrcpy.exe" + } + return "scrcpy" +} + +func (m *ScrcpyManager) findExisting() string { + exe := m.exeName() + entries, err := os.ReadDir(m.baseDir) + if err != nil { + return "" + } + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if strings.HasPrefix(name, "scrcpy-") || strings.HasPrefix(name, "scrcpy") || name == "bin" { + candidate := filepath.Join(m.baseDir, name, exe) + if st, err := os.Stat(candidate); err == nil && !st.IsDir() { + return candidate + } + } + } + candidate := filepath.Join(m.baseDir, exe) + if st, err := os.Stat(candidate); err == nil && !st.IsDir() { + return candidate + } + return "" +} + +func (m *ScrcpyManager) download(ctx context.Context, url string) error { + ext := ".tar.gz" + if runtime.GOOS == "windows" { + ext = ".zip" + } + tmp, err := os.CreateTemp(m.baseDir, "scrcpy-download-*"+ext) + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + defer tmp.Close() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := m.httpc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("scrcpy: download returned %s", resp.Status) + } + if _, err := io.Copy(tmp, resp.Body); err != nil { + return err + } + tmp.Close() + + if ext == ".zip" { + return SafeUnzip(tmp.Name(), m.baseDir) + } + return SafeUntar(tmp.Name(), m.baseDir) +} diff --git a/internal/go.mod b/internal/go.mod new file mode 100644 index 0000000..82ea9f4 --- /dev/null +++ b/internal/go.mod @@ -0,0 +1,3 @@ +module github.com/flessan/AdbPureFlow/internal + +go 1.21