Files
audi-app/companion-app/scripts/ios-teilen-einrichten.mjs
T
Paul Nothaft 63496a8e3b Plugin-Schleife stuerzte ab: addSourceFile setzt eine Gruppe "Plugins" voraus
Erster Lauf der neuen Schleife auf einem Mac. Sie brach ab mit
"TypeError: Cannot read properties of null (reading 'path')" in
correctForPluginsPath - addSourceFile() ohne Gruppe geht intern ueber
addPluginFile, und das sucht eine Projektgruppe namens "Plugins". Ein
Capacitor-Projekt hat die nicht, pbxGroupByName liefert null.

Die Gruppe wird jetzt angelegt, falls sie fehlt. Sie ist reine Ablage im
Navigator. Belegt am neu erzeugten Projekt: beide Plugin-Dateien mit Pfad
App/<name>.swift in der Sources-Phase des App-Ziels, neben AppDelegate und
SceneDelegate.
2026-09-03 09:41:54 +02:00

274 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* Hängt die Share-Erweiterung ins erzeugte Xcode-Projekt.
*
* WARUM ES DIESES SKRIPT GIBT
* ---------------------------
* `companion-app/ios/` ist absichtlich gitignored — `npx cap add ios` baut den
* Ordner aus dem Webbündel jederzeit neu. Damit verschwindet aber auch jedes
* Ziel, das man in Xcode von Hand anlegt. Dieselbe Falle, die schon die
* Team-Kennung und den Standort-Schlüssel in `ios-signieren.sh` getrieben hat:
* was den nächsten `cap add` überleben soll, muss versioniert sein und bei
* jedem Bau neu eingespielt werden.
*
* Die Quellen liegen deshalb unter `companion-app/native/`; dieses Skript
* kopiert sie nach `ios/App/` und trägt das Erweiterungsziel in die
* `project.pbxproj` ein.
*
* WAS ES ANLEGT
* -------------
* - Ziel `DataMetric360Share` (App-Erweiterung, Bundle-ID
* `app.datametric360.teilen`)
* - dessen Quelldateien, Info.plist und Entitlements
* - die App-Gruppe `group.app.datametric360` auf BEIDEN Zielen
* - die Einbettung der Erweiterung in die App (Build-Phase
* „Embed App Extensions")
* - das URL-Schema `datametric360://`, über das die Erweiterung die App holt
*
* IDEMPOTENT
* ----------
* Ein zweiter Lauf über ein bereits eingerichtetes Projekt ändert nichts und
* meldet das. Wichtig, weil `ios-signieren.sh` bei jedem Bau durchläuft und
* `ios/` nicht immer neu erzeugt wird.
*
* NICHT GETESTET AUF EINEM MAC
* ----------------------------
* Geschrieben ohne Zugriff auf macOS/Xcode. Die pbxproj-Bearbeitung folgt der
* API des npm-Pakets `xcode`; scheitert sie, bricht das Skript mit einer
* klaren Meldung ab und `docs/SHARE_EXTENSION.md` beschreibt die Handgriffe,
* die dasselbe in Xcode erledigen. Lieber ein lauter Abbruch mit Anleitung
* als ein halb eingerichtetes Projekt.
*/
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import xcode from "xcode"
const HIER = dirname(fileURLToPath(import.meta.url))
const APP = dirname(HIER)
const ZIEL_NAME = "DataMetric360Share"
const BUNDLE_ID = "app.datametric360.teilen"
const GRUPPE = "group.app.datametric360"
/* Eigene Capacitor-Plugins der App - je eine Swift-Datei, die neben der
Erweiterung ins App-Ziel gehört. Sie liegen aus demselben Grund unter
native/ wie alles andere hier: ios/ ist gitignored und wird neu erzeugt. */
const PLUGIN_DATEIEN = ["BelegZwischenablage.swift", "KalenderTermin.swift"]
/** pbxproj-Wert ohne die optionalen Anfuehrungszeichen. */
const entquotet = (wert) => String(wert ?? "").replace(/^"|"$/g, "")
const SCHEMA = "datametric360"
const iosApp = join(APP, "ios", "App")
const pbxPfad = join(iosApp, "App.xcodeproj", "project.pbxproj")
const quelleErw = join(APP, "native", "teilen")
const zielErw = join(iosApp, ZIEL_NAME)
function melden(text) {
console.log(" " + text)
}
function abbruch(text) {
console.error("")
console.error("FEHLER: " + text)
console.error("")
console.error("Die Share-Erweiterung ist damit NICHT eingerichtet. Die App selbst")
console.error("baut und läuft weiterhin - nur der Eintrag im Teilen-Blatt fehlt.")
console.error("Von Hand nachziehen: companion-app/docs/SHARE_EXTENSION.md")
process.exit(1)
}
if (!existsSync(pbxPfad)) {
abbruch(`${pbxPfad} nicht gefunden - erst 'npx cap sync ios' laufen lassen.`)
}
/* ---------------------------------------------------------------- Dateien */
mkdirSync(zielErw, { recursive: true })
for (const datei of ["ShareViewController.swift", "Info.plist", `${ZIEL_NAME}.entitlements`]) {
copyFileSync(join(quelleErw, datei), join(zielErw, datei))
}
copyFileSync(join(APP, "native", "App.entitlements"), join(iosApp, "App", "App.entitlements"))
for (const datei of PLUGIN_DATEIEN) {
copyFileSync(join(APP, "native", datei), join(iosApp, "App", datei))
}
melden(`Quellen nach ios/App/${ZIEL_NAME}/ kopiert`)
/* ------------------------------------------------------------- URL-Schema */
/* Ohne dieses Schema kann die Erweiterung die App nicht holen - sie legt den
Beleg zwar ab, aber niemand merkt es, bis die App das nächste Mal von Hand
geöffnet wird. */
const appPlistPfad = join(iosApp, "App", "Info.plist")
let appPlist = readFileSync(appPlistPfad, "utf8")
if (!appPlist.includes("CFBundleURLTypes")) {
const eintrag = [
"\t<key>CFBundleURLTypes</key>",
"\t<array>",
"\t\t<dict>",
"\t\t\t<key>CFBundleURLName</key>",
`\t\t\t<string>app.datametric360</string>`,
"\t\t\t<key>CFBundleURLSchemes</key>",
"\t\t\t<array>",
`\t\t\t\t<string>${SCHEMA}</string>`,
"\t\t\t</array>",
"\t\t</dict>",
"\t</array>",
"</dict>",
].join("\n")
const letztes = appPlist.lastIndexOf("</dict>")
if (letztes < 0) abbruch("ios/App/App/Info.plist hat keine schließende <dict>-Marke.")
appPlist = appPlist.slice(0, letztes) + eintrag + appPlist.slice(letztes + "</dict>".length)
writeFileSync(appPlistPfad, appPlist, "utf8")
melden(`URL-Schema ${SCHEMA}:// eingetragen`)
} else {
melden("URL-Schema war bereits eingetragen")
}
/* ---------------------------------------------------------------- Projekt */
const projekt = xcode.project(pbxPfad)
projekt.parseSync()
/* ------------------------------------------------ Eigene Plugins (App-Ziel)
Steht VOR dem Ausstieg unten: die Erweiterung ist nach dem ersten Lauf
vorhanden, die Plugins müssen trotzdem bei jedem Bau geprüft werden - ios/
wird von `npx cap add ios` jederzeit neu erzeugt.
`addSourceFile` ohne Gruppe hängt die Datei an die ERSTE Sources-Phase des
Projekts, und das ist die des App-Ziels - genau die richtige. (Mit Gruppe
landete die Datei nur im Navigator; daran ist am 02.09.2026 die Erweiterung
ohne Programm entstanden, siehe unten.)
Ohne Gruppe geht `addSourceFile` intern über `addPluginFile`, und das legt
die Datei in eine Projektgruppe namens "Plugins". Ein Capacitor-Projekt hat
die nicht, `pbxGroupByName` liefert null, und das Skript stirbt mit
"Cannot read properties of null (reading 'path')" (03.09.2026). Die Gruppe
wird deshalb angelegt, falls sie fehlt - sie ist reine Ablage im Navigator
und ändert am Bau nichts. */
if (!projekt.pbxGroupByName("Plugins")) {
projekt.addPbxGroup([], "Plugins", '""')
melden("Projektgruppe Plugins angelegt (von addSourceFile vorausgesetzt)")
}
let pluginsGeaendert = false
for (const datei of PLUGIN_DATEIEN) {
if (JSON.stringify(projekt.pbxSourcesBuildPhaseObj()).includes(datei)) {
melden(`${datei} war bereits im App-Ziel`)
continue
}
projekt.addSourceFile(`App/${datei}`)
melden(`${datei} ins App-Ziel gehängt`)
pluginsGeaendert = true
}
if (pluginsGeaendert) {
writeFileSync(pbxPfad, projekt.writeSync())
// Nach dem Schreiben neu einlesen: der Rest des Skripts arbeitet sonst auf
// einem Stand, der die eigene Änderung nicht kennt.
projekt.parseSync()
}
const vorhanden = Object.values(projekt.pbxNativeTargetSection()).some(
(z) => z && typeof z === "object" && z.name === ZIEL_NAME,
)
if (vorhanden) {
melden(`Ziel ${ZIEL_NAME} war bereits im Projekt - nichts geändert`)
process.exit(0)
}
let ziel
try {
ziel = projekt.addTarget(ZIEL_NAME, "app_extension", ZIEL_NAME, BUNDLE_ID)
// Eigene Build-Phasen: ohne sie hat das Ziel keine Quelldateien und keine
// Ressourcen, und Xcode baut ein leeres Bündel.
//
// Die Quelle gehoert hier hinein und nicht in ein spaeteres addSourceFile:
// reicht man diesem eine Gruppe mit, legt es die Datei nur in die Gruppe
// (Navigator) und haengt sie an keine Sources-Phase. Das Ergebnis war ein
// .appex ganz ohne Programm - nur Info.plist und Signatur, obwohl
// CFBundleExecutable eines versprach (2026-09-02 an der fertigen .ipa
// gemessen). Xcode meldet dabei nichts; der Bau gilt als erfolgreich.
// Pfad relativ zum Projektordner (ios/App), nicht nur der Dateiname: ohne
// Gruppe loest Xcode den Namen gegen die Projektwurzel auf und sucht dann
// ios/App/ShareViewController.swift - "Build input file cannot be found".
projekt.addBuildPhase(
[`${ZIEL_NAME}/ShareViewController.swift`],
"PBXSourcesBuildPhase",
"Sources",
ziel.uuid,
)
projekt.addBuildPhase([], "PBXResourcesBuildPhase", "Resources", ziel.uuid)
projekt.addBuildPhase([], "PBXFrameworksBuildPhase", "Frameworks", ziel.uuid)
// Nur zur Anzeige in Xcode. Die Quelle ist oben bereits der Sources-Phase
// zugeordnet und steht deshalb hier nicht noch einmal.
const gruppe = projekt.addPbxGroup(
["Info.plist", `${ZIEL_NAME}.entitlements`],
ZIEL_NAME,
ZIEL_NAME,
)
// In die Projektwurzel einhängen, sonst taucht die Gruppe in Xcode nicht auf.
const wurzel = Object.keys(projekt.hash.project.objects.PBXGroup).find(
(k) => projekt.hash.project.objects.PBXGroup[k].name === "CustomTemplate",
)
if (wurzel) projekt.addToPbxGroup(gruppe.uuid, wurzel)
// Einstellungen, die `addTarget` nicht selbst setzt.
const konfigs = projekt.pbxXCBuildConfigurationSection()
for (const schluessel of Object.keys(konfigs)) {
const eintrag = konfigs[schluessel]
if (!eintrag || typeof eintrag !== "object" || !eintrag.buildSettings) continue
const s = eintrag.buildSettings
// Ohne Anfuehrungszeichen vergleichen: die pbxproj fuehrt Werte mal mit,
// mal ohne. Was `addTarget` schreibt, ist gequotet ("app.datametric360.
// teilen"), was Capacitor fuer die App erzeugt, steht nackt da
// (app.datametric360). Der Vergleich auf die gequotete Form traf deshalb
// die App nie - sie wurde ohne App-Gruppe signiert, waehrend die
// Erweiterung sie hatte. Ergebnis waere ein stiller Ausfall gewesen:
// teilen meldet Erfolg, die App findet nichts (2026-09-02 an der
// fertigen .ipa gemessen - Entitlements der App ohne
// application-groups).
if (entquotet(s.PRODUCT_BUNDLE_IDENTIFIER) === BUNDLE_ID || entquotet(s.PRODUCT_NAME) === ZIEL_NAME) {
s.INFOPLIST_FILE = `"${ZIEL_NAME}/Info.plist"`
s.CODE_SIGN_ENTITLEMENTS = `"${ZIEL_NAME}/${ZIEL_NAME}.entitlements"`
s.IPHONEOS_DEPLOYMENT_TARGET = "14.0"
s.SWIFT_VERSION = "5.0"
s.TARGETED_DEVICE_FAMILY = `"1,2"`
s.SKIP_INSTALL = "YES"
}
// Die App selbst braucht ihre neue Entitlements-Datei.
if (entquotet(s.PRODUCT_BUNDLE_IDENTIFIER) === "app.datametric360") {
s.CODE_SIGN_ENTITLEMENTS = `"App/App.entitlements"`
}
}
// Eingebettet wird die Erweiterung bereits von `addTarget`: die Bibliothek
// legt fuer den Typ "app_extension" von sich aus eine Copy-Phase nach
// PlugIns am ersten Ziel an. Hier noch eine zweite anzulegen, hat genau das
// erzeugt, wogegen Xcode sich wehrt (2026-09-02, erster Lauf ueberhaupt auf
// einem Mac):
// error: Unexpected duplicate tasks
// note: Target 'App': ValidateEmbeddedBinary .../DataMetric360Share.appex
// note: Target 'App': ValidateEmbeddedBinary .../DataMetric360Share.appex
// Beide Phasen zeigten auf dieselbe Datei und dasselbe Ziel - die .appex
// waere zweimal an denselben Ort kopiert worden.
writeFileSync(pbxPfad, projekt.writeSync(), "utf8")
} catch (fehler) {
abbruch(`Das Xcode-Projekt ließ sich nicht bearbeiten: ${fehler.message}`)
}
melden(`Ziel ${ZIEL_NAME} angelegt und eingebettet`)
melden(`App-Gruppe ${GRUPPE} auf beiden Zielen`)
console.log("")
console.log(" Einmalig im Apple-Entwicklerportal nötig, sonst schlägt das Signieren fehl:")
console.log(` - App-Gruppe ${GRUPPE} anlegen`)
console.log(` - App-ID ${BUNDLE_ID} anlegen und der Gruppe zuordnen`)
console.log(` - App-ID app.datametric360 ebenfalls der Gruppe zuordnen`)
console.log("")