@drxiaozhi/minapk
v0.4.0
Published
Build Android APKs from Bun Single-file executables or source code
Maintainers
Readme
minapk
把你的Bun單一可執行檔 包成APK
minapk 是一個不使用 Gradle 的 Android APK 建置專案,以命令列工具完成資源處理、Java 編譯、R8/DEX、APK 封裝、zipalign 與簽章,並將 Buninu 幫你牛使用者空間 執行環境放入 APK。
npx @drxiaozhi/minapk /path/to/your.elf把 elf 路徑當位置參數傳入即可(詳細說明在後面)Buninu 幫你牛使用者空間原始碼:github.com/jjtseng93/buninu
本專案衍生自 Promastergame/tinyapk-lab。
以下內容從建置 APK 開始。如果你已經裝好 APK,只想知道裡面怎麼操作, 請直接前往 User Manual: Inside the APK。
安裝依賴
Termux
pkg update
pkg install aapt aapt2 zip unzip openjdk-21 nodejs npm
npm install -g bun
bun upgradeDebian/Ubuntu arm64 inside Termux proot
apt update
apt install aapt zipalign zip unzip openjdk-21-jdk-headless nodejs npm
npm i -g @oven/bun-linux-aarch64-android --force
export PATH=$(npm root -g)/@oven/bun-linux-aarch64-android/bin:$PATH
bun upgrade
# when running the build you need to see something like this: libbun.so not found; copying from: /usr/local/lib/node_modules/@oven/bun-linux-aarch64-android/bin/bun兩分鐘快速開始-你好世界專案
以下步驟假設上一節的依賴都已安裝完成。先在 Termux 建立一個只有
console.log 的 Bun 程式:
mkdir -p ~/minapk-hello
cd ~/minapk-hello
printf 'console.log("# Hello World!")\n' > hlw.js把它編譯成 Android Bun 單一可執行檔。這組參數的順序可以用
「寶寶非常明白」(bbfcmb)記憶:bun build 是 bb,後面依序是
format、compile、minify、bytecode(fcmb)。
bun build --format=esm --compile --minify --bytecode ./hlw.js目前目錄會出現 hlw,接著用 minapk 把它包進 APK:
npx @drxiaozhi/minapk ./hlw -n HelloWorld -p com.minapk.hello第一次執行時可能會出現最多三次確認提示,依序全部輸入 y:安裝
minapk、允許匯出 Buninu payload,以及安裝 Buninu。完成後目前目錄會得到
hlw.apk。
第一次讓 Termux 存取共享儲存空間時,執行下面第一行並在 Android 權限視窗 選擇允許;已授權過就不必再執行。然後把 APK 複製到下載資料夾:
termux-setup-storage
cp hlw.apk /sdcard/Download部分裝置執行 termux-setup-storage 後仍不能寫入 /sdcard/Download,需要到
Android「設定」→「應用程式」→「特殊應用程式權限」→「管理所有檔案」中,
手動允許 Termux 管理所有檔案;不同廠牌的選單名稱可能略有不同。
最後在 Android 的「檔案」或其他檔案管理器中:
- 打開「下載」並點選
hlw.apk。 - 第一次從這個檔案管理器安裝時,依提示允許「安裝未知應用程式」。
- 若 Google Play Protect 顯示安全驗證警告,展開「更多詳細資料」並選擇 「仍要安裝」。只應對你自己建置、且確認來源的 APK 這樣做。
- 安裝完成後點「開啟」。終端機畫面會執行
hlw並顯示# Hello World!。
應用程式名稱與輸出檔案名稱
npx @drxiaozhi/minapk -n MyApp-n/--appname <name> 決定:
- Android 顯示的應用程式名稱。
- 建置輸出的 APK 名稱,例如
MyApp.apk。 - repack 輸入與輸出名稱,例如
MyApp.apk→MyAppr.apk。
不帶 -n 時,會用專案根目錄 appname.txt 目前的值(Hello2)當預設;minapk 不會寫入或修改這個檔案,所以不帶 -n 的建置永遠得到同一個、可預期的名稱。
安卓套件名稱
npx @drxiaozhi/minapk -p com.drjohn.bunwv-p/--pkgname <pkgname> 決定 Android 的 package name/application ID,不是 APK 檔名。它應使用反向網域格式,並且必須是有效的 Java package 名稱;repack.sh 不會改變 package name,只有完整建置會套用。
建議使用 com.<6 字元>.<5 字元> 的格式,讓完整 package name 維持
16 個 ASCII 字元,例如 com.drjohn.bunwv。這不是 Android 的強制限制;
採用此長度是因為字串 com.termux/files 同樣是 16 bytes,未來可能可以
對含有該固定路徑的 Termux binary 進行等長 patch,而不必移動 binary
內的其他資料。目前的 build 流程尚未自動進行這種 patch。
不帶 -p 時,會用專案根目錄 pkgname.txt 目前的值(com.drjohn.bunwv)當預設,同樣不會被 minapk 寫入或修改。
建置步驟
npx @drxiaozhi/minapk [/path/to/your.elf][!NOTE] 用
npx執行(不是本地 checkout)時,第一次建置實測會依序遇到最多 3 次確認提示,全部輸入y(或Y)即可,這是正常流程:
npx詢問是否安裝@drxiaozhi/minapk本身。- 找不到本地
no_backup(npm 上發佈的套件本來就不含它)時,build.sh詢問是否執行npx buninu@latest --export。- 上一步的
npx詢問是否安裝buninu。因為每次
npx都是全新的暫存環境,第 2、3 步幾乎每次執行都會再問一次。
這是主要入口,整個流程都由 index.js 驅動:
- 若有帶 elf 路徑,這次建置就用它當
libmain.so打包(不會寫入專案根目錄,只影響這一次;不帶 elf 時使用專案根目錄現有的libmain.so,見下方說明)。 - 準備 Buninu payload 與 manifest/資源/Java 原始碼,用 aapt2、ECJ、R8 編譯,封裝 Buninu payload 與原生函式庫,執行 zipalign,並用
tools/debug.keystore簽章(不存在時自動建立),輸出到專案根目錄的<appname>.apk(例如目前設定會是Hello2.apk)。 - build 成功後,把輸出的 APK 複製一份到你目前的工作目錄(cwd):有帶 elf 時檔名是 elf 本身的檔名(去掉副檔名,若原本就沒有副檔名也一樣正確處理)加上
.apk,例如myapp.elf會產生myapp.apk;沒帶 elf 時就是<appname>.apk——例如照目前設定直接執行npx @drxiaozhi/minapk(不帶任何參數),會在你執行指令當下的目錄產生Hello2.apk。這一步是為了透過真正的npx(套件裝在 npx 暫存快取,跟你的 cwd 是兩回事)執行時也拿得到成品;如果你的 cwd 剛好就是專案根目錄,這一步會自動跳過(APK 本來就已經在那裡了)。
在有 checkout 的情況下也可以直接跑(效果相同):
bun ./index.js實際編譯步驟由 index.js 內部 spawn 的 build.sh 完成,它是 POSIX shell script,執行環境需要有 /bin/sh(或 Android 的 /system/bin/sh);一般不需要直接呼叫它。
libmain.so: 必須是使用 Android Bun 編譯、以 /system/bin/linker64 為執行載入器的執行檔(Bun >= 1.4 支援)。Android App 啟動時會自動執行。如果專案根目錄包含此檔案,建置時會將它封裝到 APK 的原生 lib/arm64-v8a 目錄中;若不存在則會直接略過,不會報錯。封裝原生函式庫前,若專案根目錄沒有
libbun.so,會執行which bun,並將找到的 Bun 複製為根目錄的libbun.so;找不到 Bun 或複製失敗時會停止建置。該 Bun 必須是可在目標 Android arm64 環境 執行的版本。要明確指定用哪一份 Bun、而不是交給PATH決定,見 指定要封裝進應用的執行檔的-b/--bun-bin。
[!IMPORTANT] 這個
which bun只在libbun.so不存在時跑一次。複製過去之後, 之後每次建置都直接用根目錄那一份,不會再看PATH——所以你後來bun upgrade、裝了新版、或切換到別的 Bun,建出來的 APK 裡還是舊的那顆。5b印出的 revision 就是拿來確認這件事的。 要換成PATH上現在這顆,明確指定一次:npx @drxiaozhi/minapk -b "$(which bun)"
- 封裝原生函式庫那一步(
5b)會執行libbun.so --revision並印出結果, 讓你知道這顆 APK 裡到底裝的是哪個版本的 Bun。因為libbun.so是 Android arm64 執行檔,不見得能在建置用的機器上直接跑;跑不起來時只會印unknown (not runnable on this build host),不會中斷建置。
清除 build/ 暫存資料夾:
npm run clean
# 等同
bun ./clean.js進階設定
完全取代封裝進應用的設定檔
npx @drxiaozhi/minapk /path/to/your.elf --config /path/to/package.json--config 指到的檔案會完全取代封裝進 APK 的 Buninu payload 裡的 package.json(是整份換掉,不是合併),可以用來自訂 buninu.shell、buninu.command、buninu.exitAfterCmd 等啟動設定,而不用去修改 no_backup 裡的原始檔案。
運作方式:
- 跟
build.sh自己內部的做法一樣,先從本地no_backup匯出一份buninu.tgz(沒有no_backup則詢問是否npx buninu@latest --export)。 - 把
--config檔案的內容原封不動 append 成 tar 裡第二個同路徑的<頂層目錄>/package.jsonentry,寫在既有內容的尾端——不重新打包整個 tgz,其餘上千筆 entry(含 symlink、可執行權限)完全不動。tar 解壓時後面的 entry 會覆蓋前面的,所以 App 實際解出來的就是--config提供的那份。 - 把處理好的 tgz 路徑傳給
build.sh,跳過它自己的 export 步驟。
[!IMPORTANT]
--config檔案必須是完整的package.json(含name/version/scripts/bin等欄位),不是只寫buninu那一段,因為是整份取代、不是合併。可以先用npx buninu@latest --export-config產生一份完整的buninu.json當起點,直接拿來改buninu區段後當--config的輸入即可。
指定應用內起始命令
不想為了改一個欄位就手寫一份完整 package.json,可以只用這個旗標:
npx @drxiaozhi/minapk /path/to/your.elf -c "echo custom startup command"-c/--command <command> 把封裝進 APK 的 package.json 裡的 buninu.command 整個欄位覆蓋成這個字串(buninu 本身也接受 "command": "字串" 這種簡寫,等同套用到所有平台,minapk 只建置 Android 所以不用管 default/android/linux 這些子欄位怎麼合併)。
[!WARNING] Buninu 預設的
buninu.command.android是:if command -v libmain.so >/dev/null 2>&1; then libmain.so; else printf ...; fi也就是偵測到
libmain.so就自動啟動它。用-c是整個欄位覆蓋,不是在這段邏輯上加東西,所以你的 elf(透過 elf 位置參數打包成libmain.so的那個)不會自動被執行,除非你自訂的command裡自己有呼叫libmain.so(例如-c "libmain.so"或包在你自己的邏輯裡)。忘記這件事最常見的症狀就是:APK 建置成功、App 也能開,但你的程式完全沒有啟動。
-c 可以跟 --config 合併使用而不是互斥:
- 有帶
--config:以--config檔案的內容當底,-c只覆蓋其中的buninu.command,其他欄位維持--config檔案原樣。 - 沒帶
--config:以本次 export 出來、Buninu payload 裡原本的package.json當底,一樣只覆蓋buninu.command,其餘欄位維持原樣,不需要另外準備--config檔案。
停用-指令跑完掉回互動式指令行
npx @drxiaozhi/minapk /path/to/your.elf -c "echo custom command" --no-shell--no-shell 不用帶值,出現就把 buninu.exitAfterCmd 設成 true(預設 false,見 Buninu README 的 exitAfterCmd 說明):buninu.command 執行完後直接結束,不會像預設那樣掉回互動式 shell。合併規則跟 -c 一樣——有 --config 就疊加在它上面,沒有就疊加在本次 export 出來的原始 package.json 上,其餘欄位都不動。
返回鍵直接離開應用
npx @drxiaozhi/minapk /path/to/your.elf --no-back-to-console--no-back-to-console 不用帶值,出現就把 buninu.backToConsole 設成 false(預設 true):在 app WebView 而且它自己沒有上一頁可回時,按返回鍵會走離開 App 那條路,而不是切回 console WebView。「離開」在哪個 WebView 按都一樣要先過確認對話框——這個欄位只決定要不要先繞去 console,不會讓返回鍵變成不問就直接退出。合併規則跟 -c/--no-shell 完全一樣——有 --config 就疊加在它上面,沒有就疊加在本次 export 出來的原始 package.json 上,其餘欄位都不動。
這個欄位是由 Android 端的 App 讀的,不是 Buninu 自己讀的,所以在 APK 以外的地方設它不會有任何效果。App 端讀不到檔案、JSON 壞掉、沒有這個欄位、值不是布林,一律當成 true(回到 console),不會因此丟出任何錯誤。
預設隱藏螢幕上額外按鍵列
npx @drxiaozhi/minapk /path/to/your.elf --hide-extra-keys--hide-extra-keys 不用帶值,出現就把 buninu.hideExtraKeys 設成
true(預設 false)。App 啟動時會隱藏額外按鍵列,之後仍可從音量鍵
選單正常切換顯示。它與 -c、--no-shell、--no-back-to-console 和
--config 的合併規則相同,其餘欄位都不動。
指定要封裝進應用的執行檔
npx @drxiaozhi/minapk /path/to/your.elf -b /path/to/android-arm64/bun-b/--bun-bin <path> 把指定的 Bun 執行檔複製成專案根目錄的 libbun.so,
取代目前那一份,然後才開始建置。用來在不同版本的 Bun(例如 canary 與
stable)之間切換,而不必自己去 cp 或去動 PATH。該 Bun 一樣必須是可在
Android arm64 上執行的版本。
[!IMPORTANT] 這個旗標跟
-n/-p/elf 位置參數不一樣:那三個只影響當次建置,磁碟上的appname.txt/pkgname.txt/libmain.so永遠不會被寫入;-b則是真的把libbun.so覆蓋掉,之後不帶-b的建置也會繼續用這一份。這是刻意的:
libbun.so本來就不是 checked-in 的檔案,而是build.sh自己 會寫的快取(不存在時它會把which bun找到的 Bun 複製過去),所以-b覆蓋掉的只是「上次從PATH撿來的那份」,沒有什麼可預期的預設值會被破壞。
不論有沒有帶 -b,建置到 5b 那一步都會印出實際封裝進去的 Bun 版本:
5b. Packaging native libraries...
Packaged Bun (libbun.so) revision:
1.4.0-canary.1+41c3f6fdb-c/--no-shell/--no-back-to-console/--config/-b 可以跟
-n/-p以及 elf
位置參數任意組合,例如:
npx @drxiaozhi/minapk /path/to/your.elf -n MyApp -p com.example.myapp -c "echo hello" --no-shell只更新幫你牛使用者空間
完成至少一次完整建置並已有根目錄 APK 後,可以執行:
./repack.shrepack.sh 不會重新編譯 Android 資源、Java 或 DEX。它會:
- 以根目錄的
<appname>.apk為來源。 - 重新匯出
buninu.tgz並產生buninu.stamp。 - 替換 APK 內的 payload。
- 重新執行 zipalign 與簽章。
- 在根目錄輸出
<appname>r.apk,不覆蓋原始 APK。
例如:
Hello2.apk → Hello2r.apk也可以明確使用 Android system shell:
/system/bin/sh ./repack.sh幫你牛使用者空間來源
Buninu npm 套件:https://www.npmjs.com/package/buninu
Buninu GitHub 原始碼:https://github.com/jjtseng93/buninu
[!IMPORTANT]
buninu.tgz必須只有一個頂層資料夾。App 解壓時會移除第一層 (--strip-components=1),再把其內容直接放入 Buninu home。
解壓縮的目的地是 Android App 的內部私有 Buninu home:
/data/data/<package-name>/no_backupAndroid 在部分版本可能將同一個 App data directory 表示為
/data/user/0/<package-name>;實際路徑由 ApplicationInfo.dataDir 取得。
壓縮檔的單一頂層只是可移除的包裝層,不會在 no_backup 裡再多建立一層。
正確:
no_backup/
no_backup/bin/
no_backup/apps/錯誤:
no_backup/
other_directory/頂層資料夾的名稱不限定為 no_backup,但整份 archive 中只能有一個頂層名稱。若首次安裝時不符合此要求,App 無法建立 Buninu home,WebView 會顯示中英文錯誤訊息;已有舊安裝時則會跳過該 payload 並啟動既有版本。
若根目錄存在 no_backup,兩支腳本會使用:
bun no_backup/bin/init.js --export buninu.tgz若不存在,腳本會詢問是否執行 npx buninu@latest --export。只有明確輸入 y 或 Y 才會從 npm 匯出。
User Manual: Inside the APK
這一區說明 APK 安裝並開啟後,使用者可以直接操作的功能。Buninu shell 內建指令的完整說明請見 Buninu 的 Commands inside the shell。
螢幕上額外按鍵列
建出來的 App 底部有一排終端機常用按鍵,很多鍵短按跟長按是不同功能:
| 按鍵 | 短按 | 長按 | | --- | --- | --- | | ESC | Esc | Ctrl+Q | | SHFT | 切換 Shift 修飾鍵 | Ctrl+D | | ^C x | Ctrl+C | Ctrl+X | | HOME | Home | Ctrl+U | | END | End | Ctrl+K | | TAB | Tab | Shift+Tab | | PGU | Page Up | 按住持續向上捲動(滑鼠滾輪) | | PGD | Page Down | 按住持續向下捲動(滑鼠滾輪) | | ↑ ↓ ← → | 方向鍵 | 按住連續輸入 | | Ent | Enter | Forward Delete | | CTRL / ALT | 切換 Ctrl / Alt 修飾鍵 | (無) |
CTRL、ALT、SHFT 是 Termux 風格的一次性(one-shot)修飾鍵:短按後按鈕會反白表示已啟用,套用到下一個按下的按鍵之後就會自動清除,所以要打 Ctrl+C 只要先點 CTRL 再點 ^C x(或任何字母鍵),不需要多點觸控同時按住兩個鍵。畫面右上角還有一個很窄的隱形輸入框,可以喚出系統輸入法直接打字/貼上文字。
音量鍵上選單
實體音量鍵 + 會攔截下來(不會真的調音量),改成跳出一個小選單:切換螢幕按鍵列、在 WebView 裡 eval JS、選取終端機文字、上一頁/下一頁、跳到指定網址、縮放、Eruda console、背景權限設定、切換 WebView(直接切到下一個,不再多一層選單;項目本身會標出要切去哪一個,例如 Switch WebView → 1: app)。
WebViews
App 裡有兩個 WebView,從啟動就都存在、不會被建立或關閉:0 是 console(Buninu 起的 jsgotty 終端機),1 是 app WebView,一開始是空白的、擺在後面。按鍵列、音量鍵選單、返回鍵一律作用在當前在前景的那一個 WebView 上,所以切換 WebView 就等於同時把這三者換過去。在 app WebView 沒有上一頁可回時按返回鍵,會切回 console 而不是結束 App——沒有任何東西被關掉,離開前景的那個 WebView 照樣繼續跑(這個行為由 buninu.backToConsole 決定,預設 true,見 --no-back-to-console)。切換的方式是音量鍵選單最後那個「Switch WebView →」項目(按下去就直接切,不會再問你要哪一個),或從 Buninu 裡呼叫下面的 showWebView。
返回鍵
返回鍵真的會離開 App 的那一步(console 也沒有上一頁可回時)會先跳出確認對話框。確認離開之後不只是關掉畫面:Buninu 行程、native bridge 的 socket 都會收掉,整個 App 行程結束,下次開啟是全新的一份。Buninu 是用 bun --no-orphans 啟動的,所以它自己 spawn 出去的 jsgotty、shell 也會跟著一起結束,不會留下孤兒行程;同一個旗標也讓 Buninu 在 App 行程被系統殺掉時自行退出。
原生橋
App 內建一座從 Buninu 通到 Android 原生層的橋(no_backup/apps/native-bridge),透過 MainActivity 開的一個 unix socket,把 Toast 與系統剪貼簿讀寫暴露給 Buninu 裡跑的 Bun 行程。Buninu 隨附的 xclip 指令(apps/xclip)就是建在這座橋上:
echo hello | xclip -selection clipboard # 寫進 Android 系統剪貼簿
xclip -o -selection clipboard # 讀出來-selection primary(不帶 -selection 時的預設值)維持純本地檔案、不碰原生剪貼簿,對應真正 X11 的語意;只有 -selection clipboard/-clip 才會透過 native-bridge 走到 Android 系統剪貼簿。jsmdcui 的剪貼簿後端偵測本來就會在偵測到 xclip 時使用它,所以 jsmdcui 裡的滑鼠中鍵貼上、選取文字自動同步、PastePrimary 指令,在這個 App 裡不需要額外設定就能動作。
要直接呼叫這座橋、不透過 xclip,可以在 js back 區塊裡 import { toast, clipboardRead, clipboardWrite } from 該路徑;每次呼叫預設 5 秒逾時,Android 端沒有回應也不會卡住呼叫端。詳見 no_backup/README.md 的「Commands inside the shell」一節。
xdg-open 在 APK 裡預設是把 URL 丟給系統的預設處理程式(等於離開 App),設 MINAPK_WEBVIEW=<id> 就改成載進那個 WebView 並直接切到前景:
MINAPK_WEBVIEW=1 xdg-open https://example.com # 開在 app WebView 並顯示
export MINAPK_WEBVIEW=1 # 或整個 session 都這樣0 是 console,會把終端機那頁導走(返回鍵可以回去、jsgotty 會重連,但通常不是你要的);-1 是當前前景那個。只有 URL 會被導向,檔案路徑一律照舊走系統處理程式——WebView 從 API 30 起 setAllowFileAccess 預設 false,file:// 讀不到 Buninu home 底下的檔案,而原生端本來就有 content:// provider 在服務同一個檔案。值不是純整數會在 stderr 提醒並當成沒設(不會亂猜),沒設或空字串維持原行為,指到不存在的 WebView 則印出錯誤後退回系統處理程式。
同一座橋也把上面那兩個 WebView 交給 Buninu 控制,共四個 function:openWebView(id, url)、evalWebView(id, js)、showWebView(id)、currWebView()。id 給 -1 代表「目前在前景的那一個」。
四個都各有一個短名 openwv/evalwv/showwv/currwv,跟剪貼簿的 getcb/setcb 同一套做法:Java 端兩種拼法都收,_discover 也兩種都列,所以 CLI、rpcraw、import 三種用法都通。
native-bridge openwv 1 https://example.com # 載入,但畫面不動
native-bridge showwv 1 # 這時候才切到前景
native-bridge evalwv 1 document.title
native-bridge currwvopenWebView 只載入、不切到前景,所以「使用者還在看終端機,背景先把 app WebView 的頁面載好」是一次呼叫就做完的事;showWebView 才是唯一會改變畫面的那個,showWebView -1 不是空操作而是切到下一個(只有兩個 WebView 時就是來回切換)。evalWebView 回傳的是運算式真正的值(數字/字串/物件),不是包成字串的值;undefined、function、丟出例外都會變成 null,因為 WebView 本身就分不出這三者。同樣的四個 function 也能 import 進 js back 區塊用(另外還有 WEBVIEW_CURRENT/WEBVIEW_CONSOLE/WEBVIEW_APP 三個常數)。
同一座橋也接了語音朗讀:tts "hello" 會唸出文字並等講完才結束,-a 不等直接返回。沒有 App 可用時會退回 espeak-ng/say/PowerShell 等桌面平台指令,一樣可以用。
可選外部工具
bunproot 是使用者執行 bunx 後自行下載的可選工具,採用
GPL-2.0-or-later;js-udocker 採用 Apache-2.0。兩者都不隨 minapk 或產生的
APK 一起散布。
bunx bunproot --git clone https://github.com/jjtseng93/js-udocker
cd js-udocker
export JS_UDOCKER_BUNPROOT=$HOME/.bun/bin/bunproot
bun udocker.js run --name=ap alpine
# bun udocker.js ps主要外部工具
- Bun
- aapt2
- zipalign
- zip
- Java
- keytool(只有建立 keystore 時需要)
其他建置用 JAR 位於 tools/。
應用簽章與金鑰工具
build.sh 與 repack.sh 預設使用:
tools/debug.keystore如果這個檔案不存在,腳本才會呼叫 keytool 自動建立 debug keystore。之後的 build 與 repack 會持續使用同一個檔案簽章,因此更新已安裝的 APK 時請保留它。
專案本身隨附一份現成的 tools/debug.keystore,刻意讓沒有 keytool(或整個 Java 環境)的環境也能完整跑完簽章這一步——末日生存情境下,能建置出可安裝的 APK 比什麼都重要。
[!WARNING] 隨附的
tools/debug.keystore是所有沒有換掉它的使用者共用同一把私鑰(密碼固定是android),因為它透過 npm 公開發佈,任何人都拿得到。這代表:
- 用預設 keystore 簽出來的 APK,跟其他人用同一份預設 keystore 簽出來的 APK,是同一把金鑰簽的。
- 只要 package name 相同,任何人都能用這把公開金鑰重新簽署別的 APK,Android 會把它當成合法更新接受安裝。
只要環境裡有
keytool,刪掉tools/debug.keystore再重新建置一次,腳本就會自動幫你產生一把只有你自己有的新金鑰。正式發佈或給別人安裝之前,請務必這樣做一次,或改用你自己的 release keystore 並妥善備份私鑰與密碼。
產生單一可執行檔
以下兩條路徑都會產生 minapk 要的那種 elf——以 /system/bin/linker64 為載入器的 arm64 執行檔。兩者都需要 Bun 1.4 以上,用最新的 1.4 正式版就可以,不需要 canary:
bun upgrade途徑一-一行建置
hlw.js:
console.log("Hello from Bun single-file executable")bun build --format=esm --compile --minify --bytecode ./hlw.js
npx @drxiaozhi/minapk hlw得到 hlw.apk。輸出檔名不用指定,會自動去掉副檔名變成 hlw(Windows 上則是 hlw.exe)。
途徑二-標記語言應用
hlw.md:
#!/usr/bin/env jsmdcui
## Question 問題
- What is 1+2+3+4+..+..+∞
```text#ans
-1/12
```
- [Submit 提交](javascript:checkAns())
- [Where am I? 我在哪?](javascript:whereAmI())
```js front
export function checkAns()
{
if($('#ans').val().trim()=='-1/12')
$('#ans').val('答對🥳Right!');
else
$('#ans').val('答錯😫Wrong!');
}
export async function whereAmI()
{
const r = await rpc.sysinfo();
alert(Object.entries(r).map(([k, v]) => `${k}: ${v}`).join('\n'));
}
```
```js back
export function sysinfo()
{
return {
bun: Bun.version,
platform: process.platform,
arch: process.arch,
buninu: process.env.BUNINU_HOME ?? '(not under Buninu)',
android: process.env.PKG_DDIR ?? '(not inside an APK)',
};
}
```npx jsmdcui --build-md-exe hlw.md
npx @drxiaozhi/minapk mdcui[!NOTE] 輸出的執行檔固定叫
mdcui,不是hlw,所以直接餵給 minapk 會得到mdcui.apk。要別的名字就先mv。(不要試圖用--outfile指定:它不是相對你的 cwd 解析的,檔案會跑到 jsmdcui 自己的目錄裡去。)
checkAns 是純前端的答案比對;whereAmI 則透過 await rpc.sysinfo() 呼叫到 js back 區塊,回報這個 app 現在跑在哪裡。同一個執行檔在 Termux 裡直接跑會顯示 (not inside an APK),包成 APK 裝起來之後,同樣那顆按鈕就會顯示 Buninu home 與 Android app 私有目錄的實際路徑。
Google Play
[!IMPORTANT] minapk 建出來的 APK 不是一個可以直接上架的成品。以下兩點是機制上的硬限制,跟政策解讀無關,照現況直接丟上 Play Console 就會被擋下來:
- 格式:新 App 自 2021 年 8 月起只收 Android App Bundle(AAB),minapk 產出的是 APK。
- 簽章:新 App 一律走 Play App Signing,你手上只留 upload key,不可能沿用上方「APK 簽章與 keytool」提到、所有人共用的那份
tools/debug.keystore。換句話說,要上架至少得自行改用 AAB 流程、換成自己的金鑰。這些都做完之後,才輪到下面比較沒有標準答案的政策問題。
政策面
Play 的 Device and Network Abuse 政策規定:App 不得從 Google Play 以外的來源下載可執行碼(dex、JAR、.so),也不得用 Play 更新機制以外的方式更新自己;但「在虛擬機或直譯器中執行的程式碼」不在此限。
minapk 建出來的 APK 目前是這樣執行的:
libbun.so/libmain.so都隨 APK 打包在lib/arm64-v8a/,App 以ProcessBuilder從nativeLibraryDir執行。該目錄是 Android 10(API 29)W^X 限制下少數仍保有執行權限的位置。- Buninu payload(JS)同樣隨 APK 打包在 assets,首次啟動才解壓到
no_backup,再由上面那個libbun.so當直譯器執行。 - 整個流程不會從網路下載任何原生可執行檔。
類似專案的做法對照:
libnode.so(nodejs-mobile)走更保守的路線——Node 編譯成真正的 JNI 共享函式庫,用System.loadLibrary("node")載入到 App 自己的行程內,不 fork/exec 子行程,所以它單純就是「APK 內的原生函式庫」。- minapk 是把 Bun 當獨立執行檔 exec,比較接近 Termux 在 Play 上的版本(該版本以
system_linker_exec處理 Android 10+ 的 W^X 限制)。Termux 本身曾因 target API 29 的執行限制長期無法在 Play 更新,後來才以這個分支回到 Play。
仍需自行評估的部分:預設會開一個互動式 shell,使用者可以在裡面執行任意程式;Buninu 的 bunx 會在執行期從 npm 安裝並執行套件。這類「執行期才取得程式碼」的行為,正是審核最容易被盯上的地方。
[!NOTE] 我本身不是法律專業,以上只是對照公開政策條文與類似專案做法的整理,不構成法律或合規建議。目前已提供
--no-shell(即buninu.exitAfterCmd)讓指令跑完就直接結束、不落回互動式 shell,作為縮小暴露面的選項。實際的上架審核規範與結果,有待各位使用者自行發掘。
未來規劃
- 修復xterm終端機捲動問題
- ~~加入Ctrl Alt Shift等按鍵~~(已完成)
- 一直包裝直到做到
- ~~npx @drxiaozhi/minapk your_binary~~(已完成,見「建置」)
- npx @drxiaozhi/minapk myapp.md
- ~~原生 bridge(架構還在設計中)~~(已完成:
native-bridge/xclip/tts,目前有 toast、剪貼簿、TTS;缺的是繼續暴露更多 Android 能力,見下) BUN_BE_BUN機制:libmain.so本質上是「Bun 執行檔本體 + 附加上去的 standalone module graph」(bun build --compile的輸出),正常執行會直接偵測並啟動內嵌的 app。Bun 官方文件(single-file executable)記載了BUN_BE_BUN=1這個環境變數,設定後同一個檔案會改成表現得像單純的bunCLI、跳過 standalone graph 偵測。理論上可以拿libmain.so兼職當作libbun.so用(呼叫時帶BUN_BE_BUN=1),不用再額外打包一份完整 Bun 執行檔,省下可觀的 APK 空間。架構還沒定案——目前libbun.so/libmain.so各自一份的好處是彼此可以互相 fallback(例如libmain.so的 standalone graph 或BUN_BE_BUN行為出狀況時還有獨立的libbun.so可用),改成共用一份就要想清楚失去這層保險的取捨
目錄
- 安裝依賴
- 兩分鐘快速開始-你好世界專案
- 應用程式名稱與輸出檔案名稱
- 安卓套件名稱
- 建置步驟
- 進階設定
- 只更新幫你牛使用者空間
- 幫你牛使用者空間來源
- User Manual: Inside the APK
- 主要外部工具
- 應用簽章與金鑰工具
- 產生單一可執行檔
- Google Play 上架與政策
- 未來規劃
- License
License
本專案依照 MIT License 發布。原始上游專案為 tinyapk-lab。Android SDK、建置工具及其他第三方元件各自適用其原有授權;完整工具版本、來源與授權對照請見 NOTICE 及 LICENSES。
