Skip to content

Commit dedeae7

Browse files
authored
Implement installer module for app installation
1 parent 17b1c9c commit dedeae7

File tree

1 file changed

+193
-0
lines changed

1 file changed

+193
-0
lines changed

web/installer.js

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,196 @@ function pulse(state) {
5454

5555
pulseState = requestAnimationFrame(animate);
5656
}
57+
58+
59+
60+
// INSTALLER
61+
let installer = (function () {
62+
// Config defaults (change if you need)
63+
const PREFLIGHT_URL = "https://ipa.s0n1c.ca/preflight";
64+
const INSTALL_BASE = "https://ipa.s0n1c.ca";
65+
const DEFAULT_REPO = "ProStore-iOS/ProStore"; // repo to inspect by default
66+
67+
// internal state
68+
let _progress = 0; // 0..100
69+
let _finished = false;
70+
let _lastError = null;
71+
let _installData = null; // response from preflight (contains id, name, version, etc.)
72+
let _chosenAsset = null; // metadata about chosen release asset
73+
let _releaseInfo = null; // chosen release object
74+
75+
// helpers
76+
function _setProgress(n) {
77+
_progress = Math.max(0, Math.min(100, Math.round(n)));
78+
}
79+
80+
function _chooseAssetFromRelease(release) {
81+
const assets = Array.isArray(release.assets) ? release.assets : [];
82+
// candidate .ipa assets
83+
const ipaAssets = assets.filter(a => a && a.name && /\.ipa$/i.test(a.name));
84+
if (!ipaAssets.length) return null;
85+
// prefer those with "signed" in the filename
86+
const signed = ipaAssets.filter(a => /signed/i.test(a.name));
87+
if (signed.length) return signed[0];
88+
// fallback: return the first ipa asset
89+
return ipaAssets[0];
90+
}
91+
92+
async function _fetchJson(url, opts = {}) {
93+
const res = await fetch(url, opts);
94+
if (!res.ok) {
95+
const text = await res.text().catch(() => "");
96+
const msg = `HTTP ${res.status}${text ? " - " + text : ""}`;
97+
const e = new Error(msg);
98+
e.status = res.status;
99+
throw e;
100+
}
101+
return res.json();
102+
}
103+
104+
return {
105+
// Return percentage (integer)
106+
getStatus: function () {
107+
return _progress;
108+
},
109+
110+
// Begin the install flow (auto-select latest release & preferred asset)
111+
// options: { repo: "owner/repo", token: "GITHUB_PAT (optional)", preferPrerelease: false }
112+
// Returns a Promise that resolves to the final preflight response object when finished.
113+
beginInstall: async function (options = {}) {
114+
const repo = options.repo || DEFAULT_REPO;
115+
const token = options.token || null;
116+
const preferPrerelease = !!options.preferPrerelease;
117+
118+
_finished = false;
119+
_lastError = null;
120+
_installData = null;
121+
_chosenAsset = null;
122+
_releaseInfo = null;
123+
_setProgress(5);
124+
125+
try {
126+
_setProgress(12);
127+
// get releases (latest first)
128+
const apiUrl = `https://api.github.com/repos/${repo}/releases?per_page=50`;
129+
const headers = {
130+
Accept: "application/vnd.github.v3+json",
131+
...(token ? { Authorization: `token ${token}` } : {})
132+
};
133+
134+
_setProgress(18);
135+
136+
const releases = await _fetchJson(apiUrl, { headers });
137+
if (!Array.isArray(releases) || releases.length === 0) {
138+
throw new Error("No releases returned from GitHub.");
139+
}
140+
141+
// filter out drafts; optionally include prereleases if user requested
142+
const visible = releases
143+
.filter(r => !r.draft)
144+
.filter(r => preferPrerelease ? true : !r.prerelease)
145+
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
146+
147+
if (!visible.length) {
148+
// if strict filter removed everything, try less strict (non-draft)
149+
const fallback = releases.filter(r => !r.draft).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
150+
if (!fallback.length) throw new Error("No suitable releases found (all drafts?).");
151+
_releaseInfo = fallback[0];
152+
} else {
153+
_releaseInfo = visible[0]; // latest
154+
}
155+
156+
_setProgress(30);
157+
158+
// pick an asset (prefer "signed" .ipa)
159+
const chosen = _chooseAssetFromRelease(_releaseInfo);
160+
if (!chosen) throw new Error("No .ipa assets found in the latest release.");
161+
_chosenAsset = chosen;
162+
163+
_setProgress(45);
164+
165+
// Prepare signing using preflight endpoint (same as original flow)
166+
const ipaUrl = chosen.browser_download_url;
167+
if (!ipaUrl) throw new Error("Chosen asset has no browser_download_url.");
168+
169+
_setProgress(60);
170+
171+
const resp = await fetch(PREFLIGHT_URL, {
172+
method: "POST",
173+
headers: { "Content-Type": "application/json" },
174+
body: JSON.stringify({ url: ipaUrl })
175+
});
176+
177+
if (!resp.ok) {
178+
const t = await resp.text().catch(() => "");
179+
throw new Error(`Signing service error HTTP ${resp.status} ${t}`);
180+
}
181+
182+
const data = await resp.json();
183+
if (!data || !data.id) throw new Error("Signing/preflight response did not include an id.");
184+
185+
_installData = data;
186+
_setProgress(90);
187+
188+
// finalise
189+
_finished = true;
190+
_setProgress(100);
191+
192+
return data; // contains id, name, version, etc.
193+
} catch (err) {
194+
_lastError = err;
195+
_finished = false;
196+
_setProgress(0);
197+
throw err;
198+
}
199+
},
200+
201+
// When the flow is finished, returns the itms-services link string (or null if not finished).
202+
// The returned link uses the same install path as the original flow:
203+
// itms-services://?action=download-manifest&url=<encoded INSTALL_BASE>/<id>/install
204+
getInstallLink: function () {
205+
if (!_finished || !_installData || !_installData.id) return null;
206+
const manifestUrl = `${INSTALL_BASE}/${_installData.id}/install`;
207+
return `itms-services://?action=download-manifest&url=${encodeURIComponent(manifestUrl)}`;
208+
},
209+
210+
// Optional helpers for debugging / info
211+
getChosenAsset: function () {
212+
return _chosenAsset;
213+
},
214+
getReleaseInfo: function () {
215+
return _releaseInfo;
216+
},
217+
getLastError: function () {
218+
return _lastError;
219+
}
220+
};
221+
})();
222+
223+
function install() {
224+
pulse(true);
225+
loadingImg(0);
226+
227+
// start polling every 0.5s immediately
228+
const interval = setInterval(() => {
229+
const status = installer.getStatus(); // % progress
230+
console.log("Progress:", status, "%");
231+
loadingImg(status);
232+
233+
// stop when finished
234+
if (status >= 100 || installer.isFinished) {
235+
clearInterval(interval);
236+
console.log("Install finished!");
237+
console.log(installer.getInstallLink());
238+
pulse(false);
239+
}
240+
}, 500);
241+
242+
// start the install
243+
installer.beginInstall({ repo: "ProStore-iOS/ProStore", token: null })
244+
.catch(err => {
245+
console.error("Install failed:", err);
246+
clearInterval(interval);
247+
pulse(false);
248+
});
249+
}

0 commit comments

Comments
 (0)