Skip to main content

andysu_cli/
init.rs

1//! `andysu init` and the shared `andysu.toml` model.
2//!
3//! ```toml
4//! app = "com.example.app"
5//! host_api = 1
6//! public_key = "andysu-public.pem"
7//! [remote]  type = "github"  repo = "owner/app"  channel = "stable"   # or type = "custom", base = "https://..."
8//! [policy]  mode = "auto"  network = "unmetered"  apply = "live"
9//! [host]    permissions = ["CAMERA"]  stubs = { activities = 4, services = 2 }
10//! ```
11
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::{write, R};
18
19#[derive(Serialize, Deserialize, Clone, Debug)]
20pub struct Remote {
21    #[serde(rename = "type")]
22    pub kind: String,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub repo: Option<String>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub base: Option<String>,
27    #[serde(default = "stable")]
28    pub channel: String,
29}
30
31fn stable() -> String {
32    "stable".into()
33}
34
35#[derive(Serialize, Deserialize, Clone, Debug)]
36pub struct Policy {
37    #[serde(default = "auto")]
38    pub mode: String,
39    #[serde(default = "any")]
40    pub network: String,
41    /// live (default) | restart | next_start
42    #[serde(default = "live")]
43    pub apply: String,
44    /// apply = restart: now | background
45    #[serde(default = "now")]
46    pub restart_when: String,
47    /// apply = live when a live swap is impossible: restart | next_start
48    #[serde(default = "next_start")]
49    pub live_fallback: String,
50    /// Android: who owns the native crash signals while a payload is on trial (D30):
51    /// payload (default: only report a replaced handler) | host (re-install, chain the payload's)
52    #[serde(default = "payload")]
53    pub crash_signals: String,
54    #[serde(default)]
55    pub charging: bool,
56    #[serde(default = "yes")]
57    pub battery_not_low: bool,
58    #[serde(default = "six_hours")]
59    pub check_interval_minutes: u64,
60    #[serde(default = "watchdog")]
61    pub watchdog_ms: u64,
62}
63
64fn auto() -> String {
65    "auto".into()
66}
67fn any() -> String {
68    "any".into()
69}
70fn live() -> String {
71    "live".into()
72}
73fn now() -> String {
74    "now".into()
75}
76fn payload() -> String {
77    "payload".into()
78}
79fn next_start() -> String {
80    "next_start".into()
81}
82fn yes() -> bool {
83    true
84}
85fn six_hours() -> u64 {
86    360
87}
88fn watchdog() -> u64 {
89    10_000
90}
91
92impl Default for Policy {
93    fn default() -> Self {
94        toml::from_str("").unwrap()
95    }
96}
97
98#[derive(Serialize, Deserialize, Clone, Debug, Default)]
99pub struct Stubs {
100    #[serde(default = "four")]
101    pub activities: u32,
102    #[serde(default = "two")]
103    pub services: u32,
104    #[serde(default = "two")]
105    pub receivers: u32,
106    #[serde(default = "one")]
107    pub providers: u32,
108}
109
110fn four() -> u32 {
111    4
112}
113fn two() -> u32 {
114    2
115}
116fn one() -> u32 {
117    1
118}
119
120#[derive(Serialize, Deserialize, Clone, Debug, Default)]
121pub struct Host {
122    #[serde(default)]
123    pub permissions: Vec<String>,
124    #[serde(default)]
125    pub features: Vec<String>,
126    #[serde(default)]
127    pub stubs: Option<Stubs>,
128    #[serde(default)]
129    pub shell_version: u64,
130    #[serde(default)]
131    pub min_sdk: u64,
132}
133
134#[derive(Serialize, Deserialize, Clone, Debug)]
135pub struct AndySUToml {
136    pub app: String,
137    #[serde(default = "one")]
138    pub host_api: u32,
139    pub public_key: String,
140    pub remote: Remote,
141    #[serde(default)]
142    pub policy: Policy,
143    #[serde(default)]
144    pub host: Host,
145}
146
147impl AndySUToml {
148    pub fn load(p: &Path) -> R<AndySUToml> {
149        let s = fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?;
150        let t: AndySUToml = toml::from_str(&s).map_err(|e| format!("{}: {e}", p.display()))?;
151        let check = |what: &str, v: &str, ok: &[&str]| -> R<()> {
152            if ok.contains(&v) {
153                Ok(())
154            } else {
155                Err(format!("{}: policy.{what} = {v:?}, expected one of {ok:?}", p.display()))
156            }
157        };
158        check("mode", &t.policy.mode, &["auto", "download_only", "check_only", "off"])?;
159        check("apply", &t.policy.apply, &["live", "restart", "next_start"])?;
160        check("restart_when", &t.policy.restart_when, &["now", "background"])?;
161        check("live_fallback", &t.policy.live_fallback, &["restart", "next_start"])?;
162        check("crash_signals", &t.policy.crash_signals, &["payload", "host"])?;
163        check("network", &t.policy.network, &["any", "unmetered"])?;
164        Ok(t)
165    }
166
167    /// Manifest URL the host fetches.
168    pub fn manifest_url(&self) -> R<String> {
169        let c = &self.remote.channel;
170        match self.remote.kind.as_str() {
171            "github" => {
172                let repo =
173                    self.remote.repo.as_deref().ok_or("[remote] type = \"github\" needs repo = \"owner/name\"")?;
174                Ok(if c == "stable" {
175                    format!("https://github.com/{repo}/releases/latest/download/update.json")
176                } else {
177                    format!("https://github.com/{repo}/releases/download/channel-{c}/update.json")
178                })
179            }
180            "pages" => {
181                let repo = self.remote.repo.as_deref().ok_or("[remote] type = \"pages\" needs repo")?;
182                let (owner, name) = repo.split_once('/').ok_or("repo must be owner/name")?;
183                Ok(format!("https://{}.github.io/{name}/{c}/update.json", owner.to_lowercase()))
184            }
185            "custom" => {
186                let base =
187                    self.remote.base.as_deref().ok_or("[remote] type = \"custom\" needs base = \"https://...\"")?;
188                Ok(format!("{}/{c}/update.json", base.trim_end_matches('/')))
189            }
190            k => Err(format!("unknown remote type {k:?} (github, pages, custom)")),
191        }
192    }
193
194    /// The host's `assets/andysu/config.json`.
195    pub fn host_config(&self, dir: &Path) -> R<serde_json::Value> {
196        let pem = fs::read_to_string(dir.join(&self.public_key)).map_err(|e| format!("{}: {e}", self.public_key))?;
197        andysu_core::keys::PublicKey::parse_any(&pem).map_err(|e| e.to_string())?;
198        let s = self.host.stubs.clone().unwrap_or(Stubs { activities: 4, services: 2, receivers: 2, providers: 1 });
199        Ok(serde_json::json!({
200            "app": self.app, "channel": self.remote.channel, "roots": [pem], "manifest_url": self.manifest_url()?,
201            "mode": self.policy.mode, "apply": self.policy.apply, "restart_when": self.policy.restart_when,
202            "live_fallback": self.policy.live_fallback, "crash_signals": self.policy.crash_signals, "network": self.policy.network,
203            "charging": self.policy.charging, "battery_not_low": self.policy.battery_not_low,
204            "check_interval_minutes": self.policy.check_interval_minutes, "watchdog_ms": self.policy.watchdog_ms,
205            "host_api": self.host_api, "shell_version": self.host.shell_version,
206            "stub_activities": s.activities, "stub_services": s.services,
207        }))
208    }
209
210    /// `andysu-host.json`: what the frozen host declares (input to `andysu pack --host`).
211    pub fn host_spec(&self) -> crate::pack::HostSpec {
212        let s = self.host.stubs.clone().unwrap_or(Stubs { activities: 4, services: 2, receivers: 2, providers: 1 });
213        let mut perms: Vec<String> = ["INTERNET", "ACCESS_NETWORK_STATE", "RECEIVE_BOOT_COMPLETED"]
214            .iter()
215            .map(|p| format!("android.permission.{p}"))
216            .collect();
217        // Declared by the host AAR for AndroidX core in payloads (see its AndroidManifest.xml).
218        perms.push(format!("{}.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION", self.app));
219        for p in &self.host.permissions {
220            let full = if p.contains('.') { p.clone() } else { format!("android.permission.{p}") };
221            if !perms.contains(&full) {
222                perms.push(full);
223            }
224        }
225        crate::pack::HostSpec {
226            app: self.app.clone(),
227            host_api: self.host_api,
228            shell_version: self.host.shell_version,
229            min_sdk: self.host.min_sdk,
230            permissions: perms,
231            activities: s.activities,
232            services: s.services,
233            receivers: s.receivers,
234            providers: s.providers,
235            features: self.host.features.clone(),
236        }
237    }
238}
239
240/// Project kinds `init` recognises, with the adoption snippet for each.
241pub fn detect(dir: &Path) -> &'static str {
242    let has = |p: &str| dir.join(p).exists();
243    let read = |p: &str| fs::read_to_string(dir.join(p)).unwrap_or_default();
244    if has("pubspec.yaml") && read("pubspec.yaml").contains("flutter") {
245        "flutter"
246    } else if has("project.godot") {
247        "godot"
248    } else if has("ProjectSettings/ProjectVersion.txt") {
249        "unity"
250    } else if has("capacitor.config.ts") || has("capacitor.config.json") {
251        "capacitor"
252    } else if has("package.json") && read("package.json").contains("\"expo\"") {
253        "expo"
254    } else if has("package.json") && read("package.json").contains("react-native") {
255        "react-native"
256    } else if has("config.xml") && read("config.xml").contains("cordova") {
257        "cordova"
258    } else if has("settings.gradle.kts") || has("settings.gradle") {
259        "gradle"
260    } else if has("CMakeLists.txt") {
261        "cmake"
262    } else {
263        "generic"
264    }
265}
266
267fn next_steps(kind: &str) -> &'static str {
268    match kind {
269        "gradle" => "Add the plugin: plugins { id(\"io.github.dixonsolutions.andysu\") } (see docs/REGISTRIES.md),\nimplement com.andysu.api.Payload, and set andysu { role = \"payload\"; entryClass = \"...\" }.",
270        "flutter" => "Add the andysu Flutter package (bindings/flutter) and build with `andysu split build/app/outputs/flutter-apk/app-release.apk`.",
271        "react-native" | "expo" => "Add @andysu/react-native (bindings/react-native); Expo: add the config plugin to app.json.",
272        "capacitor" | "cordova" => "Add @andysu/capacitor (bindings/capacitor), then `andysu split app.apk --transparent`;\nupdates apply on restart (live_fallback).",
273        "unity" => "Add the com.andysu.unity package (bindings/unity) through the scoped registry.",
274        "godot" => "Copy bindings/godot/addons/andysu into the project.",
275        "cmake" => "Link AndySU::payload (bindings/c) and export ANDYSU_PAYLOAD(start, suspend).",
276        _ => "Build your APK, then: andysu split app.apk --out build/andysu --entry-class <Payload class>.",
277    }
278}
279
280const WORKFLOW: &str = r#"# Generated by `andysu init`. Publishes a signed payload on every push to main.
281name: andysu-publish
282on:
283  push:
284    branches: [main]
285permissions:
286  contents: write
287jobs:
288  publish:
289    runs-on: ubuntu-latest
290    steps:
291      - uses: actions/checkout@v4
292      # Build your app's payload here (for Gradle: ./gradlew andysuPayloadRelease).
293      - uses: dixonSolutions/AndySU/actions/publish@main
294        with:
295          payload: build/andysu/payload
296          config: andysu.toml
297          signing-key: ${{ secrets.ANDYSU_SIGNING_KEY }}
298"#;
299
300pub fn init(dir: &Path, app: Option<String>, repo: Option<String>, keys: bool) -> R<String> {
301    let kind = detect(dir);
302    let toml_path = dir.join("andysu.toml");
303    if toml_path.exists() {
304        return Err(format!("{} already exists", toml_path.display()));
305    }
306    let app = app.ok_or("--app is required (the application id, e.g. com.example.app)")?;
307    if !andysu_core::manifest::is_app_id(&app) {
308        return Err(format!("{app:?} is not a valid application id"));
309    }
310    let mut out = format!("Detected a {kind} project.\n");
311    let keydir: PathBuf = dir.join(".andysu");
312    if keys {
313        fs::create_dir_all(&keydir).map_err(|e| e.to_string())?;
314        let g = crate::keys::keygen(&keydir, "andysu")?;
315        fs::rename(&g.public, dir.join("andysu-public.pem")).map_err(|e| e.to_string())?;
316        out.push_str(&format!(
317            "Generated root key {} in .andysu/andysu-root.key: store it as the ANDYSU_SIGNING_KEY secret\n\
318             (gh secret set ANDYSU_SIGNING_KEY < .andysu/andysu-root.key), keep a copy offline, then delete it here.\n",
319            g.id
320        ));
321        let gi = dir.join(".gitignore");
322        let mut s = fs::read_to_string(&gi).unwrap_or_default();
323        if !s.contains(".andysu/") {
324            s.push_str("\n# AndySU private keys never go into git\n.andysu/\n*.key\n");
325            write(&gi, s.as_bytes())?;
326        }
327    }
328    let (kind_r, repo_line) = match &repo {
329        Some(r) => ("github", format!("repo = \"{r}\"")),
330        None => ("custom", "base = \"https://example.com/updates\"".to_string()),
331    };
332    let t = format!(
333        "app = \"{app}\"\nhost_api = 1\npublic_key = \"andysu-public.pem\"\n\n[remote]\ntype = \"{kind_r}\"\n{repo_line}\nchannel = \"stable\"\n\n\
334         [policy]\nmode = \"auto\"          # auto | download_only | check_only | off\nnetwork = \"any\"        # any | unmetered\napply = \"live\"          # live | restart | next_start\nrestart_when = \"now\"    # for apply = restart: now | background\n\n\
335         [host]\n# Everything the app may ever need, declared once in the frozen host:\npermissions = []\nstubs = {{ activities = 4, services = 2 }}\n"
336    );
337    write(&toml_path, t.as_bytes())?;
338    AndySUToml::load(&toml_path)?;
339    write(&dir.join(".github/workflows/andysu.yml"), WORKFLOW.as_bytes())?;
340    out.push_str("Wrote andysu.toml and .github/workflows/andysu.yml.\n\nNext: ");
341    out.push_str(next_steps(kind));
342    Ok(out)
343}