1use 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 #[serde(default = "live")]
43 pub apply: String,
44 #[serde(default = "now")]
46 pub restart_when: String,
47 #[serde(default = "next_start")]
49 pub live_fallback: String,
50 #[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 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 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 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 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
240pub 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}