Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ plugins {

android {
namespace = "app.pi"
compileSdk = 36
// SDK 37 ships as a *minor-version* platform: there is no `platforms;android-37`
// package, only 37.0/37.1/37.2. AGP 8.13.2 supports that scheme through
// `compileSdkMinor`; `compileSdk = 37` alone resolves to the nonexistent
// `platforms;android-37`. The minor setter must come *after* `compileSdk` —
// it reads the API level already set on the extension.
compileSdk = 37
compileSdkMinor = 0

// ---------------------------------------------------------------------
// targetSdk is a *runtime capability switch*, not a style choice.
Expand Down Expand Up @@ -134,6 +140,13 @@ dependencies {
implementation(libs.webkit)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.markdown.renderer.m3)

// The elevated (uid=2000) shell backend. `api` is what ShizukuShellBackend
// compiles against; `provider` is required at runtime for the binder handoff
// (see the ShizukuProvider entry in AndroidManifest.xml). Both are MIT and
// neither pulls native code.
implementation(libs.shizuku.api)
implementation(libs.shizuku.provider)
}

// The markdown renderer is built by a newer Kotlin than this project's compiler
Expand Down
42 changes: 42 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,48 @@
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />

<!--
The pre-scoped-storage path, and only there.

`minSdk 26` means Android 8/9 are supported, where writing the public
Download folder needs WRITE_EXTERNAL_STORAGE and reading another app's file
there needs READ_EXTERNAL_STORAGE. Both are declared with `maxSdkVersion` so
they are requestable exactly where they still mean anything: on API 29+ the
app uses MediaStore, and from API 30 onwards the OS ignores
WRITE_EXTERNAL_STORAGE anyway. Before this, the code told the user to "let the
maintainer add the permission to the manifest" — i.e. android_export could not
work at all on the API levels this app claims to support.
-->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />

<!--
Shizuku (dev.rikka.shizuku:provider) delivers the ADB-level binder to this
app through this provider. Without it the binder never arrives, so
ShizukuShellBackend would report 「未运行」 even while Shizuku is running.

`android:permission="android.permission.INTERACT_ACROSS_USERS_FULL"` is what
the Shizuku-API README specifies for the declaration: it keeps ordinary apps
from calling the provider, so only the platform (and therefore Shizuku) can.
-->
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:enabled="true"
android:exported="true"
android:multiprocess="false"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />

<!-- Package visibility for the manager app, so the status card can say
「未安装」 instead of 「未运行」. -->
<queries>
<package android:name="moe.shizuku.manager" />
</queries>

<uses-feature android:name="android.hardware.touchscreen" android:required="false" />

<application
Expand Down
25 changes: 24 additions & 1 deletion app/src/main/assets/pi-extensions/pi-highlight/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,32 @@ function handleHighlight(response: ServerResponse, request: IncomingMessage): Pr
}
const language = parsed.language.trim();

// An empty listing means highlight.js itself could not be found next to pi.
// That is a configuration failure worth naming, not "unknown language":
// otherwise every block would silently render plain.
let available: Set<string>;
try {
available = knownLanguages();
} catch (error) {
return fail(
response,
503,
"ENGINE_UNAVAILABLE",
`找不到 highlight.js:${error instanceof Error ? error.message : String(error)}。`,
);
}
if (available.size === 0) {
return fail(
response,
503,
"ENGINE_UNAVAILABLE",
`highlight.js 没有列出任何语言(${loadState().error ?? "未知原因"})。`,
);
}

// Unknown language is not an error: it is pi's normal "render plain" path
// (hcl/graphql/toml/fish are not in highlight.js 10.7.3 at all).
if (!knownLanguages().has(language)) {
if (!available.has(language)) {
return send(response, 200, {
ok: true,
data: { language, known: false, spans: [], codeUnits: parsed.code.length, hljs: loadState().hljsVersion },
Expand Down
27 changes: 25 additions & 2 deletions app/src/main/kotlin/app/pi/bridge/DeviceBridgeController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,27 @@ object DeviceBridgeController {
/** Assets shipped in the APK that make up the extension. */
private const val ASSET_ROOT = "pi-extensions"

/** Bumped whenever the shipped extension changes, to force re-installation. */
const val ASSET_VERSION = "1"
/**
* Bumped whenever anything under `assets/pi-extensions/` changes, to force
* re-installation into the guest.
*
* This is a manual gate and it fails silently: the stamp is compared before
* copying, so a device that already ran an older build keeps the old
* extension tree and never sees the new one. The symptom is not an error —
* it is a feature that "does not work", which sends whoever debugs it off to
* check ports, tokens and networks instead of the installer.
*
* So: **every change to `pi-extensions/ 目录` must bump this string.**
* History: "1" shipped the device bridge; "2" adds `pi-highlight/`; "3" adds the
* session-scoped approvals, the workspace-relative shell policy and the SAF file
* tools to the device extension.
*
* A content-derived fingerprint (hashing the asset tree's names and sizes)
* would remove the human step entirely and is the better long-term design —
* recorded in docs/known-gaps.md rather than done here, because the file is
* not the one being worked on right now.
*/
const val ASSET_VERSION = "3"

@Volatile
private var server: DeviceBridgeHttpServer? = null
Expand Down Expand Up @@ -91,6 +110,10 @@ object DeviceBridgeController {
val log = DeviceAuditLog(File(paths.home, "device-bridge-audit.log"))
auditLog = log

// The shell's write boundary is the user's workspace; re-read it on every
// start so a workspace change is picked up without a rebuild.
DeviceWorkspace.refresh(appContext)

val minted = mintToken()
token = minted

Expand Down
91 changes: 88 additions & 3 deletions app/src/main/kotlin/app/pi/bridge/DeviceBridgeRouter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,60 @@ class DeviceBridgeRouter(

"/app/shell" -> withCapability(DeviceCapability.Shell) {
val command = params.strRequired("command")
DeviceShellGuard.inspect(command)?.let { throw DeviceActionException(it) }
// One switch, two enforcers: the TS gate reads the same boolean
// from /app/health, so the dialog and this guard cannot disagree.
val relaxed = store.isShellSyntaxRelaxed()
DeviceWorkspace.refresh(context)
DeviceShellGuard.inspect(command, relaxed, DeviceWorkspace)?.let { throw DeviceActionException(it) }
val backend = DeviceShellGuard.active()
DeviceShellGuard.toJson(backend.run(command, params.int("timeoutMs", 15_000)))
DeviceShellGuard.toJson(
result = backend.run(command, params.int("timeoutMs", 15_000)),
relaxedShellSyntax = relaxed,
boundaryLabel = DeviceWorkspace.shellPath(),
)
}

// Raw key injection. The accessibility channel can only do the five
// GLOBAL_ACTIONs; `input keyevent` needs uid 2000, so this endpoint
// exists only to make the Shizuku payoff reachable.
"/app/ui/keyevent" -> withCapability(DeviceCapability.Accessibility) {
DeviceUiAutomation.keyEvent(
keys = params.strRequired("keys"),
repeat = params.int("repeat", 1),
backend = DeviceShellGuard.active(),
)
}

"/app/files" -> withCapability(DeviceCapability.Storage) {
DeviceSafStore.get(context).describe()
}

"/app/files/list" -> withCapability(DeviceCapability.Storage) {
DeviceSafStore.get(context).list(params.str("path"))
}

"/app/files/read" -> withCapability(DeviceCapability.Storage) {
DeviceSafStore.get(context).read(
path = params.strRequired("path"),
maxBytes = params.int("maxBytes", 1024 * 1024),
)
}

"/app/files/write" -> withCapability(DeviceCapability.Storage) {
DeviceSafStore.get(context).write(
path = params.strRequired("path"),
text = params.str("content"),
base64 = params.str("base64"),
mimeType = params.str("mimeType") ?: "text/plain",
)
}

// The pi-side permission gate reports what it has approved. Display
// only: nothing here changes policy (the gate's own memory is the
// enforcement), and the UI labels it as extension-reported.
"/app/gate/report" -> {
DeviceApprovalLedger.report(params.json)
BridgeHttpResponse.okRaw(DeviceApprovalLedger.toJson())
}

else -> BridgeHttpResponse(404, notFound(path).toString())
Expand Down Expand Up @@ -276,6 +327,33 @@ class DeviceBridgeRouter(
put("locationPermissionGranted", store.hasLocationPermission())
put("notificationPermissionGranted", store.hasNotificationPermission())
put("vibratePermissionGranted", store.hasVibratePermission())
put("legacyStoragePermissionGranted", store.hasLegacyStoragePermission())
// The relaxed-syntax switch is published so the pi-side gate can honour the
// exact same boolean the Kotlin guard enforces.
put("shellSyntaxRelaxed", store.isShellSyntaxRelaxed())
DeviceWorkspace.refresh(context)
put("workspace", JSONObject().apply {
put("shellPath", DeviceWorkspace.shellPath() ?: JSONObject.NULL)
put("guestPath", "/workspace")
put("known", DeviceWorkspace.isKnown())
})
put("shizuku", DeviceShizuku.status(context))
put("gate", DeviceApprovalLedger.toJson())
// The whole policy, so a model (and the diagnostics page) can see exactly
// what is permitted instead of inferring it from refusals.
put("shellPolicy", JSONObject().apply {
put("allowedCommands", JSONArray(DeviceShellGuard.allowedCommands))
put("blocked", JSONArray(DeviceShellGuard.blockedSummary()))
put("writeBoundary", JSONArray(DeviceShellGuard.writeBoundarySummary()))
put("syntax", JSONArray(DeviceShellGuard.syntaxSummary(store.isShellSyntaxRelaxed())))
put("relaxedCost", DeviceShellGuard.relaxedCost())
put("elevatedBackend", DeviceShellGuard.hasElevatedBackend())
})
put("saf", JSONObject().apply {
val grants = DeviceSafStore.get(context).grants()
put("count", grants.size)
put("roots", org.json.JSONArray(grants.map { it.name }))
})
put("shellBackends", JSONArray().apply {
for (backend in DeviceShellGuard.backends()) {
put(
Expand Down Expand Up @@ -413,7 +491,8 @@ class DeviceBridgeRouter(
}

companion object {
const val BRIDGE_VERSION = "1"
/** Bumped whenever the endpoint set or a payload shape changes. */
const val BRIDGE_VERSION = "2"

/**
* The port the bridge listens on. Deliberately not 3090: the shipping DSH
Expand All @@ -430,6 +509,7 @@ class DeviceBridgeRouter(
"POST /app/ui/tap",
"POST /app/ui/input",
"POST /app/ui/key",
"POST /app/ui/keyevent",
"POST /app/ui/swipe",
"POST /app/screenshot",
"GET /app/apps",
Expand All @@ -450,7 +530,12 @@ class DeviceBridgeRouter(
"POST /app/torch",
"POST /app/export",
"POST /app/import",
"GET /app/files",
"POST /app/files/list",
"POST /app/files/read",
"POST /app/files/write",
"POST /app/shell",
"POST /app/gate/report",
)
}
}
13 changes: 7 additions & 6 deletions app/src/main/kotlin/app/pi/bridge/DeviceCapability.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,11 @@ enum class DeviceCapability(
Storage(
id = "storage",
title = "存储",
summary = "把文件导出到公共 Download 目录,或从其中读回",
summary = "读写用户授权(SAF)的目录,并把文件导出到公共 Download",
allows = listOf(
"读写你在本页授权的目录(SAF,重启后仍然有效)",
"把 Agent 生成的文件写入 Download(用户可见、可撤销)",
"按文件名从 Download 读回文件交给 Agent",
" Download 读回文件交给 Agent(API 33+ 只能读本应用自己的文件)",
),
defaultEnabled = false,
),
Expand Down Expand Up @@ -84,11 +85,11 @@ enum class DeviceCapability(
Shell(
id = "shell",
title = "Shell",
summary = "在设备上执行受策略守卫限制的命令(默认关闭,且每次都要确认)",
summary = "在设备上执行受策略守卫限制的命令(默认关闭;危险操作第一次确认后可选择本会话不再询问)",
allows = listOf(
"执行只读设备查询(getprop、dumpsys、pm list、logcat 等)",
"写入 Download 目录",
"读取 App 自己有权限读取的目录",
"执行日常读命令(getprop、dumpsys、pm list、logcat、ls、cat、df、ps 等)",
"执行日常写命令(cp、mv、rm、mkdir、sed、tar、curl 等),但只能写工作区之内",
"在装有 Shizuku 的设备上以 ADB 身份(uid=2000)运行,从而使用 input、pm、am、settings get 等",
),
defaultEnabled = false,
),
Expand Down
52 changes: 51 additions & 1 deletion app/src/main/kotlin/app/pi/bridge/DeviceCapabilityStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ class DeviceCapabilityStore private constructor(context: Context) {
fun isSessionDisabled(capability: DeviceCapability): Boolean =
sessionDisabled.contains(capability.id)

/**
* 放宽模式: the opt-in that lets the shell guard accept command substitution
* (`$(...)`, backticks) and the nesting heads (`sh`, `eval`, `source`, …).
*
* One stored boolean, read by three consumers: the Kotlin guard
* ([DeviceShellGuard.inspect]), the authorization page, and the pi-side
* permission gate — the gate reads it from `/app/health`. That single source is
* the point: a mode only one side honoured would be worse than no mode, because
* the disagreement between "the dialog let it through" and "the guard refuses"
* is invisible.
*
* Default OFF, and it is not part of any capability group: turning 「Shell」 on
* must not silently widen what shell *syntax* is allowed.
*/
fun isShellSyntaxRelaxed(): Boolean = prefs.getBoolean(KEY_RELAXED_SHELL, false)

fun setShellSyntaxRelaxed(relaxed: Boolean) {
prefs.edit().putBoolean(KEY_RELAXED_SHELL, relaxed).apply()
}

/** Persisted decision **and** not cut for this session. */
fun isEnabled(capability: DeviceCapability): Boolean =
isPersistentlyEnabled(capability) && !isSessionDisabled(capability)
Expand Down Expand Up @@ -149,12 +169,16 @@ class DeviceCapabilityStore private constructor(context: Context) {

DeviceCapability.Storage ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// API 29+ has MediaStore, which needs no storage permission for the
// app's own exports; SAF grants cover everything else.
null
} else if (hasLegacyStoragePermission()) {
null
} else {
DeviceDenial(
code = DeviceDenial.NO_PERMISSION,
reason = "这台设备(Android ${Build.VERSION.RELEASE})导出文件需要存储权限,当前未授予。",
hint = "请让用户在系统设置中为 pi-android 授予存储权限,或在应用内改用支持 MediaStore 的路径。",
hint = "请让用户在「设置 → 设备能力 → 存储」点「授予存储权限」,或在系统设置里为本应用打开存储权限。",
)
}

Expand Down Expand Up @@ -186,6 +210,31 @@ class DeviceCapabilityStore private constructor(context: Context) {
return fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED
}

/**
* The pre-API-29 storage path.
*
* The manifest declares `WRITE_EXTERNAL_STORAGE` with `maxSdkVersion="29"` and
* `READ_EXTERNAL_STORAGE` with `maxSdkVersion="32"`, so both are requestable
* exactly where they still mean something and invisible above that. Before this
* the code told the user to add the permission to the manifest — the reason
* `android_export` and `android_import` simply could not work on Android 8/9,
* which `minSdk 26` says this app supports.
*/
fun hasLegacyStoragePermission(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return true
val read = ContextCompat.checkSelfPermission(appContext, Manifest.permission.READ_EXTERNAL_STORAGE)
if (read != PackageManager.PERMISSION_GRANTED) return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return true
val write = ContextCompat.checkSelfPermission(appContext, Manifest.permission.WRITE_EXTERNAL_STORAGE)
return write == PackageManager.PERMISSION_GRANTED
}

/** The permissions the storage card should request on this API level. */
fun legacyStoragePermissions(): List<String> = buildList {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) add(Manifest.permission.READ_EXTERNAL_STORAGE)
}

/** True when the app may post notifications (always true below API 33). */
fun hasNotificationPermission(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Expand All @@ -202,6 +251,7 @@ class DeviceCapabilityStore private constructor(context: Context) {

companion object {
private const val PREFS_NAME = "pi-device-capabilities"
private const val KEY_RELAXED_SHELL = "shell.relaxed-syntax"

@Volatile
private var instance: DeviceCapabilityStore? = null
Expand Down
Loading
Loading