feat(InstalledApps): implement InstalledApps

This commit is contained in:
uncor3
2026-06-08 21:02:45 +00:00
parent b14f52ff58
commit 5c165a1794
2 changed files with 440 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ".."
Button {
id: root
required property var device
required property string appName
required property string bundleId
property string version: ""
required property string iconSource
property bool selected: false
signal appClicked(string bundleId)
width: ListView.view ? ListView.view.width : implicitWidth
height: 60
padding: 0
hoverEnabled: true
enabled: true
onClicked: root.appClicked(root.bundleId)
Component.onCompleted: {
device.sb_client.fetch_app_icon(root.bundleId)
}
background: Rectangle {
anchors.fill: parent
radius: 10
color: root.selected ? palette.highlight : (root.hovered ? Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.1) : Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.06))
border.width: 1
border.color: root.selected ? Qt.lighter(palette.highlight, 1.15) : Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.12)
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
anchors.topMargin: 8
anchors.bottomMargin: 8
spacing: 10
// Rectangle {
// Layout.preferredWidth: 32
// Layout.preferredHeight: 32
// radius: 7
// color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.08)
// clip: true
// IconLoader {
// id: iconImg
// iconSource: root.iconSource
// // visible: root.iconSource.length > 0 && status === Image.Ready
// }
// // Text {
// // anchors.centerIn: parent
// // text: root.appName.length > 0 ? root.appName.charAt(0).toUpperCase() : qsTr("?")
// // color: root.selected ? palette.highlightedText : palette.text
// // font.bold: true
// // font.pixelSize: 14
// // visible: root.iconSource.length === 0 || iconImg.status !== Image.Ready
// // }
// }
IconLoader {
id: iconImg
iconSource: root.iconSource
// visible: root.iconSource.length > 0 && status === Image.Ready
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
Layout.fillWidth: true
text: root.appName
color: root.selected ? palette.highlightedText : palette.text
font.weight: Font.Medium
elide: Text.ElideRight
maximumLineCount: 1
}
Text {
Layout.fillWidth: true
text: root.version
color: root.selected ? Qt.rgba(palette.highlightedText.r, palette.highlightedText.g, palette.highlightedText.b, 0.75) : Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.62)
font.pixelSize: 11
elide: Text.ElideRight
maximumLineCount: 1
visible: root.version.length > 0
}
}
}
}
+339
View File
@@ -0,0 +1,339 @@
import ".."
import "../base"
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Item {
id: root
required property string udid
required property var device
property bool loaded: false
property bool loading: true
property bool fileSharingOnly: true
property string errorMessage: ""
property string searchText: ""
property string selectedBundleId: ""
property var houseArrestAfcClient: null
property bool tabsDisabled: false
property var iconForBundleId: ({})
property var appTabForBundleId: ({})
function init() {
if (loaded)
return ;
loaded = true;
refresh();
}
function refresh() {
root.loading = true;
root.errorMessage = "";
root.selectedBundleId = "";
root.houseArrestAfcClient = null;
allAppsModel.clear();
filteredAppsModel.clear();
updateViewState();
if (!root.device || !root.device.service_manager) {
root.loading = false;
root.errorMessage = qsTr("Failed to retrieve installed apps.");
updateViewState();
return ;
}
root.device.service_manager.fetch_installed_apps();
}
function updateViewState() {
if (root.loading) {
stateView.viewState = StateView.State.Loading;
return ;
}
if (root.errorMessage.length > 0) {
stateView.errorText = root.errorMessage;
stateView.viewState = StateView.State.Error;
return ;
}
stateView.viewState = StateView.State.Content;
}
function appDisplayName(app) {
var name = app.displayName && app.displayName.length > 0 ? app.displayName : app.bundleId;
if (app.appType === "System")
name += qsTr(" (System)");
return name;
}
function appendApp(appJson) {
var app = null;
try {
app = JSON.parse(appJson);
} catch (e) {
console.log("InstalledApps: failed to parse app JSON:", e);
return ;
}
var bundleId = app.bundle_id || "";
/*
Always fails to load Fitness app container
even though file sharing is enabled
*/
if (bundleId.length === 0 || bundleId === "com.apple.Fitness")
return ;
allAppsModel.append({
"appName": appDisplayName({
"displayName": app.CFBundleDisplayName || "",
"bundleId": bundleId,
"appType": app.app_type || ""
}),
"displayName": app.CFBundleDisplayName || bundleId,
"bundleId": bundleId,
"version": app.CFBundleShortVersionString || "",
"appType": app.app_type || "",
"fileSharingEnabled": !!app.UIFileSharingEnabled,
"iconSource": ""
});
}
// FIXME: use a proper filtering model
function rebuildFilteredApps() {
filteredAppsModel.clear();
var needle = root.searchText.toLowerCase();
for (var i = 0; i < allAppsModel.count; i++) {
var app = allAppsModel.get(i);
if (root.fileSharingOnly && !app.fileSharingEnabled)
continue;
if (needle.length > 0) {
var name = (app.appName || "").toLowerCase();
var bundleId = (app.bundleId || "").toLowerCase();
if (name.indexOf(needle) === -1 && bundleId.indexOf(needle) === -1)
continue;
}
filteredAppsModel.append({
"appName": app.appName,
"displayName": app.displayName,
"bundleId": app.bundleId,
"version": app.version,
"appType": app.appType,
"fileSharingEnabled": app.fileSharingEnabled,
"iconSource" : app.iconSource,
"selected": root.selectedBundleId === app.bundleId
});
}
if (filteredAppsModel.count > 0 && !root.selectedBundleId)
selectApp(filteredAppsModel.get(0).bundleId);
}
function selectApp(bundleId) {
if (!bundleId || bundleId.length === 0 || root.tabsDisabled)
return ;
root.selectedBundleId = bundleId;
root.tabsDisabled = true;
root.houseArrestAfcClient = null;
if (typeof serviceFactory === "undefined" || !serviceFactory) {
root.tabsDisabled = false;
root.errorMessage = qsTr("serviceFactory is not available in QML scope.");
updateViewState();
return ;
}
var client = serviceFactory.create_hause_arrest_afc_client(root.udid, bundleId);
root.houseArrestAfcClient = client;
root.tabsDisabled = false;
if (client === null)
// FIXME: expose a success/error signal for house arrest session creation if this factory can fail asynchronously.
containerMessage.text = qsTr("No data available for this app.");
}
anchors.fill: parent
Component.onCompleted: {
updateViewState();
loadTimer.start();
}
Timer {
id: loadTimer
interval: 300
repeat: false
onTriggered: root.init()
}
Connections {
function onInstalled_apps_retrieved(success, apps) {
allAppsModel.clear();
filteredAppsModel.clear();
if (!success) {
root.loading = false;
root.errorMessage = qsTr("Failed to retrieve installed apps.");
updateViewState();
return ;
}
for (var key in apps) appendApp(apps[key])
root.loading = false;
if (allAppsModel.count === 0)
// FIXME: return success as well
root.errorMessage = qsTr("No apps found or failed to retrieve apps.");
else
root.errorMessage = "";
updateViewState();
rebuildFilteredApps();
}
target: root.device && root.device.service_manager ? root.device.service_manager : null
}
Connections {
target : root.device.sb_client
function onApp_icon_loaded(bundleId, iconData) {
var tab = root.appTabForBundleId[bundleId];
if (tab) {
tab.iconSource = iconData;
}
}
}
ListModel {
id: allAppsModel
}
ListModel {
id: filteredAppsModel
}
StateView {
id: stateView
autoSwitchContent: false
anchors.fill: parent
viewState: StateView.State.Loading
retryable: true
onRetryRequested: root.refresh()
contentItem: RowLayout {
anchors.fill: parent
spacing: 0
Rectangle {
Layout.preferredWidth: 400
Layout.minimumWidth: 100
Layout.maximumWidth: 500
Layout.fillHeight: true
color: "transparent"
border.width: 1
border.color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.12)
ColumnLayout {
anchors.fill: parent
spacing: 0
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: 60
Layout.leftMargin: 5
Layout.rightMargin: 5
Layout.bottomMargin: 5
spacing: 8
TextField {
id: searchEdit
Layout.fillWidth: true
placeholderText: qsTr("Search apps...")
text: root.searchText
onTextChanged: {
root.searchText = text;
rebuildFilteredApps();
}
}
CheckBox {
id: fileSharingCheck
checked: root.fileSharingOnly
text: qsTr("File Sharing")
font.pixelSize: 10
onToggled: {
root.fileSharingOnly = checked;
rebuildFilteredApps();
}
}
}
ListView {
id: appList
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
spacing: 10
boundsBehavior: Flickable.StopAtBounds
model: filteredAppsModel
delegate: ItemDelegate {
width: appList.width
height: 60
AppTab {
id : tab
Component.onCompleted: {
root.appTabForBundleId[model.bundleId] = tab;
}
device: root.device
width: appList.width
appName: model.appName
bundleId: model.bundleId
version: model.version
selected: root.selectedBundleId === model.bundleId
enabled: !root.tabsDisabled
iconSource: model.iconSource
onAppClicked: (bundleId) => {
return root.selectApp(bundleId);
}
}
}
}
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
FileExplorer {
id: explorer
anchors.fill: parent
afcClient: root.houseArrestAfcClient
rootPath: "/Documents"
favEnabled: false
visible: !!root.houseArrestAfcClient
}
Text {
id: containerMessage
anchors.centerIn: parent
width: Math.min(parent.width * 0.8, 420)
text: root.selectedBundleId.length > 0 ? qsTr("No data available for this app.") : qsTr("Select an app to browse its documents.")
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.72)
visible: !root.houseArrestAfcClient
}
}
}
}
}