diff --git a/docs/user-guide/apk-manager.md b/docs/user-guide/apk-manager.md index 29a36f3..a7cf98a 100644 --- a/docs/user-guide/apk-manager.md +++ b/docs/user-guide/apk-manager.md @@ -218,3 +218,9 @@ L2/R2 digital button activation is adjustable in **APK Manager → Settings**, f ## Per-app graphics compatibility Under **Manage app → Advanced settings**, **Framebuffer compatibility fix** is off by default. Enable it only for games that play sound but show a black screen (tested with Rock Kingdom on the current Mesa/Zink driver). Restart the app after changing it. The GLES layer validates framebuffer status after texture attachment, only in the selected application; other apps, system drivers, game files and saved data remain unchanged. Turning it off restores the normal rendering path on the next app launch. It is a targeted workaround, not a general graphics or performance enhancement. + +## In-app updates + +When an application downloads an APK and requests installation, Framely retains the update and automatically opens a separate installation confirmation window. Verify the versions, display mode and Quest conversion option, then confirm; opening the management panel first is unnecessary. Cancelled updates remain available under **In-app updates** in the APK manager. Updates use the existing review, container backup and installation flow. Signature failures and downgrades never cause an automatic uninstall. Cancelling review retains the download; **Ignore** removes its queued copy. + +Insufficient space leaves the existing application unchanged and reports required and available space. Free space or change storage locations, then retry. Interception becomes available on the next container startup. Complete single-APK updates are supported; split APK, multi-package and staged sessions are reported as unsupported rather than partially installed. Privileged silent package-manager installations are outside this interception path. diff --git a/docs/zh-CN/user-guide/apk-manager.md b/docs/zh-CN/user-guide/apk-manager.md index 2925c1a..f5abcc1 100644 --- a/docs/zh-CN/user-guide/apk-manager.md +++ b/docs/zh-CN/user-guide/apk-manager.md @@ -182,3 +182,9 @@ Framely 在自己的私有 Devkit 入口目录记录生成图片的内容摘要 Android 输入保护层清零后台输入,并使用现有输入及震动开关控制游戏请求的力反馈。控制租约到期后停止输入和震动,避免会话辅助进程退出后继续响应。直通震动直接交给 Steam 原设备;游戏本身必须发出手柄震动请求。桥接专用的强度及持续时间限制不适用于直通。 只挂载选中的设备,不暴露整个主机输入目录,不修改 SteamOS 或 Lepton 安装文件。设备节点重建或 Steam 手柄归属变化后停止输入;重新连接后需关闭并重启该容器,不会静默切换到其他设备或重启游戏。日志位于 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键事件。 + +## 应用内更新 + +应用自行下载 APK 并请求安装时,Framely 会保留更新文件并直接弹出更新安装确认窗口。核对版本、显示方式及 Quest 转换选项,再确认更新;无需先进入管理面板。取消后仍可在 APK 管理页的“应用内更新”中再次审核。更新继续使用现有审核、容器备份及安装流程,保留数据;签名不匹配或降级不会自动卸载重装。取消审核会保留下载包,选择“忽略”才删除该待处理副本。 + +存储空间不足时,原应用保持不变,页面会提示所需与可用空间;释放空间或调整存储位置后重试。此能力在容器下次启动后生效。目前支持完整的单 APK 更新;拆分 APK、多包和 staged Session 会提示不支持,不会只安装其中一部分。特权应用静默调用包管理器的安装不在接管范围内。 diff --git a/native/android/FramelyUpdates.java b/native/android/FramelyUpdates.java new file mode 100644 index 0000000..75f6df3 --- /dev/null +++ b/native/android/FramelyUpdates.java @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Android 11 helper. Runs only inside a Framely-launched container. +import android.app.*; +import android.content.*; +import android.content.pm.*; +import android.net.Uri; +import android.os.*; +import org.json.JSONObject; +import java.io.*; +import java.lang.reflect.*; +import java.util.*; +import java.util.concurrent.*; + +public final class FramelyUpdates extends Binder { + private static final String DESCRIPTOR="android.app.IActivityController"; + private static final long MAX_BYTES=8L*1024*1024*1024; + private static final BlockingQueue work=new ArrayBlockingQueue<>(8); + private static final Map methods=new HashMap<>(); + private static final BlockingQueue forwards=new ArrayBlockingQueue<>(64); + private static final Set allowed=new HashSet<>(),sessions=new HashSet<>(); + private static Context context; + private static File directory; + private static void log(String event){System.out.println("Framely APK updates: "+event);} + private static void publish(JSONObject data, String id)throws Exception { + File temporary=new File(directory,id+".json.part"), ready=new File(directory,id+".json"); + try(FileOutputStream f=new FileOutputStream(temporary)){f.write(data.toString().getBytes("UTF-8"));f.getFD().sync();} + if(!temporary.renameTo(ready))throw new IOException("Cannot publish update request"); + } + private static void failure(String reason){ + log(reason); + try {String id=UUID.randomUUID().toString().replace("-","");publish(new JSONObject().put("error",reason),id);}catch(Exception ignored){} + } + public boolean onTransact(int code,Parcel data,Parcel reply,int flags)throws RemoteException { + if(code==INTERFACE_TRANSACTION){reply.writeString(DESCRIPTOR);return true;} + String method=methods.get(code); + if(method==null)return super.onTransact(code,data,reply,flags); + data.enforceInterface(DESCRIPTOR); + boolean allow=true; + if(method.equals("activityStarting")) { + Intent i=data.readInt()!=0?Intent.CREATOR.createFromParcel(data):null;data.readString(); + String action=i==null?null:i.getAction(); + if(i!=null && (Intent.ACTION_INSTALL_PACKAGE.equals(action)||"application/vnd.android.package-archive".equals(i.getType()))) { + final String uri=i.getDataString();allow=false; + if(uri==null || !work.offer(()->capture(uri,false)))failure("Cannot queue APK installation request; retry from the application"); + } else if("android.content.pm.action.CONFIRM_INSTALL".equals(action)) { + // ActivityController receives cloneFilter(), so Session ID extras are absent. + if(hasManagedSession()){allow=false;if(!work.offer(FramelyUpdates::captureSessions))failure("Update queue is busy; retry from the application");} + } + } + reply.writeNoException(); + if(method.equals("appNotResponding")||method.equals("appEarlyNotResponding")||method.equals("systemNotResponding"))reply.writeInt(method.equals("systemNotResponding")?-1:0); + else reply.writeInt(allow?1:0); + return true; + } + private static void forward(String text) { + // Opening Steam's FIFO can block when Steam is stopped. Keep that off Binder/reader threads. + if(!forwards.offer(text))log("Steam URI forwarding queue is full"); + } + private static final class ProviderInput implements AutoCloseable { + InputStream stream;Object manager;IBinder token;String authority; + public void close()throws Exception {try{if(stream!=null)stream.close();}finally{if(manager!=null)Class.forName("android.app.IActivityManager").getMethod("removeContentProviderExternal",String.class,IBinder.class).invoke(manager,authority,token);}} + } + private static ProviderInput open(Uri uri)throws Exception { + ProviderInput in=new ProviderInput(); + if("file".equals(uri.getScheme())) { + File file=new File(uri.getPath()).getCanonicalFile();String path=file.getPath(); + List bases=new ArrayList<>(Arrays.asList("/sdcard","/storage/emulated/0","/data/media/0")); + for(String pkg:allowed){bases.add("/data/user/0/"+pkg);bases.add("/data/data/"+pkg);} + boolean owned=false; + for(String base:bases){File root=new File(base).getCanonicalFile();if(root.isDirectory() && !root.getPath().equals("/"))owned|=path.startsWith(root.getPath()+"/");} + if(!owned || !file.isFile())throw new IOException("APK file is outside application storage"); + in.stream=new FileInputStream(file);return in; + } + if(!"content".equals(uri.getScheme()) || uri.getUserInfo()!=null)throw new IOException("Unsupported APK URI"); + ProviderInfo info=context.getPackageManager().resolveContentProvider(uri.getAuthority(),0); + boolean downloads=info!=null && ("com.android.providers.downloads".equals(info.packageName)||"com.android.externalstorage".equals(info.packageName)); + if(info==null || !(allowed.contains(info.packageName)||downloads))throw new IOException("APK provider does not belong to a managed application"); + in.authority=uri.getAuthority();in.token=new Binder(); + in.manager=Class.forName("android.app.ActivityManager").getMethod("getService").invoke(null); + try { + Object holder=Class.forName("android.app.IActivityManager").getMethod("getContentProviderExternal",String.class,int.class,IBinder.class,String.class).invoke(in.manager,in.authority,0,in.token,"Framely APK update"); + Object provider=holder.getClass().getField("provider").get(holder); + Method m=Class.forName("android.content.IContentProvider").getMethod("openFile",String.class,String.class,Uri.class,String.class,Class.forName("android.os.ICancellationSignal"),IBinder.class); + ParcelFileDescriptor fd=(ParcelFileDescriptor)m.invoke(provider,null,null,uri,"r",null,null); + in.stream=new ParcelFileDescriptor.AutoCloseInputStream(fd);return in; + }catch(Exception e){in.close();throw e;} + } + private static boolean stage(InputStream in,String id)throws Exception { + File partial=new File(directory,id+".apk.part"),apk=new File(directory,id+".apk"); + try { + File[] pending=directory.listFiles(f->f.getName().endsWith(".apk")||f.getName().endsWith(".apk.part")); + long retained=0;if(pending!=null)for(File f:pending)retained+=f.length(); + if(pending!=null && (pending.length>=8 || retained>=MAX_BYTES))throw new IOException("Update queue storage is full"); + try(FileOutputStream f=new FileOutputStream(partial)){ + byte[] bytes=new byte[262144];long total=0;int n; + while((n=in.read(bytes))!=-1){total+=n;if(total+retained>MAX_BYTES||directory.getUsableSpace()<256*1024*1024)throw new IOException("Insufficient space for APK extraction");f.write(bytes,0,n);}f.getFD().sync(); + } + PackageInfo info=context.getPackageManager().getPackageArchiveInfo(partial.getPath(),0); + if(info==null || !allowed.contains(info.packageName))return false; + PackageInfo installed=context.getPackageManager().getPackageInfo(info.packageName,0); + if(info.getLongVersionCode()f.isFile()&&f.getName().endsWith(".apk")); + if(apks==null||apks.length!=1){failure("Split APK update cannot be installed as a single APK; select a complete APK in Framely");continue;} + String key=UUID.randomUUID().toString().replace("-",""); + try(InputStream stream=new FileInputStream(apks[0])) { + if(!stage(stream,key)){failure("Session APK does not match an installed application");continue;} + } + // Only abandon the original session after retaining the complete APK. + installer.abandonSession(info.getSessionId()); + } + }catch(Exception e){failure("Cannot extract pending installation session; retry from the application");} + } + public static void main(String[] args)throws Exception { + if(args.length!=2)throw new IllegalArgumentException("Expected private update directory and package list"); + directory=new File(args[0]); + try(BufferedReader r=new BufferedReader(new FileReader(args[1]))){String p;while((p=r.readLine())!=null)if(p.matches("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z0-9_]+)+"))allowed.add(p);} + Looper.prepareMainLooper();Class thread=Class.forName("android.app.ActivityThread");Object at=thread.getMethod("systemMain").invoke(null);context=(Context)thread.getMethod("getSystemContext").invoke(at); + Class stub=Class.forName("android.app.IActivityController$Stub"); + for(Field f:stub.getDeclaredFields())if(f.getName().startsWith("TRANSACTION_")){f.setAccessible(true);methods.put(f.getInt(null),f.getName().substring(12));} + FramelyUpdates gate=new FramelyUpdates();Class iface=Class.forName("android.app.IActivityController"); + Object proxy=Proxy.newProxyInstance(iface.getClassLoader(),new Class[]{iface},(p,m,a)->m.getName().equals("asBinder")?gate:null); + Object manager=Class.forName("android.app.ActivityManager").getMethod("getService").invoke(null); + Class.forName("android.app.IActivityManager").getMethod("setActivityController",iface,boolean.class).invoke(manager,proxy,false); + new Thread(()->{while(true)try{String line=forwards.take();try(FileOutputStream f=new FileOutputStream("/lepton/framely-original.pipe")){f.write((line+"\n").getBytes("UTF-8"));}}catch(Exception e){log("Steam URI forwarding failed");}},"steam-forward").start(); + new Thread(()->{while(true)try{work.take().run();}catch(Exception e){failure("Update extraction worker failed");}},"apk-extract").start(); + new Thread(()->{while(true)try(BufferedReader reader=new BufferedReader(new FileReader("/lepton/steam.pipe"))){ + String line;while((line=reader.readLine())!=null){ + if(line.length()>16384){log("Oversized URI ignored");continue;} + if(line.startsWith("steam://openurl/content://")||line.startsWith("steam://openurl/file://")){ + final String uri=line.substring(16);if(!work.offer(()->capture(uri,true)))failure("Update extraction queue is full; retry from the application"); + }else forward(line); + } + }catch(Exception e){log("Steam URI pipe unavailable");try{Thread.sleep(1000);}catch(InterruptedException ignored){}}},"apk-uri").start(); + new File(directory,"ready").createNewFile();log("ready"); + Class.forName("com.android.internal.os.BinderInternal").getMethod("joinThreadPool").invoke(null); + } +} diff --git a/native/android/README.md b/native/android/README.md index de62384..19b6c5e 100644 --- a/native/android/README.md +++ b/native/android/README.md @@ -28,3 +28,22 @@ Binder 调用编号。 执行 `bash tools/build-android-window.sh` 可重新生成,需要 JDK 和 curl。 构建使用 Google D8 8.3.37,并校验固定 SHA-256;安装包不包含 D8。 + +## APK update interception + +`FramelyUpdates.java` runs as Android root inside Framely-launched Lepton containers. +The private launch adapter mounts the helper and a package allowlist read-only, +redirects Lepton's VIEW pipe to a bounded reader, and retains the original pipe +for unrelated links. Android's activity controller intercepts installer intents +and pending PackageInstaller confirmations. Complete, single-package APKs are +retained in a private host queue; review and installation use Framely's existing +APK manager. No system image, host root daemon or runtime JDK is required. + +The helper targets Android 11's Binder APIs. If startup fails, its supervisor +forwards URI requests through the original Steam pipe. Split APK, multi-package +and staged sessions are reported as unsupported rather than partially installed. +The controller does not intercept privileged silent package-manager installations. +Source URIs, credentials and tokens are never written to the queue or its logs. + +Rebuild the embedded DEX with `bash tools/build-android-updates.sh`. Android SDK +platform 30 and D8 are downloaded with pinned SHA-256 checksums at build time only. diff --git a/native/android/apk-updates-hooks.sh b/native/android/apk-updates-hooks.sh new file mode 100644 index 0000000..933b0a2 --- /dev/null +++ b/native/android/apk-updates-hooks.sh @@ -0,0 +1,43 @@ +# This hook runs only in Framely's private Lepton adapter, never in its image. +if [[ -n "${FRAMELY_UPDATE_DIR:-}" ]]; then + if declare -F setup_podman_mounts >/dev/null && declare -F generate_zygote_launch_rc >/dev/null; then + eval "$(declare -f setup_podman_mounts | sed '1s/setup_podman_mounts/framely_update_original_mounts/')" + function setup_podman_mounts() { + framely_update_original_mounts "$@" || return + # Replace the single original entry, retaining a forwarding endpoint. + local i found=false + for ((i=0; i<${#PODMAN_CMDLINE[@]}; i++)); do + if [[ "${PODMAN_CMDLINE[i]}" == *,destination=/lepton/steam.pipe,* ]]; then + PODMAN_CMDLINE[i]="${PODMAN_CMDLINE[i]/,destination=\/lepton\/steam.pipe,/,destination=\/lepton\/framely-original.pipe,}" + found=true + elif [[ "${PODMAN_CMDLINE[i]}" == *:/lepton/steam.pipe:* ]]; then + PODMAN_CMDLINE[i]="${PODMAN_CMDLINE[i]/:\/lepton\/steam.pipe:/:\/lepton\/framely-original.pipe:}" + found=true + fi + done + [[ "$found" == true ]] || { echo 'Framely: unsupported Steam pipe mount' >&2; return 64; } + podman_mount_entry "$FRAMELY_UPDATE_DIR" /framely-apk-updates rw + podman_mount_entry "${FRAMELY_UPDATE_PIPE:?}" /lepton/steam.pipe rw + podman_mount_entry "${FRAMELY_UPDATE_DEX:?}" /vendor/share/framely-updates.dex ro + podman_mount_entry "${FRAMELY_UPDATE_SCRIPT:?}" /vendor/share/framely-updates.sh ro + podman_mount_entry "${FRAMELY_UPDATE_PACKAGES:?}" /vendor/share/framely-update-packages ro + } + eval "$(declare -f generate_zygote_launch_rc | sed '1s/generate_zygote_launch_rc/framely_update_original_rc/')" + function generate_zygote_launch_rc() { + framely_update_original_rc "$@" || return + cat >> "$(prefix)/init.zygote64.rc" <<'FRAMELY_UPDATE_RC' + +service framely-apk-updates /system/bin/sh /vendor/share/framely-updates.sh + class late_start + user root + group root + disabled + +on property:sys.boot_completed=1 + start framely-apk-updates +FRAMELY_UPDATE_RC + } + else + echo 'Framely: APK update interception unavailable with this Lepton entry' >&2 + fi +fi diff --git a/native/android/apk-updates.sh b/native/android/apk-updates.sh new file mode 100644 index 0000000..b86e137 --- /dev/null +++ b/native/android/apk-updates.sh @@ -0,0 +1,17 @@ +#!/system/bin/sh +# Keep the original URI path usable if the Android helper is incompatible. +if ( + set -eu + for entry in $(cat /proc/$(pidof system_server)/environ | tr '\0' '\n' | grep -E '^(ANDROID_(ROOT|DATA|ART_ROOT|I18N_ROOT|TZDATA_ROOT)|BOOTCLASSPATH)='); do + export "$entry" + done + export CLASSPATH=/vendor/share/framely-updates.dex + exec app_process /system/bin FramelyUpdates /framely-apk-updates /vendor/share/framely-update-packages +); then + exit 0 +fi +echo 'Framely: APK update helper failed; restoring original Steam URI forwarding' >&2 +while true; do + cat /lepton/steam.pipe > /lepton/framely-original.pipe + sleep 1 +done diff --git a/native/android/framely-updates.dex b/native/android/framely-updates.dex new file mode 100644 index 0000000..3276703 Binary files /dev/null and b/native/android/framely-updates.dex differ diff --git a/native/host.cpp b/native/host.cpp index 6562b17..d36f569 100644 --- a/native/host.cpp +++ b/native/host.cpp @@ -59,7 +59,7 @@ using json=nlohmann::json; static volatile sig_atomic_t running=1; static void stop(int){running=0;} -static std::string origin,runtime,token; +static std::string origin,runtime,token,boot_url; static vr::IVROverlay* overlays=nullptr; static vr::IVRSystem* vr_system=nullptr; static Display* xdisplay=nullptr; @@ -211,9 +211,11 @@ static std::string pending_screenshot_hotkey,pending_screenshot_close,pending_sy static uint64_t system_action_sequence=0; static RecordingRegionOverlay screenshot_region_overlay{"framely.screenshot.region"}; static std::string pending_region_close; +static std::string pending_apk_update_close; +static bool apk_prompt(const std::string& key){return key=="framely.apk.permission"||key=="framely.apk.update";} static RecordingRegionOverlay recording_region_overlay; static RecordingIndicator recording_indicator; -static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string payload;size_t closed_count=0;std::string stop_id,region_closed,screenshot_closed,hotkey,system_action;{std::lock_guard lock(visibility_mutex);closed_count=closed_casts.size();stop_id=pending_recording_stop;region_closed=pending_region_close;screenshot_closed=pending_screenshot_close;hotkey=pending_screenshot_hotkey;system_action=pending_system_action;payload=json{{"views",visible_views},{"closedCasts",closed_casts},{"stopRecording",stop_id},{"framingClosed",region_closed},{"screenshotClosed",screenshot_closed},{"screenshotHotkey",hotkey},{"radialAction",system_action}}.dump();}std::string req="POST /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nContent-Type: application/json\r\nContent-Length: "+std::to_string(payload.size())+"\r\nConnection: close\r\n\r\n"+payload;size_t sent=0;while(sent0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");auto parsed=json::parse(response.substr(body+4));{std::lock_guard lock(visibility_mutex);if(pending_region_close==region_closed)pending_region_close.clear();if(pending_screenshot_close==screenshot_closed)pending_screenshot_close.clear();if(pending_screenshot_hotkey==hotkey)pending_screenshot_hotkey.clear();if(pending_system_action==system_action)pending_system_action.clear();if(pending_recording_stop==stop_id)pending_recording_stop.clear();closed_casts.erase(closed_casts.begin(),closed_casts.begin()+std::min(closed_count,closed_casts.size()));}return parsed;} +static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string payload;size_t closed_count=0;std::string stop_id,region_closed,screenshot_closed,hotkey,system_action,apk_update_closed;{std::lock_guard lock(visibility_mutex);closed_count=closed_casts.size();stop_id=pending_recording_stop;region_closed=pending_region_close;apk_update_closed=pending_apk_update_close;screenshot_closed=pending_screenshot_close;hotkey=pending_screenshot_hotkey;system_action=pending_system_action;payload=json{{"views",visible_views},{"closedCasts",closed_casts},{"stopRecording",stop_id},{"framingClosed",region_closed},{"screenshotClosed",screenshot_closed},{"apkUpdateClosed",apk_update_closed},{"screenshotHotkey",hotkey},{"radialAction",system_action}}.dump();}std::string req="POST /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nContent-Type: application/json\r\nContent-Length: "+std::to_string(payload.size())+"\r\nConnection: close\r\n\r\n"+payload;size_t sent=0;while(sent0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");auto parsed=json::parse(response.substr(body+4));{std::lock_guard lock(visibility_mutex);if(pending_region_close==region_closed)pending_region_close.clear();if(pending_apk_update_close==apk_update_closed)pending_apk_update_close.clear();if(pending_screenshot_close==screenshot_closed)pending_screenshot_close.clear();if(pending_screenshot_hotkey==hotkey)pending_screenshot_hotkey.clear();if(pending_system_action==system_action)pending_system_action.clear();if(pending_recording_stop==stop_id)pending_recording_stop.clear();closed_casts.erase(closed_casts.begin(),closed_casts.begin()+std::min(closed_count,closed_casts.size()));}return parsed;} static json service_call(const std::string& method,const json& params){ const char* configured=std::getenv("FRAMELY_CONTROL_SOCKET"); const std::string path=configured&&*configured?configured:"/run/framely/control.sock"; @@ -505,7 +507,7 @@ static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_o }else {keyboard_done=keyboard_done||e.eventType==vr::VREvent_KeyboardDone;keyboard_close_pending=true;} return true; } -static void close_view(View& v){if(v.closing)return;if(external_keyboard.active&&v.key==external_keyboard.view_key)external_keyboard_clear(true);if(v.key==frame_keyboard_window_key){std::lock_guard lock(queue_mutex);queue.erase(std::remove_if(queue.begin(),queue.end(),[](const json& command){return command.value("kind",std::string{})=="window.open"&&command.value("plugin",std::string{})=="tooru.frame-keyboard";}),queue.end());if(running)std::thread([]{frame_keyboard_command("dashboardClose",json::object());}).detach();}if(v.key.rfind("framely.cast.",0)==0){std::lock_guard lock(visibility_mutex);closed_casts.push_back(v.key.substr(13));}if(v.key=="framely.framing"){std::lock_guard lock(visibility_mutex);pending_region_close=v.region_editor_id;}if(v.key=="framely.screenshot"){std::lock_guard lock(visibility_mutex);pending_screenshot_close=v.region_editor_id;}cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu"||v.key=="framely.apk.permission")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);} +static void close_view(View& v){if(v.closing)return;if(external_keyboard.active&&v.key==external_keyboard.view_key)external_keyboard_clear(true);if(v.key==frame_keyboard_window_key){std::lock_guard lock(queue_mutex);queue.erase(std::remove_if(queue.begin(),queue.end(),[](const json& command){return command.value("kind",std::string{})=="window.open"&&command.value("plugin",std::string{})=="tooru.frame-keyboard";}),queue.end());if(running)std::thread([]{frame_keyboard_command("dashboardClose",json::object());}).detach();}if(v.key.rfind("framely.cast.",0)==0){std::lock_guard lock(visibility_mutex);closed_casts.push_back(v.key.substr(13));}if(v.key=="framely.framing"){std::lock_guard lock(visibility_mutex);pending_region_close=v.region_editor_id;}if(v.key=="framely.screenshot"){std::lock_guard lock(visibility_mutex);pending_screenshot_close=v.region_editor_id;}if(v.key=="framely.apk.update"){std::lock_guard lock(visibility_mutex);pending_apk_update_close=v.region_editor_id;}cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu"||apk_prompt(v.key))overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);} static void anchor_to_head(View& v,float width,float x=0,float y=0,float z=-1.35f){vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);if(!poses[0].bPoseIsValid)return;auto m=poses[0].mDeviceToAbsoluteTracking;for(int r=0;r<3;r++)m.m[r][3]+=m.m[r][0]*x+m.m[r][1]*y+m.m[r][2]*z;overlays->SetOverlayWidthInMeters(v.overlay,width);overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&m);} static void anchor_frame_keyboard(View& v){ // A keyboard button must not move keyboard focus away from the edited app. @@ -525,7 +527,7 @@ static void anchor_frame_keyboard(View& v){ overlays->SetOverlayCurvature(v.overlay,curvature);overlays->SetOverlayPreCurvePitch(v.overlay,pitch); }else anchor_to_head(v,1.35f,0,-.38f,-1.2f); } -static void anchor_permission(View& v){ +static void anchor_permission(View& v,float width=.85f){ vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{}; vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount); if(!poses[0].bPoseIsValid)return; @@ -539,7 +541,7 @@ static void anchor_permission(View& v){ upright.m[0][3]=head.m[0][3]-.65f*fx; upright.m[1][3]=head.m[1][3]; upright.m[2][3]=head.m[2][3]-.65f*fz; - overlays->SetOverlayWidthInMeters(v.overlay,.85f); + overlays->SetOverlayWidthInMeters(v.overlay,width); overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&upright); } static void anchor_notification(View& v){ @@ -564,14 +566,14 @@ static View& create_view(const std::string& key,const std::string& title,const s if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dashboard;v->width=width;v->height=height;v->render_scale=key=="launcher"?1.5f:(key=="framely.manager"||key=="menu"||key=="notifications"||key=="framely.framing"||key=="framely.screenshot"||key=="framely.system.radial"||key=="framely.system.radial.progress")?2:1; auto error=dashboard?overlays->CreateDashboardOverlay(key.c_str(),title.c_str(),&v->overlay,&v->thumbnail):overlays->CreateOverlay(key.c_str(),title.c_str(),&v->overlay);if(error)throw std::runtime_error("Overlay creation failed: "+std::to_string(error)); const bool radial=key=="framely.system.radial"||key=="framely.system.radial.progress"; - overlays->SetOverlayInputMethod(v->overlay,key=="notifications"||radial?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,!radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,!radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,key==frame_keyboard_window_key||key=="framely.apk.permission"||radial);overlays->SetOverlaySortOrder(v->overlay,key==frame_keyboard_window_key?20:key=="framely.apk.permission"?100:radial?200:0);if(key=="framely.framing"||key=="framely.screenshot")recording_region_foreground(overlays,v->overlay);if(key=="framely.apk.permission")overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_WantsModalBehavior,true); + overlays->SetOverlayInputMethod(v->overlay,key=="notifications"||radial?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,radial||key=="framely.apk.update");overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,!radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,!radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,key==frame_keyboard_window_key||apk_prompt(key)||radial);overlays->SetOverlaySortOrder(v->overlay,key==frame_keyboard_window_key?20:apk_prompt(key)?100:radial?200:0);if(key=="framely.framing"||key=="framely.screenshot")recording_region_foreground(overlays,v->overlay);if(apk_prompt(key))overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_WantsModalBehavior,true); if(key=="framely.system.radial"){ vr::VROverlayIntersectionMaskPrimitive_t mask{}; mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Circle; mask.m_Primitive.m_Circle={width*.5f,height*.5f,260.f}; if(auto e=overlays->SetOverlayIntersectionMask(v->overlay,&mask,1))std::cerr<<"Radial input mask failed "< lock(visibility_mutex);pending_recording_stop=stop_recording;} - auto permission=views.find("framely.apk.permission"); + auto permission=views.find("framely.apk.update"); + if(permission==views.end()||permission->second->closing)permission=views.find("framely.apk.permission"); if(permission!=views.end()&&!permission->second->closing&&permission->second->shown&&pose_ok){ auto& view=*permission->second; const auto& m=pose.pose.mDeviceToAbsoluteTracking; @@ -1082,7 +1085,7 @@ static void commands(){std::deque commands;{std::lock_guard lo if(screenshot_radial_held){auto key="framely.screenshot.preview";if(views.count(key)&&views.at(key)->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}double aspect=c.value("aspect",1.0);if(!std::isfinite(aspect)||aspect<.3||aspect>4)aspect=1.0;int width=960,height=std::clamp(int(960/aspect),240,960);auto& v=create_view(key,"Screenshot preview",origin+"/cast/screenshot/shortcut",false,width,height,"",.95f);overlays->SetOverlayInputMethod(v.overlay,vr::VROverlayInputMethod_None);} }else if(kind=="cast.screenshot.shortcutPreview.close"){ if(auto preview=views.find("framely.screenshot.preview");preview!=views.end())close_view(*preview->second); - }else if(kind=="cast.framing.open"){auto key="framely.framing";if(views.count(key)&&views.at(key)->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}auto geometry=c.at("geometry");RecordingRegionPlane plane;auto view=geometry.at("view");if(!recording_region_plane(view,geometry.at("referenceTan"),0,0,1,plane))continue;auto& v=create_view(key,"Recording frame",origin+"/cast/framing",false,1600,1600);v.region_editor_id=geometry.at("id");v.region_geometry=geometry;update_framing_placement(v);menu_open=false;hide_launcher(false);}else if(kind=="cast.framing.reference"){auto found=views.find("framely.framing");if(found!=views.end()&&!found->second->closing&&found->second->region_editor_id==c.value("id",std::string{})){double cx=c.at("centerTanX"),cy=c.at("centerTanY");if(std::isfinite(cx)&&std::isfinite(cy)&&std::abs(cx)<=1.733&&std::abs(cy)<=1.733){found->second->region_geometry["view"]["centerTanX"]=cx;found->second->region_geometry["view"]["centerTanY"]=cy;}}}else if(kind=="cast.framing.close"){if(views.count("framely.framing")&&views.at("framely.framing")->region_editor_id==c.value("id",std::string{}))close_view(*views.at("framely.framing"));}else if(kind=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",false);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="apk.permission.open"){auto key="framely.apk.permission";if(views.count(key)&&views.at(key)->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}auto& v=create_view(key,"Android permission",origin+"/apk/permission",false,1000,560,"",.85f);anchor_permission(v);}else if(kind=="apk.permission.close"){if(views.count("framely.apk.permission"))close_view(*views.at("framely.apk.permission"));}else if(kind=="cast.screenshot.sound"){ui_audio.screenshot();}else if(kind=="cast.window"){auto id=c.at("id").get();auto key="framely.cast."+id;if(views.count(key)&&views.at(key)->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}int width=c.value("width",640),height=c.value("height",240);if(width<160||width>1920||height<112||height>8192)continue;auto& v=create_view(key,"Framely casting",origin+"/cast/window/"+id,false,width,height,"",2.f);v.cast_frame.path=c.at("frame").get();if(v.width!=width||v.height!=height){v.width=width;v.height=height;v.pixels.clear();v.texture_ready[0]=v.texture_ready[1]=false;v.host->was_resized(v.host);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v.overlay,&scale);}menu_open=false;launcher_open=false;}else if(kind=="cast.close"){auto key="framely.cast."+c.at("id").get();if(views.count(key))close_view(*views.at(key));}else if(kind=="window.open"){std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;const bool keyboard_surface=key==frame_keyboard_window_key;if(!keyboard_surface)hide_launcher(false);if(views.count(key)&&views.at(key)->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));if(!keyboard_surface){menu_open=false;launcher_open=false;}}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get()+"."+c.at("window").get();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){bool requested=c.value("visible",false);if(!requested){if(views.count("notifications")){auto& v=*views.at("notifications");v.notification_requested=false;v.notification_content=false;show(v,false);}continue;}auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);v.notification_requested=true;anchor_notification(v);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing||key==frame_keyboard_window_key)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");if(key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);auto e=overlays->ShowKeyboardForOverlay(v.overlay,c.value("password",false)?vr::k_EGamepadTextInputModePassword:vr::k_EGamepadTextInputModeNormal,c.value("multiline",false)?vr::k_EGamepadTextInputLineModeMultipleLines:vr::k_EGamepadTextInputLineModeSingleLine,vr::KeyboardFlag_Modal|vr::KeyboardFlag_Minimal|vr::KeyboardFlag_ShowArrowKeys,"Framely",4096,existing.c_str(),keyboard_token);keyboard_open=e==vr::VROverlayError_None;keyboard_menu.begin(keyboard_open&&key=="menu");keyboard_state(keyboard_open);if(!keyboard_open)close_keyboard();}else if(kind=="notifications.empty"){if(views.count("notifications")){views.at("notifications")->notification_requested=false;views.at("notifications")->notification_content=false;show(*views.at("notifications"),false);}}}catch(const std::exception& e){std::cerr<<"Native command error: "<closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}auto geometry=c.at("geometry");RecordingRegionPlane plane;auto view=geometry.at("view");if(!recording_region_plane(view,geometry.at("referenceTan"),0,0,1,plane))continue;auto& v=create_view(key,"Recording frame",origin+"/cast/framing",false,1600,1600);v.region_editor_id=geometry.at("id");v.region_geometry=geometry;update_framing_placement(v);menu_open=false;hide_launcher(false);}else if(kind=="cast.framing.reference"){auto found=views.find("framely.framing");if(found!=views.end()&&!found->second->closing&&found->second->region_editor_id==c.value("id",std::string{})){double cx=c.at("centerTanX"),cy=c.at("centerTanY");if(std::isfinite(cx)&&std::isfinite(cy)&&std::abs(cx)<=1.733&&std::abs(cy)<=1.733){found->second->region_geometry["view"]["centerTanX"]=cx;found->second->region_geometry["view"]["centerTanY"]=cy;}}}else if(kind=="cast.framing.close"){if(views.count("framely.framing")&&views.at("framely.framing")->region_editor_id==c.value("id",std::string{}))close_view(*views.at("framely.framing"));}else if(kind=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",false);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="apk.update.open"){auto key="framely.apk.update";if(views.count(key)&&views.at(key)->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}auto id=c.at("id").get();if(views.count(key)){if(views.at(key)->region_editor_id!=id){close_view(*views.at(key));std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}show(*views.at(key),true);}else{std::cout<<"APK update request="<region_editor_id==c.value("id",std::string{}))close_view(*views.at("framely.apk.update"));}else if(kind=="apk.permission.open"){auto key="framely.apk.permission";if(views.count(key)&&views.at(key)->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}auto& v=create_view(key,"Android permission",origin+"/apk/permission",false,1000,560,"",.85f);anchor_permission(v);}else if(kind=="apk.permission.close"){if(views.count("framely.apk.permission"))close_view(*views.at("framely.apk.permission"));}else if(kind=="cast.screenshot.sound"){ui_audio.screenshot();}else if(kind=="cast.window"){auto id=c.at("id").get();auto key="framely.cast."+id;if(views.count(key)&&views.at(key)->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}int width=c.value("width",640),height=c.value("height",240);if(width<160||width>1920||height<112||height>8192)continue;auto& v=create_view(key,"Framely casting",origin+"/cast/window/"+id,false,width,height,"",2.f);v.cast_frame.path=c.at("frame").get();if(v.width!=width||v.height!=height){v.width=width;v.height=height;v.pixels.clear();v.texture_ready[0]=v.texture_ready[1]=false;v.host->was_resized(v.host);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v.overlay,&scale);}menu_open=false;launcher_open=false;}else if(kind=="cast.close"){auto key="framely.cast."+c.at("id").get();if(views.count(key))close_view(*views.at(key));}else if(kind=="window.open"){std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;const bool keyboard_surface=key==frame_keyboard_window_key;if(!keyboard_surface)hide_launcher(false);if(views.count(key)&&views.at(key)->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));if(!keyboard_surface){menu_open=false;launcher_open=false;}}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get()+"."+c.at("window").get();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){bool requested=c.value("visible",false);if(!requested){if(views.count("notifications")){auto& v=*views.at("notifications");v.notification_requested=false;v.notification_content=false;show(v,false);}continue;}auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);v.notification_requested=true;anchor_notification(v);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing||key==frame_keyboard_window_key)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");if(key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);auto e=overlays->ShowKeyboardForOverlay(v.overlay,c.value("password",false)?vr::k_EGamepadTextInputModePassword:vr::k_EGamepadTextInputModeNormal,c.value("multiline",false)?vr::k_EGamepadTextInputLineModeMultipleLines:vr::k_EGamepadTextInputLineModeSingleLine,vr::KeyboardFlag_Modal|vr::KeyboardFlag_Minimal|vr::KeyboardFlag_ShowArrowKeys,"Framely",4096,existing.c_str(),keyboard_token);keyboard_open=e==vr::VROverlayError_None;keyboard_menu.begin(keyboard_open&&key=="menu");keyboard_state(keyboard_open);if(!keyboard_open)close_keyboard();}else if(kind=="notifications.empty"){if(views.count("notifications")){views.at("notifications")->notification_requested=false;views.at("notifications")->notification_content=false;show(*views.at("notifications"),false);}}}catch(const std::exception& e){std::cerr<<"Native command error: "<IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))release_framing_pointer(v);if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))mouse.modifiers=v.framing_left_down?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{if(v.key!=frame_keyboard_window_key)v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view")))&&button==MBT_LEFT)v.framing_left_down=e.eventType==vr::VREvent_MouseButtonDown;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||(keyboard_open&&keyboard_view!=v.key)||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))release_framing_pointer(v);if(!keyboard_open&&!v.dashboard&&v.key!=frame_keyboard_window_key&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher"&&v.key!="framely.system.radial"&&v.key.rfind("framely.cast.",0)!=0&&v.key!="framely.apk.permission"&&v.key!="framely.framing"&&v.key!="framely.screenshot")close_view(v);break;default:break;}} +static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if(v.key=="framely.system.radial"&&e.eventType==vr::VREvent_FocusLeave)v.pointer_valid=false;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;if(menu_open&&v.key!="menu"&&e.eventType==vr::VREvent_MouseButtonDown&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now()))menu_open=false;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))release_framing_pointer(v);if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))mouse.modifiers=v.framing_left_down?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{if(v.key!=frame_keyboard_window_key)v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view")))&&button==MBT_LEFT)v.framing_left_down=e.eventType==vr::VREvent_MouseButtonDown;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||(keyboard_open&&keyboard_view!=v.key)||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))release_framing_pointer(v);if(!keyboard_open&&!v.dashboard&&v.key!=frame_keyboard_window_key&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher"&&v.key!="framely.system.radial"&&v.key.rfind("framely.cast.",0)!=0&&!apk_prompt(v.key)&&v.key!="framely.framing"&&v.key!="framely.screenshot")close_view(v);break;default:break;}} int main(int argc,char** argv){ std::cout.setf(std::ios::unitbuf);std::string initial;int port=0;for(int i=1;i+1=0)return child; - if(initial.empty()||runtime.empty()||!port||token.empty())return 2;origin="http://127.0.0.1:"+std::to_string(port);std::signal(SIGINT,stop);std::signal(SIGTERM,stop);std::signal(SIGPIPE,SIG_IGN); + if(initial.empty()||runtime.empty()||!port||token.empty())return 2;boot_url=initial;origin="http://127.0.0.1:"+std::to_string(port);std::signal(SIGINT,stop);std::signal(SIGTERM,stop);std::signal(SIGPIPE,SIG_IGN); // Check the existing runtime without bootstrapping it before opening the renderer. vr::EVRInitError vr_error;vr_system=vr::VR_Init(&vr_error,vr::VRApplication_Background);if(vr_error){std::cerr<<"SteamVR unavailable "<key=="launcher"){const auto value=text(message);if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=1")launcher_fade.reduced=true;else if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=0")launcher_fade.reduced=false;else if(value=="FRAMELY_GAZE_FOCUS"&&gaze_active&&launcher_gaze_allowed()){ auto now=std::chrono::steady_clock::now();if(now-gaze_feedback_at>=std::chrono::milliseconds(180)){gaze_feedback_at=now;if(ui_sounds)ui_audio.play();int hand=gaze_feedback_hand;auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);if(device==vr::k_unTrackedDeviceIndexInvalid){hand=1-hand;device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);}if(ui_haptics&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr::VRInput()->TriggerHapticVibrationAction(gaze_haptic_action,0,.012f,110.f,.16f,hands[hand])!=vr::VRInputError_None)vr_system->TriggerHapticPulse(device,0,1200);} return 1;}}std::cout<<"UI ["<<(v?v->key:"loading")<<"] "<is_main(frame)&&status>=400){auto* v=find_browser(browser);if(v&&v->key=="framely.apk.update"){std::cerr<<"APK update window HTTP error: "<get_url(r);auto s=text(uri);cef_string_userfree_utf16_free(uri);auto* v=find_browser(b);return int(!allow_plugin_navigation(s,origin,!f||f->is_main(f),v&&!v->plugin.empty()));}; request_handler.api.on_render_process_terminated=[](cef_request_handler_t*,cef_browser_t* b,cef_termination_status_t,int,const cef_string_t*){auto* v=find_browser(b);if(v){std::cerr<<"Renderer crashed: "<key<<"\n";close_view(*v);if(v->key=="menu")running=0;if(v->key=="launcher")launcher_open=false;}}; @@ -1162,7 +1165,7 @@ int main(int argc,char** argv){ if(!main_overlay)main_overlay=active_main_window(); if(main_overlay&&!overlays->GetOverlayMouseScale(main_overlay,&main_scale)&&!overlays->GetTransformForOverlayCoordinates(main_overlay,vr::TrackingUniverseStanding,{{main_scale.v[0]*.5f,main_scale.v[1]*.5f}},&main_pose))main=&main_pose; if(evidence.radius>0?dock_menu_placement(anchor,size,evidence.radius,evidence.pre_curve_pitch,popup):menu_placement(anchor,size,popup,0,main)){v.placement.apply(overlays,v.overlay,popup.transform,popup.width);v.placement.curve(overlays,v.overlay,popup.width,popup.radius);show(v,menu_open);}else show(v,false);}}else if(!keyboard_menu.holds(now)){if(overlays->IsOverlayVisible(button))overlays->ClearOverlayCursorPositionOverride(button);overlays->HideOverlay(button);if(views.count("menu"))show(*views.at("menu"),false);} - if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!=frame_keyboard_window_key&&key!="menu"&&key!="notifications"&&key!="launcher"&&key!="framely.system.radial"&&key!="framely.system.radial.progress"&&key.rfind("framely.cast.",0)!=0&&key!="framely.apk.permission")close_view(*v);}was_visible=visible; + if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!=frame_keyboard_window_key&&key!="menu"&&key!="notifications"&&key!="launcher"&&key!="framely.system.radial"&&key!="framely.system.radial.progress"&&key.rfind("framely.cast.",0)!=0&&!apk_prompt(key))close_view(*v);}was_visible=visible; if(launcher_close_at.time_since_epoch().count()&&now>=launcher_close_at){launcher_close_at={};if(!launcher_open)finish_launcher_close();} // The runtime owns dashboard placement, curvature, visibility and switching. // Never move the launcher into standing space or hide another app's overlay. diff --git a/src/apk.rs b/src/apk.rs index 5b53d0a..1c4f6f3 100644 --- a/src/apk.rs +++ b/src/apk.rs @@ -22,6 +22,7 @@ use std::{ time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; mod cleanup; +pub(crate) mod updates; mod graphics; mod install_rollback; mod lifecycle; @@ -1276,7 +1277,9 @@ fn space(p: &Path, needed: u64) -> Result<()> { let free = (s.f_bavail as u64).saturating_mul(s.f_frsize as u64); ensure!( free > needed.saturating_add(128 * 1024 * 1024), - "Not enough disk space for APK, installation and backup" + "Not enough disk space for APK, installation and backup: need {:.2} GiB, available {:.2} GiB. Free space or move APK/container storage, then retry; existing data has not been replaced", + needed.saturating_add(128*1024*1024) as f64 / (1024.0*1024.0*1024.0), + free as f64 / (1024.0*1024.0*1024.0) ); Ok(()) } @@ -1668,7 +1671,7 @@ function setup_props() { &format!( "{include}\n{}\n{hooks}\n{}", storage::HOOKS, - graphics::HOOKS + format!("{}\n{}",graphics::HOOKS, updates::hooks()) ), )) } @@ -1929,6 +1932,7 @@ fn start_oriented_container_for( let (window_dex, window_script) = window_runtime(home)?; cmd.env("FRAMELY_WINDOW_DEX", window_dex) .env("FRAMELY_WINDOW_SCRIPT", window_script); + updates::configure(&mut cmd, home, c)?; graphics::configure_command(&mut cmd, false, "")?; cmd.env_remove("SteamAppId"); storage::configure( @@ -2835,7 +2839,9 @@ pub fn operate( } } } + if kind == "install" { updates::validate_install(home, p)?; } let result = operate_internal(home, kind, p, cancel, progress, None); + if kind == "install" && result.is_ok() { updates::installed(home, p); } if matches!(kind, "install" | "steam.retention.settings" | "settings") && result.is_ok() { steam_shortcuts::wake_registration(); } @@ -3076,7 +3082,7 @@ fn operate_internal( }; let app_id = format!("{context}/{}", metadata.package); let existing = cs.iter().find(|c| c.id == context).cloned(); - let c = existing.unwrap_or_else(|| Container { + let mut c = existing.unwrap_or_else(|| Container { id: context.clone(), name: context.clone(), baked: contexts_root(home) @@ -3097,7 +3103,14 @@ fn operate_internal( "Unknown target container" ); ensure!( - safe(&c.name) && !c.name.starts_with("steamlaunch-"), + safe(&c.name) && (!c.name.starts_with("steamlaunch-") || ( + // Framely's Steam shortcut changes only the runtime name. + // Accept that exact recorded binding, never an unrelated Steam container. + safe(&c.id) && !c.id.starts_with("steamlaunch-") + && cs.iter().any(|existing| existing.id == c.id && existing.baked == c.baked) + && db.records.values().any(|record| record.context == c.id + && native::runtime_name(record).as_deref() == Some(c.name.as_str())) + )), "Invalid side-loaded container name" ); if !cs.iter().any(|x| x.id == context) { @@ -3170,6 +3183,12 @@ fn operate_internal( cancel.commit(||{ progress(json!({"cancellable":false})); if fresh.is_none() && c.baked.exists(){progress(json!({"phase":"backing-up"}));stop(&c,&log)?;backup_id=Some(backup(home,&c,&db,&log)?);} +// Steam runtime names require Steam's APK launch arguments. The installer + // instead boots the same baked data under its stable Framely context name. + if c.name.starts_with("steamlaunch-") { + let _=fs::OpenOptions::new().append(true).open(&log).and_then(|mut file|writeln!(file,"Installer runtime: {} -> {}; data location retained",c.name,c.id)); + c.name=c.id.clone();c.running=false; + } progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log).with_context(||format!("Cannot initialize Android container {}",c.baked.display()))?; let sdk=podman(&["exec",&format!("lepton-{}",c.name),"getprop","ro.build.version.sdk"],Some(&log))?.trim().parse::()?;ensure!(metadata.min_sdk<=sdk,"APK requires Android SDK {}, container provides {}",metadata.min_sdk,sdk); progress(json!({"phase":"installing","cancellable":false}));install_package(&c,&review.join("base.apk"),&log)?; @@ -6719,4 +6738,106 @@ esac std::os::unix::fs::symlink(f.dir.path(), r.join("reviews")).unwrap(); assert!(upload_dir(&f.home, 100).is_err()); } + #[test] + fn updating_running_native_steam_shortcut_uses_its_owned_container() { + let f=Fixture::new(); + list(&f.home).unwrap(); + let id="test/com.example.app"; + let mut db=load(&f.home).unwrap(); + let record=db.records.get_mut(id).unwrap(); + record.steam_app_id=Some(0x92345678); + record.steam_binding=Some(steam_shortcuts::Binding {game_id:steam_shortcuts::game_id(id),native:true,revision:String::new()}); + save(&f.home,&db).unwrap(); + let tool=f.dir.path().join("tools/podman"); + let compat=f.home.join(".local/share/lepton/contexts/test"); + let source=fs::read_to_string(&tool).unwrap() + .replace("lepton-test|", "lepton-steamlaunch-2452903544|") + .replace("echo lepton-test", "echo lepton-steamlaunch-2452903544") + .replace(&format!("fixture|{}|[]",compat.display()),&compat.to_string_lossy()); + fs::write(&tool,source).unwrap(); + let cs=containers(&f.home,&db); + let c=cs.iter().find(|c|c.id=="test").unwrap(); + assert_eq!(c.name,"steamlaunch-2452903544"); + assert!(!c.steam); + assert!(c.running); + // Model Lepton's reserved-name behaviour instead of accepting every start. + let lepton=runner(&f.home).unwrap(); + let source=fs::read_to_string(&lepton).unwrap().replace("setup_props\necho true",&format!("if [[ \"${{2:-}}\" == steamlaunch-* ]]; then echo 'No package id found' >&2; exit 97; fi\nprintf '%s\\n' \"$2\" > '{}'\nsetup_props\necho true",f.dir.path().join("installer-context").display())); + fs::write(&lepton,source).unwrap(); + let incoming=f.dir.path().join("update.apk"); + crate::apk_metadata::fixture(&incoming,true,43); + let review=inspect_local(&f.home,&incoming,&Cancellation::default()).unwrap(); + let result=f.operation("install",json!({"app":id,"context":"test","ticket":review["ticket"],"approve":true})).unwrap(); + assert!(result["backup"].is_string()); + assert_eq!(fs::read_to_string(f.save_file()).unwrap(),"saved progress"); + let baked=compat.join("baked"); + assert_eq!(cached_metadata(&baked.join("app_overlay/base.apk")).unwrap().version_code,43); + let commands=fs::read_to_string(f.dir.path().join("commands")).unwrap(); + assert!(commands.contains("stop --time 10 lepton-steamlaunch-2452903544")); + assert_eq!(fs::read_to_string(f.dir.path().join("installer-context")).unwrap().trim(),"test"); + assert!(commands.contains("exec lepton-test getprop ro.build.version.sdk")); + // Without a matching Framely binding the same runtime remains Steam-owned. + let mut db=load(&f.home).unwrap(); + db.records.get_mut(id).unwrap().steam_binding=None; + save(&f.home,&db).unwrap(); + fs::write(f.dir.path().join("running"),"true").unwrap(); + assert!(containers(&f.home,&db).iter().find(|c|c.id=="test").unwrap().steam); + } + #[test] + fn captured_update_requires_review_keeps_backup_and_preserves_data() { + let f=Fixture::new(); + let mut db=load(&f.home).unwrap(); + let (a,c)=app(&f.home,&db,"test/com.example.app").unwrap(); + record_app(&mut db,&a);save(&f.home,&db).unwrap(); + let env=updates::prepare(&f.home,&c).unwrap().unwrap(); + let dir=PathBuf::from(env["FRAMELY_UPDATE_DIR"].as_str().unwrap()); + let id="0123456789abcdef0123456789abcdef"; + let duplicate="abcdef0123456789abcdef0123456789"; + let downgrade="1123456789abcdef0123456789abcdef"; + for (key,version) in [(id,43),(duplicate,43),(downgrade,41)] { + let path=dir.join(format!("{key}.apk")); + crate::apk_metadata::fixture(&path,true,version); + fs::write(dir.join(format!("{key}.json")),serde_json::to_vec(&json!({"package":"com.example.app","bytes":fs::metadata(path).unwrap().len()})).unwrap()).unwrap(); + } + let queue=updates::poll(&f.home).unwrap(); + assert_eq!(queue.len(),2); + let id=queue.iter().find(|r|r.error.is_none()).unwrap().id.as_str(); + assert_eq!(cached_metadata(&c.baked.join("app_overlay/base.apk")).unwrap().version_code,42); + let mut windows=updates::Windows::default(); + assert_eq!(windows.next(&queue),Some(id.to_owned())); + windows.close(id); + assert_eq!(windows.next(&queue),None); + // All three Android request paths stage the same APK in this format. + // Each fresh request must reopen the retained confirmation, even in the same second. + for repeated in ["2123456789abcdef0123456789abcdef", "3123456789abcdef0123456789abcdef", "4123456789abcdef0123456789abcdef"] { + fs::copy(dir.join(format!("{id}.apk")),dir.join(format!("{repeated}.apk"))).unwrap(); + fs::write(dir.join(format!("{repeated}.json")),serde_json::to_vec(&json!({"package":"com.example.app","bytes":fs::metadata(dir.join(format!("{repeated}.apk"))).unwrap().len()})).unwrap()).unwrap(); + let repeated_queue=updates::poll(&f.home).unwrap(); + assert_eq!(repeated_queue.len(),2); + assert!(!dir.join(format!("{repeated}.apk")).exists()); + assert_eq!(windows.next(&repeated_queue),Some(id.to_owned())); + windows.close(id); + assert_eq!(windows.next(&repeated_queue),None); + updates::presented(&f.home,id).unwrap(); + let after_receipt=updates::poll(&f.home).unwrap(); + assert_eq!(updates::Windows::default().next(&after_receipt),None); + } + let review=updates::review_request(&f.home,id,&Cancellation::default()).unwrap(); + let invalid=json!({"app":"other/com.example.app","context":"other","updateRequest":id}); + assert!(updates::validate_install(&f.home,&invalid).is_err()); + let p=json!({"ticket":review["ticket"],"approve":true,"app":a.id,"context":c.id,"updateRequest":id}); + updates::validate_install(&f.home,&p).unwrap(); + let result=f.operation("install",p.clone()).unwrap(); + assert!(result["backup"].is_string()); + updates::installed(&f.home,&p); + assert_eq!(fs::read_to_string(f.save_file()).unwrap(),"saved progress"); + assert_eq!(cached_metadata(&c.baked.join("app_overlay/base.apk")).unwrap().version_code,43); + assert!(!dir.join(format!("{id}.apk")).exists()); + assert_eq!(updates::list(&f.home).unwrap().as_array().unwrap().len(),1); + // The FIFO lives outside persistent APK storage and cannot break migration. + let fifo=PathBuf::from(env["FRAMELY_UPDATE_PIPE"].as_str().unwrap()); + assert!(!fifo.starts_with(root(&f.home))); + fs::remove_file(fifo).unwrap(); + } + } diff --git a/src/apk/native.rs b/src/apk/native.rs index e9beab5..1449708 100644 --- a/src/apk/native.rs +++ b/src/apk/native.rs @@ -184,6 +184,10 @@ pub(super) fn prepare( "APP_WANTS_FLATSCREEN":if flat {"true"}else{"false"}, "LEPTON_NO_CLEANUP":"true", "TERM":"dumb" }); + for key in ["FRAMELY_UPDATE_DIR","FRAMELY_UPDATE_PIPE","FRAMELY_UPDATE_DEX","FRAMELY_UPDATE_SCRIPT","FRAMELY_UPDATE_PACKAGES"] {env[key]=json!("");} + if let Some(update_env) = updates::prepare(home, &c)? { + env.as_object_mut().unwrap().extend(update_env.as_object().unwrap().clone()); + } graphics::configure_environment(&mut env, a.framebuffer_compatibility, &a.metadata.package)?; let installed_dir = c .baked diff --git a/src/apk/updates.rs b/src/apk/updates.rs new file mode 100644 index 0000000..5c5dcfd --- /dev/null +++ b/src/apk/updates.rs @@ -0,0 +1,602 @@ +//! App-originated APK updates are queued, never installed without review. +use super::*; +use std::collections::{BTreeMap, BTreeSet}; +const HOOKS: &str = include_str!("../../native/android/apk-updates-hooks.sh"); +const MAX_AGE: u64 = 7 * 24 * 3600; +static QUEUE_GATE: std::sync::Mutex<()> = std::sync::Mutex::new(()); +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Bridge { + context: String, +} +#[derive(Deserialize)] +struct Captured { + package: Option, + bytes: Option, + error: Option, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Request { + pub id: String, + pub app: String, + pub name: String, + pub previous: String, + pub version: String, + pub bytes: u64, + pub created: u64, + pub error: Option, + #[serde(default)] + sha256: String, + #[serde(default)] + revision: u64, + #[serde(default)] + presented_revision: Option, + // Old records have no trustworthy delivery history and stay manually accessible. + #[serde(default)] + presentation_tracked: bool, +} +/// One confirmation at a time; cancelling retains the APK without immediately reopening it. +#[derive(Default)] +pub(crate) struct Windows { + active: Option, + shown: BTreeMap, + sent_at: Option, + attempts: u8, + failed: Option, +} +impl Windows { + pub(crate) fn next(&mut self, requests: &[Request]) -> Option { + self.next_at(requests, std::time::Instant::now()) + } + fn next_at(&mut self, requests: &[Request], now: std::time::Instant) -> Option { + self.shown.retain(|id, _| requests.iter().any(|r| &r.id == id)); + if let Some(active) = &self.active { + // Coalesce repeats while their confirmation is already open. + if let Some(r) = requests.iter().find(|r| &r.id == active) { + self.shown.insert(r.id.clone(), r.revision); + if r.presented_revision != Some(r.revision) + && self.sent_at.is_some_and(|sent| now.saturating_duration_since(sent) >= Duration::from_secs(5)) { + if self.attempts < 3 { + self.attempts += 1; + self.sent_at = Some(now); + return Some(r.id.clone()); + } + if self.attempts == 3 { + self.failed = Some(r.id.clone()); + self.attempts = 4; + } + } + return None; + } + // Installed/dismissed requests must not block the next application. + self.active = None; + } + let request = requests + .iter() + .filter(|r| r.error.is_none() && r.presentation_tracked && r.presented_revision != Some(r.revision) && self.shown.get(&r.id) != Some(&r.revision)) + .min_by_key(|r| r.created)?; + let id = request.id.clone(); + self.shown.insert(id.clone(), request.revision); + self.active = Some(id.clone()); + self.sent_at = Some(now); + self.attempts = 1; + Some(id) + } + pub(crate) fn take_failure(&mut self) -> Option { self.failed.take() } + pub(crate) fn close(&mut self, id: &str) { + if self.active.as_deref() == Some(id) { + self.active = None; + } + } + pub(crate) fn reopen(&mut self, id: &str) -> Result<()> { + ensure!(key(id), "Invalid APK update request"); + ensure!( + self.active.as_deref().is_none_or(|active| active == id), + "Finish the current APK update first" + ); + self.active = Some(id.to_owned()); + self.sent_at = Some(std::time::Instant::now()); + self.attempts = 1; + self.shown.entry(id.to_owned()).or_insert(0); + Ok(()) + } +} +fn directory(home: &Path) -> PathBuf { + root(home).join("updates") +} +fn key(id: &str) -> bool { + id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()) +} +fn private(path: &Path) -> Result<()> { + fs::create_dir_all(path)?; + ensure!( + !fs::symlink_metadata(path)?.file_type().is_symlink(), + "Invalid APK update directory" + ); + ensure!( + fs::metadata(path)?.uid() == unsafe { libc::geteuid() }, + "APK update directory belongs to another user" + ); + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) +} +fn write(path: &Path, value: &impl Serialize) -> Result<()> { + steam_shortcuts::write_owned(path, &serde_json::to_vec(value)?, 0o600) +} +pub(super) fn hooks() -> &'static str { + HOOKS +} +pub(super) fn prepare(home: &Path, c: &Container) -> Result> { + let db = load(home)?; + let packages: BTreeSet<_> = db + .records + .values() + .filter(|r| r.context == c.id && (!r.removed || r.pending.is_some())) + .map(|r| r.metadata.package.as_str()) + .filter(|p| apk_metadata::valid_package(p)) + .collect(); + if packages.is_empty() { + return Ok(None); + } + let base = directory(home); + private(&base)?; + let runtime = base.join("runtime"); + private(&runtime)?; + let dir = base.join(format!("bridge-{}", hash(&c.id))); + private(&dir)?; + write( + &dir.join("context.json"), + &Bridge { + context: c.id.clone(), + }, + )?; + let pipe_root = std::env::temp_dir().join(format!("framely-apk-updates-{}", unsafe { + libc::geteuid() + })); + private(&pipe_root)?; + let fifo = pipe_root.join(format!("{}.pipe", hash(&c.id))); + match fs::symlink_metadata(&fifo) { + Ok(m) => ensure!(m.file_type().is_fifo(), "APK update pipe is not a FIFO"), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let name = std::ffi::CString::new(fifo.as_os_str().as_encoded_bytes())?; + ensure!( + unsafe { libc::mkfifo(name.as_ptr(), 0o600) } == 0, + "Cannot create APK update pipe" + ); + } + Err(e) => return Err(e.into()), + } + let dex = runtime.join("framely-updates.dex"); + let script = runtime.join("updates.sh"); + steam_shortcuts::write_owned( + &dex, + include_bytes!("../../native/android/framely-updates.dex"), + 0o600, + )?; + steam_shortcuts::write_owned( + &script, + include_bytes!("../../native/android/apk-updates.sh"), + 0o600, + )?; + let allowlist = dir.join("packages"); + steam_shortcuts::write_owned( + &allowlist, + packages + .into_iter() + .collect::>() + .join("\n") + .as_bytes(), + 0o600, + )?; + let _ = fs::remove_file(dir.join("ready")); + Ok(Some( + json!({"FRAMELY_UPDATE_DIR":dir,"FRAMELY_UPDATE_PIPE":fifo,"FRAMELY_UPDATE_DEX":dex, + "FRAMELY_UPDATE_SCRIPT":script,"FRAMELY_UPDATE_PACKAGES":allowlist}), + )) +} +pub(super) fn configure(cmd: &mut Command, home: &Path, c: &Container) -> Result<()> { + for key in [ + "FRAMELY_UPDATE_DIR", + "FRAMELY_UPDATE_PIPE", + "FRAMELY_UPDATE_DEX", + "FRAMELY_UPDATE_SCRIPT", + "FRAMELY_UPDATE_PACKAGES", + ] { + cmd.env_remove(key); + } + if let Some(env) = prepare(home, c)? { + for (k, v) in env.as_object().unwrap() { + cmd.env(k, v.as_str().context("Invalid APK update environment")?); + } + } + Ok(()) +} +fn bridge_dirs(home: &Path) -> Vec { + fs::read_dir(directory(home)) + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_str() + .is_some_and(|n| n.starts_with("bridge-")) + && e.file_type().is_ok_and(|t| t.is_dir()) + }) + .map(|e| e.path()) + .collect() +} +fn regular(path: &Path, max: u64) -> Result { + use std::os::unix::fs::OpenOptionsExt; + let f = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path)?; + ensure!( + f.metadata()?.is_file() && f.metadata()?.len() <= max, + "Invalid APK update file" + ); + Ok(f) +} +fn request(home: &Path, id: &str) -> Result<(PathBuf, Request)> { + ensure!(key(id), "Invalid APK update request"); + for dir in bridge_dirs(home) { + if let Ok(f) = regular(&dir.join(format!("{id}.request")), 16384) { + let r: Request = serde_json::from_reader(f)?; + ensure!(r.id == id, "APK update request changed"); + return Ok((dir, r)); + } + } + bail!("APK update request has ended; retry from the application") +} +fn remove(dir: &Path, id: &str) -> Result<()> { + ensure!(key(id), "Invalid APK update request"); + for ext in ["json", "apk", "apk.part", "request", "json.part"] { + let p = dir.join(format!("{id}.{ext}")); + match fs::remove_file(p) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + } + Ok(()) +} +/// Called by the session worker. Metadata parsing happens off the UI request thread. +pub(crate) fn poll(home: &Path) -> Result> { + let _gate = QUEUE_GATE.lock().unwrap(); + let db = load(home)?; + let mut notices = Vec::new(); + for dir in bridge_dirs(home) { + let bridge: Bridge = serde_json::from_reader(regular(&dir.join("context.json"), 4096)?)?; + for entry in fs::read_dir(&dir)?.filter_map(|e| e.ok()) { + let name = entry.file_name(); + let Some(id) = name + .to_str() + .and_then(|n| n.strip_suffix(".json")) + .filter(|n| key(n)) + else { + continue; + }; + let id = id.to_owned(); + if dir.join(format!("{id}.request")).exists() { + continue; + } + let captured: Result = + (|| Ok(serde_json::from_reader(regular(&entry.path(), 16384)?)?))(); + let result = (|| -> Result { + let captured = captured?; + if let Some(error) = captured.error { + bail!("{error}"); + } + let package = captured + .package + .context("Update package identity missing")?; + let rec = db + .records + .values() + .find(|r| { + r.context == bridge.context && !r.removed && r.metadata.package == package + }) + .context("Update does not match an installed application")?; + let apk = dir.join(format!("{id}.apk")); + let f = regular(&apk, 8 * 1024 * 1024 * 1024)?; + ensure!( + captured.bytes == Some(f.metadata()?.len()), + "Extracted APK size changed" + ); + let metadata = apk_metadata::read(&apk)?; + ensure!( + metadata.package == package + && metadata.version_code >= rec.metadata.version_code, + "Update package mismatch or downgrade" + ); + let sha256 = crate::package::digest_reader(&mut fs::File::open(&apk)?)?; + // Retain one APK, but treat a fresh installer request as a new prompt. + for previous in list(home)?.as_array().into_iter().flatten() { + if previous["app"] == rec.id + && previous["version"] == metadata.version + && previous["sha256"] == sha256 + { + let (previous_dir, mut previous) = request(home, previous["id"].as_str().context("Missing retained update identity")?)?; + previous.presentation_tracked = true; + previous.revision = previous.revision.checked_add(1).context("APK update request revision exhausted")?; + write(&previous_dir.join(format!("{}.request", previous.id)), &previous)?; + eprintln!("APK update request={} revision={} repeated request received", previous.id, previous.revision); + remove(&dir, &id)?; + return Err(anyhow::anyhow!("duplicate")); + } + } + Ok(Request { + id: id.clone(), + app: rec.id.clone(), + name: rec.metadata.name.clone(), + previous: rec.metadata.version.clone(), + version: metadata.version, + bytes: captured.bytes.unwrap(), + created: now(), + error: None, + sha256, + revision: 0, + presented_revision: None, + presentation_tracked: true, + }) + })(); + let value = match result { + Ok(r) => r, + Err(e) if e.to_string() == "duplicate" => continue, + Err(e) => Request { + id: id.clone(), + app: String::new(), + name: String::new(), + previous: String::new(), + version: String::new(), + bytes: 0, + created: now(), + error: Some(e.to_string()), + sha256: String::new(), + revision: 0, + presented_revision: None, + presentation_tracked: true, + }, + }; + write(&dir.join(format!("{id}.request")), &value)?; + eprintln!("APK update request={id} revision=0 captured valid={}", value.error.is_none()); + notices.push(value); + } + } + let _ = notices; + Ok(serde_json::from_value(list(home)?)?) +} +pub(crate) fn list(home: &Path) -> Result { + let mut values = Vec::new(); + for dir in bridge_dirs(home) { + for entry in fs::read_dir(&dir)?.filter_map(|e| e.ok()) { + let name = entry.file_name(); + let Some(id) = name + .to_str() + .and_then(|n| n.strip_suffix(".request")) + .filter(|n| key(n)) + else { + continue; + }; + let value: Request = serde_json::from_reader(regular(&entry.path(), 16384)?)?; + if now().saturating_sub(value.created) > MAX_AGE { + remove(&dir, id)?; + } else { + values.push(value); + } + } + } + values.sort_by_key(|r| r.created); + Ok(json!(values)) +} +pub(crate) fn presented(home: &Path, id: &str) -> Result<()> { + let _gate = QUEUE_GATE.lock().unwrap(); + let (dir, mut value) = request(home, id)?; + if value.presented_revision != Some(value.revision) { + value.presentation_tracked = true; + value.presented_revision = Some(value.revision); + write(&dir.join(format!("{id}.request")), &value)?; + eprintln!("APK update request={id} revision={} confirmation presented", value.revision); + } + Ok(()) +} +pub(crate) fn review_request(home: &Path, id: &str, cancel: &Cancellation) -> Result { + let (dir, r) = request(home, id)?; + ensure!( + r.error.is_none(), + "Update could not be extracted; retry from the application" + ); + let db = load(home)?; + let rec = db + .records + .get(&r.app) + .context("Application no longer exists")?; + ensure!(!rec.removed, "Application was uninstalled"); + let source = dir.join(format!("{id}.apk")); + regular(&source, 8 * 1024 * 1024 * 1024)?; + let review = inspect(home, &source, cancel)?; + if review["metadata"]["package"] != rec.metadata.package { + let _ = reviews_drop(home, review["ticket"].as_str().unwrap()); + bail!("APK update package changed"); + } + Ok(review) +} +pub(crate) fn dismiss(home: &Path, id: &str) -> Result { + let (dir, _) = request(home, id)?; + remove(&dir, id)?; + Ok(json!(true)) +} +pub(super) fn validate_install(home: &Path, p: &Value) -> Result<()> { + let Some(id) = p["updateRequest"].as_str() else { + return Ok(()); + }; + let (_, r) = request(home, id)?; + ensure!( + p["app"] == r.app && p["context"].as_str() == r.app.split_once('/').map(|v| v.0), + "APK update target changed" + ); + Ok(()) +} +pub(super) fn installed(home: &Path, p: &Value) { + if let Some(id) = p["updateRequest"].as_str() { + let _ = dismiss(home, id); + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn ids_do_not_escape_queue() { + for id in ["../x", "", "abc", "0123456789abcdef0123456789abcde/ff"] { + assert!(!key(id)); + } + assert!(key("0123456789abcdef0123456789abcdef")); + } + #[test] + fn dismiss_preserves_other_requests() { + let t = tempfile::tempdir().unwrap(); + let a = "0123456789abcdef0123456789abcdef"; + let b = "abcdef0123456789abcdef0123456789"; + fs::write(t.path().join(format!("{a}.apk")), b"test").unwrap(); + fs::write(t.path().join(format!("{b}.apk")), b"keep").unwrap(); + remove(t.path(), a).unwrap(); + assert!(!t.path().join(format!("{a}.apk")).exists()); + assert!(t.path().join(format!("{b}.apk")).exists()); + } + #[test] + fn no_follow_rejects_symlinks() { + let t = tempfile::tempdir().unwrap(); + fs::write(t.path().join("data"), b"test").unwrap(); + std::os::unix::fs::symlink(t.path().join("data"), t.path().join("link")).unwrap(); + assert!(regular(&t.path().join("link"), 100).is_err()); + } + #[test] + fn boot_hook_preserves_steam_forwarding_and_starts_helper_only_inside_container() { + let t = tempfile::tempdir().unwrap(); + let script = format!( + r#" +function setup_podman_mounts() {{ PODMAN_CMDLINE+=(--mount 'type=bind,source=/original/steam.pipe,destination=/lepton/steam.pipe,rw'); }} +function generate_zygote_launch_rc() {{ echo '# original zygote' > "$TEST_RC/init.zygote64.rc"; }} +function prefix() {{ echo "$TEST_RC"; }} +function podman_mount_entry() {{ PODMAN_CMDLINE+=(--mount "type=bind,source=$1,destination=$2,$3"); }} +PODMAN_CMDLINE=() +{HOOKS} +setup_podman_mounts +generate_zygote_launch_rc +printf '%s\n' "${{PODMAN_CMDLINE[@]}}" +"# + ); + let out = Command::new("bash") + .args(["-euc", &script]) + .env("TEST_RC", t.path()) + .env("FRAMELY_UPDATE_DIR", "/private/updates") + .env("FRAMELY_UPDATE_PIPE", "/private/fifo") + .env("FRAMELY_UPDATE_DEX", "/private/helper.dex") + .env("FRAMELY_UPDATE_SCRIPT", "/private/helper.sh") + .env("FRAMELY_UPDATE_PACKAGES", "/private/packages") + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let mounts = String::from_utf8(out.stdout).unwrap(); + assert!(mounts + .contains("source=/original/steam.pipe,destination=/lepton/framely-original.pipe,rw")); + assert!(mounts.contains("source=/private/fifo,destination=/lepton/steam.pipe,rw")); + assert!(mounts.contains("destination=/vendor/share/framely-updates.dex,ro")); + let rc = fs::read_to_string(t.path().join("init.zygote64.rc")).unwrap(); + assert!(rc.starts_with("# original zygote")); + assert!(rc.contains("on property:sys.boot_completed=1\n start framely-apk-updates")); + } + #[test] + fn confirmations_are_automatic_sequential_and_cancel_does_not_reopen() { + let request = |id: &str, created| Request { + id: id.into(), + app: "test/com.example.app".into(), + name: "test".into(), + previous: "1".into(), + version: "2".into(), + bytes: 100, + created, + error: None, + sha256: String::new(), + revision: 0, + presented_revision: None, + presentation_tracked: true, + }; + let a = request("0123456789abcdef0123456789abcdef", 1); + let b = request("abcdef0123456789abcdef0123456789", 2); + let mut legacy = serde_json::to_value(&a).unwrap(); + legacy.as_object_mut().unwrap().remove("presentedRevision"); + legacy.as_object_mut().unwrap().remove("presentationTracked"); + legacy.as_object_mut().unwrap().remove("revision"); + let legacy: Request = serde_json::from_value(legacy).unwrap(); + assert_eq!(Windows::default().next(&[legacy]), None); + let mut state = Windows::default(); + let queue = vec![b.clone(), a.clone()]; + assert_eq!(state.next(&queue), Some(a.id.clone())); + assert_eq!(state.next(&queue), None); + state.close(&b.id); + assert_eq!(state.next(&queue), None); + state.close(&a.id); + assert_eq!(state.next(&queue), Some(b.id.clone())); + state.close(&b.id); + assert_eq!(state.next(&queue), None); + state.reopen(&a.id).unwrap(); + assert_eq!(state.next(&queue), None); + state.close(&a.id); + // A new request for the same retained APK reopens after cancellation. + let mut repeated = a.clone(); + repeated.revision = 1; + assert_eq!(state.next(&[repeated.clone()]), Some(a.id.clone())); + assert_eq!(state.next(&[repeated.clone()]), None); + // Repeats received while already visible do not reopen after closing. + repeated.revision = 2; + assert_eq!(state.next(&[repeated.clone()]), None); + state.close(&a.id); + assert_eq!(state.next(&[repeated]), None); + let mut removed = Windows::default(); + assert_eq!(removed.next(&[a.clone()]), Some(a.id.clone())); + assert_eq!(removed.next(&[b.clone()]), Some(b.id.clone())); + // A dropped open command is retried, and missing presentation is surfaced. + let now = std::time::Instant::now(); + let mut delivery = Windows::default(); + assert_eq!(delivery.next_at(&[a.clone()], now), Some(a.id.clone())); + assert_eq!(delivery.next_at(&[a.clone()], now + Duration::from_secs(4)), None); + assert_eq!(delivery.next_at(&[a.clone()], now + Duration::from_secs(5)), Some(a.id.clone())); + assert_eq!(delivery.next_at(&[a.clone()], now + Duration::from_secs(10)), Some(a.id.clone())); + assert_eq!(delivery.next_at(&[a.clone()], now + Duration::from_secs(15)), None); + assert_eq!(delivery.take_failure(), Some(a.id.clone())); + assert_eq!(delivery.next_at(&[a.clone()], now + Duration::from_secs(20)), None); + let mut delivered = a.clone(); + delivered.presented_revision = Some(delivered.revision); + let mut delivery = Windows::default(); + assert_eq!(delivery.next_at(&[a.clone()], now), Some(a.id.clone())); + assert_eq!(delivery.next_at(&[delivered], now + Duration::from_secs(20)), None); + assert_eq!(delivery.take_failure(), None); + // Installation/session restart must not reopen retained requests. + let mut presented_queue = queue.clone(); + for r in &mut presented_queue { r.presented_revision = Some(r.revision); } + let mut resumed = Windows::default(); + assert_eq!(resumed.next(&presented_queue), None); + assert_eq!(Windows::default().next(&queue), Some(a.id.clone())); + // Manual review remains possible, without automatic reopening on close. + resumed.reopen(&a.id).unwrap(); + resumed.close(&a.id); + assert_eq!(resumed.next(&presented_queue), None); + // A new capture prompts even with older retained requests in the queue. + let mut fresh = a.clone(); + fresh.id = "1123456789abcdef0123456789abcdef".into(); + let mut pending = presented_queue.clone(); + pending.push(fresh.clone()); + assert_eq!(resumed.next(&pending), Some(fresh.id.clone())); + resumed.close(&fresh.id); + // A newly submitted identical APK must also prompt after restart. + let mut repeated = a.clone(); + repeated.revision += 1; + assert_eq!(resumed.next(&[repeated]), Some(a.id.clone())); + } +} diff --git a/src/service.rs b/src/service.rs index d0cdf97..3ac1ea1 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1463,6 +1463,14 @@ impl Service { self.insert_notification("", "Framely", notification, None)?; Ok(json!(true)) }, + "apk.update.notification" => { + ensure!(id.is_empty(), "Core notification only"); + let notification: Notification=serde_json::from_value(p["notification"].clone())?; + notification.validate()?; + ensure!(notification.id.starts_with("apk.update."), "Invalid APK update notification"); + self.insert_notification("", "Framely", notification, None)?; + Ok(json!(true)) + }, "notification.send" => self.notify(&id, p["notification"].clone()), "notification.remove" | "notification.dismiss" => { if !id.is_empty() { diff --git a/src/session.rs b/src/session.rs index bd20234..dfc25b9 100644 --- a/src/session.rs +++ b/src/session.rs @@ -48,6 +48,7 @@ struct Agent { web_key: String, network_key: Mutex, network_status: Mutex, + apk_updates: Mutex, frame_access: Mutex>, native_key: String, commands: Arc>>, @@ -533,6 +534,9 @@ impl Agent { if let Some(id)=views["framingClosed"].as_str().filter(|id|!id.is_empty()) { self.casting.lock().unwrap().region.acknowledge_editor_closed(id); } + if let Some(id)=views["apkUpdateClosed"].as_str().filter(|id|!id.is_empty()) { + self.apk_updates.lock().unwrap().close(id); + } if let Some(id)=views["screenshotClosed"].as_str().filter(|id|!id.is_empty()) { self.screenshot.lock().unwrap().acknowledge(id); } @@ -844,6 +848,12 @@ impl Agent { } if let Some(key) = path.strip_prefix("/boot/") { ensure!(key == self.web_key, "Invalid boot token"); + let query:std::collections::BTreeMap<_,_>=url::form_urlencoded::parse(r.url().split_once('?').map(|(_,q)|q).unwrap_or("").as_bytes()).into_owned().collect(); + let update_target=if query.get("view").map(String::as_str)==Some("apk-update") { + let id=query.get("id").context("Missing APK update request")?; + ensure!(id.len()==32&&id.bytes().all(|b|b.is_ascii_hexdigit()),"Invalid APK update request"); + Some(format!("/apk/update?id={id}")) + } else { None }; let response = Response::empty(StatusCode(302)) .with_header(header("Cache-Control", "no-store")) .with_header(header( @@ -855,7 +865,9 @@ impl Agent { )) .with_header(header( "Location", - if r.url().ends_with("?view=radial") { + if let Some(target)=update_target.as_deref() { + target + } else if r.url().ends_with("?view=radial") { "/system/radial" } else if r.url().ends_with("?view=radial-progress") { "/system/radial/progress" @@ -1247,6 +1259,7 @@ impl Agent { || path == "/cast/screenshot" || path.starts_with("/cast/window/") || path == "/apk/permission" + || path == "/apk/update" || path == "/manager" || path == "/notifications" || path == "/launcher" @@ -1649,6 +1662,45 @@ impl Agent { } "apk.cleanup.list" => crate::apk::cleanup_list(&crate::steam::home()?), "apk.storage.status" => crate::apk::storage_status(&crate::steam::home()?), + "apk.updates.window.ready" => { + let id=p["id"].as_str().context("Missing APK update request")?; + crate::apk::updates::presented(&crate::steam::home()?,id)?; + Ok(json!(true)) + }, + "apk.updates.window.close" => { + let id=p["id"].as_str().context("Missing APK update request")?; + let home=crate::steam::home()?; + if crate::apk::updates::list(&home)?.as_array().is_some_and(|requests|requests.iter().any(|r|r["id"]==id)) { + crate::apk::updates::presented(&home,id)?; + } + self.apk_updates.lock().unwrap().close(id); + push(&self.commands,json!({"kind":"apk.update.close","id":id})); + Ok(json!(true)) + }, + "apk.updates.list" => crate::apk::updates::list(&crate::steam::home()?), + "apk.updates.review" => { + let home=crate::steam::home()?; + let id=p["id"].as_str().context("Missing APK update request")?.to_owned(); + self.jobs.task_progress("apk:inspect", move |cancel, _| crate::apk::updates::review_request(&home,&id,&cancel)) + }, + "apk.updates.dismiss" => { + let id=p["id"].as_str().context("Missing APK update request")?; + let result=crate::apk::updates::dismiss(&crate::steam::home()?,id)?; + self.core("notification.remove",json!({"plugin":"","id":format!("apk.update.{id}")}))?; + Ok(result) + }, + "notification.action" if p["plugin"]=="" && p["id"].as_str().is_some_and(|id|id.starts_with("apk.update.")) => { + let notices=self.core("status",json!({}))?; + ensure!(notices["notifications"].as_array().into_iter().flatten().chain(notices["inbox"].as_array().into_iter().flatten()).any(|n|n["plugin"]==""&&n["notification"]["id"]==p["id"]&&n["createdAt"]==p["createdAt"]),"APK update notification has expired"); + let id=p["id"].as_str().unwrap().strip_prefix("apk.update.").unwrap(); + match p["action"].as_str() { + Some("review") => {self.apk_updates.lock().unwrap().reopen(id)?;push(&self.commands,json!({"kind":"apk.update.open","id":id}));}, + Some("dismiss") => {crate::apk::updates::dismiss(&crate::steam::home()?,id)?;}, + _=>anyhow::bail!("Unknown APK update action"), + } + self.core("notification.remove",json!({"plugin":"","id":p["id"]}))?; + Ok(json!(true)) + }, "apk.permission.pending" => Ok(json!(crate::apk::permissions::pending(&crate::steam::home()?)?)), "apk.permission.frame" => crate::apk::permissions::frame( &crate::steam::home()?, p["id"].as_str().context("Missing permission request")? @@ -2009,7 +2061,7 @@ impl Agent { view.len() <= 160 && (matches!( view, - "menu" | "launcher" | "framely.manager" | "notifications" | "framely.framing" + "menu" | "launcher" | "framely.manager" | "notifications" | "framely.framing" | "framely.apk.update" ) || view.starts_with("framely.window.") || view.starts_with("framely.cast.")), "Invalid haptic view" ); @@ -2563,6 +2615,7 @@ pub fn serve( web_key: web_key.clone(), network_key: Mutex::new(random_key()), network_status: Mutex::new(json!({"enabled":false})), + apk_updates: Mutex::default(), frame_access: Mutex::default(), native_key: native_key.clone(), commands: Arc::default(), @@ -2581,6 +2634,44 @@ pub fn serve( } cast_agent.maintain_cast();std::thread::sleep(Duration::from_millis(100)); }}); + let update_agent=agent.clone(); + std::thread::spawn(move || { + let mut announced=std::collections::BTreeSet::new(); + loop { + if let Ok(home)=crate::steam::home() { + match crate::apk::updates::poll(&home) { + Ok(requests)=>{ + let current:std::collections::BTreeSet<_>=requests.iter().map(|r|r.id.clone()).collect(); + for id in announced.difference(¤t) { + let _=update_agent.core("notification.remove",json!({"plugin":"","id":format!("apk.update.{id}")})); + } + announced.retain(|id|current.contains(id)); + let (open,failed)={let mut windows=update_agent.apk_updates.lock().unwrap();(windows.next(&requests),windows.take_failure())}; + if let Some(id)=open { + eprintln!("APK update request={id} confirmation dispatched"); + push(&update_agent.commands,json!({"kind":"apk.update.open","id":id})); + } + if let Some(id)=failed { + eprintln!("APK update request={id} confirmation receipt timed out after three attempts; retained for manual review"); + let _=update_agent.core("apk.update.notification",json!({"notification":{"id":format!("apk.update.{id}"),"title":"无法显示应用更新确认","body":"已重试三次,仍未收到确认窗口的展示回执。请查看待更新项目,或从应用重新发起更新。","durationMs":15000,"inbox":true,"actions":[{"id":"review","label":"查看更新"},{"id":"dismiss","label":"忽略"}]}})); + } + for r in requests { + if announced.contains(&r.id) {continue;} + if r.error.is_none() {announced.insert(r.id);continue;} + let (title,body,actions)=if let Some(error)=r.error { + ("应用更新提取失败".to_owned(),error.chars().take(1000).collect::(),json!([{"id":"dismiss","label":"忽略"}])) + } else { + (format!("{} 有应用更新",r.name.chars().take(60).collect::()),format!("{} → {},请审核后更新。",r.previous,r.version),json!([{"id":"review","label":"查看更新"},{"id":"dismiss","label":"忽略"}])) + }; + if update_agent.core("apk.update.notification",json!({"notification":{"id":format!("apk.update.{}",r.id),"title":title,"body":body,"durationMs":15000,"inbox":true,"actions":actions}})).is_ok() {announced.insert(r.id);} + } + }, + Err(error)=>eprintln!("APK update queue check failed: {error:#}"), + } + } + std::thread::sleep(Duration::from_secs(3)); + } + }); let permission_agent = agent.clone(); std::thread::spawn(move || { let mut shown = String::new(); @@ -3121,6 +3212,7 @@ mod tests { web_key: random_key(), network_key: Mutex::new(random_key()), network_status: Mutex::new(json!({"enabled":false})), + apk_updates: Mutex::default(), frame_access: Mutex::default(), native_key: random_key(), commands: Arc::default(), @@ -3146,6 +3238,27 @@ mod tests { }) } #[test] + fn apk_update_boot_authenticates_before_opening_pending_confirmation() { + let root=tempfile::tempdir().unwrap(); + let server=Server::http("127.0.0.1:0").unwrap(); + let agent=agent(&server,root.path()); + let stop=Arc::new(AtomicBool::new(false)); + let worker=http_server(server,agent.clone(),stop.clone()); + let client=ureq::AgentBuilder::new().redirects(0).timeout(Duration::from_secs(3)).build(); + let id="0123456789abcdef0123456789abcdef"; + let response=client.get(&format!("{}/boot/{}?view=apk-update&id={id}",agent.origin,agent.web_key)).call().unwrap(); + assert_eq!(response.status(),302); + assert_eq!(response.header("Location"),Some(format!("/apk/update?id={id}").as_str())); + let cookie=response.header("Set-Cookie").unwrap().split(';').next().unwrap(); + let page=client.get(&format!("{}/apk/update?id={id}",agent.origin)).set("Cookie",cookie).call().unwrap(); + assert_eq!(page.status(),200); + assert!(page.into_string().unwrap().contains("/assets/app.js")); + for invalid in ["bad","%2F%2Fevil.example","%0d%0aLocation%3Aevil"] { + assert!(client.get(&format!("{}/boot/{}?view=apk-update&id={invalid}",agent.origin,agent.web_key)).call().is_err()); + } + stop.store(true,Ordering::Relaxed);worker.join().unwrap(); + } + #[test] fn framing_apply_restarts_live_stream_and_closes_even_when_restart_fails() { use std::os::unix::fs::PermissionsExt; for running in [false,true] { diff --git a/tests/apk_update_window_fixture.tsx b/tests/apk_update_window_fixture.tsx new file mode 100644 index 0000000..60912f6 --- /dev/null +++ b/tests/apk_update_window_fixture.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {ApkUpdate} from '../ui/src/apk-update'; +import {configureLanguage} from '../ui/src/i18n'; +import '../ui/src/style.css'; +const id='0123456789abcdef0123456789abcdef'; +const metadata={package:'com.example.game',name:'Update test game',version:'1.0',versionCode:1,minSdk:24,activities:['com.example.game.Main'],declaredActivities:['com.example.game.Main'],abis:['arm64-v8a'],vr:false}; +const app={id:'test/com.example.game',context:'test',metadata,installed:true,running:false,managed:true,stateKnown:true,size:100}; +const request={id,app:app.id,name:metadata.name,previous:'1.0',version:'2.0',bytes:200}; +let language='zh-CN'; +let pending=true,fail=false,installs=0,closes=0,reviews=0,jobCounter=0,receipts=0; +const jobs=new Map(); +window.fetch=async(_url,init)=>{ + const {method,params:p}=JSON.parse(String(init?.body));let result:any=true; + if(method==='status')result={database:{language}}; + else if(method==='language.list')result={packs:[]}; + else if(method==='apk.updates.list')result=pending?[request]:[]; + else if(method==='apk.q2f.status')result={id:'test',bytes:100,cached:true}; + else if(method==='apk.review.drop')result=true; + else if(method==='apk.updates.window.ready'){if(p.id!==id||!document.querySelector('.apk-install-review'))throw Error('Receipt preceded rendered confirmation');receipts++;} + else if(method==='apk.updates.window.close'){if(p.id!==id)throw Error('Wrong close identity');closes++;} + else if(method==='job.status')result=jobs.get(p.job); + else if(method==='apk.list.start'||method==='apk.updates.review'||method==='apk.operation.start'){ + let value:any; + if(method==='apk.list.start')value={apps:[app],containers:[{id:'test',name:'test',managed:true,running:false}],roots:[],warnings:[],available:true}; + if(method==='apk.updates.review'){reviews++;if(p.id!==id)throw Error('Wrong review identity');value={ticket:'review-'+reviews,metadata:{...metadata,version:'2.0',versionCode:2},bytes:200};} + if(method==='apk.operation.start'){ + if(p.operation!=='install'||p.updateRequest!==id||p.app!==app.id||p.context!=='test'||!p.approve)throw Error('Confirmation lost normal installation parameters'); + installs++;if(!fail){pending=false;value={backup:'preserved-test-backup'};} + } + const job='job-'+jobCounter++;jobs.set(job,{id:job,kind:'apk',phase:method==='apk.operation.start'&&fail?'failed':'done',error:'Fixture signature mismatch',result:value,received:0});result={job}; + } + return new Response(JSON.stringify({result})); +}; +const root=createRoot(document.getElementById('root')!); +const wait=()=>new Promise(r=>setTimeout(r,30)); +async function until(check:()=>unknown){for(let i=0;i<250;i++){if(check())return;await wait();}throw Error('Timeout: '+document.body.innerText);} +const button=(label:string)=>Array.from(document.querySelectorAll('button')).find(b=>b.textContent?.trim()===label)!; +(window as any).runInstallReviewChecks=async()=>{ + try{ + await until(()=>!!button('确认更新')); + if(document.documentElement.lang!=='zh-CN')throw Error('Saved Chinese language was ignored'); + await until(()=>receipts===1); + if(installs!==0||reviews!==1)throw Error('Update installed without explicit confirmation'); + if(document.querySelector('.apk-tabs')||document.querySelector('.list-heading'))throw Error('Management panel leaked into update confirmation'); + if(!document.body.textContent?.includes('1.0 → 2.0'))throw Error('Version comparison missing'); + document.dispatchEvent(new MouseEvent('mousemove',{clientX:150,clientY:180,bubbles:true})); + await until(()=>document.querySelector('.apk-update-pointer')); + const cursor=document.querySelector('.apk-update-pointer')!; + const bounds=cursor.getBoundingClientRect(); + if(Math.abs(bounds.x+bounds.width/2-150)>1||Math.abs(bounds.y+bounds.height/2-180)>1)throw Error('Laser marker does not match hit coordinates'); + if(getComputedStyle(cursor).pointerEvents!=='none'||Number(getComputedStyle(cursor).zIndex)<10000)throw Error('Laser marker obscures input or sits behind confirmation'); + if(document.elementFromPoint(150,180)===cursor)throw Error('Marker intercepts clicks'); + document.dispatchEvent(new MouseEvent('mouseleave')); + await until(()=>!document.querySelector('.apk-update-pointer')); + console.log('FRAMELY_PREVIEW_INSTALL_APP_UPDATE'); + button('取消').click();await until(()=>closes===1); + if(!pending||installs)throw Error('Cancel deleted or installed the queued update'); + language='en-US';root.render(); + await until(()=>reviews===2&&!!button('Confirm update')); + fail=true;button('Confirm update').click(); + await until(()=>document.body.textContent?.includes('Fixture signature mismatch')); + if(!pending||closes!==1)throw Error('Failed installation lost queued APK or closed confirmation'); + await until(()=>!!button('Confirm update')&&!button('Confirm update').disabled); + fail=false;button('Confirm update').click(); + await until(()=>!pending&&!!button('Close')&&!document.querySelector('.apk-install-review')); + if(installs!==2)throw Error('Unexpected install submission count'); + button('Close').click();await until(()=>closes===2); + console.log('FRAMELY_BRIDGE_PASS'); + }catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error);} +}; +configureLanguage('en-US',[]);document.documentElement.classList.add('apk-update-view');root.render();console.log('FRAMELY_VIEW_READY'); diff --git a/tests/browser_probe.cpp b/tests/browser_probe.cpp index eca644f..5e53c5a 100644 --- a/tests/browser_probe.cpp +++ b/tests/browser_probe.cpp @@ -51,5 +51,5 @@ if(s=="FRAMELY_DPI_CLICK_REQUEST"){auto* host=b->get_host(b);cef_mouse_event_t m auto end=std::chrono::steady_clock::now()+std::chrono::seconds(45);bool injected=false; while(std::chrono::steady_clock::now()send_mouse_move_event(host,&mouse,0);if(now-test_pointer.pressed>=std::chrono::milliseconds(press_duration)){mouse.modifiers=0;host->send_mouse_click_event(host,&mouse,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;}}if(ready&&!injected){auto* f=browser->get_main_frame(browser);cef_string_t code{},source{};str(code,install_review_mode?"window.runInstallReviewChecks()":notification_mode?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));for(let i=0;i<100&&!document.querySelector('.toast');i++)await wait(30);if(innerWidth!==600||innerHeight!==360||devicePixelRatio!==2)throw Error('notification dimensions');const toast=document.querySelector('.toast');if(!toast?.textContent.includes('Toast integration'))throw Error('notification missing');if(toast.getBoundingClientRect().height>240)throw Error('notification too large');if(toast.querySelectorAll('.toast-actions button').length!==3)throw Error('actions missing');toast.querySelector('[aria-label="确认"]').click();await wait(150);document.querySelector('[aria-label="关闭通知"]').click();await wait(150);const status=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method:'status',params:{}})}).then(r=>r.json());if(status.result.notifications.length)throw Error('notification not withdrawn');console.log('FRAMELY_BRIDGE_PASS');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_REMOTE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));await wait(300);const button=document.createElement('button');button.style.cssText='position:fixed;left:20px;top:20px;width:100px;height:50px;z-index:9999';button.onclick=()=>console.log('FRAMELY_DPI_CLICK_PASS');document.body.append(button);console.log('FRAMELY_DPI_CLICK_REQUEST');await wait(200);button.remove();for(let i=0;i<100;i++){const plugin=document.querySelector('.plugin-main');if(plugin){plugin.click();return;}await wait(50);}throw Error('remote plugin list timeout');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_STORE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));const until=async find=>{for(let i=0;i<160;i++){const value=find();if(value)return value;await wait(50);}throw Error("store timeout "+document.body.innerText);};await wait(300);if(innerWidth!==1440||innerHeight!==810||devicePixelRatio!==4)throw Error("manager layout or DPI mismatch");[...document.querySelectorAll("nav button")].find(b=>b.textContent==="插件源").click();await wait(200);if(!document.body.textContent.includes("Subscription fixture"))throw Error("subscription settings missing");const autoRefresh=[...document.querySelectorAll("button")].find(b=>b.textContent==="关闭自动刷新");if(!autoRefresh)throw Error("subscription automatic refresh missing");autoRefresh.click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="开启自动刷新"));[...document.querySelectorAll("nav button")].find(b=>b.textContent==="关于").click();await wait(150);const updates=document.querySelector(".system-updates");if(!updates||updates.querySelector("input:not([type=checkbox]),textarea"))throw Error("release configuration leaked into user settings");if(!updates.querySelector(".automatic-update-settings input[type=checkbox]")?.disabled)throw Error("automatic check controls enabled without source");if(!updates.textContent.includes("尚未配置更新服务"))throw Error("missing unconfigured update service message");const check=[...updates.querySelectorAll("button")].find(b=>b.textContent==="检查更新");if(!check?.disabled)throw Error("unconfigured check enabled");[...document.querySelectorAll("button")].find(b=>b.textContent==="查看协议与隐私声明").click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议"));document.querySelector('.language-picker button').click();const english=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='English'));english.click();await until(()=>document.querySelector('h1')?.textContent==='Before using Framely');document.querySelector('.language-picker button').click();const chinese=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='简体中文'));chinese.click();await until(()=>[...document.querySelectorAll('button')].find(b=>b.textContent==='拒绝协议'));[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议").click();await until(()=>document.body.textContent.includes("拒绝协议将停用全部插件"));[...document.querySelectorAll("button")].find(b=>b.textContent==="取消").click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="返回关于"));[...document.querySelectorAll("button")].find(b=>b.textContent==="返回关于").click();await until(()=>document.querySelector(".system-updates"));[...document.querySelectorAll("nav button")].find(b=>b.textContent.includes("插件库")).click();const card=await until(()=>document.querySelector(".store-card"));if(!card.textContent.includes("可更新"))throw Error("update not detected");if(card.querySelector(".plugin-avatar").getBoundingClientRect().width<64)throw Error("store icon not enlarged");if(document.querySelectorAll(".store-card").length!==1)throw Error("versions duplicated plugin cards");document.querySelector(".framely-select button").click();const tag=await until(()=>[...document.querySelectorAll("[role=option]")].find(b=>b.textContent==="工具"));if(!tag)throw Error("tag missing");tag.click();await wait(100);if(document.querySelector(".store-card .run-user-badge")?.textContent!=="root")throw Error("store run user missing");document.querySelector(".store-card").click();await wait(100);if(!document.body.textContent.includes("Store integration details"))throw Error("detail missing");if(!document.querySelector(".store-detail .run-user-notice.is-root"))throw Error("store root warning missing");console.log("FRAMELY_PREVIEW_STORE");document.querySelector(".detail-heading button").click();const approve=await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="确认安装"));if(document.querySelector(".modal h2")?.textContent!=="更新 Bridge")throw Error("manual install update title missing");if(document.querySelector(".install-review .install-account")?.textContent!=="steamos")throw Error("install must use package identity");if(document.body.textContent.includes("权限变化"))throw Error("unexpected privilege change");approve.click();await until(()=>!document.querySelector(".modal"));const status=await fetch("/api",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({method:"status",params:{}})}).then(r=>r.json());if(status.result.database.plugins["test.bridge"].manifest.version!=="2.0.0")throw Error("install not persisted");const chooseVersion=async v=>{document.querySelector('.store-detail .framely-select button').click();const option=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent.split(' · ')[0]===v));option.click();await wait(100);if(document.querySelector('.store-detail .install-account')?.textContent!==(v==='2.0.0'?'root':'steamos'))throw Error('selected version user incorrect');document.querySelector('.detail-heading button').click();const approveVersion=await until(()=>[...document.querySelectorAll('button')].find(b=>b.textContent==='确认安装'));approveVersion.click();await until(()=>!document.querySelector('.modal'));const result=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method:'status',params:{}})}).then(r=>r.json());if(result.result.database.plugins['test.bridge'].manifest.version!==v)throw Error('selected version not installed '+v);};await chooseVersion('1');await chooseVersion('2.0.0');console.log('FRAMELY_VERSION_SELECTION_PASS');[...document.querySelectorAll("nav button")].find(b=>b.textContent==="插件源").click();const removeSub=await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="删除订阅"));removeSub.click();const confirm=await until(()=>[...document.querySelectorAll(".modal button")].find(b=>b.textContent==="确认"));confirm.click();await until(()=>!document.body.textContent.includes("Subscription fixture"));console.log("FRAMELY_BRIDGE_PASS");})().catch(e=>console.error("FRAMELY_BRIDGE_FAIL "+e)))JS":R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));await wait(300);if(innerWidth!==600||innerHeight!==840||devicePixelRatio!==2)throw Error('quick layout or DPI changed');const dpiButton=document.createElement('button');dpiButton.style.cssText='position:fixed;left:20px;top:20px;width:100px;height:50px;z-index:9999';dpiButton.textContent='DPI test';let clicked=false;dpiButton.onclick=()=>{clicked=true;console.log('FRAMELY_DPI_CLICK_PASS');};document.body.append(dpiButton);console.log('FRAMELY_DPI_CLICK_REQUEST');await wait(200);dpiButton.remove();if(!clicked)throw Error('logical pointer coordinates failed');const originalFetch=window.fetch;let pulses=0;window.fetch=function(url,init){if(init?.body&&JSON.parse(init.body).method==='host.haptic')pulses++;return originalFetch.call(this,url,init);};const fixture=document.createElement('div');fixture.innerHTML='';document.body.append(fixture);const children=fixture.querySelectorAll('span'),buttons=fixture.querySelectorAll('button');const over=e=>e.dispatchEvent(new PointerEvent('pointerover',{bubbles:true}));const leave=(e,target)=>e.dispatchEvent(new PointerEvent('pointerout',{bubbles:true,relatedTarget:target}));over(children[0]);leave(children[0],children[1]);over(children[1]);leave(children[1],children[2]);over(children[2]);leave(children[2],buttons[2]);over(buttons[2]);await wait(100);fixture.remove();window.fetch=originalFetch;if(pulses!==2)throw Error('hover count='+pulses);console.log('FRAMELY_HOVER_PASS');console.log('FRAMELY_PREVIEW_FAVORITES');[...document.querySelectorAll('nav button')].find(b=>b.textContent==='设置').click();await wait(200);console.log('FRAMELY_PREVIEW_SETTINGS');if(!document.querySelector('.quick-settings').firstElementChild.classList.contains('setting-entry'))throw Error('manager entry is below language settings');document.querySelector('.setting-entry').click();await wait(100);[...document.querySelectorAll('nav button')].find(b=>b.textContent==='已安装').click();await wait(200);console.log('FRAMELY_PREVIEW_INSTALLED');for(let i=0;i<100;i++){await wait(50);const b=document.querySelector('.plugin-main');if(b){b.click();return;}}console.error('FRAMELY_BRIDGE_FAIL manager plugin list timeout');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS");f->execute_java_script(f,&code,&source,1);cef_string_utf16_clear(&code);f->base.release(&f->base);injected=true;}std::this_thread::sleep_for(std::chrono::milliseconds(5));} if(!passed&&!failed){auto* f=browser->get_main_frame(browser);cef_string_t diagnostic{},source{};str(diagnostic,"console.error('FRAMELY_TIMEOUT '+location.pathname.replace(/\\/boot\\/[^/]+/,'/boot/token')+' '+document.body.innerText.slice(0,400))");f->execute_java_script(f,&diagnostic,&source,1);cef_string_utf16_clear(&diagnostic);f->base.release(&f->base);for(int i=0;i<40;i++){cef_do_message_loop_work();std::this_thread::sleep_for(std::chrono::milliseconds(5));}snapshot("FAILURE");} - host->close_browser(host,1);for(int i=0;i<400&&!closed;i++){cef_do_message_loop_work();std::this_thread::sleep_for(std::chrono::milliseconds(5));}bool ok=passed&&!failed&&paints>0&&closed&&scaled_paint&&(notification_mode||rounded_paint)&&(notification_mode||install_review_mode||std::getenv("FRAMELY_STORE_TEST")||dpi_click);std::cout<<"BROWSER_RESULT pass="<base.release(&host->base);browser->base.release(&browser->base);cef_shutdown();return ok?0:5; + host->close_browser(host,1);for(int i=0;i<400&&!closed;i++){cef_do_message_loop_work();std::this_thread::sleep_for(std::chrono::milliseconds(5));}bool ok=passed&&!failed&&paints>0&&closed&&scaled_paint&&(notification_mode||std::getenv("FRAMELY_APK_UPDATE_TEST")||rounded_paint)&&(notification_mode||install_review_mode||std::getenv("FRAMELY_STORE_TEST")||dpi_click);std::cout<<"BROWSER_RESULT pass="<base.release(&host->base);browser->base.release(&browser->base);cef_shutdown();return ok?0:5; } diff --git a/tools/build-android-updates.sh b/tools/build-android-updates.sh new file mode 100644 index 0000000..5b5547a --- /dev/null +++ b/tools/build-android-updates.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Reproduce the embedded Android 11 APK-update helper. No runtime JDK needed. +set -euo pipefail +base=$(cd -- "$(dirname -- "$0")/.." && pwd) +work="$base/target/android-updates-build" +mkdir -p "$work/classes" "$work/dex" +fetch() { + local url=$1 file=$2 digest=$3 + if [[ ! -f $file ]] || ! printf '%s %s\n' "$digest" "$file" | sha256sum -c - >/dev/null 2>&1; then + curl -fSL --retry 3 "$url" -o "$file" + fi + printf '%s %s\n' "$digest" "$file" | sha256sum -c - +} +fetch https://dl.google.com/android/repository/platform-30_r03.zip "$work/platform.zip" f3f5b75744dbf6ee6ed3e8174a71e513bfee502d0bc3463ea97e517bff68d84e +fetch https://dl.google.com/dl/android/maven2/com/android/tools/r8/8.3.37/r8-8.3.37.jar "$work/r8.jar" 59753e70a74f918389cc87f1b7d66b5c0862932559167425708ded159e3de439 +unzip -p "$work/platform.zip" android-11/android.jar > "$work/android.jar" +javac --release 8 -Xlint:-options -cp "$work/android.jar" -d "$work/classes" "$base/native/android/FramelyUpdates.java" +java -cp "$work/r8.jar" com.android.tools.r8.D8 --release --min-api 30 --lib "$work/android.jar" --output "$work/dex" "$work"/classes/FramelyUpdates*.class +cp "$work/dex/classes.dex" "$base/native/android/framely-updates.dex" diff --git a/ui/locales/en-US.json b/ui/locales/en-US.json index c95d930..e770221 100644 --- a/ui/locales/en-US.json +++ b/ui/locales/en-US.json @@ -1398,6 +1398,15 @@ "工具包已缓存": "Converter cached", "首次使用需下载 {0} 工具包": "First use downloads the {0} converter", "Quest 转换": "Quest conversion", - "要使用新版转换运行包,请重新选择原始 Quest APK 安装。": "To use an updated converter, select the original Quest APK and install it again." + "要使用新版转换运行包,请重新选择原始 Quest APK 安装。": "To use an updated converter, select the original Quest APK and install it again.", + "应用内更新": "In-app updates", + "应用下载的更新已保留。审核确认后,由 Framely 备份并安装。": "Downloaded updates are retained. Review and confirm to back up and install with Framely.", + "更新提取失败": "Update extraction failed", + "审核更新": "Review update", + "请刷新应用列表后重试。": "Refresh the application list and try again.", + "更新请求已结束,请从应用重试。": "The update request has ended. Retry from the application.", + "更新完成": "Update complete", + "无法打开更新": "Cannot open update", + "正在读取更新": "Reading update" } } diff --git a/ui/locales/zh-CN.json b/ui/locales/zh-CN.json index accebe5..5f450f1 100644 --- a/ui/locales/zh-CN.json +++ b/ui/locales/zh-CN.json @@ -1398,6 +1398,15 @@ "工具包已缓存": "工具包已缓存", "首次使用需下载 {0} 工具包": "首次使用需下载 {0} 工具包", "Quest 转换": "Quest 转换", - "要使用新版转换运行包,请重新选择原始 Quest APK 安装。": "要使用新版转换运行包,请重新选择原始 Quest APK 安装。" + "要使用新版转换运行包,请重新选择原始 Quest APK 安装。": "要使用新版转换运行包,请重新选择原始 Quest APK 安装。", + "应用内更新": "应用内更新", + "应用下载的更新已保留。审核确认后,由 Framely 备份并安装。": "应用下载的更新已保留。审核确认后,由 Framely 备份并安装。", + "更新提取失败": "更新提取失败", + "审核更新": "审核更新", + "请刷新应用列表后重试。": "请刷新应用列表后重试。", + "更新请求已结束,请从应用重试。": "更新请求已结束,请从应用重试。", + "更新完成": "更新完成", + "无法打开更新": "无法打开更新", + "正在读取更新": "正在读取更新" } } diff --git a/ui/src/api.ts b/ui/src/api.ts index 9a97146..9d8573b 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -2,7 +2,7 @@ import {t} from './i18n'; import {installKeyboard} from '../../sdk/src/keyboard'; import {installHoverFeedback} from '../../sdk/src/hover'; export async function api(method:string,params:unknown={},signal?:AbortSignal):Promise{const r=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method,params}),signal});if(r.status===401&&r.headers.get('X-Framely-Reauthenticate')==='1')location.replace('/manager');if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new Error(t(v.error));return v.result;} -export function viewKey(){const p=location.pathname.split('/');return p[1]==='cast'&&p[2]==='framing'?'framely.framing':p[1]==='cast'&&p[2]==='screenshot'?'framely.screenshot':p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='cast'&&p[2]==='window'?`framely.cast.${p[3]}`:p[1]==='apk'&&p[2]==='permission'?'framely.apk.permission':p[1]==='notifications'?'notifications':p[1]==='launcher'?'launcher':'menu';} +export function viewKey(){const p=location.pathname.split('/');return p[1]==='cast'&&p[2]==='framing'?'framely.framing':p[1]==='cast'&&p[2]==='screenshot'?'framely.screenshot':p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='cast'&&p[2]==='window'?`framely.cast.${p[3]}`:p[1]==='apk'&&p[2]==='permission'?'framely.apk.permission':p[1]==='apk'&&p[2]==='update'?'framely.apk.update':p[1]==='notifications'?'notifications':p[1]==='launcher'?'launcher':'menu';} export const nativeKeyboardHost=['localhost','127.0.0.1','[::1]'].includes(location.hostname); installKeyboard(params=>nativeKeyboardHost?api('host.keyboard',{view:viewKey(),...params}):Promise.resolve(),(method,params)=>nativeKeyboardHost?api('plugin.call',{plugin:'tooru.frame-keyboard',method,params:{...params,view:viewKey()}}):Promise.resolve({ok:false}),{requestOnFocus:viewKey()!=='launcher'}); diff --git a/ui/src/apk-manager.css b/ui/src/apk-manager.css index 7038327..dfedb22 100644 --- a/ui/src/apk-manager.css +++ b/ui/src/apk-manager.css @@ -26,3 +26,8 @@ .apk-steam-names{width:100%;margin-bottom:24px}.apk-steam-names>p{color:var(--muted);line-height:1.55}.apk-steam-name{display:flex;align-items:center;gap:24px;padding:18px 0;border-bottom:1px solid var(--line);width:100%}.apk-steam-name .apk-copy code{display:block;margin-top:6px;color:var(--muted)}.apk-steam-name-controls{display:flex;align-items:center;gap:12px;flex:0 1 50%;min-width:0}.apk-steam-name-controls input{flex:1;min-width:0;margin:0}.apk-steam-name-controls button{flex:none;margin:0}@media(max-width:700px){.apk-steam-name{flex-wrap:wrap;gap:12px}.apk-steam-name-controls{flex-basis:100%}} .apk-meta dd small{display:block;margin-top:6px;color:var(--muted);line-height:1.5} + +.apk-update-view body{padding:0;background:transparent}.apk-update-view .apk-modal-backdrop{background:transparent}.apk-update-view .apk-dialog{inset:0;transform:none;margin:0;max-width:none;max-height:100%;width:100%;border:0;border-radius:0;box-shadow:none;padding:28px;box-sizing:border-box}.apk-update-status{min-height:100vh;padding:32px;display:flex;flex-direction:column;gap:18px;background:var(--panel,#202020);box-sizing:border-box}.apk-update-status footer{margin-top:auto;display:flex;justify-content:flex-end;gap:12px} + +.apk-update-view,.apk-update-view *{cursor:none!important} +.apk-update-pointer{position:fixed;z-index:2147483647;display:block;width:12px;height:12px;box-sizing:border-box;border:1px solid #0009;border-radius:50%;background:#fff;box-shadow:0 0 0 2px #0003;transform:translate(-50%,-50%);pointer-events:none} diff --git a/ui/src/apk-manager.tsx b/ui/src/apk-manager.tsx index 2683ee2..89366f3 100644 --- a/ui/src/apk-manager.tsx +++ b/ui/src/apk-manager.tsx @@ -18,6 +18,7 @@ type App={steamName?:string|null;steamLaunch?:boolean;steamRegistered?:boolean;s type Container={acceptsAdditionalApps?:boolean;id:string;name:string;baked:string;running:boolean;managed:boolean;steam:boolean}; type Data={apps:App[];containers:Container[];warnings:string[];roots:string[];available:boolean;retainSteamEntries?:boolean;gamepadEnabled?:boolean;gamepadSource?:string;gamepadRumble?:boolean;gamepadTriggerThreshold?:number;autoStopContainer?:boolean;stopContainerOnClose?:boolean}; type Cleanup={id:string;kind:string;name:string;bytes:number;sensitive:boolean;app?:string;context?:string;restorable?:boolean;unknown?:boolean}; +type AppUpdate={revision?:number;id:string;app:string;name:string;previous:string;version:string;bytes:number;error?:string}; type Review={ticket:string;metadata:Metadata;bytes:number;q2fVersion?:string}; type Q2fStatus={id:string;bytes:number;cached:boolean}; type Confirm={operation:string;app?:App;container?:Container;backup?:string;items?:string[];title:string;description:string;purge?:boolean;deleteContainer?:boolean}; @@ -32,26 +33,41 @@ function SteamNameSetting({app,busy,onSave}:{app:App;busy:boolean;onSave:(name:s
setName(e.target.value)}/>
; } -export function ApkManager({view:externalView,onViewChange}:{view?:string;onViewChange?:(view:string)=>void}={}){ +export function ApkManager({view:externalView,onViewChange,updateId}:{view?:string;onViewChange?:(view:string)=>void;updateId?:string}={}){ const[data,setData]=useState({apps:[],containers:[],warnings:[],roots:[],available:true}),[loading,setLoading]=useState(true),[localView,setLocalView]=useState('apps'),[filter,setFilter]=useState('installed'),[query,setQuery]=useState(''),[error,setError]=useState(''),[review,setReview]=useState(null),[originalReview,setOriginalReview]=useState(null),[questEnabled,setQuestEnabled]=useState(false),[q2f,setQ2f]=useState(null),[q2fError,setQ2fError]=useState(''),[target,setTarget]=useState(''),[displayMode,setDisplayMode]=useState('flat'),[updating,setUpdating]=useState(null),[details,setDetails]=useState(null),[confirm,setConfirm]=useState(null),[job,setJob]=useState<(Job&{cancellable?:boolean;operation?:string})|null>(null),[busy,setBusy]=useState(false),[cleanup,setCleanup]=useState([]),[selected,setSelected]=useState([]),[log,setLog]=useState<{name:string;text:string}|null>(null),[path,setPath]=useState(''),[contextName,setContextName]=useState(''),[notice,setNotice]=useState(''); + const [appUpdates,setAppUpdates]=useState([]),[updateRequest,setUpdateRequest]=useState(''),[updatesLoaded,setUpdatesLoaded]=useState(false); + const autoReviewed=useRef(''); + async function closeUpdateWindow(){if(updateId)await api('apk.updates.window.close',{id:updateId}).catch(e=>setError(apkError(e)));} + async function refreshAppUpdates(){try{const value=await api('apk.updates.list');if(alive.current){setAppUpdates(value);setUpdatesLoaded(true);}}catch{}} + useEffect(()=>{void refreshAppUpdates();const timer=setInterval(()=>void refreshAppUpdates(),4000);return()=>clearInterval(timer);},[]); + async function reviewAppUpdate(item:AppUpdate){const app=data.apps.find(a=>a.id===item.app);if(!app){setError(t('请刷新应用列表后重试。'));return;}setBusy(true);busyRef.current=true;setError('');try{const result=await runJob('apk.updates.review',{id:item.id},updateJob);if(!alive.current)return;setOriginalReview(null);setReview(result.result);setUpdating(app);setTarget(app.context);setUpdateRequest(item.id);setQuestEnabled(!!app.q2fVersion);setDisplayMode((app.showWindow??!app.metadata.vr)?'flat':'vr');}catch(e){setError(apkError(e));}finally{setJob(null);setBusy(false);busyRef.current=false;}} + useEffect(()=>{if(!updateId||loading||!updatesLoaded||busy||autoReviewed.current===updateId)return;autoReviewed.current=updateId;const item=appUpdates.find(r=>r.id===updateId);if(!item){setError(t('更新请求已结束,请从应用重试。'));return;}void reviewAppUpdate(item);},[updateId,loading,updatesLoaded,busy,appUpdates]); + useEffect(()=>{ + if(!updateId||!review||busy)return; + // A receipt follows the rendered confirmation, not just the open command. + let frame=0;let live=true; + frame=requestAnimationFrame(()=>{frame=requestAnimationFrame(()=>{if(live)void api('apk.updates.window.ready',{id:updateId}).catch(e=>{if(alive.current)setError(apkError(e));});});}); + return()=>{live=false;cancelAnimationFrame(frame);}; + },[updateId,review?.ticket,busy,appUpdates.find(r=>r.id===updateId)?.revision]); + async function dismissAppUpdate(id:string){try{await api('apk.updates.dismiss',{id});await refreshAppUpdates();}catch(e){setError(apkError(e));}} const view=externalView??localView;const setView=(value:string)=>{setLocalView(value);onViewChange?.(value);}; useEffect(()=>{if(view==='cleanup')void loadCleanup();},[view]); useEffect(()=>{if(!review||review.q2fVersion){setQ2f(null);setQ2fError('');return;}let active=true;setQ2f(null);setQ2fError('');void api('apk.q2f.status').then(value=>{if(active)setQ2f(value);}).catch(e=>{if(active)setQ2fError(String(e));});return()=>{active=false;};},[review?.ticket]); const input=useRef(null),controller=useRef(null),selection=useRef<{app:string;uninstall:boolean}|null>(null),alive=useRef(true),busyRef=useRef(false); const updateJob=(v:Job)=>{if(alive.current)setJob(v);}; - async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');let saved:{job:string;app:App|null;context:string;review?:Review;displayMode?:string}|null=null;try{saved=JSON.parse(sessionStorage.getItem('framely.apk.q2f.job')||'null');}catch{}const id=active.job||saved?.job;if(!id||!alive.current||busyRef.current)return;ownsBusy=true;busyRef.current=true;setBusy(true);while(alive.current){const value=await api('job.status',{job:id});if(['done','failed','cancelled'].includes(value.phase)){setJob(null);if(saved?.job===id)sessionStorage.removeItem('framely.apk.q2f.job');if(value.phase==='failed'||value.phase==='cancelled'){if(saved?.review){setReview(saved.review);setQuestEnabled(true);setDisplayMode(saved.displayMode??'vr');setUpdating(saved.app);setTarget(saved.context);}setError(apkError(value.phase==='cancelled'?'操作已取消':value.error??'操作失败'));}else if(value.result?.q2fVersion){setReview(value.result);setOriginalReview(saved?.review??null);setQuestEnabled(true);setDisplayMode(saved?.displayMode??'vr');setUpdating(saved?.app??null);setTarget(saved?.context??'');if(saved?.review)await perform('install',{ticket:value.result.ticket,context:saved.context,app:saved.app?.id,showWindow:saved.displayMode==='flat',approve:true},saved.review.ticket);else setNotice(t('转换完成,请确认安装。'));}else setNotice(t('操作完成'));await refresh();break;}setJob(value);await new Promise(resolve=>setTimeout(resolve,300));}}catch(e){if(alive.current)setError(apkError(e));}finally{if(ownsBusy&&alive.current){busyRef.current=false;setBusy(false);}}} + async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');let saved:{job:string;app:App|null;context:string;review?:Review;displayMode?:string;updateRequest?:string}|null=null;try{saved=JSON.parse(sessionStorage.getItem('framely.apk.q2f.job')||'null');}catch{}const id=active.job||saved?.job;if(!id||!alive.current||busyRef.current)return;ownsBusy=true;busyRef.current=true;setBusy(true);while(alive.current){const value=await api('job.status',{job:id});if(['done','failed','cancelled'].includes(value.phase)){setJob(null);if(saved?.job===id)sessionStorage.removeItem('framely.apk.q2f.job');if(value.phase==='failed'||value.phase==='cancelled'){if(saved?.review){setReview(saved.review);setUpdateRequest(saved.updateRequest??'');setQuestEnabled(true);setDisplayMode(saved.displayMode??'vr');setUpdating(saved.app);setTarget(saved.context);}setError(apkError(value.phase==='cancelled'?'操作已取消':value.error??'操作失败'));}else if(value.result?.q2fVersion){setUpdateRequest(saved?.updateRequest??'');setReview(value.result);setOriginalReview(saved?.review??null);setQuestEnabled(true);setDisplayMode(saved?.displayMode??'vr');setUpdating(saved?.app??null);setTarget(saved?.context??'');if(saved?.review)await perform('install',{ticket:value.result.ticket,context:saved.context,app:saved.app?.id,updateRequest:saved.updateRequest||undefined,showWindow:saved.displayMode==='flat',approve:true},saved.review.ticket);else setNotice(t('转换完成,请确认安装。'));}else setNotice(t('操作完成'));await refresh();break;}setJob(value);await new Promise(resolve=>setTimeout(resolve,300));}}catch(e){if(alive.current)setError(apkError(e));}finally{if(ownsBusy&&alive.current){busyRef.current=false;setBusy(false);}}} useEffect(()=>{if(data.retainSteamEntries===false||!data.apps.some(a=>a.installed&&!a.steam&&a.stateKnown!==false&&!a.pending&&(a.metadata.activities.length||a.activity)&&(a.steamLaunch??true)&&!a.steamRegistered))return;const timer=setInterval(()=>{if(!busyRef.current)void refresh();},20000);return()=>clearInterval(timer);},[data.apps,data.retainSteamEntries]); async function refresh(){setLoading(true);try{const r=await runJob('apk.list.start');if(alive.current){setData(r.result);setDetails(old=>old?r.result.apps.find((a:App)=>a.id===old.id)??null:null);}}catch(e){if(alive.current)setError(apkError(e));}finally{if(alive.current)setLoading(false);}} - useEffect(()=>{alive.current=true;void refresh();void resumeOperation();const select=(e:Event)=>{const v=(e as CustomEvent).detail;if(v?.kind==='apk.selection'){selection.current={app:v.app,uninstall:!!v.uninstall};void refresh();}};window.addEventListener('framely.event',select);return()=>{alive.current=false;window.removeEventListener('framely.event',select);controller.current?.abort();};},[]); + useEffect(()=>{alive.current=true;void refresh();if(!updateId)void resumeOperation();const select=(e:Event)=>{const v=(e as CustomEvent).detail;if(v?.kind==='apk.selection'){selection.current={app:v.app,uninstall:!!v.uninstall};void refresh();}};window.addEventListener('framely.event',select);return()=>{alive.current=false;window.removeEventListener('framely.event',select);controller.current?.abort();};},[]); useEffect(()=>{if(selection.current){const a=data.apps.find(a=>a.id===selection.current!.app);if(a){if(a.steam){setError(t('请通过 Steam 管理此应用。'));selection.current=null;return;}if(selection.current.uninstall)askUninstall(a);else setDetails(a);selection.current=null;}}},[data]); useEffect(()=>{const close=(e:Event)=>{if(e instanceof KeyboardEvent&&e.key!=='Escape')return;if(busyRef.current)return;if(confirm)setConfirm(null);else if(log)setLog(null);else if(review)void discard();else setDetails(null);};window.addEventListener('keydown',close);window.addEventListener('framely.back',close);return()=>{window.removeEventListener('keydown',close);window.removeEventListener('framely.back',close);};},[confirm,log,review]); useEffect(()=>{const dialog=document.querySelector('.apk-dialog');if(!dialog)return;const prev=document.activeElement as HTMLElement;const controls=()=>Array.from(dialog.querySelectorAll('button:not(:disabled),input,select,a[href]')).filter(e=>e.getClientRects().length);controls()[0]?.focus();const key=(e:KeyboardEvent)=>{if(e.key!=='Tab')return;const a=controls();if(!a.length)return;if(e.shiftKey&&(document.activeElement===a[0]||!dialog.contains(document.activeElement))){e.preventDefault();a.at(-1)?.focus();}else if(!e.shiftKey&&(document.activeElement===a.at(-1)||!dialog.contains(document.activeElement))){e.preventDefault();a[0].focus();}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);prev?.focus();};},[review,details,confirm,log,job?.id]); - async function discard(){if(review)await api('apk.review.drop',{ticket:review.ticket}).catch(()=>{});if(originalReview)await api('apk.review.drop',{ticket:originalReview.ticket}).catch(()=>{});setReview(null);setOriginalReview(null);setQuestEnabled(false);setUpdating(null);} - async function installReviewed(){if(!review)return;const original=review;if(!questEnabled||review.q2fVersion){await perform('install',{ticket:review.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true});return;}setBusy(true);busyRef.current=true;setError('');setNotice('');try{const started=await api<{job:string}>('apk.q2f.start',{ticket:original.ticket});sessionStorage.setItem('framely.apk.q2f.job',JSON.stringify({job:started.job,app:updating,context:target,review:original,displayMode}));const result=await waitJob(started.job,updateJob);sessionStorage.removeItem('framely.apk.q2f.job');const converted=result.result as Review;setOriginalReview(original);setReview(converted);await perform('install',{ticket:converted.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true},original.ticket);}catch(e){sessionStorage.removeItem('framely.apk.q2f.job');setError(apkError(e));}finally{setJob(null);setBusy(false);busyRef.current=false;}} + async function discard(){setUpdateRequest('');if(review)await api('apk.review.drop',{ticket:review.ticket}).catch(()=>{});if(originalReview)await api('apk.review.drop',{ticket:originalReview.ticket}).catch(()=>{});setReview(null);setOriginalReview(null);setQuestEnabled(false);setUpdating(null);if(updateId)await closeUpdateWindow();} + async function installReviewed(){if(!review)return;const original=review;if(!questEnabled||review.q2fVersion){await perform('install',{ticket:review.ticket,context:target,app:updating?.id,updateRequest:updateRequest||undefined,showWindow:displayMode==='flat',approve:true});return;}setBusy(true);busyRef.current=true;setError('');setNotice('');try{const started=await api<{job:string}>('apk.q2f.start',{ticket:original.ticket});sessionStorage.setItem('framely.apk.q2f.job',JSON.stringify({job:started.job,app:updating,context:target,review:original,displayMode,updateRequest}));const result=await waitJob(started.job,updateJob);sessionStorage.removeItem('framely.apk.q2f.job');const converted=result.result as Review;setOriginalReview(original);setReview(converted);await perform('install',{ticket:converted.ticket,context:target,app:updating?.id,updateRequest:updateRequest||undefined,showWindow:displayMode==='flat',approve:true},original.ticket);}catch(e){sessionStorage.removeItem('framely.apk.q2f.job');setError(apkError(e));}finally{setJob(null);setBusy(false);busyRef.current=false;}} async function revertConversion(){if(!review?.q2fVersion||!originalReview)return;setBusy(true);try{await api('apk.review.drop',{ticket:review.ticket});setReview(originalReview);setOriginalReview(null);setQuestEnabled(false);setDisplayMode((updating?.showWindow??!originalReview.metadata.vr)?'flat':'vr');setError('');}catch(e){setError(apkError(e));}finally{setBusy(false);}} - async function inspect(file:File|string,selectedApp:App|null=updating){if(!(typeof file==='string'?file:file.name).toLowerCase().endsWith('.apk')){setError(t('第一版仅支持单个 APK 文件。'));return;}setBusy(true);busyRef.current=true;setError('');const c=new AbortController();controller.current=c;setJob(typeof file==='string'?{id:'local',kind:'apk',phase:'inspecting',received:0,cancellable:false}:{id:'upload',kind:'apk',phase:'uploading',received:0,total:file.size});try{let result;if(typeof file==='string'){const response=await fetch('/host/files/apk-inspect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[file]}),signal:c.signal});if(!response.ok)throw new Error(t('服务请求失败:{0}',{0:response.status}));const data=await response.json();if(data.error)throw new Error(t(data.error));result=await waitJob(data.result.job,updateJob);}else{const upload=await uploadFile(file,c.signal,percent=>setJob(j=>j?{...j,received:Math.round(file.size*percent/100)}:null),{start:size=>api('apk.upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});result=await runJob('apk.inspect.start',{upload},updateJob);}if(!alive.current){await api('apk.review.drop',{ticket:result.result.ticket}).catch(()=>{});return;}setOriginalReview(null);setQuestEnabled(false);setReview(result.result);setDisplayMode((selectedApp?.showWindow??!result.result.metadata.vr)?'flat':'vr');setTarget(selectedApp?.context??'');setJob(null);}catch(e){setJob(null);setError(apkError(e));}finally{setBusy(false);busyRef.current=false;controller.current=null;}} + async function inspect(file:File|string,selectedApp:App|null=updating){setUpdateRequest('');if(!(typeof file==='string'?file:file.name).toLowerCase().endsWith('.apk')){setError(t('第一版仅支持单个 APK 文件。'));return;}setBusy(true);busyRef.current=true;setError('');const c=new AbortController();controller.current=c;setJob(typeof file==='string'?{id:'local',kind:'apk',phase:'inspecting',received:0,cancellable:false}:{id:'upload',kind:'apk',phase:'uploading',received:0,total:file.size});try{let result;if(typeof file==='string'){const response=await fetch('/host/files/apk-inspect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[file]}),signal:c.signal});if(!response.ok)throw new Error(t('服务请求失败:{0}',{0:response.status}));const data=await response.json();if(data.error)throw new Error(t(data.error));result=await waitJob(data.result.job,updateJob);}else{const upload=await uploadFile(file,c.signal,percent=>setJob(j=>j?{...j,received:Math.round(file.size*percent/100)}:null),{start:size=>api('apk.upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});result=await runJob('apk.inspect.start',{upload},updateJob);}if(!alive.current){await api('apk.review.drop',{ticket:result.result.ticket}).catch(()=>{});return;}setOriginalReview(null);setQuestEnabled(false);setReview(result.result);setDisplayMode((selectedApp?.showWindow??!result.result.metadata.vr)?'flat':'vr');setTarget(selectedApp?.context??'');setJob(null);}catch(e){setJob(null);setError(apkError(e));}finally{setBusy(false);busyRef.current=false;controller.current=null;}} function choose(a?:App){setUpdating(a??null);if(input.current)openLocalFileInput(input.current,path=>{if(alive.current)void inspect(path,a??null);});} - async function perform(operation:string,params:Record={},originalTicket=originalReview?.ticket){setBusy(true);busyRef.current=true;setError('');setNotice('');try{const r=await runJob('apk.operation.start',{operation,...params},updateJob);setJob(null);setConfirm(null);if(operation==='install'){if(originalTicket)await api('apk.review.drop',{ticket:originalTicket}).catch(()=>{});setOriginalReview(null);setQuestEnabled(false);setReview(null);setUpdating(null);setNotice(t('APK 已安装。应用数据和图标排序会保留。'));}else if(operation==='cleanup'){setNotice(t('已释放 {0},失败 {1} 项。',{0:bytes(r.result.freedBytes),1:r.result.failed.length}));if(r.result.failed.length)setError(r.result.failed.map((v:any)=>`${v.id}: ${v.error}`).join('\n'));await loadCleanup();}else if(operation==='restore')setNotice(t('备份已恢复,请启动应用验证存档;恢复前状态已另行备份。'));else setNotice(t('操作完成'));await refresh();}catch(e){setJob(null);setError(apkError(e));await refresh();}finally{setBusy(false);busyRef.current=false;}} + async function perform(operation:string,params:Record={},originalTicket=originalReview?.ticket){setBusy(true);busyRef.current=true;setError('');setNotice('');try{const r=await runJob('apk.operation.start',{operation,...params},updateJob);setJob(null);setConfirm(null);if(operation==='install'){setUpdateRequest('');await refreshAppUpdates();if(originalTicket)await api('apk.review.drop',{ticket:originalTicket}).catch(()=>{});setOriginalReview(null);setQuestEnabled(false);setReview(null);setUpdating(null);setNotice(t('APK 已安装。应用数据和图标排序会保留。'));}else if(operation==='cleanup'){setNotice(t('已释放 {0},失败 {1} 项。',{0:bytes(r.result.freedBytes),1:r.result.failed.length}));if(r.result.failed.length)setError(r.result.failed.map((v:any)=>`${v.id}: ${v.error}`).join('\n'));await loadCleanup();}else if(operation==='restore')setNotice(t('备份已恢复,请启动应用验证存档;恢复前状态已另行备份。'));else setNotice(t('操作完成'));await refresh();}catch(e){setJob(null);setError(apkError(e));await refresh();}finally{setBusy(false);busyRef.current=false;}} function canDeleteContainer(a:App){const members=data.apps.filter(other=>other.context===a.context);const container=data.containers.find(c=>c.id===a.context);return !!container&&!container.steam&&members.length===1&&members[0].id===a.id&&a.stateKnown!==false&&!a.pending;} function askUninstall(a:App){setError('');setConfirm({operation:a.installed?'uninstall':'purge',app:a,title:a.installed?t('卸载应用'):t('清除保留数据'),description:'',purge:!a.installed,deleteContainer:!a.installed&&canDeleteContainer(a)});} async function loadCleanup(){try{const items=await api('apk.cleanup.list');setCleanup(items);setSelected([]);}catch(e){setError(apkError(e));}} @@ -65,16 +81,19 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView const deletesData=!!confirm&&(['purge','clear','cleanup','container.delete'].includes(confirm.operation)||(confirm.operation==='uninstall'&&!!confirm.purge)); const dialog=(children:React.ReactNode,label:string)=>createPortal(
{error&&!review&&!confirm&&

{error}

}{children}
,document.body); return
+ {!updateId&&<>

{t('APK 管理')}

{t('安装和管理 Lepton 侧载应用。')}

{if(e.target.files?.[0])void inspect(e.target.files[0]);}}/> {!data.available&&

{t('未检测到 Lepton,请先通过 Steam 安装。')}

} {notice&&

{notice}

}{error&&
{error}
} {!externalView&&
{[['apps','应用'],['containers','容器'],['cleanup','数据清理'],['settings','设置']].map(([key,label])=>)}
} {view==='settings'&&<>void perform('steam.retention.settings',{enabled})}/>void perform('lifecycle.settings',{autoStopContainer,stopContainerOnClose:data.stopContainerOnClose??true})}/>void perform('lifecycle.settings',{autoStopContainer:!!data.autoStopContainer,stopContainerOnClose})}/>void perform('gamepad.settings',{enabled})}/>
{t('手柄输入来源')}{t('来源切换需关闭容器后重新启动。直通使用 Steam 的第一只有效手柄,保留线性扳机,不补充数字 L2/R2;设备节点重建后需重启容器。桥接支持多手柄切换和数字扳机。')}
setQuery(e.target.value)}/>setQuery(e.target.value)}/>setPath(e.target.value)}/>setContextName(e.target.value)}/>
{data.roots.map(p=>
{p}
)}
} {view==='cleanup'&&<>

{t('数据清理')}

{t('存档、设置与恢复备份默认不选中。未知归属的数据不会自动删除。')}

{cleanup.map(i=>
setSelected(s=>checked?[...s,i.id]:s.filter(v=>v!==i.id))}/>
{i.name}

{t(i.kind==='unknown'?'未知归属的数据':i.kind==='data'?'保留的应用数据':i.kind==='backups'?'恢复备份':(i.kind==='reviews'||i.kind==='uploads')?'过期上传文件':'旧 APK')} · {bytes(i.bytes)}

{i.unknown?{t('无法确认归属,不提供自动删除。')}:i.sensitive&&{t('删除后不可恢复')}}
{i.restorable&&}
)}{!cleanup.length&&

{t('没有可清理的数据。')}

}} {!!data.warnings.length&&
{t('部分容器无法读取')}{data.warnings.map((w,i)=>

{w}

)}
} + } + {updateId&&!review&&!confirm&&!job&&

{t(notice?'更新完成':error?'无法打开更新':'正在读取更新')}

{notice&&

{notice}

}{error&&

{error}

}{!notice&&!error&&}
{error&&}
} {details&&!confirm&&!review&&!log&&!job&&dialog(<>

{details.metadata.name}

{details.metadata.package}
{t('版本')}
{details.metadata.version} ({details.metadata.versionCode})
{t('容器')}
{contextFor(details.context)?.name??details.context}
{t('空间占用')}
{bytes(details.size)}
{details.installed&&details.q2fVersion&&<>
{t('Quest 转换')}
{details.q2fVersion}{t('要使用新版转换运行包,请重新选择原始 Quest APK 安装。')}
}
{details.installed&&details.stateKnown!==false&&!details.steam&&

{t('自定义 Steam 名称')}

{t('分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。')}

void perform('settings',{app:details.id,steamName})}/>
}{details.pending&&<>

{t(details.pending)}

}
{details.stateKnown===false&&contextFor(details.context)?.managed&&!contextFor(details.context)?.steam&&}{details.installed&&details.stateKnown!==false&&!details.steam&&<>}{!details.installed&&details.stateKnown!==false&&!details.steam&&}
{details.installed&&details.stateKnown!==false&&!details.steam&&
{t('高级设置')}void perform('steam.settings',{app:details.id,enabled})}/>{data.retainSteamEntries!==false&&(details.steamLaunch??true)&&!details.steamRegistered&&

{t('等待 Steam 注册,Steam 就绪后会自动重试。')}

}{details.steamRegistrationError&&

{details.steamRegistrationError}

}

{t('默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。')}

void perform('settings',{app:details.id,showWindow:checked?null:true})}/>{details.showWindow!=null&&void perform('settings',{app:details.id,showWindow:checked})}/>}

{t('窗口设置将在下次打开应用时应用;必要时自动重启容器。')}

void perform('settings',{app:details.id,framebufferCompatibility:enabled})}/>
}
,t('管理应用'))} {review&&!confirm&&!job&&dialog(installContainer&&containerAsk(installContainer,'container.stop')} onCancel={()=>void discard()} onQuestChange={checked=>{if(!checked&&review.q2fVersion)void revertConversion();else setQuestEnabled(checked);}} onConfirm={()=>void installReviewed()}/>,t('安装 APK'))} {confirm?.app&&!job&&['uninstall','purge'].includes(confirm.operation)&&dialog(setConfirm(c=>c?.app?{...c,purge,deleteContainer:purge&&canDeleteContainer(c.app)}:c)} onDeleteContainer={deleteContainer=>setConfirm(c=>c?{...c,deleteContainer}:c)} onCancel={()=>setConfirm(null)} onConfirm={()=>void perform(confirm.operation,{app:confirm.app!.id,purge:confirm.purge,deleteContainer:!!confirm.deleteContainer&&!!confirm.purge&&canDeleteContainer(confirm.app!),approve:true})}/>,confirm.title)} diff --git a/ui/src/apk-update.tsx b/ui/src/apk-update.tsx new file mode 100644 index 0000000..c233db5 --- /dev/null +++ b/ui/src/apk-update.tsx @@ -0,0 +1,29 @@ +import React,{useEffect,useState} from 'react'; +import {createPortal} from 'react-dom'; +import {api} from './api'; +import {ApkManager} from './apk-manager'; +import {configureLanguage,t,type LanguagePack} from './i18n'; + +export function ApkUpdate({id}:{id:string}){ + const [pointer,setPointer]=useState<{x:number;y:number}|null>(null); + useEffect(()=>{ + const move=(event:MouseEvent)=>setPointer(event.clientX>=0&&event.clientY>=0&&event.clientXsetPointer(null); + document.addEventListener('mousemove',move); + document.addEventListener('mouseleave',leave); + window.addEventListener('blur',leave); + return()=>{document.removeEventListener('mousemove',move);document.removeEventListener('mouseleave',leave);window.removeEventListener('blur',leave);}; + },[]); + const [ready,setReady]=useState(false),[error,setError]=useState(''),[attempt,setAttempt]=useState(0); + useEffect(()=>{ + let live=true;setError(''); + void Promise.all([api<{database:{language?:string}}>('status'),api<{packs?:LanguagePack[]}>('language.list')]).then(([status,languages])=>{ + if(!live)return; + configureLanguage(status.database.language??'auto',languages.packs??[]);setReady(true); + }).catch(e=>{if(live)setError(String(e));}); + return()=>{live=false;}; + },[attempt]); + const cursor=pointer?createPortal(