diff --git a/src/ui/installed-apps/AppTab.qml b/src/ui/installed-apps/AppTab.qml new file mode 100644 index 0000000..c851afd --- /dev/null +++ b/src/ui/installed-apps/AppTab.qml @@ -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 + } + + } + + } + +} diff --git a/src/ui/installed-apps/InstalledApps.qml b/src/ui/installed-apps/InstalledApps.qml new file mode 100644 index 0000000..0fcc14b --- /dev/null +++ b/src/ui/installed-apps/InstalledApps.qml @@ -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 + } + + } + + } + + } + +}