use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::meta::Project;
pub struct Resolved {
pub route: String,
pub id: String,
pub labels: BTreeMap<String, String>,
pub base: String,
}
const DEFAULT_LOCALE: &str = "en";
pub fn resolved(project: &Project) -> Result<Vec<Resolved>, String> {
let shortcuts = &project.manifest.shortcuts;
if shortcuts.is_empty() {
return Ok(Vec::new());
}
let root = project.root.join("resource/locales");
let mut locales: Vec<String> = std::fs::read_dir(&root)
.map_err(|e| format!("[[shortcuts]] needs {}: {e}", root.display()))?
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().to_str().map(String::from))
.collect();
locales.sort();
if !locales.iter().any(|l| l == DEFAULT_LOCALE) {
return Err(format!(
"[[shortcuts]] labels resolve from resource/locales/, which has no {DEFAULT_LOCALE}/"
));
}
let mut out = Vec::new();
for (i, s) in shortcuts.iter().enumerate() {
let mut labels = BTreeMap::new();
for loc in &locales {
match ftl_value(&root.join(loc), &s.label)? {
Some(v) => {
labels.insert(loc.clone(), v);
}
None => {
return Err(format!(
"[[shortcuts]] label `{}` is missing from resource/locales/{loc}/ — \
shortcut labels must be translated in every locale",
s.label
));
}
}
}
let base = labels.get(DEFAULT_LOCALE).cloned().unwrap_or_default();
out.push(Resolved {
route: s.route.clone(),
id: format!("day_shortcut_{i}"),
labels,
base,
});
}
Ok(out)
}
fn ftl_value(dir: &Path, key: &str) -> Result<Option<String>, String> {
let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
.map_err(|e| format!("{}: {e}", dir.display()))?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "ftl"))
.collect();
files.sort();
for file in files {
let text =
std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
let mut lines = text.lines().peekable();
while let Some(line) = lines.next() {
let Some(rest) = line.strip_prefix(key) else {
continue;
};
let Some(value) = rest.trim_start().strip_prefix('=') else {
continue;
};
let value = value.trim();
let continued = lines
.peek()
.is_some_and(|n| !n.trim().is_empty() && n.starts_with([' ', '\t']));
if value.is_empty() || continued {
return Err(format!(
"{}: `{key}` is a multi-line message — shortcut labels must be single-line",
file.display()
));
}
if value.contains('{') {
return Err(format!(
"{}: `{key}` uses a placeable — shortcut labels must be static text",
file.display()
));
}
return Ok(Some(value.to_string()));
}
}
Ok(None)
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn android_values_dir(locale: &str) -> String {
if locale == DEFAULT_LOCALE {
return "values".to_string();
}
let parts: Vec<&str> = locale.split('-').collect();
match parts.as_slice() {
[lang, region] if region.len() == 2 => format!("values-{lang}-r{region}"),
[lang] => format!("values-{lang}"),
parts => format!("values-b+{}", parts.join("+")),
}
}
pub fn sync_android(project: &Project) -> Result<(), String> {
let manifest = project
.root
.join("platform/android/app/src/main/AndroidManifest.xml");
if !manifest.exists() {
return Ok(());
}
let res = project.root.join("build/day/android/res");
if let Ok(rd) = std::fs::read_dir(&res) {
for dir in rd.flatten() {
let stale = dir.path().join("day_shortcuts.xml");
let _ = std::fs::remove_file(stale);
}
}
let shortcuts = resolved(project)?;
if shortcuts.is_empty() {
return Ok(());
}
let text =
std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
let scheme = android_scheme(&text).ok_or_else(|| {
format!(
"[[shortcuts]] needs a deep-link scheme, and {} has no android:scheme in an \
intent-filter (docs/deep-links.md)",
manifest.display()
)
})?;
let activity = android_launcher_activity(&text)
.ok_or_else(|| format!("{}: no LAUNCHER activity found", manifest.display()))?;
let app_id = project.manifest.resolve("android-mdc").id;
let xml_dir = res.join("xml");
std::fs::create_dir_all(&xml_dir).map_err(|e| format!("mkdir {}: {e}", xml_dir.display()))?;
let mut s = String::from(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<!-- Generated by `day build` from Day.toml [[shortcuts]]. Do not edit. -->\n\
<shortcuts xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
);
for sc in &shortcuts {
s.push_str(&format!(
" <shortcut\n android:shortcutId=\"{id}\"\n \
android:enabled=\"true\"\n \
android:shortcutShortLabel=\"@string/{id}\">\n \
<intent\n android:action=\"android.intent.action.VIEW\"\n \
android:data=\"{data}\"\n \
android:targetPackage=\"{app_id}\"\n \
android:targetClass=\"{activity}\" />\n </shortcut>\n",
id = sc.id,
data = xml_escape(&format!("{scheme}://{}", sc.route)),
));
}
s.push_str("</shortcuts>\n");
std::fs::write(xml_dir.join("day_shortcuts.xml"), s).map_err(|e| e.to_string())?;
let locales: Vec<String> = shortcuts[0].labels.keys().cloned().collect();
for loc in &locales {
let dir = res.join(android_values_dir(loc));
std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?;
let mut s = String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n");
for sc in &shortcuts {
let label = sc.labels.get(loc).unwrap_or(&sc.base);
s.push_str(&format!(
" <string name=\"{}\">{}</string>\n",
sc.id,
xml_escape(label)
));
}
s.push_str("</resources>\n");
std::fs::write(dir.join("day_shortcuts.xml"), s).map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn android_manifest_fragment(project: &Project) -> Option<String> {
if project.manifest.shortcuts.is_empty() {
return None;
}
let manifest = project
.root
.join("platform/android/app/src/main/AndroidManifest.xml");
let text = std::fs::read_to_string(manifest).ok()?;
let activity = android_launcher_activity(&text)?;
Some(format!(
"<!-- Day.toml [[shortcuts]] — merged into the launcher activity by name. -->\n\
<activity android:name=\"{activity}\">\n \
<meta-data android:name=\"android.app.shortcuts\" \
android:resource=\"@xml/day_shortcuts\" />\n</activity>"
))
}
fn android_scheme(manifest: &str) -> Option<String> {
let at = manifest.find("android:scheme=\"")?;
let rest = &manifest[at + "android:scheme=\"".len()..];
rest.split('"').next().map(String::from)
}
fn android_launcher_activity(manifest: &str) -> Option<String> {
for chunk in manifest.split("<activity").skip(1) {
let body = chunk.split("</activity>").next().unwrap_or(chunk);
if !body.contains("android.intent.category.LAUNCHER") {
continue;
}
let at = body.find("android:name=\"")?;
let rest = &body[at + "android:name=\"".len()..];
return rest.split('"').next().map(String::from);
}
None
}
fn lproj_name(locale: &str) -> &str {
match locale {
"zh-CN" => "zh-Hans",
"zh-TW" => "zh-Hant",
other => other,
}
}
fn ios_scheme(plist: &str) -> Option<String> {
let at = plist.find("CFBundleURLSchemes")?;
let rest = &plist[at..];
let s = rest.find("<string>")?;
let rest = &rest[s + "<string>".len()..];
rest.split('<').next().map(str::trim).map(String::from)
}
pub fn sync_ios(project: &Project, plist_path: &Path) -> Result<(), String> {
if !plist_path.exists() {
return Ok(());
}
let shortcuts = resolved(project)?;
let before = std::fs::read_to_string(plist_path)
.map_err(|e| format!("{}: {e}", plist_path.display()))?;
let items: Vec<Vec<(String, String)>> = shortcuts
.iter()
.map(|sc| {
let link = match ios_scheme(&before) {
Some(scheme) => format!("{scheme}://{}", sc.route),
None => sc.route.clone(),
};
vec![
("UIApplicationShortcutItemType".to_string(), link),
(
"UIApplicationShortcutItemTitle".to_string(),
sc.base.clone(),
),
]
})
.collect();
let value = (!items.is_empty()).then_some(items.as_slice());
let after = crate::plist::apply_dict_array_key(&before, "UIApplicationShortcutItems", value)?;
if after != before {
std::fs::write(plist_path, after).map_err(|e| format!("{}: {e}", plist_path.display()))?;
}
Ok(())
}
pub fn stage_ios_strings(project: &Project, bundle: &Path) -> Result<(), String> {
if let Ok(rd) = std::fs::read_dir(bundle) {
for entry in rd.flatten() {
let p = entry.path();
if p.extension().is_some_and(|x| x == "lproj") {
let _ = std::fs::remove_file(p.join("InfoPlist.strings"));
let _ = std::fs::remove_dir(&p);
}
}
}
let shortcuts = resolved(project)?;
if shortcuts.is_empty() {
return Ok(());
}
let locales: Vec<String> = shortcuts[0].labels.keys().cloned().collect();
for loc in &locales {
if loc == DEFAULT_LOCALE {
continue; }
let dir = bundle.join(format!("{}.lproj", lproj_name(loc)));
std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?;
let mut s = String::from(
"/* Generated by `day build` from Day.toml [[shortcuts]]. Do not edit. */\n",
);
for sc in &shortcuts {
let label = sc.labels.get(loc).unwrap_or(&sc.base);
s.push_str(&format!(
"\"{}\" = \"{}\";\n",
strings_escape(&sc.base),
strings_escape(label)
));
}
std::fs::write(dir.join("InfoPlist.strings"), s).map_err(|e| e.to_string())?;
}
Ok(())
}
fn strings_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
pub fn ensure_ios_strings_phase(project: &Project) -> Result<(), String> {
if project.manifest.shortcuts.is_empty() {
return Ok(());
}
let pbxproj = project
.root
.join("platform/ios/DayApp.xcodeproj/project.pbxproj");
if !pbxproj.exists() {
return Ok(());
}
let before =
std::fs::read_to_string(&pbxproj).map_err(|e| format!("{}: {e}", pbxproj.display()))?;
if before.contains("xcode-backend stage-strings") {
return Ok(());
}
let phase_ref = "\t\t\t\tDA0000000000000000000043 /* Resources */,";
let section_end = "/* End PBXShellScriptBuildPhase section */";
if !before.contains(phase_ref)
|| !before.contains(section_end)
|| before.contains("DA0000000000000000000044")
{
return Err(format!(
"{}: can't add the `Stage Day Strings` build phase automatically — add a Run \
Script phase running `\"${{DAY_BIN:-day}}\" xcode-backend stage-strings` \
(docs/deep-links.md)",
pbxproj.display()
));
}
let block = "\t\tDA0000000000000000000044 /* Stage Day Strings */ = {\n\
\t\t\tisa = PBXShellScriptBuildPhase;\n\
\t\t\talwaysOutOfDate = 1;\n\
\t\t\tbuildActionMask = 2147483647;\n\
\t\t\tfiles = (\n\t\t\t);\n\
\t\t\tinputPaths = (\n\t\t\t);\n\
\t\t\tname = \"Stage Day Strings\";\n\
\t\t\toutputPaths = (\n\t\t\t);\n\
\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\
\t\t\tshellPath = /bin/sh;\n\
\t\t\tshellScript = \"\\\"${DAY_BIN:-day}\\\" xcode-backend stage-strings\\n\";\n\
\t\t};\n";
let after = before
.replace(
phase_ref,
&format!("{phase_ref}\n\t\t\t\tDA0000000000000000000044 /* Stage Day Strings */,"),
)
.replace(section_end, &format!("{block}{section_end}"));
std::fs::write(&pbxproj, after).map_err(|e| format!("{}: {e}", pbxproj.display()))?;
crate::ops::status(
"Adding",
"iOS `Stage Day Strings` build phase (shortcut labels)",
);
Ok(())
}
pub fn harmony_resource_dir(locale: &str) -> String {
if locale == DEFAULT_LOCALE {
"base".to_string()
} else {
locale.replace('-', "_")
}
}
pub fn harmony_shortcuts_config(
shortcuts: &[Resolved],
scheme: Option<&str>,
bundle: &str,
module: &str,
ability: &str,
) -> String {
let mut items = Vec::new();
for sc in shortcuts {
let link = match scheme {
Some(s) => format!("{s}://{}", sc.route),
None => sc.route.clone(),
};
items.push(serde_json::json!({
"label": format!("$string:{}", sc.id),
"shortcutId": sc.id,
"wants": [{
"abilityName": ability,
"bundleName": bundle,
"moduleName": module,
"parameters": { "day.uri": link },
}],
}));
}
let doc = serde_json::json!({ "shortcuts": items });
let mut out = serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string());
out.push('\n');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn android_values_dirs() {
assert_eq!(android_values_dir("en"), "values");
assert_eq!(android_values_dir("fr"), "values-fr");
assert_eq!(android_values_dir("zh-CN"), "values-zh-rCN");
assert_eq!(android_values_dir("zh-Hans-CN"), "values-b+zh+Hans+CN");
}
#[test]
fn lproj_names() {
assert_eq!(lproj_name("fr"), "fr");
assert_eq!(lproj_name("zh-CN"), "zh-Hans");
assert_eq!(lproj_name("pt-BR"), "pt-BR");
}
#[test]
fn manifest_scrapes() {
let manifest = r#"
<activity android:name="dev.daybrite.day.bridge.DayActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<data android:scheme="dayshowcase" />
</intent-filter>
</activity>"#;
assert_eq!(
android_launcher_activity(manifest).as_deref(),
Some("dev.daybrite.day.bridge.DayActivity")
);
assert_eq!(android_scheme(manifest).as_deref(), Some("dayshowcase"));
}
#[test]
fn ftl_lookup_rules() {
let dir = std::env::temp_dir().join(format!("day-ftl-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("app.ftl"),
"plain = Menus & dialogs\nmulti =\n one\n two\nvar = Hello { $name }\n",
)
.unwrap();
assert_eq!(
ftl_value(&dir, "plain").unwrap().as_deref(),
Some("Menus & dialogs")
);
assert!(ftl_value(&dir, "multi").is_err());
assert!(ftl_value(&dir, "var").is_err());
assert_eq!(ftl_value(&dir, "absent").unwrap(), None);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn harmony_config_shape() {
let sc = Resolved {
route: "menus".into(),
id: "day_shortcut_0".into(),
labels: BTreeMap::new(),
base: "Menus".into(),
};
let json =
harmony_shortcuts_config(&[sc], Some("dayshowcase"), "dev.b", "entry", "EntryAbility");
let doc: serde_json::Value = serde_json::from_str(&json).unwrap();
let want = &doc["shortcuts"][0]["wants"][0];
assert_eq!(want["parameters"]["day.uri"], "dayshowcase://menus");
assert_eq!(doc["shortcuts"][0]["label"], "$string:day_shortcut_0");
}
#[test]
fn harmony_config_bytes_are_pinned() {
let sc = Resolved {
route: "menus".into(),
id: "day_shortcut_0".into(),
labels: BTreeMap::new(),
base: "Menus".into(),
};
let json =
harmony_shortcuts_config(&[sc], Some("dayshowcase"), "dev.b", "entry", "EntryAbility");
let want = r#"{
"shortcuts": [
{
"label": "$string:day_shortcut_0",
"shortcutId": "day_shortcut_0",
"wants": [
{
"abilityName": "EntryAbility",
"bundleName": "dev.b",
"moduleName": "entry",
"parameters": {
"day.uri": "dayshowcase://menus"
}
}
]
}
]
}
"#;
assert_eq!(json, want);
}
}